diff --git a/pkg/runner/action.go b/pkg/runner/action.go index b9024fb..a50efdd 100644 --- a/pkg/runner/action.go +++ b/pkg/runner/action.go @@ -1,27 +1,42 @@ package runner import ( + "context" "embed" + "fmt" "io" "io/fs" "os" + "path" "path/filepath" + "regexp" + "runtime" + "strings" + "github.com/kballard/go-shellquote" + "github.com/nektos/act/pkg/common" + "github.com/nektos/act/pkg/container" "github.com/nektos/act/pkg/model" log "github.com/sirupsen/logrus" ) -type ActionReader interface { - readAction(step *model.Step, actionDir string, actionPath string, readFile actionyamlReader) (*model.Action, error) +type actionStep interface { + step + + getActionModel() *model.Action } -type actionyamlReader func(filename string) (io.Reader, io.Closer, error) +type readAction func(step *model.Step, actionDir string, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) + +type actionYamlReader func(filename string) (io.Reader, io.Closer, error) type fileWriter func(filename string, data []byte, perm fs.FileMode) error +type runAction func(step actionStep, actionDir string, actionPath string, actionRepository string, actionRef string, localAction bool) common.Executor + //go:embed res/trampoline.js var trampoline embed.FS -func (sc *StepContext) readAction(step *model.Step, actionDir string, actionPath string, readFile actionyamlReader, writeFile fileWriter) (*model.Action, error) { +func readActionImpl(step *model.Step, actionDir string, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) { reader, closer, err := readFile("action.yml") if os.IsNotExist(err) { reader, closer, err = readFile("action.yaml") @@ -82,3 +97,366 @@ func (sc *StepContext) readAction(step *model.Step, actionDir string, actionPath log.Debugf("Read action %v from '%s'", action, "Unknown") return action, err } + +func runActionImpl(step actionStep, actionDir string, actionPath string, actionRepository string, actionRef string, localAction bool) common.Executor { + rc := step.getRunContext() + stepModel := step.getStepModel() + return func(ctx context.Context) error { + // Backup the parent composite action path and restore it on continue + parentActionPath := rc.ActionPath + parentActionRepository := rc.ActionRepository + parentActionRef := rc.ActionRef + defer func() { + rc.ActionPath = parentActionPath + rc.ActionRef = parentActionRef + rc.ActionRepository = parentActionRepository + }() + rc.ActionRef = actionRef + rc.ActionRepository = actionRepository + action := step.getActionModel() + log.Debugf("About to run action %v", action) + + populateEnvsFromInput(step.getEnv(), action, rc) + + actionLocation := "" + if actionPath != "" { + actionLocation = path.Join(actionDir, actionPath) + } else { + actionLocation = actionDir + } + actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc) + + log.Debugf("type=%v actionDir=%s actionPath=%s workdir=%s actionCacheDir=%s actionName=%s containerActionDir=%s", stepModel.Type(), actionDir, actionPath, rc.Config.Workdir, rc.ActionCacheDir(), actionName, containerActionDir) + + maybeCopyToActionDir := func() error { + rc.ActionPath = containerActionDir + if stepModel.Type() != model.StepTypeUsesActionRemote { + return nil + } + if err := removeGitIgnore(actionDir); err != nil { + return err + } + + var containerActionDirCopy string + containerActionDirCopy = strings.TrimSuffix(containerActionDir, actionPath) + log.Debug(containerActionDirCopy) + + if !strings.HasSuffix(containerActionDirCopy, `/`) { + containerActionDirCopy += `/` + } + return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore)(ctx) + } + + switch action.Runs.Using { + case model.ActionRunsUsingNode12, model.ActionRunsUsingNode16: + if err := maybeCopyToActionDir(); err != nil { + return err + } + containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Main)} + log.Debugf("executing remote job container: %s", containerArgs) + return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx) + case model.ActionRunsUsingDocker: + return execAsDocker(ctx, action, actionName, containerActionDir, actionLocation, rc, step, localAction) + case model.ActionRunsUsingComposite: + return execAsComposite(ctx, step, actionDir, rc, containerActionDir, actionName, actionPath, action, maybeCopyToActionDir) + default: + return fmt.Errorf(fmt.Sprintf("The runs.using key must be one of: %v, got %s", []string{ + model.ActionRunsUsingDocker, + model.ActionRunsUsingNode12, + model.ActionRunsUsingNode16, + model.ActionRunsUsingComposite, + }, action.Runs.Using)) + } + } +} + +// https://github.com/nektos/act/issues/228#issuecomment-629709055 +// files in .gitignore are not copied in a Docker container +// this causes issues with actions that ignore other important resources +// such as `node_modules` for example +func removeGitIgnore(directory string) error { + gitIgnorePath := path.Join(directory, ".gitignore") + if _, err := os.Stat(gitIgnorePath); err == nil { + // .gitignore exists + log.Debugf("Removing %s before docker cp", gitIgnorePath) + err := os.Remove(gitIgnorePath) + if err != nil { + return err + } + } + return nil +} + +// TODO: break out parts of function to reduce complexicity +// nolint:gocyclo +func execAsDocker(ctx context.Context, action *model.Action, actionName string, containerLocation string, actionLocation string, rc *RunContext, step step, localAction bool) error { + var prepImage common.Executor + var image string + if strings.HasPrefix(action.Runs.Image, "docker://") { + image = strings.TrimPrefix(action.Runs.Image, "docker://") + } else { + // "-dockeraction" enshures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names + image = fmt.Sprintf("%s-dockeraction:%s", regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(actionName, "-"), "latest") + image = fmt.Sprintf("act-%s", strings.TrimLeft(image, "-")) + image = strings.ToLower(image) + basedir := actionLocation + if localAction { + basedir = containerLocation + } + contextDir := filepath.Join(basedir, action.Runs.Main) + + anyArchExists, err := container.ImageExistsLocally(ctx, image, "any") + if err != nil { + return err + } + + correctArchExists, err := container.ImageExistsLocally(ctx, image, rc.Config.ContainerArchitecture) + if err != nil { + return err + } + + if anyArchExists && !correctArchExists { + wasRemoved, err := container.RemoveImage(ctx, image, true, true) + if err != nil { + return err + } + if !wasRemoved { + return fmt.Errorf("failed to remove image '%s'", image) + } + } + + if !correctArchExists || rc.Config.ForceRebuild { + log.Debugf("image '%s' for architecture '%s' will be built from context '%s", image, rc.Config.ContainerArchitecture, contextDir) + var actionContainer container.Container + if localAction { + actionContainer = step.getRunContext().JobContainer + } + prepImage = container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{ + ContextDir: contextDir, + ImageTag: image, + Container: actionContainer, + Platform: rc.Config.ContainerArchitecture, + }) + } else { + log.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture) + } + } + eval := step.getRunContext().NewStepExpressionEvaluator(step) + cmd, err := shellquote.Split(eval.Interpolate(step.getStepModel().With["args"])) + if err != nil { + return err + } + if len(cmd) == 0 { + cmd = action.Runs.Args + evalDockerArgs(step, action, &cmd) + } + entrypoint := strings.Fields(eval.Interpolate(step.getStepModel().With["entrypoint"])) + if len(entrypoint) == 0 { + if action.Runs.Entrypoint != "" { + entrypoint, err = shellquote.Split(action.Runs.Entrypoint) + if err != nil { + return err + } + } else { + entrypoint = nil + } + } + stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint) + return common.NewPipelineExecutor( + prepImage, + stepContainer.Pull(rc.Config.ForcePull), + stepContainer.Remove().IfBool(!rc.Config.ReuseContainers), + stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop), + stepContainer.Start(true), + ).Finally( + stepContainer.Remove().IfBool(!rc.Config.ReuseContainers), + ).Finally(stepContainer.Close())(ctx) +} + +func evalDockerArgs(step step, action *model.Action, cmd *[]string) { + rc := step.getRunContext() + stepModel := step.getStepModel() + oldInputs := rc.Inputs + defer func() { + rc.Inputs = oldInputs + }() + inputs := make(map[string]interface{}) + eval := rc.NewExpressionEvaluator() + // Set Defaults + for k, input := range action.Inputs { + inputs[k] = eval.Interpolate(input.Default) + } + if stepModel.With != nil { + for k, v := range stepModel.With { + inputs[k] = eval.Interpolate(v) + } + } + rc.Inputs = inputs + stepEE := rc.NewStepExpressionEvaluator(step) + for i, v := range *cmd { + (*cmd)[i] = stepEE.Interpolate(v) + } + mergeIntoMap(step.getEnv(), action.Runs.Env) + + ee := rc.NewStepExpressionEvaluator(step) + for k, v := range *step.getEnv() { + (*step.getEnv())[k] = ee.Interpolate(v) + } +} + +func newStepContainer(ctx context.Context, step step, image string, cmd []string, entrypoint []string) container.Container { + rc := step.getRunContext() + stepModel := step.getStepModel() + rawLogger := common.Logger(ctx).WithField("raw_output", true) + logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool { + if rc.Config.LogOutput { + rawLogger.Infof("%s", s) + } else { + rawLogger.Debugf("%s", s) + } + return true + }) + envList := make([]string, 0) + for k, v := range *step.getEnv() { + envList = append(envList, fmt.Sprintf("%s=%s", k, v)) + } + + envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache")) + envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux")) + envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp")) + + binds, mounts := rc.GetBindsAndMounts() + + stepContainer := container.NewContainer(&container.NewContainerInput{ + Cmd: cmd, + Entrypoint: entrypoint, + WorkingDir: rc.Config.ContainerWorkdir(), + Image: image, + Username: rc.Config.Secrets["DOCKER_USERNAME"], + Password: rc.Config.Secrets["DOCKER_PASSWORD"], + Name: createContainerName(rc.jobContainerName(), stepModel.ID), + Env: envList, + Mounts: mounts, + NetworkMode: fmt.Sprintf("container:%s", rc.jobContainerName()), + Binds: binds, + Stdout: logWriter, + Stderr: logWriter, + Privileged: rc.Config.Privileged, + UsernsMode: rc.Config.UsernsMode, + Platform: rc.Config.ContainerArchitecture, + }) + return stepContainer +} + +func execAsComposite(ctx context.Context, step step, _ string, rc *RunContext, containerActionDir string, actionName string, _ string, action *model.Action, maybeCopyToActionDir func() error) error { + err := maybeCopyToActionDir() + + if err != nil { + return err + } + // Disable some features of composite actions, only for feature parity with github + for _, compositeStep := range action.Runs.Steps { + if err := compositeStep.Validate(rc.Config.CompositeRestrictions); err != nil { + return err + } + } + inputs := make(map[string]interface{}) + eval := step.getRunContext().NewExpressionEvaluator() + // Set Defaults + for k, input := range action.Inputs { + inputs[k] = eval.Interpolate(input.Default) + } + if step.getStepModel().With != nil { + for k, v := range step.getStepModel().With { + inputs[k] = eval.Interpolate(v) + } + } + // Doesn't work with the command processor has a pointer to the original rc + // compositerc := rc.Clone() + // Workaround start + backup := *rc + defer func() { *rc = backup }() + *rc = *rc.Clone() + scriptName := backup.CurrentStep + for rcs := &backup; rcs.Parent != nil; rcs = rcs.Parent { + scriptName = fmt.Sprintf("%s-composite-%s", rcs.Parent.CurrentStep, scriptName) + } + compositerc := rc + compositerc.Parent = &RunContext{ + CurrentStep: scriptName, + } + // Workaround end + compositerc.Composite = action + envToEvaluate := mergeMaps(compositerc.Env, step.getStepModel().Environment()) + compositerc.Env = make(map[string]string) + // origEnvMap: is used to pass env changes back to parent runcontext + origEnvMap := make(map[string]string) + for k, v := range envToEvaluate { + ev := eval.Interpolate(v) + origEnvMap[k] = ev + compositerc.Env[k] = ev + } + compositerc.Inputs = inputs + compositerc.ExprEval = compositerc.NewExpressionEvaluator() + err = compositerc.CompositeExecutor()(ctx) + + // Map outputs to parent rc + eval = compositerc.NewStepExpressionEvaluator(step) + for outputName, output := range action.Outputs { + backup.setOutput(ctx, map[string]string{ + "name": outputName, + }, eval.Interpolate(output.Value)) + } + + backup.Masks = append(backup.Masks, compositerc.Masks...) + // Test if evaluated parent env was altered by this composite step + // Known Issues: + // - you try to set an env variable to the same value as a scoped step env, will be discared + for k, v := range compositerc.Env { + if ov, ok := origEnvMap[k]; !ok || ov != v { + backup.Env[k] = v + } + } + return err +} + +func populateEnvsFromInput(env *map[string]string, action *model.Action, rc *RunContext) { + for inputID, input := range action.Inputs { + envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_") + envKey = fmt.Sprintf("INPUT_%s", envKey) + if _, ok := (*env)[envKey]; !ok { + (*env)[envKey] = rc.ExprEval.Interpolate(input.Default) + } + } +} + +func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) { + actionName := "" + containerActionDir := "." + if step.Type() != model.StepTypeUsesActionRemote { + actionName = getOsSafeRelativePath(actionDir, rc.Config.Workdir) + containerActionDir = rc.Config.ContainerWorkdir() + "/" + actionName + actionName = "./" + actionName + } else if step.Type() == model.StepTypeUsesActionRemote { + actionName = getOsSafeRelativePath(actionDir, rc.ActionCacheDir()) + containerActionDir = ActPath + "/actions/" + actionName + } + + if actionName == "" { + actionName = filepath.Base(actionDir) + if runtime.GOOS == "windows" { + actionName = strings.ReplaceAll(actionName, "\\", "/") + } + } + return actionName, containerActionDir +} + +func getOsSafeRelativePath(s, prefix string) string { + actionName := strings.TrimPrefix(s, prefix) + if runtime.GOOS == "windows" { + actionName = strings.ReplaceAll(actionName, "\\", "/") + } + actionName = strings.TrimPrefix(actionName, "/") + + return actionName +} diff --git a/pkg/runner/action_test.go b/pkg/runner/action_test.go index 1edb80f..2a47dc0 100644 --- a/pkg/runner/action_test.go +++ b/pkg/runner/action_test.go @@ -1,6 +1,7 @@ package runner import ( + "context" "io" "io/fs" "strings" @@ -121,13 +122,92 @@ runs: return nil } - closerMock.On("Close") + if tt.filename != "" { + closerMock.On("Close") + } - sc := &StepContext{} - action, err := sc.readAction(tt.step, "actionDir", "actionPath", readFile, writeFile) + action, err := readActionImpl(tt.step, "actionDir", "actionPath", readFile, writeFile) assert.Nil(t, err) assert.Equal(t, tt.expected, action) + + closerMock.AssertExpectations(t) + }) + } +} + +type exprEvalMock struct { + ExpressionEvaluator + mock.Mock +} + +func (e *exprEvalMock) Interpolate(expr string) string { + args := e.Called(expr) + return args.String(0) +} + +func TestActionRunner(t *testing.T) { + table := []struct { + name string + step actionStep + }{ + { + name: "Test", + step: &stepActionRemote{ + Step: &model.Step{ + Uses: "repo@ref", + }, + RunContext: &RunContext{ + ActionRepository: "repo", + ActionPath: "path", + ActionRef: "ref", + Config: &Config{}, + Run: &model.Run{ + JobID: "job", + Workflow: &model.Workflow{ + Jobs: map[string]*model.Job{ + "job": { + Name: "job", + }, + }, + }, + }, + }, + action: &model.Action{ + Inputs: map[string]model.Input{ + "key": { + Default: "default value", + }, + }, + Runs: model.ActionRuns{ + Using: "node16", + }, + }, + env: map[string]string{}, + }, + }, + } + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + cm := &containerMock{} + cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false).Return(func(ctx context.Context) error { return nil }) + cm.On("Exec", []string{"node", "/var/run/act/actions/dir/path"}, map[string]string{"INPUT_KEY": "default value"}, "", "").Return(func(ctx context.Context) error { return nil }) + tt.step.getRunContext().JobContainer = cm + + ee := &exprEvalMock{} + ee.On("Interpolate", "default value").Return("default value") + tt.step.getRunContext().ExprEval = ee + + _, localAction := tt.step.(*stepActionRemote) + + err := runActionImpl(tt.step, "dir", "path", "repo", "ref", localAction)(ctx) + + assert.Nil(t, err) + ee.AssertExpectations(t) + cm.AssertExpectations(t) }) } } diff --git a/pkg/runner/container_mock_test.go b/pkg/runner/container_mock_test.go new file mode 100644 index 0000000..a336d40 --- /dev/null +++ b/pkg/runner/container_mock_test.go @@ -0,0 +1,68 @@ +package runner + +import ( + "context" + + "github.com/nektos/act/pkg/common" + "github.com/nektos/act/pkg/container" + "github.com/stretchr/testify/mock" +) + +type containerMock struct { + mock.Mock + container.Container +} + +func (cm *containerMock) Create(capAdd []string, capDrop []string) common.Executor { + args := cm.Called(capAdd, capDrop) + return args.Get(0).(func(context.Context) error) +} + +func (cm *containerMock) Pull(forcePull bool) common.Executor { + args := cm.Called(forcePull) + return args.Get(0).(func(context.Context) error) +} + +func (cm *containerMock) Start(attach bool) common.Executor { + args := cm.Called(attach) + return args.Get(0).(func(context.Context) error) +} + +func (cm *containerMock) Remove() common.Executor { + args := cm.Called() + return args.Get(0).(func(context.Context) error) +} + +func (cm *containerMock) Close() common.Executor { + args := cm.Called() + return args.Get(0).(func(context.Context) error) +} + +func (cm *containerMock) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { + args := cm.Called(srcPath, env) + return args.Get(0).(func(context.Context) error) +} + +func (cm *containerMock) UpdateFromImageEnv(env *map[string]string) common.Executor { + args := cm.Called(env) + return args.Get(0).(func(context.Context) error) +} + +func (cm *containerMock) UpdateFromPath(env *map[string]string) common.Executor { + args := cm.Called(env) + return args.Get(0).(func(context.Context) error) +} + +func (cm *containerMock) Copy(destPath string, files ...*container.FileEntry) common.Executor { + args := cm.Called(destPath, files) + return args.Get(0).(func(context.Context) error) +} + +func (cm *containerMock) CopyDir(destPath string, srcPath string, useGitIgnore bool) common.Executor { + args := cm.Called(destPath, srcPath, useGitIgnore) + return args.Get(0).(func(context.Context) error) +} +func (cm *containerMock) Exec(command []string, env map[string]string, user, workdir string) common.Executor { + args := cm.Called(command, env, user, workdir) + return args.Get(0).(func(context.Context) error) +} diff --git a/pkg/runner/expression.go b/pkg/runner/expression.go index 5be4f9e..9dbb16f 100644 --- a/pkg/runner/expression.go +++ b/pkg/runner/expression.go @@ -70,8 +70,7 @@ func (rc *RunContext) NewExpressionEvaluator() ExpressionEvaluator { } // NewExpressionEvaluator creates a new evaluator -func (sc *StepContext) NewExpressionEvaluator() ExpressionEvaluator { - rc := sc.RunContext +func (rc *RunContext) NewStepExpressionEvaluator(step step) ExpressionEvaluator { // todo: cleanup EvaluationEnvironment creation job := rc.Run.Job() strategy := make(map[string]interface{}) @@ -97,7 +96,7 @@ func (sc *StepContext) NewExpressionEvaluator() ExpressionEvaluator { ee := &exprparser.EvaluationEnvironment{ Github: rc.getGithubContext(), - Env: rc.GetEnv(), + Env: *step.getEnv(), Job: rc.getJobContext(), Steps: rc.getStepsContext(), Runner: map[string]interface{}{ diff --git a/pkg/runner/expression_test.go b/pkg/runner/expression_test.go index 4846bd2..9cbf3b6 100644 --- a/pkg/runner/expression_test.go +++ b/pkg/runner/expression_test.go @@ -147,13 +147,13 @@ func TestEvaluateRunContext(t *testing.T) { } } -func TestEvaluateStepContext(t *testing.T) { +func TestEvaluateStep(t *testing.T) { rc := createRunContext(t) - - sc := &StepContext{ + step := &stepRun{ RunContext: rc, } - ee := sc.NewExpressionEvaluator() + + ee := rc.NewStepExpressionEvaluator(step) tables := []struct { in string diff --git a/pkg/runner/job_executor.go b/pkg/runner/job_executor.go index 79d9399..7c9da4d 100644 --- a/pkg/runner/job_executor.go +++ b/pkg/runner/job_executor.go @@ -14,13 +14,14 @@ type jobInfo interface { startContainer() common.Executor stopContainer() common.Executor closeContainer() common.Executor - newStepExecutor(step *model.Step) common.Executor interpolateOutputs() common.Executor result(result string) } -func newJobExecutor(info jobInfo) common.Executor { +func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor { steps := make([]common.Executor, 0) + preSteps := make([]common.Executor, 0) + postSteps := make([]common.Executor, 0) steps = append(steps, func(ctx context.Context) error { if len(info.matrix()) > 0 { @@ -29,15 +30,30 @@ func newJobExecutor(info jobInfo) common.Executor { return nil }) - steps = append(steps, info.startContainer()) + infoSteps := info.steps() - for i, step := range info.steps() { - if step.ID == "" { - step.ID = fmt.Sprintf("%d", i) + if len(infoSteps) == 0 { + return common.NewDebugExecutor("No steps found") + } + + preSteps = append(preSteps, info.startContainer()) + + for i, stepModel := range infoSteps { + if stepModel.ID == "" { + stepModel.ID = fmt.Sprintf("%d", i) } - stepExec := info.newStepExecutor(step) + + step, err := sf.newStep(stepModel, rc) + + if err != nil { + return common.NewErrorExecutor(err) + } + + preSteps = append(preSteps, step.pre()) + + stepExec := step.main() steps = append(steps, func(ctx context.Context) error { - stepName := step.String() + stepName := stepModel.String() return (func(ctx context.Context) error { err := stepExec(ctx) if err != nil { @@ -50,9 +66,11 @@ func newJobExecutor(info jobInfo) common.Executor { return nil })(withStepLogger(ctx, stepName)) }) + + postSteps = append([]common.Executor{step.post()}, postSteps...) } - steps = append(steps, func(ctx context.Context) error { + postSteps = append(postSteps, func(ctx context.Context) error { jobError := common.JobError(ctx) if jobError != nil { info.result("failure") @@ -67,5 +85,10 @@ func newJobExecutor(info jobInfo) common.Executor { return nil }) - return common.NewPipelineExecutor(steps...).Finally(info.interpolateOutputs()).Finally(info.closeContainer()) + pipeline := make([]common.Executor, 0) + pipeline = append(pipeline, preSteps...) + pipeline = append(pipeline, steps...) + pipeline = append(pipeline, postSteps...) + + return common.NewPipelineExecutor(pipeline...).Finally(info.interpolateOutputs()).Finally(info.closeContainer()) } diff --git a/pkg/runner/job_executor_test.go b/pkg/runner/job_executor_test.go index 8417308..abc34c9 100644 --- a/pkg/runner/job_executor_test.go +++ b/pkg/runner/job_executor_test.go @@ -11,80 +11,105 @@ import ( "github.com/stretchr/testify/mock" ) +func TestJobExecutor(t *testing.T) { + platforms := map[string]string{ + "ubuntu-latest": baseImage, + } + tables := []TestJobFileInfo{ + {"testdata", "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, ""}, + {"testdata", "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms, ""}, + {"testdata", "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, ""}, + {"testdata", "uses-github-root", "push", "", platforms, ""}, + {"testdata", "uses-github-path", "push", "", platforms, ""}, + {"testdata", "uses-docker-url", "push", "", platforms, ""}, + {"testdata", "uses-github-full-sha", "push", "", platforms, ""}, + {"testdata", "uses-github-short-sha", "push", "Unable to resolve action `actions/hello-world-docker-action@b136eb8`, the provided ref `b136eb8` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `b136eb8894c5cb1dd5807da824be97ccdf9b5423` instead", platforms, ""}, + } + // These tests are sufficient to only check syntax. + ctx := common.WithDryrun(context.Background(), true) + for _, table := range tables { + runTestJobFile(ctx, t, table) + } +} + type jobInfoMock struct { mock.Mock } -func (jpm *jobInfoMock) matrix() map[string]interface{} { - args := jpm.Called() +func (jim *jobInfoMock) matrix() map[string]interface{} { + args := jim.Called() return args.Get(0).(map[string]interface{}) } -func (jpm *jobInfoMock) steps() []*model.Step { - args := jpm.Called() +func (jim *jobInfoMock) steps() []*model.Step { + args := jim.Called() return args.Get(0).([]*model.Step) } -func (jpm *jobInfoMock) startContainer() common.Executor { - args := jpm.Called() +func (jim *jobInfoMock) startContainer() common.Executor { + args := jim.Called() return args.Get(0).(func(context.Context) error) } -func (jpm *jobInfoMock) stopContainer() common.Executor { - args := jpm.Called() +func (jim *jobInfoMock) stopContainer() common.Executor { + args := jim.Called() return args.Get(0).(func(context.Context) error) } -func (jpm *jobInfoMock) closeContainer() common.Executor { - args := jpm.Called() +func (jim *jobInfoMock) closeContainer() common.Executor { + args := jim.Called() return args.Get(0).(func(context.Context) error) } -func (jpm *jobInfoMock) newStepExecutor(step *model.Step) common.Executor { - args := jpm.Called(step) +func (jim *jobInfoMock) interpolateOutputs() common.Executor { + args := jim.Called() return args.Get(0).(func(context.Context) error) } -func (jpm *jobInfoMock) interpolateOutputs() common.Executor { - args := jpm.Called() - - return args.Get(0).(func(context.Context) error) +func (jim *jobInfoMock) result(result string) { + jim.Called(result) } -func (jpm *jobInfoMock) result(result string) { - jpm.Called(result) +type stepFactoryMock struct { + mock.Mock +} + +func (sfm *stepFactoryMock) newStep(model *model.Step, rc *RunContext) (step, error) { + args := sfm.Called(model, rc) + return args.Get(0).(step), args.Error(1) } func TestNewJobExecutor(t *testing.T) { table := []struct { name string steps []*model.Step + preSteps []bool + postSteps []bool executedSteps []string result string hasError bool }{ { - name: "zeroSteps", - steps: []*model.Step{}, - executedSteps: []string{ - "startContainer", - "stopContainer", - "interpolateOutputs", - "closeContainer", - }, - result: "success", - hasError: false, + name: "zeroSteps", + steps: []*model.Step{}, + preSteps: []bool{}, + postSteps: []bool{}, + executedSteps: []string{}, + result: "success", + hasError: false, }, { name: "stepWithoutPrePost", steps: []*model.Step{{ ID: "1", }}, + preSteps: []bool{false}, + postSteps: []bool{false}, executedSteps: []string{ "startContainer", "step1", @@ -100,6 +125,8 @@ func TestNewJobExecutor(t *testing.T) { steps: []*model.Step{{ ID: "1", }}, + preSteps: []bool{false}, + postSteps: []bool{false}, executedSteps: []string{ "startContainer", "step1", @@ -110,16 +137,80 @@ func TestNewJobExecutor(t *testing.T) { hasError: true, }, { - name: "multipleSteps", + name: "stepWithPre", + steps: []*model.Step{{ + ID: "1", + }}, + preSteps: []bool{true}, + postSteps: []bool{false}, + executedSteps: []string{ + "startContainer", + "pre1", + "step1", + "stopContainer", + "interpolateOutputs", + "closeContainer", + }, + result: "success", + hasError: false, + }, + { + name: "stepWithPost", + steps: []*model.Step{{ + ID: "1", + }}, + preSteps: []bool{false}, + postSteps: []bool{true}, + executedSteps: []string{ + "startContainer", + "step1", + "post1", + "stopContainer", + "interpolateOutputs", + "closeContainer", + }, + result: "success", + hasError: false, + }, + { + name: "stepWithPreAndPost", + steps: []*model.Step{{ + ID: "1", + }}, + preSteps: []bool{true}, + postSteps: []bool{true}, + executedSteps: []string{ + "startContainer", + "pre1", + "step1", + "post1", + "stopContainer", + "interpolateOutputs", + "closeContainer", + }, + result: "success", + hasError: false, + }, + { + name: "stepsWithPreAndPost", steps: []*model.Step{{ ID: "1", }, { ID: "2", + }, { + ID: "3", }}, + preSteps: []bool{true, false, true}, + postSteps: []bool{false, true, true}, executedSteps: []string{ "startContainer", + "pre1", + "pre3", "step1", "step2", + "step3", + "post3", + "post2", "stopContainer", "interpolateOutputs", "closeContainer", @@ -129,54 +220,95 @@ func TestNewJobExecutor(t *testing.T) { }, } + contains := func(needle string, haystack []string) bool { + for _, item := range haystack { + if item == needle { + return true + } + } + return false + } + for _, tt := range table { t.Run(tt.name, func(t *testing.T) { ctx := common.WithJobErrorContainer(context.Background()) - jpm := &jobInfoMock{} + jim := &jobInfoMock{} + sfm := &stepFactoryMock{} + rc := &RunContext{} executorOrder := make([]string, 0) - jpm.On("startContainer").Return(func(ctx context.Context) error { - executorOrder = append(executorOrder, "startContainer") - return nil - }) + jim.On("steps").Return(tt.steps) - jpm.On("steps").Return(tt.steps) - - for _, stepMock := range tt.steps { - func(stepMock *model.Step) { - jpm.On("newStepExecutor", stepMock).Return(func(ctx context.Context) error { - executorOrder = append(executorOrder, "step"+stepMock.ID) - if tt.hasError { - return fmt.Errorf("error") - } - return nil - }) - }(stepMock) + if len(tt.steps) > 0 { + jim.On("startContainer").Return(func(ctx context.Context) error { + executorOrder = append(executorOrder, "startContainer") + return nil + }) } - jpm.On("interpolateOutputs").Return(func(ctx context.Context) error { - executorOrder = append(executorOrder, "interpolateOutputs") - return nil - }) + for i, stepModel := range tt.steps { + i := i + stepModel := stepModel - jpm.On("matrix").Return(map[string]interface{}{}) + sm := &stepMock{} - jpm.On("stopContainer").Return(func(ctx context.Context) error { - executorOrder = append(executorOrder, "stopContainer") - return nil - }) + sfm.On("newStep", stepModel, rc).Return(sm, nil) - jpm.On("result", tt.result) + sm.On("pre").Return(func(ctx context.Context) error { + if tt.preSteps[i] { + executorOrder = append(executorOrder, "pre"+stepModel.ID) + } + return nil + }) - jpm.On("closeContainer").Return(func(ctx context.Context) error { - executorOrder = append(executorOrder, "closeContainer") - return nil - }) + sm.On("main").Return(func(ctx context.Context) error { + executorOrder = append(executorOrder, "step"+stepModel.ID) + if tt.hasError { + return fmt.Errorf("error") + } + return nil + }) - executor := newJobExecutor(jpm) + sm.On("post").Return(func(ctx context.Context) error { + if tt.postSteps[i] { + executorOrder = append(executorOrder, "post"+stepModel.ID) + } + return nil + }) + + defer sm.AssertExpectations(t) + } + + if len(tt.steps) > 0 { + jim.On("matrix").Return(map[string]interface{}{}) + + jim.On("interpolateOutputs").Return(func(ctx context.Context) error { + executorOrder = append(executorOrder, "interpolateOutputs") + return nil + }) + + if contains("stopContainer", tt.executedSteps) { + jim.On("stopContainer").Return(func(ctx context.Context) error { + executorOrder = append(executorOrder, "stopContainer") + return nil + }) + } + + jim.On("result", tt.result) + + jim.On("closeContainer").Return(func(ctx context.Context) error { + executorOrder = append(executorOrder, "closeContainer") + return nil + }) + } + + executor := newJobExecutor(jim, sfm, rc) err := executor(ctx) assert.Nil(t, err) assert.Equal(t, tt.executedSteps, executorOrder) + + jim.AssertExpectations(t) + sfm.AssertExpectations(t) }) } } diff --git a/pkg/runner/run_context.go b/pkg/runner/run_context.go index 9bb94a5..133a40d 100644 --- a/pkg/runner/run_context.go +++ b/pkg/runner/run_context.go @@ -287,7 +287,7 @@ func (rc *RunContext) Executor() common.Executor { } if isEnabled { - return newJobExecutor(rc)(ctx) + return newJobExecutor(rc, &stepFactoryImpl{}, rc)(ctx) } return nil @@ -298,12 +298,23 @@ func (rc *RunContext) Executor() common.Executor { func (rc *RunContext) CompositeExecutor() common.Executor { steps := make([]common.Executor, 0) + sf := &stepFactoryImpl{} + for i, step := range rc.Composite.Runs.Steps { if step.ID == "" { step.ID = fmt.Sprintf("%d", i) } + + // create a copy of the step, since this composite action could + // run multiple times and we might modify the instance stepcopy := step - stepExec := rc.newStepExecutor(&stepcopy) + + step, err := sf.newStep(&stepcopy, rc) + if err != nil { + return common.NewErrorExecutor(err) + } + stepExec := common.NewPipelineExecutor(step.pre(), step.main(), step.post()) + steps = append(steps, func(ctx context.Context) error { err := stepExec(ctx) if err != nil { @@ -323,59 +334,6 @@ func (rc *RunContext) CompositeExecutor() common.Executor { } } -func (rc *RunContext) newStepExecutor(step *model.Step) common.Executor { - sc := &StepContext{ - RunContext: rc, - Step: step, - } - return func(ctx context.Context) error { - rc.CurrentStep = sc.Step.ID - rc.StepResults[rc.CurrentStep] = &model.StepResult{ - Outcome: model.StepStatusSuccess, - Conclusion: model.StepStatusSuccess, - Outputs: make(map[string]string), - } - - runStep, err := sc.isEnabled(ctx) - if err != nil { - rc.StepResults[rc.CurrentStep].Conclusion = model.StepStatusFailure - rc.StepResults[rc.CurrentStep].Outcome = model.StepStatusFailure - return err - } - - if !runStep { - log.Debugf("Skipping step '%s' due to '%s'", sc.Step.String(), sc.Step.If.Value) - rc.StepResults[rc.CurrentStep].Conclusion = model.StepStatusSkipped - rc.StepResults[rc.CurrentStep].Outcome = model.StepStatusSkipped - return nil - } - - exprEval, err := sc.setupEnv(ctx) - if err != nil { - return err - } - rc.ExprEval = exprEval - - common.Logger(ctx).Infof("\u2B50 Run %s", sc.Step) - err = sc.Executor(ctx)(ctx) - if err == nil { - common.Logger(ctx).Infof(" \u2705 Success - %s", sc.Step) - } else { - common.Logger(ctx).Errorf(" \u274C Failure - %s", sc.Step) - - rc.StepResults[rc.CurrentStep].Outcome = model.StepStatusFailure - if sc.Step.ContinueOnError { - common.Logger(ctx).Infof("Failed but continue next step") - err = nil - rc.StepResults[rc.CurrentStep].Conclusion = model.StepStatusSuccess - } else { - rc.StepResults[rc.CurrentStep].Conclusion = model.StepStatusFailure - } - } - return err - } -} - func (rc *RunContext) platformImage() string { job := rc.Run.Job() diff --git a/pkg/runner/step.go b/pkg/runner/step.go new file mode 100644 index 0000000..0f63558 --- /dev/null +++ b/pkg/runner/step.go @@ -0,0 +1,145 @@ +package runner + +import ( + "context" + "fmt" + "strings" + + "github.com/nektos/act/pkg/common" + "github.com/nektos/act/pkg/model" + log "github.com/sirupsen/logrus" +) + +type step interface { + pre() common.Executor + main() common.Executor + post() common.Executor + + getRunContext() *RunContext + getStepModel() *model.Step + getEnv() *map[string]string +} + +func runStepExecutor(step step, executor common.Executor) common.Executor { + return func(ctx context.Context) error { + rc := step.getRunContext() + stepModel := step.getStepModel() + + rc.CurrentStep = stepModel.ID + rc.StepResults[rc.CurrentStep] = &model.StepResult{ + Outcome: model.StepStatusSuccess, + Conclusion: model.StepStatusSuccess, + Outputs: make(map[string]string), + } + + err := setupEnv(ctx, step) + if err != nil { + return err + } + + runStep, err := isStepEnabled(ctx, step) + if err != nil { + rc.StepResults[rc.CurrentStep].Conclusion = model.StepStatusFailure + rc.StepResults[rc.CurrentStep].Outcome = model.StepStatusFailure + return err + } + + if !runStep { + log.Debugf("Skipping step '%s' due to '%s'", stepModel.String(), stepModel.If.Value) + rc.StepResults[rc.CurrentStep].Conclusion = model.StepStatusSkipped + rc.StepResults[rc.CurrentStep].Outcome = model.StepStatusSkipped + return nil + } + + common.Logger(ctx).Infof("\u2B50 Run %s", stepModel) + + err = executor(ctx) + + if err == nil { + common.Logger(ctx).Infof(" \u2705 Success - %s", stepModel) + } else { + common.Logger(ctx).Errorf(" \u274C Failure - %s", stepModel) + + rc.StepResults[rc.CurrentStep].Outcome = model.StepStatusFailure + if stepModel.ContinueOnError { + common.Logger(ctx).Infof("Failed but continue next step") + err = nil + rc.StepResults[rc.CurrentStep].Conclusion = model.StepStatusSuccess + } else { + rc.StepResults[rc.CurrentStep].Conclusion = model.StepStatusFailure + } + } + return err + } +} + +func setupEnv(ctx context.Context, step step) error { + rc := step.getRunContext() + + mergeEnv(step) + err := rc.JobContainer.UpdateFromImageEnv(step.getEnv())(ctx) + if err != nil { + return err + } + err = rc.JobContainer.UpdateFromEnv((*step.getEnv())["GITHUB_ENV"], step.getEnv())(ctx) + if err != nil { + return err + } + err = rc.JobContainer.UpdateFromPath(step.getEnv())(ctx) + if err != nil { + return err + } + mergeIntoMap(step.getEnv(), step.getStepModel().GetEnv()) // step env should not be overwritten + + exprEval := rc.NewStepExpressionEvaluator(step) + for k, v := range *step.getEnv() { + (*step.getEnv())[k] = exprEval.Interpolate(v) + } + + common.Logger(ctx).Debugf("setupEnv => %v", *step.getEnv()) + + return nil +} + +func mergeEnv(step step) { + env := step.getEnv() + rc := step.getRunContext() + job := rc.Run.Job() + + c := job.Container() + if c != nil { + mergeIntoMap(env, rc.GetEnv(), c.Env) + } else { + mergeIntoMap(env, rc.GetEnv()) + } + + if (*env)["PATH"] == "" { + (*env)["PATH"] = `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` + } + if rc.ExtraPath != nil && len(rc.ExtraPath) > 0 { + p := (*env)["PATH"] + (*env)["PATH"] = strings.Join(rc.ExtraPath, `:`) + (*env)["PATH"] += `:` + p + } + + mergeIntoMap(env, rc.withGithubEnv(*env)) +} + +func isStepEnabled(ctx context.Context, step step) (bool, error) { + rc := step.getRunContext() + + runStep, err := EvalBool(rc.NewStepExpressionEvaluator(step), step.getStepModel().If.Value) + if err != nil { + return false, fmt.Errorf(" \u274C Error in if-expression: \"if: %s\" (%s)", step.getStepModel().If.Value, err) + } + + return runStep, nil +} + +func mergeIntoMap(target *map[string]string, maps ...map[string]string) { + for _, m := range maps { + for k, v := range m { + (*target)[k] = v + } + } +} diff --git a/pkg/runner/step_action_local.go b/pkg/runner/step_action_local.go new file mode 100644 index 0000000..9eb6fa4 --- /dev/null +++ b/pkg/runner/step_action_local.go @@ -0,0 +1,85 @@ +package runner + +import ( + "archive/tar" + "context" + "io" + "io/ioutil" + "os" + "path" + "path/filepath" + + "github.com/nektos/act/pkg/common" + "github.com/nektos/act/pkg/model" + log "github.com/sirupsen/logrus" +) + +type stepActionLocal struct { + Step *model.Step + RunContext *RunContext + runAction runAction + readAction readAction + env map[string]string + action *model.Action +} + +func (sal *stepActionLocal) pre() common.Executor { + return func(ctx context.Context) error { + return nil + } +} + +func (sal *stepActionLocal) main() common.Executor { + sal.env = map[string]string{} + + return runStepExecutor(sal, func(ctx context.Context) error { + actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses) + + localReader := func(ctx context.Context) actionYamlReader { + _, cpath := getContainerActionPaths(sal.Step, path.Join(actionDir, ""), sal.RunContext) + return func(filename string) (io.Reader, io.Closer, error) { + tars, err := sal.RunContext.JobContainer.GetContainerArchive(ctx, path.Join(cpath, filename)) + if err != nil { + return nil, nil, os.ErrNotExist + } + treader := tar.NewReader(tars) + if _, err := treader.Next(); err != nil { + return nil, nil, os.ErrNotExist + } + return treader, tars, nil + } + } + + actionModel, err := sal.readAction(sal.Step, actionDir, "", localReader(ctx), ioutil.WriteFile) + if err != nil { + return err + } + + sal.action = actionModel + log.Debugf("Read action %v from '%s'", sal.action, "Unknown") + + return sal.runAction(sal, actionDir, "", "", "", true)(ctx) + }) +} + +func (sal *stepActionLocal) post() common.Executor { + return func(ctx context.Context) error { + return nil + } +} + +func (sal *stepActionLocal) getRunContext() *RunContext { + return sal.RunContext +} + +func (sal *stepActionLocal) getStepModel() *model.Step { + return sal.Step +} + +func (sal *stepActionLocal) getEnv() *map[string]string { + return &sal.env +} + +func (sal *stepActionLocal) getActionModel() *model.Action { + return sal.action +} diff --git a/pkg/runner/step_action_local_test.go b/pkg/runner/step_action_local_test.go new file mode 100644 index 0000000..235fae3 --- /dev/null +++ b/pkg/runner/step_action_local_test.go @@ -0,0 +1,101 @@ +package runner + +import ( + "context" + "testing" + + "github.com/nektos/act/pkg/common" + "github.com/nektos/act/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type stepActionLocalMocks struct { + mock.Mock +} + +func (salm *stepActionLocalMocks) runAction(step actionStep, actionDir string, actionPath string, actionRepository string, actionRef string, localAction bool) common.Executor { + args := salm.Called(step, actionDir, actionPath, actionRepository, actionRef, localAction) + return args.Get(0).(func(context.Context) error) +} + +func (salm *stepActionLocalMocks) readAction(step *model.Step, actionDir string, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) { + args := salm.Called(step, actionDir, actionPath, readFile, writeFile) + return args.Get(0).(*model.Action), args.Error(1) +} + +func TestStepActionLocalTest(t *testing.T) { + ctx := context.Background() + + cm := &containerMock{} + salm := &stepActionLocalMocks{} + + sal := &stepActionLocal{ + readAction: salm.readAction, + runAction: salm.runAction, + RunContext: &RunContext{ + StepResults: map[string]*model.StepResult{}, + ExprEval: &expressionEvaluator{}, + Config: &Config{ + Workdir: "/tmp", + }, + Run: &model.Run{ + JobID: "1", + Workflow: &model.Workflow{ + Jobs: map[string]*model.Job{ + "1": { + Defaults: model.Defaults{ + Run: model.RunDefaults{ + Shell: "bash", + }, + }, + }, + }, + }, + }, + JobContainer: cm, + }, + Step: &model.Step{ + ID: "1", + Uses: "./path/to/action", + }, + } + + salm.On("readAction", sal.Step, "/tmp/path/to/action", "", mock.Anything, mock.Anything). + Return(&model.Action{}, nil) + + cm.On("UpdateFromImageEnv", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error { + return nil + }) + + cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error { + return nil + }) + + cm.On("UpdateFromPath", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error { + return nil + }) + + salm.On("runAction", sal, "/tmp/path/to/action", "", "", "", true).Return(func(ctx context.Context) error { + return nil + }) + + err := sal.main()(ctx) + + assert.Nil(t, err) + + cm.AssertExpectations(t) + salm.AssertExpectations(t) +} + +func TestStepActionLocalPrePost(t *testing.T) { + ctx := context.Background() + + sal := &stepActionLocal{} + + err := sal.pre()(ctx) + assert.Nil(t, err) + + err = sal.post()(ctx) + assert.Nil(t, err) +} diff --git a/pkg/runner/step_action_remote.go b/pkg/runner/step_action_remote.go new file mode 100644 index 0000000..03895b8 --- /dev/null +++ b/pkg/runner/step_action_remote.go @@ -0,0 +1,153 @@ +package runner + +import ( + "context" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/nektos/act/pkg/common" + "github.com/nektos/act/pkg/model" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +type stepActionRemote struct { + Step *model.Step + RunContext *RunContext + readAction readAction + runAction runAction + action *model.Action + env map[string]string +} + +func (sar *stepActionRemote) pre() common.Executor { + return func(ctx context.Context) error { + return nil + } +} + +var ( + stepActionRemoteNewCloneExecutor = common.NewGitCloneExecutor +) + +func (sar *stepActionRemote) main() common.Executor { + sar.env = map[string]string{} + + return runStepExecutor(sar, func(ctx context.Context) error { + remoteAction := newRemoteAction(sar.Step.Uses) + if remoteAction == nil { + return fmt.Errorf("Expected format {org}/{repo}[/path]@ref. Actual '%s' Input string was not in a correct format", sar.Step.Uses) + } + + remoteAction.URL = sar.RunContext.Config.GitHubInstance + + github := sar.RunContext.getGithubContext() + if remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout { + common.Logger(ctx).Debugf("Skipping local actions/checkout because workdir was already copied") + return nil + } + + actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), strings.ReplaceAll(sar.Step.Uses, "/", "-")) + gitClone := stepActionRemoteNewCloneExecutor(common.NewGitCloneExecutorInput{ + URL: remoteAction.CloneURL(), + Ref: remoteAction.Ref, + Dir: actionDir, + Token: github.Token, + }) + var ntErr common.Executor + if err := gitClone(ctx); err != nil { + if err.Error() == "short SHA references are not supported" { + err = errors.Cause(err) + return fmt.Errorf("Unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead", sar.Step.Uses, remoteAction.Ref, err.Error()) + } else if err.Error() != "some refs were not updated" { + return err + } else { + ntErr = common.NewInfoExecutor("Non-terminating error while running 'git clone': %v", err) + } + } + + remoteReader := func(ctx context.Context) actionYamlReader { + return func(filename string) (io.Reader, io.Closer, error) { + f, err := os.Open(filepath.Join(actionDir, remoteAction.Path, filename)) + return f, f, err + } + } + + return common.NewPipelineExecutor( + ntErr, + func(ctx context.Context) error { + actionModel, err := sar.readAction(sar.Step, actionDir, remoteAction.Path, remoteReader(ctx), ioutil.WriteFile) + sar.action = actionModel + log.Debugf("Read action %v from '%s'", sar.action, "Unknown") + return err + }, + sar.runAction(sar, actionDir, remoteAction.Path, remoteAction.Repo, remoteAction.Ref, false), + )(ctx) + }) +} + +func (sar *stepActionRemote) post() common.Executor { + return func(ctx context.Context) error { + return nil + } +} + +func (sar *stepActionRemote) getRunContext() *RunContext { + return sar.RunContext +} + +func (sar *stepActionRemote) getStepModel() *model.Step { + return sar.Step +} + +func (sar *stepActionRemote) getEnv() *map[string]string { + return &sar.env +} + +func (sar *stepActionRemote) getActionModel() *model.Action { + return sar.action +} + +type remoteAction struct { + URL string + Org string + Repo string + Path string + Ref string +} + +func (ra *remoteAction) CloneURL() string { + return fmt.Sprintf("https://%s/%s/%s", ra.URL, ra.Org, ra.Repo) +} + +func (ra *remoteAction) IsCheckout() bool { + if ra.Org == "actions" && ra.Repo == "checkout" { + return true + } + return false +} + +func newRemoteAction(action string) *remoteAction { + // GitHub's document[^] describes: + // > We strongly recommend that you include the version of + // > the action you are using by specifying a Git ref, SHA, or Docker tag number. + // Actually, the workflow stops if there is the uses directive that hasn't @ref. + // [^]: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions + r := regexp.MustCompile(`^([^/@]+)/([^/@]+)(/([^@]*))?(@(.*))?$`) + matches := r.FindStringSubmatch(action) + if len(matches) < 7 || matches[6] == "" { + return nil + } + return &remoteAction{ + Org: matches[1], + Repo: matches[2], + Path: matches[4], + Ref: matches[6], + URL: "github.com", + } +} diff --git a/pkg/runner/step_action_remote_test.go b/pkg/runner/step_action_remote_test.go new file mode 100644 index 0000000..d41109f --- /dev/null +++ b/pkg/runner/step_action_remote_test.go @@ -0,0 +1,102 @@ +package runner + +import ( + "context" + "strings" + "testing" + + "github.com/nektos/act/pkg/common" + "github.com/nektos/act/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type stepActionRemoteMocks struct { + mock.Mock +} + +func (sarm *stepActionRemoteMocks) readAction(step *model.Step, actionDir string, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) { + args := sarm.Called(step, actionDir, actionPath, readFile, writeFile) + return args.Get(0).(*model.Action), args.Error(1) +} + +func (sarm *stepActionRemoteMocks) runAction(step actionStep, actionDir string, actionPath string, actionRepository string, actionRef string, localAction bool) common.Executor { + args := sarm.Called(step, actionDir, actionPath, actionRepository, actionRef, localAction) + return args.Get(0).(func(context.Context) error) +} + +func TestStepActionRemoteTest(t *testing.T) { + ctx := context.Background() + + cm := &containerMock{} + + sarm := &stepActionRemoteMocks{} + + clonedAction := false + + origStepAtionRemoteNewCloneExecutor := stepActionRemoteNewCloneExecutor + stepActionRemoteNewCloneExecutor = func(input common.NewGitCloneExecutorInput) common.Executor { + return func(ctx context.Context) error { + clonedAction = true + return nil + } + } + defer (func() { + stepActionRemoteNewCloneExecutor = origStepAtionRemoteNewCloneExecutor + })() + + sar := &stepActionRemote{ + RunContext: &RunContext{ + Config: &Config{ + GitHubInstance: "https://github.com", + }, + Run: &model.Run{ + JobID: "1", + Workflow: &model.Workflow{ + Jobs: map[string]*model.Job{ + "1": {}, + }, + }, + }, + StepResults: map[string]*model.StepResult{}, + JobContainer: cm, + }, + Step: &model.Step{ + Uses: "remote/action@v1", + }, + readAction: sarm.readAction, + runAction: sarm.runAction, + } + + suffixMatcher := func(suffix string) interface{} { + return mock.MatchedBy(func(actionDir string) bool { + return strings.HasSuffix(actionDir, suffix) + }) + } + + cm.On("UpdateFromImageEnv", &sar.env).Return(func(ctx context.Context) error { return nil }) + cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", &sar.env).Return(func(ctx context.Context) error { return nil }) + cm.On("UpdateFromPath", &sar.env).Return(func(ctx context.Context) error { return nil }) + + sarm.On("readAction", sar.Step, suffixMatcher("act/remote-action@v1"), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil) + sarm.On("runAction", sar, suffixMatcher("act/remote-action@v1"), "", "action", "v1", false).Return(func(ctx context.Context) error { return nil }) + + err := sar.main()(ctx) + + assert.Nil(t, err) + assert.True(t, clonedAction) + sarm.AssertExpectations(t) + cm.AssertExpectations(t) +} + +func TestStepActionRemotePrePost(t *testing.T) { + ctx := context.Background() + + sar := &stepActionRemote{} + + err := sar.pre()(ctx) + assert.Nil(t, err) + + err = sar.post()(ctx) + assert.Nil(t, err) +} diff --git a/pkg/runner/step_context.go b/pkg/runner/step_context.go deleted file mode 100644 index 9b7a1f8..0000000 --- a/pkg/runner/step_context.go +++ /dev/null @@ -1,751 +0,0 @@ -package runner - -import ( - "archive/tar" - "context" - "fmt" - "io" - "io/ioutil" - "os" - "path" - "path/filepath" - "regexp" - "runtime" - "strings" - - "github.com/kballard/go-shellquote" - "github.com/pkg/errors" - log "github.com/sirupsen/logrus" - - "github.com/nektos/act/pkg/common" - "github.com/nektos/act/pkg/container" - "github.com/nektos/act/pkg/model" -) - -// StepContext contains info about current job -type StepContext struct { - RunContext *RunContext - Step *model.Step - Env map[string]string - Cmd []string - Action *model.Action - Needs *model.Job -} - -func (sc *StepContext) execJobContainer() common.Executor { - return func(ctx context.Context) error { - return sc.RunContext.execJobContainer(sc.Cmd, sc.Env, "", sc.Step.WorkingDirectory)(ctx) - } -} - -type formatError string - -func (e formatError) Error() string { - return fmt.Sprintf("Expected format {org}/{repo}[/path]@ref. Actual '%s' Input string was not in a correct format.", string(e)) -} - -// Executor for a step context -func (sc *StepContext) Executor(ctx context.Context) common.Executor { - rc := sc.RunContext - step := sc.Step - - switch step.Type() { - case model.StepTypeRun: - return common.NewPipelineExecutor( - sc.setupShellCommandExecutor(), - sc.execJobContainer(), - ) - - case model.StepTypeUsesDockerURL: - return common.NewPipelineExecutor( - sc.runUsesContainer(), - ) - - case model.StepTypeUsesActionLocal: - actionDir := filepath.Join(rc.Config.Workdir, step.Uses) - - localReader := func(ctx context.Context) actionyamlReader { - _, cpath := sc.getContainerActionPaths(sc.Step, path.Join(actionDir, ""), sc.RunContext) - return func(filename string) (io.Reader, io.Closer, error) { - tars, err := sc.RunContext.JobContainer.GetContainerArchive(ctx, path.Join(cpath, filename)) - if err != nil { - return nil, nil, os.ErrNotExist - } - treader := tar.NewReader(tars) - if _, err := treader.Next(); err != nil { - return nil, nil, os.ErrNotExist - } - return treader, tars, nil - } - } - - return common.NewPipelineExecutor( - sc.setupAction(actionDir, "", localReader), - sc.runAction(actionDir, "", "", "", true), - ) - case model.StepTypeUsesActionRemote: - remoteAction := newRemoteAction(step.Uses) - if remoteAction == nil { - return common.NewErrorExecutor(formatError(step.Uses)) - } - - remoteAction.URL = rc.Config.GitHubInstance - - github := rc.getGithubContext() - if remoteAction.IsCheckout() && isLocalCheckout(github, step) && !rc.Config.NoSkipCheckout { - return func(ctx context.Context) error { - common.Logger(ctx).Debugf("Skipping local actions/checkout because workdir was already copied") - return nil - } - } - - actionDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), strings.ReplaceAll(step.Uses, "/", "-")) - gitClone := common.NewGitCloneExecutor(common.NewGitCloneExecutorInput{ - URL: remoteAction.CloneURL(), - Ref: remoteAction.Ref, - Dir: actionDir, - Token: github.Token, - }) - var ntErr common.Executor - if err := gitClone(ctx); err != nil { - if err.Error() == "short SHA references are not supported" { - err = errors.Cause(err) - return common.NewErrorExecutor(fmt.Errorf("Unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead", step.Uses, remoteAction.Ref, err.Error())) - } else if err.Error() != "some refs were not updated" { - return common.NewErrorExecutor(err) - } else { - ntErr = common.NewInfoExecutor("Non-terminating error while running 'git clone': %v", err) - } - } - - remoteReader := func(ctx context.Context) actionyamlReader { - return func(filename string) (io.Reader, io.Closer, error) { - f, err := os.Open(filepath.Join(actionDir, remoteAction.Path, filename)) - return f, f, err - } - } - - return common.NewPipelineExecutor( - ntErr, - sc.setupAction(actionDir, remoteAction.Path, remoteReader), - sc.runAction(actionDir, remoteAction.Path, remoteAction.Repo, remoteAction.Ref, false), - ) - case model.StepTypeInvalid: - return common.NewErrorExecutor(fmt.Errorf("Invalid run/uses syntax for job:%s step:%+v", rc.Run, step)) - } - - return common.NewErrorExecutor(fmt.Errorf("Unable to determine how to run job:%s step:%+v", rc.Run, step)) -} - -func (sc *StepContext) mergeEnv() map[string]string { - rc := sc.RunContext - job := rc.Run.Job() - - var env map[string]string - c := job.Container() - if c != nil { - env = mergeMaps(rc.GetEnv(), c.Env) - } else { - env = rc.GetEnv() - } - - if env["PATH"] == "" { - env["PATH"] = `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` - } - if rc.ExtraPath != nil && len(rc.ExtraPath) > 0 { - p := env["PATH"] - env["PATH"] = strings.Join(rc.ExtraPath, `:`) - env["PATH"] += `:` + p - } - - sc.Env = rc.withGithubEnv(env) - return env -} - -func (sc *StepContext) interpolateEnv(exprEval ExpressionEvaluator) { - for k, v := range sc.Env { - sc.Env[k] = exprEval.Interpolate(v) - } -} - -func (sc *StepContext) isEnabled(ctx context.Context) (bool, error) { - runStep, err := EvalBool(sc.NewExpressionEvaluator(), sc.Step.If.Value) - if err != nil { - return false, fmt.Errorf(" \u274C Error in if-expression: \"if: %s\" (%s)", sc.Step.If.Value, err) - } - - return runStep, nil -} - -func (sc *StepContext) setupEnv(ctx context.Context) (ExpressionEvaluator, error) { - rc := sc.RunContext - sc.Env = sc.mergeEnv() - if sc.Env != nil { - err := rc.JobContainer.UpdateFromImageEnv(&sc.Env)(ctx) - if err != nil { - return nil, err - } - err = rc.JobContainer.UpdateFromEnv(sc.Env["GITHUB_ENV"], &sc.Env)(ctx) - if err != nil { - return nil, err - } - err = rc.JobContainer.UpdateFromPath(&sc.Env)(ctx) - if err != nil { - return nil, err - } - } - sc.Env = mergeMaps(sc.Env, sc.Step.GetEnv()) // step env should not be overwritten - evaluator := sc.NewExpressionEvaluator() - sc.interpolateEnv(evaluator) - - common.Logger(ctx).Debugf("setupEnv => %v", sc.Env) - return evaluator, nil -} - -func (sc *StepContext) setupWorkingDirectory() { - rc := sc.RunContext - step := sc.Step - - if step.WorkingDirectory == "" { - step.WorkingDirectory = rc.Run.Job().Defaults.Run.WorkingDirectory - } - - // jobs can receive context values, so we interpolate - step.WorkingDirectory = rc.ExprEval.Interpolate(step.WorkingDirectory) - - // but top level keys in workflow file like `defaults` or `env` can't - if step.WorkingDirectory == "" { - step.WorkingDirectory = rc.Run.Workflow.Defaults.Run.WorkingDirectory - } -} - -func (sc *StepContext) setupShell() { - rc := sc.RunContext - step := sc.Step - - if step.Shell == "" { - step.Shell = rc.Run.Job().Defaults.Run.Shell - } - - step.Shell = rc.ExprEval.Interpolate(step.Shell) - - if step.Shell == "" { - step.Shell = rc.Run.Workflow.Defaults.Run.Shell - } - - // current GitHub Runner behaviour is that default is `sh`, - // but if it's not container it validates with `which` command - // if `bash` is available, and provides `bash` if it is - // for now I'm going to leave below logic, will address it in different PR - // https://github.com/actions/runner/blob/9a829995e02d2db64efb939dc2f283002595d4d9/src/Runner.Worker/Handlers/ScriptHandler.cs#L87-L91 - if rc.Run.Job().Container() != nil { - if rc.Run.Job().Container().Image != "" && step.Shell == "" { - step.Shell = "sh" - } - } -} - -func getScriptName(rc *RunContext, step *model.Step) string { - scriptName := step.ID - for rcs := rc; rcs.Parent != nil; rcs = rcs.Parent { - scriptName = fmt.Sprintf("%s-composite-%s", rcs.Parent.CurrentStep, scriptName) - } - return fmt.Sprintf("workflow/%s", scriptName) -} - -// TODO: Currently we just ignore top level keys, BUT we should return proper error on them -// BUTx2 I leave this for when we rewrite act to use actionlint for workflow validation -// so we return proper errors before any execution or spawning containers -// it will error anyway with: -// OCI runtime exec failed: exec failed: container_linux.go:380: starting container process caused: exec: "${{": executable file not found in $PATH: unknown -func (sc *StepContext) setupShellCommand() (name, script string, err error) { - sc.setupShell() - sc.setupWorkingDirectory() - - step := sc.Step - - script = sc.RunContext.ExprEval.Interpolate(step.Run) - - scCmd := step.ShellCommand() - - name = getScriptName(sc.RunContext, step) - - // Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L47-L64 - // Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L19-L27 - runPrepend := "" - runAppend := "" - switch step.Shell { - case "bash", "sh": - name += ".sh" - case "pwsh", "powershell": - name += ".ps1" - runPrepend = "$ErrorActionPreference = 'stop'" - runAppend = "if ((Test-Path -LiteralPath variable:/LASTEXITCODE)) { exit $LASTEXITCODE }" - case "cmd": - name += ".cmd" - runPrepend = "@echo off" - case "python": - name += ".py" - } - - script = fmt.Sprintf("%s\n%s\n%s", runPrepend, script, runAppend) - - log.Debugf("Wrote command \n%s\n to '%s'", script, name) - - scriptPath := fmt.Sprintf("%s/%s", ActPath, name) - sc.Cmd, err = shellquote.Split(strings.Replace(scCmd, `{0}`, scriptPath, 1)) - - return name, script, err -} - -func (sc *StepContext) setupShellCommandExecutor() common.Executor { - rc := sc.RunContext - return func(ctx context.Context) error { - scriptName, script, err := sc.setupShellCommand() - if err != nil { - return err - } - - return rc.JobContainer.Copy(ActPath, &container.FileEntry{ - Name: scriptName, - Mode: 0755, - Body: script, - })(ctx) - } -} - -func (sc *StepContext) newStepContainer(ctx context.Context, image string, cmd []string, entrypoint []string) container.Container { - rc := sc.RunContext - step := sc.Step - rawLogger := common.Logger(ctx).WithField("raw_output", true) - logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool { - if rc.Config.LogOutput { - rawLogger.Infof("%s", s) - } else { - rawLogger.Debugf("%s", s) - } - return true - }) - envList := make([]string, 0) - for k, v := range sc.Env { - envList = append(envList, fmt.Sprintf("%s=%s", k, v)) - } - - envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache")) - envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux")) - envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp")) - - binds, mounts := rc.GetBindsAndMounts() - - stepContainer := container.NewContainer(&container.NewContainerInput{ - Cmd: cmd, - Entrypoint: entrypoint, - WorkingDir: rc.Config.ContainerWorkdir(), - Image: image, - Username: rc.Config.Secrets["DOCKER_USERNAME"], - Password: rc.Config.Secrets["DOCKER_PASSWORD"], - Name: createContainerName(rc.jobContainerName(), step.ID), - Env: envList, - Mounts: mounts, - NetworkMode: fmt.Sprintf("container:%s", rc.jobContainerName()), - Binds: binds, - Stdout: logWriter, - Stderr: logWriter, - Privileged: rc.Config.Privileged, - UsernsMode: rc.Config.UsernsMode, - Platform: rc.Config.ContainerArchitecture, - }) - return stepContainer -} - -func (sc *StepContext) runUsesContainer() common.Executor { - rc := sc.RunContext - step := sc.Step - return func(ctx context.Context) error { - image := strings.TrimPrefix(step.Uses, "docker://") - eval := sc.RunContext.NewExpressionEvaluator() - cmd, err := shellquote.Split(eval.Interpolate(step.With["args"])) - if err != nil { - return err - } - entrypoint := strings.Fields(eval.Interpolate(step.With["entrypoint"])) - stepContainer := sc.newStepContainer(ctx, image, cmd, entrypoint) - - return common.NewPipelineExecutor( - stepContainer.Pull(rc.Config.ForcePull), - stepContainer.Remove().IfBool(!rc.Config.ReuseContainers), - stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop), - stepContainer.Start(true), - ).Finally( - stepContainer.Remove().IfBool(!rc.Config.ReuseContainers), - ).Finally(stepContainer.Close())(ctx) - } -} - -func (sc *StepContext) setupAction(actionDir string, actionPath string, reader func(context.Context) actionyamlReader) common.Executor { - return func(ctx context.Context) error { - action, err := sc.readAction(sc.Step, actionDir, actionPath, reader(ctx), ioutil.WriteFile) - sc.Action = action - log.Debugf("Read action %v from '%s'", sc.Action, "Unknown") - return err - } -} - -func getOsSafeRelativePath(s, prefix string) string { - actionName := strings.TrimPrefix(s, prefix) - if runtime.GOOS == "windows" { - actionName = strings.ReplaceAll(actionName, "\\", "/") - } - actionName = strings.TrimPrefix(actionName, "/") - - return actionName -} - -func (sc *StepContext) getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) { - actionName := "" - containerActionDir := "." - if step.Type() != model.StepTypeUsesActionRemote { - actionName = getOsSafeRelativePath(actionDir, rc.Config.Workdir) - containerActionDir = rc.Config.ContainerWorkdir() + "/" + actionName - actionName = "./" + actionName - } else if step.Type() == model.StepTypeUsesActionRemote { - actionName = getOsSafeRelativePath(actionDir, rc.ActionCacheDir()) - containerActionDir = ActPath + "/actions/" + actionName - } - - if actionName == "" { - actionName = filepath.Base(actionDir) - if runtime.GOOS == "windows" { - actionName = strings.ReplaceAll(actionName, "\\", "/") - } - } - return actionName, containerActionDir -} - -func (sc *StepContext) runAction(actionDir string, actionPath string, actionRepository string, actionRef string, localAction bool) common.Executor { - rc := sc.RunContext - step := sc.Step - return func(ctx context.Context) error { - // Backup the parent composite action path and restore it on continue - parentActionPath := rc.ActionPath - parentActionRepository := rc.ActionRepository - parentActionRef := rc.ActionRef - defer func() { - rc.ActionPath = parentActionPath - rc.ActionRef = parentActionRef - rc.ActionRepository = parentActionRepository - }() - rc.ActionRef = actionRef - rc.ActionRepository = actionRepository - action := sc.Action - log.Debugf("About to run action %v", action) - sc.populateEnvsFromInput(action, rc) - actionLocation := "" - if actionPath != "" { - actionLocation = path.Join(actionDir, actionPath) - } else { - actionLocation = actionDir - } - actionName, containerActionDir := sc.getContainerActionPaths(step, actionLocation, rc) - - log.Debugf("type=%v actionDir=%s actionPath=%s workdir=%s actionCacheDir=%s actionName=%s containerActionDir=%s", step.Type(), actionDir, actionPath, rc.Config.Workdir, rc.ActionCacheDir(), actionName, containerActionDir) - - maybeCopyToActionDir := func() error { - rc.ActionPath = containerActionDir - if step.Type() != model.StepTypeUsesActionRemote { - return nil - } - if err := removeGitIgnore(actionDir); err != nil { - return err - } - - var containerActionDirCopy string - containerActionDirCopy = strings.TrimSuffix(containerActionDir, actionPath) - log.Debug(containerActionDirCopy) - - if !strings.HasSuffix(containerActionDirCopy, `/`) { - containerActionDirCopy += `/` - } - return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore)(ctx) - } - - switch action.Runs.Using { - case model.ActionRunsUsingNode12, model.ActionRunsUsingNode16: - if err := maybeCopyToActionDir(); err != nil { - return err - } - containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Main)} - log.Debugf("executing remote job container: %s", containerArgs) - return rc.execJobContainer(containerArgs, sc.Env, "", "")(ctx) - case model.ActionRunsUsingDocker: - return sc.execAsDocker(ctx, action, actionName, containerActionDir, actionLocation, rc, step, localAction) - case model.ActionRunsUsingComposite: - return sc.execAsComposite(ctx, step, actionDir, rc, containerActionDir, actionName, actionPath, action, maybeCopyToActionDir) - default: - return fmt.Errorf(fmt.Sprintf("The runs.using key must be one of: %v, got %s", []string{ - model.ActionRunsUsingDocker, - model.ActionRunsUsingNode12, - model.ActionRunsUsingNode16, - model.ActionRunsUsingComposite, - }, action.Runs.Using)) - } - } -} - -func (sc *StepContext) evalDockerArgs(action *model.Action, cmd *[]string) { - rc := sc.RunContext - step := sc.Step - oldInputs := rc.Inputs - defer func() { - rc.Inputs = oldInputs - }() - inputs := make(map[string]interface{}) - eval := sc.RunContext.NewExpressionEvaluator() - // Set Defaults - for k, input := range action.Inputs { - inputs[k] = eval.Interpolate(input.Default) - } - if step.With != nil { - for k, v := range step.With { - inputs[k] = eval.Interpolate(v) - } - } - rc.Inputs = inputs - stepEE := sc.NewExpressionEvaluator() - for i, v := range *cmd { - (*cmd)[i] = stepEE.Interpolate(v) - } - sc.Env = mergeMaps(sc.Env, action.Runs.Env) - - ee := sc.NewExpressionEvaluator() - for k, v := range sc.Env { - sc.Env[k] = ee.Interpolate(v) - } -} - -// TODO: break out parts of function to reduce complexicity -// nolint:gocyclo -func (sc *StepContext) execAsDocker(ctx context.Context, action *model.Action, actionName string, containerLocation string, actionLocation string, rc *RunContext, step *model.Step, localAction bool) error { - var prepImage common.Executor - var image string - if strings.HasPrefix(action.Runs.Image, "docker://") { - image = strings.TrimPrefix(action.Runs.Image, "docker://") - } else { - // "-dockeraction" enshures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names - image = fmt.Sprintf("%s-dockeraction:%s", regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(actionName, "-"), "latest") - image = fmt.Sprintf("act-%s", strings.TrimLeft(image, "-")) - image = strings.ToLower(image) - basedir := actionLocation - if localAction { - basedir = containerLocation - } - contextDir := filepath.Join(basedir, action.Runs.Main) - - anyArchExists, err := container.ImageExistsLocally(ctx, image, "any") - if err != nil { - return err - } - - correctArchExists, err := container.ImageExistsLocally(ctx, image, rc.Config.ContainerArchitecture) - if err != nil { - return err - } - - if anyArchExists && !correctArchExists { - wasRemoved, err := container.RemoveImage(ctx, image, true, true) - if err != nil { - return err - } - if !wasRemoved { - return fmt.Errorf("failed to remove image '%s'", image) - } - } - - if !correctArchExists || rc.Config.ForceRebuild { - log.Debugf("image '%s' for architecture '%s' will be built from context '%s", image, rc.Config.ContainerArchitecture, contextDir) - var actionContainer container.Container - if localAction { - actionContainer = sc.RunContext.JobContainer - } - prepImage = container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{ - ContextDir: contextDir, - ImageTag: image, - Container: actionContainer, - Platform: rc.Config.ContainerArchitecture, - }) - } else { - log.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture) - } - } - eval := sc.NewExpressionEvaluator() - cmd, err := shellquote.Split(eval.Interpolate(step.With["args"])) - if err != nil { - return err - } - if len(cmd) == 0 { - cmd = action.Runs.Args - sc.evalDockerArgs(action, &cmd) - } - entrypoint := strings.Fields(eval.Interpolate(step.With["entrypoint"])) - if len(entrypoint) == 0 { - if action.Runs.Entrypoint != "" { - entrypoint, err = shellquote.Split(action.Runs.Entrypoint) - if err != nil { - return err - } - } else { - entrypoint = nil - } - } - stepContainer := sc.newStepContainer(ctx, image, cmd, entrypoint) - return common.NewPipelineExecutor( - prepImage, - stepContainer.Pull(rc.Config.ForcePull), - stepContainer.Remove().IfBool(!rc.Config.ReuseContainers), - stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop), - stepContainer.Start(true), - ).Finally( - stepContainer.Remove().IfBool(!rc.Config.ReuseContainers), - ).Finally(stepContainer.Close())(ctx) -} - -func (sc *StepContext) execAsComposite(ctx context.Context, step *model.Step, _ string, rc *RunContext, containerActionDir string, actionName string, _ string, action *model.Action, maybeCopyToActionDir func() error) error { - err := maybeCopyToActionDir() - - if err != nil { - return err - } - // Disable some features of composite actions, only for feature parity with github - for _, compositeStep := range action.Runs.Steps { - if err := compositeStep.Validate(rc.Config.CompositeRestrictions); err != nil { - return err - } - } - inputs := make(map[string]interface{}) - eval := sc.RunContext.NewExpressionEvaluator() - // Set Defaults - for k, input := range action.Inputs { - inputs[k] = eval.Interpolate(input.Default) - } - if step.With != nil { - for k, v := range step.With { - inputs[k] = eval.Interpolate(v) - } - } - // Doesn't work with the command processor has a pointer to the original rc - // compositerc := rc.Clone() - // Workaround start - backup := *rc - defer func() { *rc = backup }() - *rc = *rc.Clone() - scriptName := backup.CurrentStep - for rcs := &backup; rcs.Parent != nil; rcs = rcs.Parent { - scriptName = fmt.Sprintf("%s-composite-%s", rcs.Parent.CurrentStep, scriptName) - } - compositerc := rc - compositerc.Parent = &RunContext{ - CurrentStep: scriptName, - } - // Workaround end - compositerc.Composite = action - envToEvaluate := mergeMaps(compositerc.Env, step.Environment()) - compositerc.Env = make(map[string]string) - // origEnvMap: is used to pass env changes back to parent runcontext - origEnvMap := make(map[string]string) - for k, v := range envToEvaluate { - ev := eval.Interpolate(v) - origEnvMap[k] = ev - compositerc.Env[k] = ev - } - compositerc.Inputs = inputs - compositerc.ExprEval = compositerc.NewExpressionEvaluator() - err = compositerc.CompositeExecutor()(ctx) - - // Map outputs to parent rc - eval = (&StepContext{ - Env: compositerc.Env, - RunContext: compositerc, - }).NewExpressionEvaluator() - for outputName, output := range action.Outputs { - backup.setOutput(ctx, map[string]string{ - "name": outputName, - }, eval.Interpolate(output.Value)) - } - - backup.Masks = append(backup.Masks, compositerc.Masks...) - // Test if evaluated parent env was altered by this composite step - // Known Issues: - // - you try to set an env variable to the same value as a scoped step env, will be discared - for k, v := range compositerc.Env { - if ov, ok := origEnvMap[k]; !ok || ov != v { - backup.Env[k] = v - } - } - return err -} - -func (sc *StepContext) populateEnvsFromInput(action *model.Action, rc *RunContext) { - for inputID, input := range action.Inputs { - envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_") - envKey = fmt.Sprintf("INPUT_%s", envKey) - if _, ok := sc.Env[envKey]; !ok { - sc.Env[envKey] = rc.ExprEval.Interpolate(input.Default) - } - } -} - -type remoteAction struct { - URL string - Org string - Repo string - Path string - Ref string -} - -func (ra *remoteAction) CloneURL() string { - return fmt.Sprintf("https://%s/%s/%s", ra.URL, ra.Org, ra.Repo) -} - -func (ra *remoteAction) IsCheckout() bool { - if ra.Org == "actions" && ra.Repo == "checkout" { - return true - } - return false -} - -func newRemoteAction(action string) *remoteAction { - // GitHub's document[^] describes: - // > We strongly recommend that you include the version of - // > the action you are using by specifying a Git ref, SHA, or Docker tag number. - // Actually, the workflow stops if there is the uses directive that hasn't @ref. - // [^]: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions - r := regexp.MustCompile(`^([^/@]+)/([^/@]+)(/([^@]*))?(@(.*))?$`) - matches := r.FindStringSubmatch(action) - if len(matches) < 7 || matches[6] == "" { - return nil - } - return &remoteAction{ - Org: matches[1], - Repo: matches[2], - Path: matches[4], - Ref: matches[6], - URL: "github.com", - } -} - -// https://github.com/nektos/act/issues/228#issuecomment-629709055 -// files in .gitignore are not copied in a Docker container -// this causes issues with actions that ignore other important resources -// such as `node_modules` for example -func removeGitIgnore(directory string) error { - gitIgnorePath := path.Join(directory, ".gitignore") - if _, err := os.Stat(gitIgnorePath); err == nil { - // .gitignore exists - log.Debugf("Removing %s before docker cp", gitIgnorePath) - err := os.Remove(gitIgnorePath) - if err != nil { - return err - } - } - return nil -} diff --git a/pkg/runner/step_context_test.go b/pkg/runner/step_context_test.go deleted file mode 100644 index f3d6dc8..0000000 --- a/pkg/runner/step_context_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package runner - -import ( - "context" - "testing" - - log "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" - - "github.com/nektos/act/pkg/common" - "github.com/nektos/act/pkg/model" -) - -func TestStepContextExecutor(t *testing.T) { - platforms := map[string]string{ - "ubuntu-latest": baseImage, - } - tables := []TestJobFileInfo{ - {"testdata", "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, ""}, - {"testdata", "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms, ""}, - {"testdata", "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, ""}, - {"testdata", "uses-github-root", "push", "", platforms, ""}, - {"testdata", "uses-github-path", "push", "", platforms, ""}, - {"testdata", "uses-docker-url", "push", "", platforms, ""}, - {"testdata", "uses-github-full-sha", "push", "", platforms, ""}, - {"testdata", "uses-github-short-sha", "push", "Unable to resolve action `actions/hello-world-docker-action@b136eb8`, the provided ref `b136eb8` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `b136eb8894c5cb1dd5807da824be97ccdf9b5423` instead", platforms, ""}, - } - // These tests are sufficient to only check syntax. - ctx := common.WithDryrun(context.Background(), true) - for _, table := range tables { - runTestJobFile(ctx, t, table) - } -} - -func createIfTestStepContext(t *testing.T, input string) *StepContext { - var step *model.Step - err := yaml.Unmarshal([]byte(input), &step) - assert.NoError(t, err) - - return &StepContext{ - RunContext: &RunContext{ - Config: &Config{ - Workdir: ".", - Platforms: map[string]string{ - "ubuntu-latest": "ubuntu-latest", - }, - }, - StepResults: map[string]*model.StepResult{}, - Env: map[string]string{}, - Run: &model.Run{ - JobID: "job1", - Workflow: &model.Workflow{ - Name: "workflow1", - Jobs: map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, ""), - }, - }, - }, - }, - Step: step, - } -} - -func TestStepContextIsEnabled(t *testing.T) { - log.SetLevel(log.DebugLevel) - assertObject := assert.New(t) - - // success() - sc := createIfTestStepContext(t, "if: success()") - assertObject.True(sc.isEnabled(context.Background())) - - sc = createIfTestStepContext(t, "if: success()") - sc.RunContext.StepResults["a"] = &model.StepResult{ - Conclusion: model.StepStatusSuccess, - } - assertObject.True(sc.isEnabled(context.Background())) - - sc = createIfTestStepContext(t, "if: success()") - sc.RunContext.StepResults["a"] = &model.StepResult{ - Conclusion: model.StepStatusFailure, - } - assertObject.False(sc.isEnabled(context.Background())) - - // failure() - sc = createIfTestStepContext(t, "if: failure()") - assertObject.False(sc.isEnabled(context.Background())) - - sc = createIfTestStepContext(t, "if: failure()") - sc.RunContext.StepResults["a"] = &model.StepResult{ - Conclusion: model.StepStatusSuccess, - } - assertObject.False(sc.isEnabled(context.Background())) - - sc = createIfTestStepContext(t, "if: failure()") - sc.RunContext.StepResults["a"] = &model.StepResult{ - Conclusion: model.StepStatusFailure, - } - assertObject.True(sc.isEnabled(context.Background())) - - // always() - sc = createIfTestStepContext(t, "if: always()") - assertObject.True(sc.isEnabled(context.Background())) - - sc = createIfTestStepContext(t, "if: always()") - sc.RunContext.StepResults["a"] = &model.StepResult{ - Conclusion: model.StepStatusSuccess, - } - assertObject.True(sc.isEnabled(context.Background())) - - sc = createIfTestStepContext(t, "if: always()") - sc.RunContext.StepResults["a"] = &model.StepResult{ - Conclusion: model.StepStatusFailure, - } - assertObject.True(sc.isEnabled(context.Background())) -} diff --git a/pkg/runner/step_docker.go b/pkg/runner/step_docker.go new file mode 100644 index 0000000..5625a15 --- /dev/null +++ b/pkg/runner/step_docker.go @@ -0,0 +1,121 @@ +package runner + +import ( + "context" + "fmt" + "strings" + + "github.com/kballard/go-shellquote" + "github.com/nektos/act/pkg/common" + "github.com/nektos/act/pkg/container" + "github.com/nektos/act/pkg/model" +) + +type stepDocker struct { + Step *model.Step + RunContext *RunContext + env map[string]string +} + +func (sd *stepDocker) pre() common.Executor { + return func(ctx context.Context) error { + return nil + } +} + +func (sd *stepDocker) main() common.Executor { + sd.env = map[string]string{} + + return runStepExecutor(sd, sd.runUsesContainer()) +} + +func (sd *stepDocker) post() common.Executor { + return func(ctx context.Context) error { + return nil + } +} + +func (sd *stepDocker) getRunContext() *RunContext { + return sd.RunContext +} + +func (sd *stepDocker) getStepModel() *model.Step { + return sd.Step +} + +func (sd *stepDocker) getEnv() *map[string]string { + return &sd.env +} + +func (sd *stepDocker) runUsesContainer() common.Executor { + rc := sd.RunContext + step := sd.Step + + return func(ctx context.Context) error { + image := strings.TrimPrefix(step.Uses, "docker://") + eval := rc.NewExpressionEvaluator() + cmd, err := shellquote.Split(eval.Interpolate(step.With["args"])) + if err != nil { + return err + } + entrypoint := strings.Fields(eval.Interpolate(step.With["entrypoint"])) + stepContainer := sd.newStepContainer(ctx, image, cmd, entrypoint) + + return common.NewPipelineExecutor( + stepContainer.Pull(rc.Config.ForcePull), + stepContainer.Remove().IfBool(!rc.Config.ReuseContainers), + stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop), + stepContainer.Start(true), + ).Finally( + stepContainer.Remove().IfBool(!rc.Config.ReuseContainers), + ).Finally(stepContainer.Close())(ctx) + } +} + +var ( + ContainerNewContainer = container.NewContainer +) + +func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd []string, entrypoint []string) container.Container { + rc := sd.RunContext + step := sd.Step + + rawLogger := common.Logger(ctx).WithField("raw_output", true) + logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool { + if rc.Config.LogOutput { + rawLogger.Infof("%s", s) + } else { + rawLogger.Debugf("%s", s) + } + return true + }) + envList := make([]string, 0) + for k, v := range sd.env { + envList = append(envList, fmt.Sprintf("%s=%s", k, v)) + } + + envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache")) + envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux")) + envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp")) + + binds, mounts := rc.GetBindsAndMounts() + stepContainer := ContainerNewContainer(&container.NewContainerInput{ + Cmd: cmd, + Entrypoint: entrypoint, + WorkingDir: rc.Config.ContainerWorkdir(), + Image: image, + Username: rc.Config.Secrets["DOCKER_USERNAME"], + Password: rc.Config.Secrets["DOCKER_PASSWORD"], + Name: createContainerName(rc.jobContainerName(), step.ID), + Env: envList, + Mounts: mounts, + NetworkMode: fmt.Sprintf("container:%s", rc.jobContainerName()), + Binds: binds, + Stdout: logWriter, + Stderr: logWriter, + Privileged: rc.Config.Privileged, + UsernsMode: rc.Config.UsernsMode, + Platform: rc.Config.ContainerArchitecture, + }) + return stepContainer +} diff --git a/pkg/runner/step_docker_test.go b/pkg/runner/step_docker_test.go new file mode 100644 index 0000000..c748271 --- /dev/null +++ b/pkg/runner/step_docker_test.go @@ -0,0 +1,106 @@ +package runner + +import ( + "context" + "testing" + + "github.com/nektos/act/pkg/container" + "github.com/nektos/act/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestStepDockerMain(t *testing.T) { + cm := &containerMock{} + + var input *container.NewContainerInput + + // mock the new container call + origContainerNewContainer := ContainerNewContainer + ContainerNewContainer = func(containerInput *container.NewContainerInput) container.Container { + input = containerInput + return cm + } + defer (func() { + ContainerNewContainer = origContainerNewContainer + })() + + sd := &stepDocker{ + RunContext: &RunContext{ + StepResults: map[string]*model.StepResult{}, + Config: &Config{}, + Run: &model.Run{ + JobID: "1", + Workflow: &model.Workflow{ + Jobs: map[string]*model.Job{ + "1": { + Defaults: model.Defaults{ + Run: model.RunDefaults{ + Shell: "bash", + }, + }, + }, + }, + }, + }, + JobContainer: cm, + }, + Step: &model.Step{ + ID: "1", + Uses: "docker://node:14", + WorkingDirectory: "workdir", + }, + } + + ctx := context.Background() + + cm.On("UpdateFromImageEnv", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error { + return nil + }) + + cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error { + return nil + }) + + cm.On("UpdateFromPath", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error { + return nil + }) + + cm.On("Pull", false).Return(func(ctx context.Context) error { + return nil + }) + + cm.On("Remove").Return(func(ctx context.Context) error { + return nil + }) + + cm.On("Create", []string(nil), []string(nil)).Return(func(ctx context.Context) error { + return nil + }) + + cm.On("Start", true).Return(func(ctx context.Context) error { + return nil + }) + + cm.On("Close").Return(func(ctx context.Context) error { + return nil + }) + + err := sd.main()(ctx) + assert.Nil(t, err) + + assert.Equal(t, "node:14", input.Image) + + cm.AssertExpectations(t) +} + +func TestStepDockerPrePost(t *testing.T) { + ctx := context.Background() + sd := &stepDocker{} + + err := sd.pre()(ctx) + assert.Nil(t, err) + + err = sd.post()(ctx) + assert.Nil(t, err) +} diff --git a/pkg/runner/step_factory.go b/pkg/runner/step_factory.go new file mode 100644 index 0000000..4ac7eaf --- /dev/null +++ b/pkg/runner/step_factory.go @@ -0,0 +1,46 @@ +package runner + +import ( + "fmt" + + "github.com/nektos/act/pkg/model" +) + +type stepFactory interface { + newStep(step *model.Step, rc *RunContext) (step, error) +} + +type stepFactoryImpl struct{} + +func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step, error) { + switch stepModel.Type() { + case model.StepTypeInvalid: + return nil, fmt.Errorf("Invalid run/uses syntax for job:%s step:%+v", rc.Run, stepModel) + case model.StepTypeRun: + return &stepRun{ + Step: stepModel, + RunContext: rc, + }, nil + case model.StepTypeUsesActionLocal: + return &stepActionLocal{ + Step: stepModel, + RunContext: rc, + readAction: readActionImpl, + runAction: runActionImpl, + }, nil + case model.StepTypeUsesActionRemote: + return &stepActionRemote{ + Step: stepModel, + RunContext: rc, + readAction: readActionImpl, + runAction: runActionImpl, + }, nil + case model.StepTypeUsesDockerURL: + return &stepDocker{ + Step: stepModel, + RunContext: rc, + }, nil + } + + return nil, fmt.Errorf("Unable to determine how to run job:%s step:%+v", rc.Run, stepModel) +} diff --git a/pkg/runner/step_factory_test.go b/pkg/runner/step_factory_test.go new file mode 100644 index 0000000..c981ef5 --- /dev/null +++ b/pkg/runner/step_factory_test.go @@ -0,0 +1,81 @@ +package runner + +import ( + "testing" + + "github.com/nektos/act/pkg/model" + "github.com/stretchr/testify/assert" +) + +func TestStepFactoryNewStep(t *testing.T) { + table := []struct { + name string + model *model.Step + check func(s step) bool + }{ + { + name: "StepRemoteAction", + model: &model.Step{ + Uses: "remote/action@v1", + }, + check: func(s step) bool { + _, ok := s.(*stepActionRemote) + return ok + }, + }, + { + name: "StepLocalAction", + model: &model.Step{ + Uses: "./action@v1", + }, + check: func(s step) bool { + _, ok := s.(*stepActionLocal) + return ok + }, + }, + { + name: "StepDocker", + model: &model.Step{ + Uses: "docker://image:tag", + }, + check: func(s step) bool { + _, ok := s.(*stepDocker) + return ok + }, + }, + { + name: "StepRun", + model: &model.Step{ + Run: "cmd", + }, + check: func(s step) bool { + _, ok := s.(*stepRun) + return ok + }, + }, + } + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + sf := &stepFactoryImpl{} + + step, err := sf.newStep(tt.model, &RunContext{}) + + assert.True(t, tt.check((step))) + assert.Nil(t, err) + }) + } +} + +func TestStepFactoryInvalidStep(t *testing.T) { + model := &model.Step{ + Uses: "remote/action@v1", + Run: "cmd", + } + + sf := &stepFactoryImpl{} + + _, err := sf.newStep(model, &RunContext{}) + + assert.Error(t, err) +} diff --git a/pkg/runner/step_run.go b/pkg/runner/step_run.go new file mode 100644 index 0000000..68ebd90 --- /dev/null +++ b/pkg/runner/step_run.go @@ -0,0 +1,166 @@ +package runner + +import ( + "context" + "fmt" + "strings" + + "github.com/kballard/go-shellquote" + "github.com/nektos/act/pkg/common" + "github.com/nektos/act/pkg/container" + "github.com/nektos/act/pkg/model" + log "github.com/sirupsen/logrus" +) + +type stepRun struct { + Step *model.Step + RunContext *RunContext + cmd []string + env map[string]string +} + +func (sr *stepRun) pre() common.Executor { + return func(ctx context.Context) error { + return nil + } +} + +func (sr *stepRun) main() common.Executor { + sr.env = map[string]string{} + + return runStepExecutor(sr, common.NewPipelineExecutor( + sr.setupShellCommandExecutor(), + func(ctx context.Context) error { + return sr.getRunContext().JobContainer.Exec(sr.cmd, sr.env, "", sr.Step.WorkingDirectory)(ctx) + }, + )) +} + +func (sr *stepRun) post() common.Executor { + return func(ctx context.Context) error { + return nil + } +} + +func (sr *stepRun) getRunContext() *RunContext { + return sr.RunContext +} + +func (sr *stepRun) getStepModel() *model.Step { + return sr.Step +} + +func (sr *stepRun) getEnv() *map[string]string { + return &sr.env +} + +func (sr *stepRun) setupShellCommandExecutor() common.Executor { + return func(ctx context.Context) error { + scriptName, script, err := sr.setupShellCommand() + if err != nil { + return err + } + + return sr.RunContext.JobContainer.Copy(ActPath, &container.FileEntry{ + Name: scriptName, + Mode: 0755, + Body: script, + })(ctx) + } +} + +func getScriptName(rc *RunContext, step *model.Step) string { + scriptName := step.ID + for rcs := rc; rcs.Parent != nil; rcs = rcs.Parent { + scriptName = fmt.Sprintf("%s-composite-%s", rcs.Parent.CurrentStep, scriptName) + } + return fmt.Sprintf("workflow/%s", scriptName) +} + +// TODO: Currently we just ignore top level keys, BUT we should return proper error on them +// BUTx2 I leave this for when we rewrite act to use actionlint for workflow validation +// so we return proper errors before any execution or spawning containers +// it will error anyway with: +// OCI runtime exec failed: exec failed: container_linux.go:380: starting container process caused: exec: "${{": executable file not found in $PATH: unknown +func (sr *stepRun) setupShellCommand() (name, script string, err error) { + sr.setupShell() + sr.setupWorkingDirectory() + + step := sr.Step + + script = sr.RunContext.NewStepExpressionEvaluator(sr).Interpolate(step.Run) + + scCmd := step.ShellCommand() + + name = getScriptName(sr.RunContext, step) + + // Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L47-L64 + // Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L19-L27 + runPrepend := "" + runAppend := "" + switch step.Shell { + case "bash", "sh": + name += ".sh" + case "pwsh", "powershell": + name += ".ps1" + runPrepend = "$ErrorActionPreference = 'stop'" + runAppend = "if ((Test-Path -LiteralPath variable:/LASTEXITCODE)) { exit $LASTEXITCODE }" + case "cmd": + name += ".cmd" + runPrepend = "@echo off" + case "python": + name += ".py" + } + + script = fmt.Sprintf("%s\n%s\n%s", runPrepend, script, runAppend) + + log.Debugf("Wrote command \n%s\n to '%s'", script, name) + + scriptPath := fmt.Sprintf("%s/%s", ActPath, name) + sr.cmd, err = shellquote.Split(strings.Replace(scCmd, `{0}`, scriptPath, 1)) + + return name, script, err +} + +func (sr *stepRun) setupShell() { + rc := sr.RunContext + step := sr.Step + + if step.Shell == "" { + step.Shell = rc.Run.Job().Defaults.Run.Shell + } + + step.Shell = rc.ExprEval.Interpolate(step.Shell) + + if step.Shell == "" { + step.Shell = rc.Run.Workflow.Defaults.Run.Shell + } + + // current GitHub Runner behaviour is that default is `sh`, + // but if it's not container it validates with `which` command + // if `bash` is available, and provides `bash` if it is + // for now I'm going to leave below logic, will address it in different PR + // https://github.com/actions/runner/blob/9a829995e02d2db64efb939dc2f283002595d4d9/src/Runner.Worker/Handlers/ScriptHandler.cs#L87-L91 + if rc.Run.Job().Container() != nil { + if rc.Run.Job().Container().Image != "" && step.Shell == "" { + step.Shell = "sh" + } + } +} + +func (sr *stepRun) setupWorkingDirectory() { + rc := sr.RunContext + step := sr.Step + + if step.WorkingDirectory == "" { + step.WorkingDirectory = rc.Run.Job().Defaults.Run.WorkingDirectory + } + + // jobs can receive context values, so we interpolate + step.WorkingDirectory = rc.ExprEval.Interpolate(step.WorkingDirectory) + + // but top level keys in workflow file like `defaults` or `env` can't + if step.WorkingDirectory == "" { + step.WorkingDirectory = rc.Run.Workflow.Defaults.Run.WorkingDirectory + } +} diff --git a/pkg/runner/step_run_test.go b/pkg/runner/step_run_test.go new file mode 100644 index 0000000..081d864 --- /dev/null +++ b/pkg/runner/step_run_test.go @@ -0,0 +1,85 @@ +package runner + +import ( + "context" + "testing" + + "github.com/nektos/act/pkg/container" + "github.com/nektos/act/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestStepRun(t *testing.T) { + cm := &containerMock{} + fileEntry := &container.FileEntry{ + Name: "workflow/1.sh", + Mode: 0755, + Body: "\ncmd\n", + } + + sr := &stepRun{ + RunContext: &RunContext{ + StepResults: map[string]*model.StepResult{}, + ExprEval: &expressionEvaluator{}, + Config: &Config{}, + Run: &model.Run{ + JobID: "1", + Workflow: &model.Workflow{ + Jobs: map[string]*model.Job{ + "1": { + Defaults: model.Defaults{ + Run: model.RunDefaults{ + Shell: "bash", + }, + }, + }, + }, + }, + }, + JobContainer: cm, + }, + Step: &model.Step{ + ID: "1", + Run: "cmd", + WorkingDirectory: "workdir", + }, + } + + cm.On("Copy", "/var/run/act", []*container.FileEntry{fileEntry}).Return(func(ctx context.Context) error { + return nil + }) + cm.On("Exec", []string{"bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "/var/run/act/workflow/1.sh"}, mock.AnythingOfType("map[string]string"), "", "workdir").Return(func(ctx context.Context) error { + return nil + }) + + cm.On("UpdateFromImageEnv", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error { + return nil + }) + + cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error { + return nil + }) + + cm.On("UpdateFromPath", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error { + return nil + }) + + ctx := context.Background() + + err := sr.main()(ctx) + assert.Nil(t, err) + + cm.AssertExpectations(t) +} + +func TestStepRunPrePost(t *testing.T) { + ctx := context.Background() + sr := &stepRun{} + + err := sr.pre()(ctx) + assert.Nil(t, err) + + err = sr.post()(ctx) + assert.Nil(t, err) +} diff --git a/pkg/runner/step_test.go b/pkg/runner/step_test.go new file mode 100644 index 0000000..c8f3a0c --- /dev/null +++ b/pkg/runner/step_test.go @@ -0,0 +1,276 @@ +package runner + +import ( + "context" + "testing" + + "github.com/nektos/act/pkg/common" + "github.com/nektos/act/pkg/model" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + yaml "gopkg.in/yaml.v3" +) + +func TestMergeIntoMap(t *testing.T) { + table := []struct { + name string + target map[string]string + maps []map[string]string + expected map[string]string + }{ + { + name: "testEmptyMap", + target: map[string]string{}, + maps: []map[string]string{}, + expected: map[string]string{}, + }, + { + name: "testMergeIntoEmptyMap", + target: map[string]string{}, + maps: []map[string]string{ + { + "key1": "value1", + "key2": "value2", + }, { + "key2": "overridden", + "key3": "value3", + }, + }, + expected: map[string]string{ + "key1": "value1", + "key2": "overridden", + "key3": "value3", + }, + }, + { + name: "testMergeIntoExistingMap", + target: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + maps: []map[string]string{ + { + "key1": "overridden", + }, + }, + expected: map[string]string{ + "key1": "overridden", + "key2": "value2", + }, + }, + } + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + mergeIntoMap(&tt.target, tt.maps...) + assert.Equal(t, tt.expected, tt.target) + }) + } +} + +type stepMock struct { + mock.Mock + step +} + +func (sm *stepMock) pre() common.Executor { + args := sm.Called() + return args.Get(0).(func(context.Context) error) +} + +func (sm *stepMock) main() common.Executor { + args := sm.Called() + return args.Get(0).(func(context.Context) error) +} + +func (sm *stepMock) post() common.Executor { + args := sm.Called() + return args.Get(0).(func(context.Context) error) +} + +func (sm *stepMock) getRunContext() *RunContext { + args := sm.Called() + return args.Get(0).(*RunContext) +} + +func (sm *stepMock) getStepModel() *model.Step { + args := sm.Called() + return args.Get(0).(*model.Step) +} + +func (sm *stepMock) getEnv() *map[string]string { + args := sm.Called() + return args.Get(0).(*map[string]string) +} + +func TestSetupEnv(t *testing.T) { + cm := &containerMock{} + sm := &stepMock{} + + rc := &RunContext{ + Config: &Config{ + Env: map[string]string{ + "GITHUB_RUN_ID": "runId", + }, + }, + Run: &model.Run{ + JobID: "1", + Workflow: &model.Workflow{ + Jobs: map[string]*model.Job{ + "1": { + Env: yaml.Node{ + Value: "JOB_KEY: jobvalue", + }, + }, + }, + }, + }, + Env: map[string]string{ + "RC_KEY": "rcvalue", + }, + ExtraPath: []string{"/path/to/extra/file"}, + JobContainer: cm, + } + step := &model.Step{ + With: map[string]string{ + "STEP_WITH": "with-value", + }, + } + env := map[string]string{ + "PATH": "", + } + + sm.On("getRunContext").Return(rc) + sm.On("getStepModel").Return(step) + sm.On("getEnv").Return(&env) + + cm.On("UpdateFromImageEnv", &env).Return(func(ctx context.Context) error { return nil }) + cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", &env).Return(func(ctx context.Context) error { return nil }) + cm.On("UpdateFromPath", &env).Return(func(ctx context.Context) error { return nil }) + + err := setupEnv(context.Background(), sm) + assert.Nil(t, err) + + // These are commit or system specific + delete((env), "GITHUB_REF") + delete((env), "GITHUB_SHA") + delete((env), "GITHUB_WORKSPACE") + delete((env), "GITHUB_REPOSITORY") + delete((env), "GITHUB_REPOSITORY_OWNER") + delete((env), "GITHUB_ACTOR") + + assert.Equal(t, map[string]string{ + "ACT": "true", + "CI": "true", + "GITHUB_ACTION": "", + "GITHUB_ACTIONS": "true", + "GITHUB_ACTION_PATH": "", + "GITHUB_ACTION_REF": "", + "GITHUB_ACTION_REPOSITORY": "", + "GITHUB_API_URL": "https:///api/v3", + "GITHUB_BASE_REF": "", + "GITHUB_ENV": "/var/run/act/workflow/envs.txt", + "GITHUB_EVENT_NAME": "", + "GITHUB_EVENT_PATH": "/var/run/act/workflow/event.json", + "GITHUB_GRAPHQL_URL": "https:///api/graphql", + "GITHUB_HEAD_REF": "", + "GITHUB_JOB": "", + "GITHUB_PATH": "/var/run/act/workflow/paths.txt", + "GITHUB_RETENTION_DAYS": "0", + "GITHUB_RUN_ID": "runId", + "GITHUB_RUN_NUMBER": "1", + "GITHUB_SERVER_URL": "https://", + "GITHUB_TOKEN": "", + "GITHUB_WORKFLOW": "", + "INPUT_STEP_WITH": "with-value", + "PATH": "/path/to/extra/file:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "RC_KEY": "rcvalue", + "RUNNER_PERFLOG": "/dev/null", + "RUNNER_TRACKING_ID": "", + }, env) + + cm.AssertExpectations(t) +} + +func TestIsStepEnabled(t *testing.T) { + createTestStep := func(t *testing.T, input string) step { + var step *model.Step + err := yaml.Unmarshal([]byte(input), &step) + assert.NoError(t, err) + + return &stepRun{ + RunContext: &RunContext{ + Config: &Config{ + Workdir: ".", + Platforms: map[string]string{ + "ubuntu-latest": "ubuntu-latest", + }, + }, + StepResults: map[string]*model.StepResult{}, + Env: map[string]string{}, + Run: &model.Run{ + JobID: "job1", + Workflow: &model.Workflow{ + Name: "workflow1", + Jobs: map[string]*model.Job{ + "job1": createJob(t, `runs-on: ubuntu-latest`, ""), + }, + }, + }, + }, + Step: step, + } + } + + log.SetLevel(log.DebugLevel) + assertObject := assert.New(t) + + // success() + step := createTestStep(t, "if: success()") + assertObject.True(isStepEnabled(context.Background(), step)) + + step = createTestStep(t, "if: success()") + step.getRunContext().StepResults["a"] = &model.StepResult{ + Conclusion: model.StepStatusSuccess, + } + assertObject.True(isStepEnabled(context.Background(), step)) + + step = createTestStep(t, "if: success()") + step.getRunContext().StepResults["a"] = &model.StepResult{ + Conclusion: model.StepStatusFailure, + } + assertObject.False(isStepEnabled(context.Background(), step)) + + // failure() + step = createTestStep(t, "if: failure()") + assertObject.False(isStepEnabled(context.Background(), step)) + + step = createTestStep(t, "if: failure()") + step.getRunContext().StepResults["a"] = &model.StepResult{ + Conclusion: model.StepStatusSuccess, + } + assertObject.False(isStepEnabled(context.Background(), step)) + + step = createTestStep(t, "if: failure()") + step.getRunContext().StepResults["a"] = &model.StepResult{ + Conclusion: model.StepStatusFailure, + } + assertObject.True(isStepEnabled(context.Background(), step)) + + // always() + step = createTestStep(t, "if: always()") + assertObject.True(isStepEnabled(context.Background(), step)) + + step = createTestStep(t, "if: always()") + step.getRunContext().StepResults["a"] = &model.StepResult{ + Conclusion: model.StepStatusSuccess, + } + assertObject.True(isStepEnabled(context.Background(), step)) + + step = createTestStep(t, "if: always()") + step.getRunContext().StepResults["a"] = &model.StepResult{ + Conclusion: model.StepStatusFailure, + } + assertObject.True(isStepEnabled(context.Background(), step)) +}