initial load of yaml working

This commit is contained in:
Casey Lee
2020-02-04 16:38:41 -08:00
parent 500e9677f3
commit 8c49ba0cec
30 changed files with 560 additions and 415 deletions

144
pkg/common/draw.go Normal file
View File

@@ -0,0 +1,144 @@
package common
import (
"fmt"
"io"
"os"
"strings"
)
// Style is a specific style
type Style int
// Styles
const (
StyleDoubleLine = iota
StyleSingleLine
StyleDashedLine
StyleNoLine
)
// NewPen creates a new pen
func NewPen(style Style, color int) *Pen {
bgcolor := 49
if os.Getenv("CLICOLOR") == "0" {
color = 0
bgcolor = 0
}
return &Pen{
style: style,
color: color,
bgcolor: bgcolor,
}
}
type styleDef struct {
cornerTL string
cornerTR string
cornerBL string
cornerBR string
lineH string
lineV string
}
var styleDefs = []styleDef{
{"\u2554", "\u2557", "\u255a", "\u255d", "\u2550", "\u2551"},
//{"\u250c", "\u2510", "\u2514", "\u2518", "\u2500", "\u2502"},
{"\u256d", "\u256e", "\u2570", "\u256f", "\u2500", "\u2502"},
{"\u250c", "\u2510", "\u2514", "\u2518", "\u254c", "\u254e"},
{" ", " ", " ", " ", " ", " "},
}
// Pen struct
type Pen struct {
style Style
color int
bgcolor int
}
// Drawing struct
type Drawing struct {
buf *strings.Builder
width int
}
func (p *Pen) drawTopBars(buf io.Writer, labels ...string) {
style := styleDefs[p.style]
for _, label := range labels {
bar := strings.Repeat(style.lineH, len(label)+2)
fmt.Fprintf(buf, " ")
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
fmt.Fprintf(buf, "%s%s%s", style.cornerTL, bar, style.cornerTR)
fmt.Fprintf(buf, "\x1b[%dm", 0)
}
fmt.Fprintf(buf, "\n")
}
func (p *Pen) drawBottomBars(buf io.Writer, labels ...string) {
style := styleDefs[p.style]
for _, label := range labels {
bar := strings.Repeat(style.lineH, len(label)+2)
fmt.Fprintf(buf, " ")
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
fmt.Fprintf(buf, "%s%s%s", style.cornerBL, bar, style.cornerBR)
fmt.Fprintf(buf, "\x1b[%dm", 0)
}
fmt.Fprintf(buf, "\n")
}
func (p *Pen) drawLabels(buf io.Writer, labels ...string) {
style := styleDefs[p.style]
for _, label := range labels {
fmt.Fprintf(buf, " ")
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
fmt.Fprintf(buf, "%s %s %s", style.lineV, label, style.lineV)
fmt.Fprintf(buf, "\x1b[%dm", 0)
}
fmt.Fprintf(buf, "\n")
}
// DrawArrow between boxes
func (p *Pen) DrawArrow() *Drawing {
drawing := &Drawing{
buf: new(strings.Builder),
width: 1,
}
fmt.Fprintf(drawing.buf, "\x1b[%dm", p.color)
fmt.Fprintf(drawing.buf, "\u2b07")
fmt.Fprintf(drawing.buf, "\x1b[%dm", 0)
return drawing
}
// DrawBoxes to draw boxes
func (p *Pen) DrawBoxes(labels ...string) *Drawing {
width := 0
for _, l := range labels {
width += len(l) + 2 + 2 + 1
}
drawing := &Drawing{
buf: new(strings.Builder),
width: width,
}
p.drawTopBars(drawing.buf, labels...)
p.drawLabels(drawing.buf, labels...)
p.drawBottomBars(drawing.buf, labels...)
return drawing
}
// Draw to writer
func (d *Drawing) Draw(writer io.Writer, centerOnWidth int) {
padSize := (centerOnWidth - d.GetWidth()) / 2
if padSize < 0 {
padSize = 0
}
for _, l := range strings.Split(d.buf.String(), "\n") {
if len(l) > 0 {
padding := strings.Repeat(" ", padSize)
fmt.Fprintf(writer, "%s%s\n", padding, l)
}
}
}
// GetWidth of drawing
func (d *Drawing) GetWidth() int {
return d.width
}

100
pkg/common/executor.go Normal file
View File

@@ -0,0 +1,100 @@
package common
import (
"fmt"
log "github.com/sirupsen/logrus"
)
// Warning that implements `error` but safe to ignore
type Warning struct {
Message string
}
// Error the contract for error
func (w Warning) Error() string {
return w.Message
}
// Warningf create a warning
func Warningf(format string, args ...interface{}) Warning {
w := Warning{
Message: fmt.Sprintf(format, args...),
}
return w
}
// Executor define contract for the steps of a workflow
type Executor func() error
// Conditional define contract for the conditional predicate
type Conditional func() bool
// NewPipelineExecutor creates a new executor from a series of other executors
func NewPipelineExecutor(executors ...Executor) Executor {
return func() error {
for _, executor := range executors {
if executor == nil {
continue
}
err := executor()
if err != nil {
switch err.(type) {
case Warning:
log.Warning(err.Error())
return nil
default:
log.Debugf("%+v", err)
return err
}
}
}
return nil
}
}
// NewConditionalExecutor creates a new executor based on conditions
func NewConditionalExecutor(conditional Conditional, trueExecutor Executor, falseExecutor Executor) Executor {
return func() error {
if conditional() {
if trueExecutor != nil {
return trueExecutor()
}
} else {
if falseExecutor != nil {
return falseExecutor()
}
}
return nil
}
}
func executeWithChan(executor Executor, errChan chan error) {
errChan <- executor()
}
// NewErrorExecutor creates a new executor that always errors out
func NewErrorExecutor(err error) Executor {
return func() error {
return err
}
}
// NewParallelExecutor creates a new executor from a parallel of other executors
func NewParallelExecutor(executors ...Executor) Executor {
return func() error {
errChan := make(chan error)
for _, executor := range executors {
go executeWithChan(executor, errChan)
}
for i := 0; i < len(executors); i++ {
err := <-errChan
if err != nil {
return err
}
}
return nil
}
}

View File

@@ -0,0 +1,84 @@
package common
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewWorkflow(t *testing.T) {
assert := assert.New(t)
// empty
emptyWorkflow := NewPipelineExecutor()
assert.Nil(emptyWorkflow())
// error case
errorWorkflow := NewErrorExecutor(fmt.Errorf("test error"))
assert.NotNil(errorWorkflow())
// multiple success case
runcount := 0
successWorkflow := NewPipelineExecutor(
func() error {
runcount++
return nil
},
func() error {
runcount++
return nil
})
assert.Nil(successWorkflow())
assert.Equal(2, runcount)
}
func TestNewConditionalExecutor(t *testing.T) {
assert := assert.New(t)
trueCount := 0
falseCount := 0
err := NewConditionalExecutor(func() bool {
return false
}, func() error {
trueCount++
return nil
}, func() error {
falseCount++
return nil
})()
assert.Nil(err)
assert.Equal(0, trueCount)
assert.Equal(1, falseCount)
err = NewConditionalExecutor(func() bool {
return true
}, func() error {
trueCount++
return nil
}, func() error {
falseCount++
return nil
})()
assert.Nil(err)
assert.Equal(1, trueCount)
assert.Equal(1, falseCount)
}
func TestNewParallelExecutor(t *testing.T) {
assert := assert.New(t)
count := 0
emptyWorkflow := NewPipelineExecutor(func() error {
count++
return nil
})
err := NewParallelExecutor(emptyWorkflow, emptyWorkflow)()
assert.Equal(2, count)
assert.Nil(err)
}

79
pkg/common/file.go Normal file
View File

@@ -0,0 +1,79 @@
package common
import (
"fmt"
"io"
"os"
)
// CopyFile copy file
func CopyFile(source string, dest string) (err error) {
sourcefile, err := os.Open(source)
if err != nil {
return err
}
defer sourcefile.Close()
destfile, err := os.Create(dest)
if err != nil {
return err
}
defer destfile.Close()
_, err = io.Copy(destfile, sourcefile)
if err == nil {
sourceinfo, err := os.Stat(source)
if err != nil {
_ = os.Chmod(dest, sourceinfo.Mode())
}
}
return
}
// CopyDir recursive copy of directory
func CopyDir(source string, dest string) (err error) {
// get properties of source dir
sourceinfo, err := os.Stat(source)
if err != nil {
return err
}
// create dest dir
err = os.MkdirAll(dest, sourceinfo.Mode())
if err != nil {
return err
}
directory, _ := os.Open(source)
objects, err := directory.Readdir(-1)
for _, obj := range objects {
sourcefilepointer := source + "/" + obj.Name()
destinationfilepointer := dest + "/" + obj.Name()
if obj.IsDir() {
// create sub-directories - recursively
err = CopyDir(sourcefilepointer, destinationfilepointer)
if err != nil {
fmt.Println(err)
}
} else {
// perform copy
err = CopyFile(sourcefilepointer, destinationfilepointer)
if err != nil {
fmt.Println(err)
}
}
}
return err
}

252
pkg/common/git.go Normal file
View File

@@ -0,0 +1,252 @@
package common
import (
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"github.com/go-ini/ini"
log "github.com/sirupsen/logrus"
git "gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing"
)
var (
codeCommitHTTPRegex = regexp.MustCompile(`^https?://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
codeCommitSSHRegex = regexp.MustCompile(`ssh://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+).git$`)
cloneLock sync.Mutex
)
// FindGitRevision get the current git revision
func FindGitRevision(file string) (shortSha string, sha string, err error) {
gitDir, err := findGitDirectory(file)
if err != nil {
return "", "", err
}
bts, err := ioutil.ReadFile(filepath.Join(gitDir, "HEAD"))
if err != nil {
return "", "", err
}
var ref = strings.TrimSpace(strings.TrimPrefix(string(bts), "ref:"))
var refBuf []byte
if strings.HasPrefix(ref, "refs/") {
// load commitid ref
refBuf, err = ioutil.ReadFile(filepath.Join(gitDir, ref))
if err != nil {
return "", "", err
}
} else {
refBuf = []byte(ref)
}
log.Debugf("Found revision: %s", refBuf)
return string(refBuf[:7]), strings.TrimSpace(string(refBuf)), nil
}
// FindGitRef get the current git ref
func FindGitRef(file string) (string, error) {
gitDir, err := findGitDirectory(file)
if err != nil {
return "", err
}
log.Debugf("Loading revision from git directory '%s'", gitDir)
_, ref, err := FindGitRevision(file)
if err != nil {
return "", err
}
log.Debugf("HEAD points to '%s'", ref)
// try tags first
tag, err := findGitPrettyRef(ref, gitDir, "refs/tags")
if err != nil || tag != "" {
return tag, err
}
// and then branches
return findGitPrettyRef(ref, gitDir, "refs/heads")
}
func findGitPrettyRef(head, root, sub string) (string, error) {
var name string
var err = filepath.Walk(filepath.Join(root, sub), func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if name != "" {
return nil
}
if info.IsDir() {
return nil
}
bts, err := ioutil.ReadFile(path)
if err != nil {
return err
}
var pointsTo = strings.TrimSpace(string(bts))
if head == pointsTo {
name = strings.TrimPrefix(strings.Replace(path, root, "", 1), "/")
log.Debugf("HEAD matches %s", name)
}
return nil
})
return name, err
}
// FindGithubRepo get the repo
func FindGithubRepo(file string) (string, error) {
url, err := findGitRemoteURL(file)
if err != nil {
return "", err
}
_, slug, err := findGitSlug(url)
return slug, err
}
func findGitRemoteURL(file string) (string, error) {
gitDir, err := findGitDirectory(file)
if err != nil {
return "", err
}
log.Debugf("Loading slug from git directory '%s'", gitDir)
gitconfig, err := ini.InsensitiveLoad(fmt.Sprintf("%s/config", gitDir))
if err != nil {
return "", err
}
remote, err := gitconfig.GetSection("remote \"origin\"")
if err != nil {
return "", err
}
urlKey, err := remote.GetKey("url")
if err != nil {
return "", err
}
url := urlKey.String()
return url, nil
}
func findGitSlug(url string) (string, string, error) {
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
return "CodeCommit", matches[2], nil
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
return "CodeCommit", matches[2], nil
} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
}
return "", url, nil
}
func findGitDirectory(fromFile string) (string, error) {
absPath, err := filepath.Abs(fromFile)
if err != nil {
return "", err
}
log.Debugf("Searching for git directory in %s", absPath)
fi, err := os.Stat(absPath)
if err != nil {
return "", err
}
var dir string
if fi.Mode().IsDir() {
dir = absPath
} else {
dir = filepath.Dir(absPath)
}
gitPath := filepath.Join(dir, ".git")
fi, err = os.Stat(gitPath)
if err == nil && fi.Mode().IsDir() {
return gitPath, nil
} else if dir == "/" || dir == "C:\\" || dir == "c:\\" {
return "", errors.New("unable to find git repo")
}
return findGitDirectory(filepath.Dir(dir))
}
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor
type NewGitCloneExecutorInput struct {
URL string
Ref string
Dir string
Logger *log.Entry
Dryrun bool
}
// NewGitCloneExecutor creates an executor to clone git repos
func NewGitCloneExecutor(input NewGitCloneExecutorInput) Executor {
return func() error {
input.Logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
input.Logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
if input.Dryrun {
return nil
}
cloneLock.Lock()
defer cloneLock.Unlock()
refName := plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", input.Ref))
r, err := git.PlainOpen(input.Dir)
if err != nil {
r, err = git.PlainClone(input.Dir, false, &git.CloneOptions{
URL: input.URL,
Progress: input.Logger.WriterLevel(log.DebugLevel),
//ReferenceName: refName,
})
if err != nil {
input.Logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
return err
}
}
w, err := r.Worktree()
if err != nil {
return err
}
err = w.Pull(&git.PullOptions{
//ReferenceName: refName,
Force: true,
})
if err != nil && err.Error() != "already up-to-date" {
input.Logger.Errorf("Unable to pull %s: %v", refName, err)
}
input.Logger.Debugf("Cloned %s to %s", input.URL, input.Dir)
hash, err := r.ResolveRevision(plumbing.Revision(input.Ref))
if err != nil {
input.Logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
return err
}
err = w.Checkout(&git.CheckoutOptions{
//Branch: refName,
Hash: *hash,
Force: true,
})
if err != nil {
input.Logger.Errorf("Unable to checkout %s: %v", refName, err)
return err
}
input.Logger.Debugf("Checked out %s", input.Ref)
return nil
}
}

167
pkg/common/git_test.go Normal file
View File

@@ -0,0 +1,167 @@
package common
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFindGitSlug(t *testing.T) {
assert := assert.New(t)
var slugTests = []struct {
url string // input
provider string // expected result
slug string // expected result
}{
{"https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name", "CodeCommit", "my-repo-name"},
{"ssh://git-codecommit.us-west-2.amazonaws.com/v1/repos/my-repo", "CodeCommit", "my-repo"},
{"git@github.com:nektos/act.git", "GitHub", "nektos/act"},
{"https://github.com/nektos/act.git", "GitHub", "nektos/act"},
{"http://github.com/nektos/act.git", "GitHub", "nektos/act"},
{"https://github.com/nektos/act", "GitHub", "nektos/act"},
{"http://github.com/nektos/act", "GitHub", "nektos/act"},
{"git+ssh://git@github.com/owner/repo.git", "GitHub", "owner/repo"},
{"http://myotherrepo.com/act.git", "", "http://myotherrepo.com/act.git"},
}
for _, tt := range slugTests {
provider, slug, err := findGitSlug(tt.url)
assert.Nil(err)
assert.Equal(tt.provider, provider)
assert.Equal(tt.slug, slug)
}
}
func TestFindGitRemoteURL(t *testing.T) {
assert := assert.New(t)
basedir, err := ioutil.TempDir("", "act-test")
defer os.RemoveAll(basedir)
assert.Nil(err)
gitConfig()
err = gitCmd("init", basedir)
assert.Nil(err)
remoteURL := "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name"
err = gitCmd("config", "-f", fmt.Sprintf("%s/.git/config", basedir), "--add", "remote.origin.url", remoteURL)
assert.Nil(err)
u, err := findGitRemoteURL(basedir)
assert.Nil(err)
assert.Equal(remoteURL, u)
}
func TestGitFindRef(t *testing.T) {
basedir, err := ioutil.TempDir("", "act-test")
defer os.RemoveAll(basedir)
assert.NoError(t, err)
gitConfig()
for name, tt := range map[string]struct {
Prepare func(t *testing.T, dir string)
Assert func(t *testing.T, ref string, err error)
}{
"new_repo": {
Prepare: func(t *testing.T, dir string) {},
Assert: func(t *testing.T, ref string, err error) {
require.Error(t, err)
},
},
"new_repo_with_commit": {
Prepare: func(t *testing.T, dir string) {
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg"))
},
Assert: func(t *testing.T, ref string, err error) {
require.NoError(t, err)
require.Equal(t, "refs/heads/master", ref)
},
},
"current_head_is_tag": {
Prepare: func(t *testing.T, dir string) {
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "commit msg"))
require.NoError(t, gitCmd("-C", dir, "tag", "v1.2.3"))
require.NoError(t, gitCmd("-C", dir, "checkout", "v1.2.3"))
},
Assert: func(t *testing.T, ref string, err error) {
require.NoError(t, err)
require.Equal(t, "refs/tags/v1.2.3", ref)
},
},
"current_head_is_same_as_tag": {
Prepare: func(t *testing.T, dir string) {
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "1.4.2 release"))
require.NoError(t, gitCmd("-C", dir, "tag", "v1.4.2"))
},
Assert: func(t *testing.T, ref string, err error) {
require.NoError(t, err)
require.Equal(t, "refs/tags/v1.4.2", ref)
},
},
"current_head_is_not_tag": {
Prepare: func(t *testing.T, dir string) {
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg"))
require.NoError(t, gitCmd("-C", dir, "tag", "v1.4.2"))
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg2"))
},
Assert: func(t *testing.T, ref string, err error) {
require.NoError(t, err)
require.Equal(t, "refs/heads/master", ref)
},
},
"current_head_is_another_branch": {
Prepare: func(t *testing.T, dir string) {
require.NoError(t, gitCmd("-C", dir, "checkout", "-b", "mybranch"))
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg"))
},
Assert: func(t *testing.T, ref string, err error) {
require.NoError(t, err)
require.Equal(t, "refs/heads/mybranch", ref)
},
},
} {
tt := tt
name := name
t.Run(name, func(t *testing.T) {
dir := filepath.Join(basedir, name)
require.NoError(t, os.MkdirAll(dir, 0755))
require.NoError(t, gitCmd("-C", dir, "init"))
tt.Prepare(t, dir)
ref, err := FindGitRef(dir)
tt.Assert(t, ref, err)
})
}
}
func gitConfig() {
_ = gitCmd("config","--global","user.email","test@test.com")
_ = gitCmd("config","--global","user.name","Unit Test")
}
func gitCmd(args ...string) error {
cmd := exec.Command("git", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if exitError, ok := err.(*exec.ExitError); ok {
if waitStatus, ok := exitError.Sys().(syscall.WaitStatus); ok {
return fmt.Errorf("Exit error %d", waitStatus.ExitStatus())
}
return exitError
}
return nil
}

View File

@@ -0,0 +1,108 @@
package container
import (
"io"
"os"
"path/filepath"
"github.com/docker/docker/api/types"
"github.com/docker/docker/builder/dockerignore"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"github.com/docker/docker/pkg/fileutils"
"github.com/nektos/act/pkg/common"
log "github.com/sirupsen/logrus"
)
// NewDockerBuildExecutorInput the input for the NewDockerBuildExecutor function
type NewDockerBuildExecutorInput struct {
DockerExecutorInput
ContextDir string
ImageTag string
}
// NewDockerBuildExecutor function to create a run executor for the container
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
return func() error {
input.Logger.Infof("docker build -t %s %s", input.ImageTag, input.ContextDir)
if input.Dryrun {
return nil
}
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return err
}
cli.NegotiateAPIVersion(input.Ctx)
input.Logger.Debugf("Building image from '%v'", input.ContextDir)
tags := []string{input.ImageTag}
options := types.ImageBuildOptions{
Tags: tags,
}
buildContext, err := createBuildContext(input.ContextDir, "Dockerfile")
if err != nil {
return err
}
defer buildContext.Close()
input.Logger.Debugf("Creating image from context dir '%s' with tag '%s'", input.ContextDir, input.ImageTag)
resp, err := cli.ImageBuild(input.Ctx, buildContext, options)
err = input.logDockerResponse(resp.Body, err != nil)
if err != nil {
return err
}
return nil
}
}
func createBuildContext(contextDir string, relDockerfile string) (io.ReadCloser, error) {
log.Debugf("Creating archive for build context dir '%s' with relative dockerfile '%s'", contextDir, relDockerfile)
// And canonicalize dockerfile name to a platform-independent one
relDockerfile = archive.CanonicalTarNameForPath(relDockerfile)
f, err := os.Open(filepath.Join(contextDir, ".dockerignore"))
if err != nil && !os.IsNotExist(err) {
return nil, err
}
defer f.Close()
var excludes []string
if err == nil {
excludes, err = dockerignore.ReadAll(f)
if err != nil {
return nil, err
}
}
// If .dockerignore mentions .dockerignore or the Dockerfile
// then make sure we send both files over to the daemon
// because Dockerfile is, obviously, needed no matter what, and
// .dockerignore is needed to know if either one needs to be
// removed. The daemon will remove them for us, if needed, after it
// parses the Dockerfile. Ignore errors here, as they will have been
// caught by validateContextDirectory above.
var includes = []string{"."}
keepThem1, _ := fileutils.Matches(".dockerignore", excludes)
keepThem2, _ := fileutils.Matches(relDockerfile, excludes)
if keepThem1 || keepThem2 {
includes = append(includes, ".dockerignore", relDockerfile)
}
compression := archive.Uncompressed
buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{
Compression: compression,
ExcludePatterns: excludes,
IncludeFiles: includes,
})
if err != nil {
return nil, err
}
return buildCtx, nil
}

View File

@@ -0,0 +1,115 @@
package container
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/docker/docker/pkg/stdcopy"
"io"
"os"
"github.com/sirupsen/logrus"
)
// DockerExecutorInput common input params
type DockerExecutorInput struct {
Ctx context.Context
Logger *logrus.Entry
Dryrun bool
}
type dockerMessage struct {
ID string `json:"id"`
Stream string `json:"stream"`
Error string `json:"error"`
ErrorDetail struct {
Message string
}
Status string `json:"status"`
Progress string `json:"progress"`
}
func (i *DockerExecutorInput) logDockerOutput(dockerResponse io.Reader) {
w := i.Logger.Writer()
_, err := stdcopy.StdCopy(w, w, dockerResponse)
if err != nil {
i.Logger.Error(err)
}
}
func (i *DockerExecutorInput) streamDockerOutput(dockerResponse io.Reader) {
out := os.Stdout
go func() {
<-i.Ctx.Done()
fmt.Println()
}()
_, err := io.Copy(out, dockerResponse)
if err != nil {
i.Logger.Error(err)
}
}
func (i *DockerExecutorInput) writeLog(isError bool, format string, args ...interface{}) {
if i.Logger == nil {
return
}
if isError {
i.Logger.Errorf(format, args...)
} else {
i.Logger.Debugf(format, args...)
}
}
func (i *DockerExecutorInput) logDockerResponse(dockerResponse io.ReadCloser, isError bool) error {
if dockerResponse == nil {
return nil
}
defer dockerResponse.Close()
scanner := bufio.NewScanner(dockerResponse)
msg := dockerMessage{}
for scanner.Scan() {
line := scanner.Bytes()
msg.ID = ""
msg.Stream = ""
msg.Error = ""
msg.ErrorDetail.Message = ""
msg.Status = ""
msg.Progress = ""
if err := json.Unmarshal(line, &msg); err != nil {
i.writeLog(false, "Unable to unmarshal line [%s] ==> %v", string(line), err)
continue
}
if msg.Error != "" {
i.writeLog(isError, "%s", msg.Error)
return errors.New(msg.Error)
}
if msg.ErrorDetail.Message != "" {
i.writeLog(isError, "%s", msg.ErrorDetail.Message)
return errors.New(msg.Error)
}
if msg.Status != "" {
if msg.Progress != "" {
i.writeLog(isError, "%s :: %s :: %s\n", msg.Status, msg.ID, msg.Progress)
} else {
i.writeLog(isError, "%s :: %s\n", msg.Status, msg.ID)
}
} else if msg.Stream != "" {
i.writeLog(isError, msg.Stream)
} else {
i.writeLog(false, "Unable to handle line: %s", string(line))
}
}
return nil
}

View File

@@ -0,0 +1,33 @@
package container
import (
"context"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
)
// ImageExistsLocally returns a boolean indicating if an image with the
// requested name (and tag) exist in the local docker image store
func ImageExistsLocally(ctx context.Context, imageName string) (bool, error) {
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return false, err
}
cli.NegotiateAPIVersion(ctx)
filters := filters.NewArgs()
filters.Add("reference", imageName)
imageListOptions := types.ImageListOptions{
Filters: filters,
}
images, err := cli.ImageList(ctx, imageListOptions)
if err != nil {
return false, err
}
return len(images) > 0, nil
}

View File

@@ -0,0 +1,45 @@
package container
import (
"context"
"io/ioutil"
"testing"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
func init() {
log.SetLevel(log.DebugLevel)
}
func TestImageExistsLocally(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
// to help make this test reliable and not flaky, we need to have
// an image that will exist, and onew that won't exist
exists, err := ImageExistsLocally(context.TODO(), "library/alpine:this-random-tag-will-never-exist")
assert.Nil(t, err)
assert.Equal(t, false, exists)
// pull an image
cli, err := client.NewClientWithOpts(client.FromEnv)
assert.Nil(t, err)
cli.NegotiateAPIVersion(context.TODO())
// Chose alpine latest because it's so small
// maybe we should build an image instead so that tests aren't reliable on dockerhub
reader, err := cli.ImagePull(context.TODO(), "alpine:latest", types.ImagePullOptions{})
assert.Nil(t, err)
defer reader.Close()
_, err = ioutil.ReadAll(reader)
assert.Nil(t, err)
exists, err = ImageExistsLocally(context.TODO(), "alpine:latest")
assert.Nil(t, err)
assert.Equal(t, true, exists)
}

View File

@@ -0,0 +1,56 @@
package container
import (
"fmt"
"strings"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/nektos/act/pkg/common"
)
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
type NewDockerPullExecutorInput struct {
DockerExecutorInput
Image string
}
// NewDockerPullExecutor function to create a run executor for the container
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
return func() error {
input.Logger.Infof("docker pull %v", input.Image)
if input.Dryrun {
return nil
}
imageRef := cleanImage(input.Image)
input.Logger.Debugf("pulling image '%v'", imageRef)
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return err
}
cli.NegotiateAPIVersion(input.Ctx)
reader, err := cli.ImagePull(input.Ctx, imageRef, types.ImagePullOptions{})
_ = input.logDockerResponse(reader, err != nil)
if err != nil {
return err
}
return nil
}
}
func cleanImage(image string) string {
imageParts := len(strings.Split(image, "/"))
if imageParts == 1 {
image = fmt.Sprintf("docker.io/library/%s", image)
} else if imageParts == 2 {
image = fmt.Sprintf("docker.io/%s", image)
}
return image
}

View File

@@ -0,0 +1,29 @@
package container
import (
"testing"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
func init() {
log.SetLevel(log.DebugLevel)
}
func TestCleanImage(t *testing.T) {
tables := []struct {
imageIn string
imageOut string
}{
{"myhost.com/foo/bar", "myhost.com/foo/bar"},
{"ubuntu", "docker.io/library/ubuntu"},
{"ubuntu:18.04", "docker.io/library/ubuntu:18.04"},
{"cibuilds/hugo:0.53", "docker.io/cibuilds/hugo:0.53"},
}
for _, table := range tables {
imageOut := cleanImage(table.imageIn)
assert.Equal(t, table.imageOut, imageOut)
}
}

211
pkg/container/docker_run.go Normal file
View File

@@ -0,0 +1,211 @@
package container
import (
"context"
"fmt"
"io"
"os"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/nektos/act/pkg/common"
"golang.org/x/crypto/ssh/terminal"
)
// NewDockerRunExecutorInput the input for the NewDockerRunExecutor function
type NewDockerRunExecutorInput struct {
DockerExecutorInput
Image string
Entrypoint []string
Cmd []string
WorkingDir string
Env []string
Binds []string
Content map[string]io.Reader
Volumes []string
Name string
ReuseContainers bool
}
// NewDockerRunExecutor function to create a run executor for the container
func NewDockerRunExecutor(input NewDockerRunExecutorInput) common.Executor {
return func() error {
input.Logger.Infof("docker run image=%s entrypoint=%+q cmd=%+q", input.Image, input.Entrypoint, input.Cmd)
if input.Dryrun {
return nil
}
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return err
}
cli.NegotiateAPIVersion(input.Ctx)
// check if container exists
containerID, err := findContainer(input, cli, input.Name)
if err != nil {
return err
}
// if we have an old container and we aren't reusing, remove it!
if !input.ReuseContainers && containerID != "" {
input.Logger.Debugf("Found existing container for %s...removing", input.Name)
removeContainer(input, cli, containerID)
containerID = ""
}
// create a new container if we don't have one to reuse
if containerID == "" {
containerID, err = createContainer(input, cli)
if err != nil {
return err
}
}
// be sure to cleanup container if we aren't reusing
if !input.ReuseContainers {
defer removeContainer(input, cli, containerID)
}
executor := common.NewPipelineExecutor(
func() error {
return copyContentToContainer(input, cli, containerID)
}, func() error {
return attachContainer(input, cli, containerID)
}, func() error {
return startContainer(input, cli, containerID)
}, func() error {
return waitContainer(input, cli, containerID)
},
)
return executor()
}
}
func createContainer(input NewDockerRunExecutorInput, cli *client.Client) (string, error) {
isTerminal := terminal.IsTerminal(int(os.Stdout.Fd()))
config := &container.Config{
Image: input.Image,
Cmd: input.Cmd,
Entrypoint: input.Entrypoint,
WorkingDir: input.WorkingDir,
Env: input.Env,
Tty: isTerminal,
}
if len(input.Volumes) > 0 {
config.Volumes = make(map[string]struct{})
for _, vol := range input.Volumes {
config.Volumes[vol] = struct{}{}
}
}
resp, err := cli.ContainerCreate(input.Ctx, config, &container.HostConfig{
Binds: input.Binds,
}, nil, input.Name)
if err != nil {
return "", err
}
input.Logger.Debugf("Created container name=%s id=%v from image %v", input.Name, resp.ID, input.Image)
input.Logger.Debugf("ENV ==> %v", input.Env)
return resp.ID, nil
}
func findContainer(input NewDockerRunExecutorInput, cli *client.Client, containerName string) (string, error) {
containers, err := cli.ContainerList(input.Ctx, types.ContainerListOptions{
All: true,
})
if err != nil {
return "", err
}
for _, container := range containers {
for _, name := range container.Names {
if name[1:] == containerName {
return container.ID, nil
}
}
}
return "", nil
}
func removeContainer(input NewDockerRunExecutorInput, cli *client.Client, containerID string) {
err := cli.ContainerRemove(context.Background(), containerID, types.ContainerRemoveOptions{
RemoveVolumes: true,
Force: true,
})
if err != nil {
input.Logger.Errorf("%v", err)
}
input.Logger.Debugf("Removed container: %v", containerID)
}
func copyContentToContainer(input NewDockerRunExecutorInput, cli *client.Client, containerID string) error {
for dstPath, srcReader := range input.Content {
input.Logger.Debugf("Extracting content to '%s'", dstPath)
err := cli.CopyToContainer(input.Ctx, containerID, dstPath, srcReader, types.CopyToContainerOptions{})
if err != nil {
return err
}
}
return nil
}
func attachContainer(input NewDockerRunExecutorInput, cli *client.Client, containerID string) error {
out, err := cli.ContainerAttach(input.Ctx, containerID, types.ContainerAttachOptions{
Stream: true,
Stdout: true,
Stderr: true,
})
if err != nil {
return err
}
isTerminal := terminal.IsTerminal(int(os.Stdout.Fd()))
if !isTerminal || os.Getenv("NORAW") != "" {
go input.logDockerOutput(out.Reader)
} else {
go input.streamDockerOutput(out.Reader)
}
return nil
}
func startContainer(input NewDockerRunExecutorInput, cli *client.Client, containerID string) error {
input.Logger.Debugf("STARTING image=%s entrypoint=%s cmd=%v", input.Image, input.Entrypoint, input.Cmd)
if err := cli.ContainerStart(input.Ctx, containerID, types.ContainerStartOptions{}); err != nil {
return err
}
input.Logger.Debugf("Started container: %v", containerID)
return nil
}
func waitContainer(input NewDockerRunExecutorInput, cli *client.Client, containerID string) error {
statusCh, errCh := cli.ContainerWait(input.Ctx, containerID, container.WaitConditionNotRunning)
var statusCode int64
select {
case err := <-errCh:
if err != nil {
return err
}
case status := <-statusCh:
statusCode = status.StatusCode
}
input.Logger.Debugf("Return status: %v", statusCode)
if statusCode == 0 {
return nil
} else if statusCode == 78 {
return fmt.Errorf("exit with `NEUTRAL`: 78")
}
return fmt.Errorf("exit with `FAILURE`: %v", statusCode)
}

View File

@@ -0,0 +1,56 @@
package container
import (
"bytes"
"context"
"io/ioutil"
"testing"
"github.com/nektos/act/pkg/common"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
type rawFormatter struct{}
func (f *rawFormatter) Format(entry *logrus.Entry) ([]byte, error) {
return []byte(entry.Message), nil
}
func TestNewDockerRunExecutor(t *testing.T) {
if testing.Short() {
t.Skip("skipping slower test")
}
noopLogger := logrus.New()
noopLogger.SetOutput(ioutil.Discard)
buf := &bytes.Buffer{}
logger := logrus.New()
logger.SetOutput(buf)
logger.SetFormatter(&rawFormatter{})
runner := NewDockerRunExecutor(NewDockerRunExecutorInput{
DockerExecutorInput: DockerExecutorInput{
Ctx: context.TODO(),
Logger: logrus.NewEntry(logger),
},
Image: "hello-world",
})
puller := NewDockerPullExecutor(NewDockerPullExecutorInput{
DockerExecutorInput: DockerExecutorInput{
Ctx: context.TODO(),
Logger: logrus.NewEntry(noopLogger),
},
Image: "hello-world",
})
pipeline := common.NewPipelineExecutor(puller, runner)
err := pipeline()
assert.NoError(t, err)
expected := `docker run image=hello-world entrypoint=[] cmd=[]Hello from Docker!`
actual := buf.String()
assert.Equal(t, expected, actual[:len(expected)])
}

196
pkg/model/planner.go Normal file
View File

@@ -0,0 +1,196 @@
package model
import (
"io/ioutil"
"math"
"os"
"path/filepath"
"sort"
log "github.com/sirupsen/logrus"
)
// WorkflowPlanner contains methods for creating plans
type WorkflowPlanner interface {
PlanEvent(eventName string) *Plan
PlanJob(jobName string) *Plan
GetEvents() []string
}
// Plan contains a list of stages to run in series
type Plan struct {
Stages []*Stage
}
// Stage contains a list of runs to execute in parallel
type Stage struct {
Runs []*Run
}
// Run represents a job from a workflow that needs to be run
type Run struct {
Workflow *Workflow
JobID string
}
// NewWorkflowPlanner will load all workflows from a directory
func NewWorkflowPlanner(dirname string) (WorkflowPlanner, error) {
log.Debugf("Loading workflows from '%s'", dirname)
files, err := ioutil.ReadDir(dirname)
if err != nil {
return nil, err
}
wp := new(workflowPlanner)
for _, file := range files {
ext := filepath.Ext(file.Name())
if ext == ".yml" || ext == ".yaml" {
f, err := os.Open(filepath.Join(dirname, file.Name()))
if err != nil {
return nil, err
}
workflow, err := ReadWorkflow(f)
if err != nil {
f.Close()
return nil, err
}
wp.workflows = append(wp.workflows, workflow)
f.Close()
}
}
return wp, nil
}
type workflowPlanner struct {
workflows []*Workflow
}
// PlanEvent builds a new list of runs to execute in parallel for an event name
func (wp *workflowPlanner) PlanEvent(eventName string) *Plan {
plan := new(Plan)
for _, w := range wp.workflows {
if w.On == eventName {
plan.mergeStages(createStages(w, w.GetJobIDs()...))
}
}
return plan
}
// PlanJob builds a new run to execute in parallel for a job name
func (wp *workflowPlanner) PlanJob(jobName string) *Plan {
plan := new(Plan)
for _, w := range wp.workflows {
plan.mergeStages(createStages(w, jobName))
}
return plan
}
// GetEvents gets all the events in the workflows file
func (wp *workflowPlanner) GetEvents() []string {
events := make([]string, 0)
for _, w := range wp.workflows {
found := false
for _, e := range events {
if e == w.On {
found = true
break
}
}
if !found {
events = append(events, w.On)
}
}
// sort the list based on depth of dependencies
sort.Slice(events, func(i, j int) bool {
return events[i] < events[j]
})
return events
}
// GetJobIDs will get all the job names in the stage
func (s *Stage) GetJobIDs() []string {
names := make([]string, 0)
for _, r := range s.Runs {
names = append(names, r.JobID)
}
return names
}
// Merge stages with existing stages in plan
func (p *Plan) mergeStages(stages []*Stage) {
newStages := make([]*Stage, int(math.Max(float64(len(p.Stages)), float64(len(stages)))))
for i := 0; i < len(newStages); i++ {
newStages[i] = new(Stage)
if i >= len(p.Stages) {
newStages[i].Runs = append(stages[i].Runs)
} else if i >= len(stages) {
newStages[i].Runs = append(p.Stages[i].Runs)
} else {
newStages[i].Runs = append(p.Stages[i].Runs, stages[i].Runs...)
}
}
p.Stages = newStages
}
func createStages(w *Workflow, jobIDs ...string) []*Stage {
// first, build a list of all the necessary jobs to run, and their dependencies
jobDependencies := make(map[string][]string)
for len(jobIDs) > 0 {
newJobIDs := make([]string, 0)
for _, jID := range jobIDs {
// make sure we haven't visited this job yet
if _, ok := jobDependencies[jID]; !ok {
if job := w.GetJob(jID); job != nil {
jobDependencies[jID] = job.Needs
newJobIDs = append(newJobIDs, job.Needs...)
}
}
}
jobIDs = newJobIDs
}
// next, build an execution graph
stages := make([]*Stage, 0)
for len(jobDependencies) > 0 {
stage := new(Stage)
for jID, jDeps := range jobDependencies {
// make sure all deps are in the graph already
if listInStages(jDeps, stages...) {
stage.Runs = append(stage.Runs, &Run{
Workflow: w,
JobID: jID,
})
delete(jobDependencies, jID)
}
}
if len(stage.Runs) == 0 {
log.Fatalf("Unable to build dependency graph!")
}
stages = append(stages, stage)
}
return stages
}
// return true iff all strings in srcList exist in at least one of the stages
func listInStages(srcList []string, stages ...*Stage) bool {
for _, src := range srcList {
found := false
for _, stage := range stages {
for _, search := range stage.GetJobIDs() {
if src == search {
found = true
}
}
}
if !found {
return false
}
}
return true
}

67
pkg/model/workflow.go Normal file
View File

@@ -0,0 +1,67 @@
package model
import (
"io"
"gopkg.in/yaml.v2"
)
// Workflow is the structure of the files in .github/workflows
type Workflow struct {
Name string `yaml:"name"`
On string `yaml:"on"`
Env map[string]string `yaml:"env"`
Jobs map[string]*Job `yaml:"jobs"`
}
// Job is the structure of one job in a workflow
type Job struct {
Name string `yaml:"name"`
Needs []string `yaml:"needs"`
RunsOn string `yaml:"runs-on"`
Env map[string]string `yaml:"env"`
If string `yaml:"if"`
Steps []*Step `yaml:"steps"`
TimeoutMinutes int64 `yaml:"timeout-minutes"`
}
// Step is the structure of one step in a job
type Step struct {
ID string `yaml:"id"`
If string `yaml:"if"`
Name string `yaml:"name"`
Uses string `yaml:"uses"`
Run string `yaml:"run"`
WorkingDirectory string `yaml:"working-directory"`
Shell string `yaml:"shell"`
Env map[string]string `yaml:"env"`
With map[string]string `yaml:"with"`
ContinueOnError bool `yaml:"continue-on-error"`
TimeoutMinutes int64 `yaml:"timeout-minutes"`
}
// ReadWorkflow returns a list of jobs for a given workflow file reader
func ReadWorkflow(in io.Reader) (*Workflow, error) {
w := new(Workflow)
err := yaml.NewDecoder(in).Decode(w)
return w, err
}
// GetJob will get a job by name in the workflow
func (w *Workflow) GetJob(jobID string) *Job {
for id, j := range w.Jobs {
if jobID == id {
return j
}
}
return nil
}
// GetJobIDs will get all the job names in the workflow
func (w *Workflow) GetJobIDs() []string {
ids := make([]string, 0)
for id := range w.Jobs {
ids = append(ids, id)
}
return ids
}

5
pkg/runner/api.go Normal file
View File

@@ -0,0 +1,5 @@
package runner
type environmentApplier interface {
applyEnvironment(map[string]string)
}

88
pkg/runner/runner.go Normal file
View File

@@ -0,0 +1,88 @@
package runner
import (
"io"
"io/ioutil"
"os"
"github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/model"
log "github.com/sirupsen/logrus"
)
// Runner provides capabilities to run GitHub actions
type Runner interface {
PlanRunner
io.Closer
}
// PlanRunner to run a specific actions
type PlanRunner interface {
RunPlan(plan *model.Plan) error
}
// Config contains the config for a new runner
type Config struct {
Dryrun bool // don't start any of the containers
EventName string // name of event to run
EventPath string // path to JSON file to use for event.json in containers
ReuseContainers bool // reuse containers to maintain state
ForcePull bool // force pulling of the image, if already present
}
type runnerImpl struct {
config *Config
tempDir string
eventJSON string
}
// NewRunner Creates a new Runner
func NewRunner(runnerConfig *Config) (Runner, error) {
runner := &runnerImpl{
config: runnerConfig,
}
init := common.NewPipelineExecutor(
runner.setupTempDir,
runner.setupEvent,
)
return runner, init()
}
func (runner *runnerImpl) setupTempDir() error {
var err error
runner.tempDir, err = ioutil.TempDir("", "act-")
return err
}
func (runner *runnerImpl) setupEvent() error {
runner.eventJSON = "{}"
if runner.config.EventPath != "" {
log.Debugf("Reading event.json from %s", runner.config.EventPath)
eventJSONBytes, err := ioutil.ReadFile(runner.config.EventPath)
if err != nil {
return err
}
runner.eventJSON = string(eventJSONBytes)
}
return nil
}
func (runner *runnerImpl) RunPlan(plan *model.Plan) error {
pipeline := make([]common.Executor, 0)
for _, stage := range plan.Stages {
stageExecutor := make([]common.Executor, 0)
for _, run := range stage.Runs {
stageExecutor = append(stageExecutor, runner.newRunExecutor(run))
}
pipeline = append(pipeline, common.NewParallelExecutor(stageExecutor...))
}
executor := common.NewPipelineExecutor(pipeline...)
return executor()
}
func (runner *runnerImpl) Close() error {
return os.RemoveAll(runner.tempDir)
}

254
pkg/runner/runner_exec.go Normal file
View File

@@ -0,0 +1,254 @@
package runner
import (
"archive/tar"
"bytes"
"fmt"
"io"
"path/filepath"
"regexp"
"github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/container"
"github.com/nektos/act/pkg/model"
log "github.com/sirupsen/logrus"
)
func (runner *runnerImpl) newRunExecutor(run *model.Run) common.Executor {
action := runner.workflowConfig.GetAction(actionName)
if action == nil {
return common.NewErrorExecutor(fmt.Errorf("Unable to find action named '%s'", actionName))
}
executors := make([]common.Executor, 0)
image, err := runner.addImageExecutor(action, &executors)
if err != nil {
return common.NewErrorExecutor(err)
}
err = runner.addRunExecutor(action, image, &executors)
if err != nil {
return common.NewErrorExecutor(err)
}
return common.NewPipelineExecutor(executors...)
}
/*
func (runner *runnerImpl) addImageExecutor(action *Action, executors *[]common.Executor) (string, error) {
var image string
logger := newActionLogger(action.Identifier, runner.config.Dryrun)
log.Debugf("Using '%s' for action '%s'", action.Uses, action.Identifier)
in := container.DockerExecutorInput{
Ctx: runner.config.Ctx,
Logger: logger,
Dryrun: runner.config.Dryrun,
}
switch uses := action.Uses.(type) {
case *model.UsesDockerImage:
image = uses.Image
pull := runner.config.ForcePull
if !pull {
imageExists, err := container.ImageExistsLocally(runner.config.Ctx, image)
log.Debugf("Image exists? %v", imageExists)
if err != nil {
return "", fmt.Errorf("unable to determine if image already exists for image %q", image)
}
if !imageExists {
pull = true
}
}
if pull {
*executors = append(*executors, container.NewDockerPullExecutor(container.NewDockerPullExecutorInput{
DockerExecutorInput: in,
Image: image,
}))
}
case *model.UsesPath:
contextDir := filepath.Join(runner.config.WorkingDir, uses.String())
sha, _, err := common.FindGitRevision(contextDir)
if err != nil {
log.Warnf("Unable to determine git revision: %v", err)
sha = "latest"
}
image = fmt.Sprintf("%s:%s", filepath.Base(contextDir), sha)
*executors = append(*executors, container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
DockerExecutorInput: in,
ContextDir: contextDir,
ImageTag: image,
}))
case *model.UsesRepository:
image = fmt.Sprintf("%s:%s", filepath.Base(uses.Repository), uses.Ref)
cloneURL := fmt.Sprintf("https://github.com/%s", uses.Repository)
cloneDir := filepath.Join(os.TempDir(), "act", action.Uses.String())
*executors = append(*executors, common.NewGitCloneExecutor(common.NewGitCloneExecutorInput{
URL: cloneURL,
Ref: uses.Ref,
Dir: cloneDir,
Logger: logger,
Dryrun: runner.config.Dryrun,
}))
contextDir := filepath.Join(cloneDir, uses.Path)
*executors = append(*executors, container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
DockerExecutorInput: in,
ContextDir: contextDir,
ImageTag: image,
}))
default:
return "", fmt.Errorf("unable to determine executor type for image '%s'", action.Uses)
}
return image, nil
}
*/
func (runner *runnerImpl) addRunExecutor(action *Action, image string, executors *[]common.Executor) error {
logger := newActionLogger(action.Identifier, runner.config.Dryrun)
log.Debugf("Using '%s' for action '%s'", action.Uses, action.Identifier)
in := container.DockerExecutorInput{
Ctx: runner.config.Ctx,
Logger: logger,
Dryrun: runner.config.Dryrun,
}
env := make(map[string]string)
for _, applier := range []environmentApplier{newActionEnvironmentApplier(action), runner} {
applier.applyEnvironment(env)
}
env["GITHUB_ACTION"] = action.Identifier
ghReader, err := runner.createGithubTarball()
if err != nil {
return err
}
envList := make([]string, 0)
for k, v := range env {
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
}
var cmd, entrypoint []string
if action.Args != nil {
cmd = action.Args.Split()
}
if action.Runs != nil {
entrypoint = action.Runs.Split()
}
*executors = append(*executors, container.NewDockerRunExecutor(container.NewDockerRunExecutorInput{
DockerExecutorInput: in,
Cmd: cmd,
Entrypoint: entrypoint,
Image: image,
WorkingDir: "/github/workspace",
Env: envList,
Name: runner.createContainerName(action.Identifier),
Binds: []string{
fmt.Sprintf("%s:%s", runner.config.WorkingDir, "/github/workspace"),
fmt.Sprintf("%s:%s", runner.tempDir, "/github/home"),
fmt.Sprintf("%s:%s", "/var/run/docker.sock", "/var/run/docker.sock"),
},
Content: map[string]io.Reader{"/github": ghReader},
ReuseContainers: runner.config.ReuseContainers,
}))
return nil
}
func (runner *runnerImpl) applyEnvironment(env map[string]string) {
repoPath := runner.config.WorkingDir
workflows := runner.workflowConfig.GetWorkflows(runner.config.EventName)
if len(workflows) == 0 {
return
}
workflowName := workflows[0].Identifier
env["HOME"] = "/github/home"
env["GITHUB_ACTOR"] = "nektos/act"
env["GITHUB_EVENT_PATH"] = "/github/workflow/event.json"
env["GITHUB_WORKSPACE"] = "/github/workspace"
env["GITHUB_WORKFLOW"] = workflowName
env["GITHUB_EVENT_NAME"] = runner.config.EventName
_, rev, err := common.FindGitRevision(repoPath)
if err != nil {
log.Warningf("unable to get git revision: %v", err)
} else {
env["GITHUB_SHA"] = rev
}
repo, err := common.FindGithubRepo(repoPath)
if err != nil {
log.Warningf("unable to get git repo: %v", err)
} else {
env["GITHUB_REPOSITORY"] = repo
}
ref, err := common.FindGitRef(repoPath)
if err != nil {
log.Warningf("unable to get git ref: %v", err)
} else {
log.Infof("using github ref: %s", ref)
env["GITHUB_REF"] = ref
}
}
func (runner *runnerImpl) createGithubTarball() (io.Reader, error) {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
var files = []struct {
Name string
Mode int64
Body string
}{
{"workflow/event.json", 0644, runner.eventJSON},
}
for _, file := range files {
log.Debugf("Writing entry to tarball %s len:%d", file.Name, len(runner.eventJSON))
hdr := &tar.Header{
Name: file.Name,
Mode: file.Mode,
Size: int64(len(runner.eventJSON)),
}
if err := tw.WriteHeader(hdr); err != nil {
return nil, err
}
if _, err := tw.Write([]byte(runner.eventJSON)); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
return &buf, nil
}
func (runner *runnerImpl) createContainerName(actionName string) string {
containerName := regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(actionName, "-")
prefix := fmt.Sprintf("%s-", trimToLen(filepath.Base(runner.config.WorkingDir), 10))
suffix := ""
containerName = trimToLen(containerName, 30-(len(prefix)+len(suffix)))
return fmt.Sprintf("%s%s%s", prefix, containerName, suffix)
}
func trimToLen(s string, l int) string {
if len(s) > l {
return s[:l]
}
return s
}

71
pkg/runner/runner_test.go Normal file
View File

@@ -0,0 +1,71 @@
package runner
import (
"context"
"testing"
log "github.com/sirupsen/logrus"
"gotest.tools/assert"
)
func TestGraphEvent(t *testing.T) {
runnerConfig := &RunnerConfig{
Ctx: context.Background(),
WorkflowPath: "multi.workflow",
WorkingDir: "testdata",
EventName: "push",
}
runner, err := NewRunner(runnerConfig)
assert.NilError(t, err)
graph, err := runner.GraphEvent("push")
assert.NilError(t, err)
assert.DeepEqual(t, graph, [][]string{{"build"}})
graph, err = runner.GraphEvent("release")
assert.NilError(t, err)
assert.DeepEqual(t, graph, [][]string{{"deploy"}})
}
func TestRunEvent(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
tables := []struct {
workflowPath string
eventName string
errorMessage string
}{
{"basic.workflow", "push", ""},
{"pipe.workflow", "push", ""},
{"fail.workflow", "push", "exit with `FAILURE`: 1"},
{"buildfail.workflow", "push", "COPY failed"},
{"regex.workflow", "push", "exit with `NEUTRAL`: 78"},
{"gitref.workflow", "push", ""},
{"env.workflow", "push", ""},
{"detect_event.workflow", "", ""},
}
log.SetLevel(log.DebugLevel)
for _, table := range tables {
table := table
t.Run(table.workflowPath, func(t *testing.T) {
runnerConfig := &RunnerConfig{
Ctx: context.Background(),
WorkflowPath: table.workflowPath,
WorkingDir: "testdata",
EventName: table.eventName,
}
runner, err := NewRunner(runnerConfig)
assert.NilError(t, err, table.workflowPath)
err = runner.RunEvent()
if table.errorMessage == "" {
assert.NilError(t, err, table.workflowPath)
} else {
assert.ErrorContains(t, err, table.errorMessage)
}
})
}
}