package runner import ( "context" "encoding/json" "fmt" "os" "path/filepath" "regexp" "runtime" "strings" "github.com/google/shlex" "github.com/spf13/pflag" "github.com/mitchellh/go-homedir" log "github.com/sirupsen/logrus" selinux "github.com/opencontainers/selinux/go-selinux" "github.com/nektos/act/pkg/common" "github.com/nektos/act/pkg/container" "github.com/nektos/act/pkg/model" ) const ActPath string = "/var/run/act" // RunContext contains info about current job type RunContext struct { Name string Config *Config Matrix map[string]interface{} Run *model.Run EventJSON string Env map[string]string ExtraPath []string CurrentStep string StepResults map[string]*stepResult ExprEval ExpressionEvaluator JobContainer container.Container OutputMappings map[MappableOutput]MappableOutput JobName string } type MappableOutput struct { StepID string OutputName string } func (rc *RunContext) String() string { return fmt.Sprintf("%s/%s", rc.Run.Workflow.Name, rc.Name) } type stepStatus int const ( stepStatusSuccess stepStatus = iota stepStatusFailure ) var stepStatusStrings = [...]string{ "success", "failure", } func (s stepStatus) MarshalText() ([]byte, error) { return []byte(s.String()), nil } func (s *stepStatus) UnmarshalText(b []byte) error { str := string(b) for i, name := range stepStatusStrings { if name == str { *s = stepStatus(i) return nil } } return fmt.Errorf("invalid step status %q", str) } func (s stepStatus) String() string { if int(s) >= len(stepStatusStrings) { return "" } return stepStatusStrings[s] } type stepResult struct { Outputs map[string]string `json:"outputs"` Conclusion stepStatus `json:"conclusion"` Outcome stepStatus `json:"outcome"` } // GetEnv returns the env for the context func (rc *RunContext) GetEnv() map[string]string { if rc.Env == nil { rc.Env = mergeMaps(rc.Config.Env, rc.Run.Workflow.Env, rc.Run.Job().Environment()) } rc.Env["ACT"] = "true" return rc.Env } func (rc *RunContext) jobContainerName() string { return createContainerName("act", rc.String()) } // Returns the binds and mounts for the container, resolving paths as appopriate func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) { name := rc.jobContainerName() if rc.Config.ContainerDaemonSocket == "" { rc.Config.ContainerDaemonSocket = "/var/run/docker.sock" } binds := []string{ fmt.Sprintf("%s:%s", rc.Config.ContainerDaemonSocket, "/var/run/docker.sock"), } mounts := map[string]string{ "act-toolcache": "/toolcache", name + "-env": ActPath, } if rc.Config.BindWorkdir { bindModifiers := "" if runtime.GOOS == "darwin" { bindModifiers = ":delegated" } if selinux.GetEnabled() { bindModifiers = ":z" } binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, rc.Config.ContainerWorkdir(), bindModifiers)) } else { mounts[name] = rc.Config.ContainerWorkdir() } return binds, mounts } func (rc *RunContext) startJobContainer() common.Executor { image := rc.platformImage() hostname := rc.hostname() return func(ctx context.Context) error { 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 }) username, password, err := rc.handleCredentials() if err != nil { return fmt.Errorf("failed to handle credentials: %s", err) } common.Logger(ctx).Infof("\U0001f680 Start image=%s", image) name := rc.jobContainerName() envList := make([]string, 0) 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() rc.JobContainer = container.NewContainer(&container.NewContainerInput{ Cmd: nil, Entrypoint: []string{"/usr/bin/tail", "-f", "/dev/null"}, WorkingDir: rc.Config.ContainerWorkdir(), Image: image, Username: username, Password: password, Name: name, Env: envList, Mounts: mounts, NetworkMode: "host", Binds: binds, Stdout: logWriter, Stderr: logWriter, Privileged: rc.Config.Privileged, UsernsMode: rc.Config.UsernsMode, Platform: rc.Config.ContainerArchitecture, Hostname: hostname, }) var copyWorkspace bool var copyToPath string if !rc.Config.BindWorkdir { copyToPath, copyWorkspace = rc.localCheckoutPath() copyToPath = filepath.Join(rc.Config.ContainerWorkdir(), copyToPath) } return common.NewPipelineExecutor( rc.JobContainer.Pull(rc.Config.ForcePull), rc.stopJobContainer(), rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop), rc.JobContainer.Start(false), rc.JobContainer.UpdateFromImageEnv(&rc.Env), rc.JobContainer.UpdateFromEnv("/etc/environment", &rc.Env), rc.JobContainer.Exec([]string{"mkdir", "-m", "0777", "-p", ActPath}, rc.Env, "root", ""), rc.JobContainer.CopyDir(copyToPath, rc.Config.Workdir+string(filepath.Separator)+".", rc.Config.UseGitIgnore).IfBool(copyWorkspace), rc.JobContainer.Copy(ActPath+"/", &container.FileEntry{ Name: "workflow/event.json", Mode: 0644, Body: rc.EventJSON, }, &container.FileEntry{ Name: "workflow/envs.txt", Mode: 0666, Body: "", }, &container.FileEntry{ Name: "workflow/paths.txt", Mode: 0666, Body: "", }), )(ctx) } } func (rc *RunContext) execJobContainer(cmd []string, env map[string]string, user, workdir string) common.Executor { return func(ctx context.Context) error { return rc.JobContainer.Exec(cmd, env, user, workdir)(ctx) } } // stopJobContainer removes the job container (if it exists) and its volume (if it exists) if !rc.Config.ReuseContainers func (rc *RunContext) stopJobContainer() common.Executor { return func(ctx context.Context) error { if rc.JobContainer != nil && !rc.Config.ReuseContainers { return rc.JobContainer.Remove(). Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName(), false))(ctx) } return nil } } // Prepare the mounts and binds for the worker // ActionCacheDir is for rc func (rc *RunContext) ActionCacheDir() string { var xdgCache string var ok bool if xdgCache, ok = os.LookupEnv("XDG_CACHE_HOME"); !ok || xdgCache == "" { if home, err := homedir.Dir(); err == nil { xdgCache = filepath.Join(home, ".cache") } else if xdgCache, err = filepath.Abs("."); err != nil { log.Fatal(err) } } return filepath.Join(xdgCache, "act") } // Interpolate outputs after a job is done func (rc *RunContext) interpolateOutputs() common.Executor { return func(ctx context.Context) error { ee := rc.NewExpressionEvaluator() for k, v := range rc.Run.Job().Outputs { interpolated := ee.Interpolate(v) if v != interpolated { rc.Run.Job().Outputs[k] = interpolated } } return nil } } // Executor returns a pipeline executor for all the steps in the job func (rc *RunContext) Executor() common.Executor { steps := make([]common.Executor, 0) steps = append(steps, func(ctx context.Context) error { if len(rc.Matrix) > 0 { common.Logger(ctx).Infof("\U0001F9EA Matrix: %v", rc.Matrix) } return nil }) steps = append(steps, rc.startJobContainer()) for i, step := range rc.Run.Job().Steps { if step.ID == "" { step.ID = fmt.Sprintf("%d", i) } steps = append(steps, rc.newStepExecutor(step)) } steps = append(steps, func(ctx context.Context) error { err := rc.stopJobContainer()(ctx) if err != nil { return err } rc.Run.Job().Result = "success" jobError := common.JobError(ctx) if jobError != nil { rc.Run.Job().Result = "failure" } return nil }) return common.NewPipelineExecutor(steps...).Finally(rc.interpolateOutputs()).Finally(func(ctx context.Context) error { if rc.JobContainer != nil { return rc.JobContainer.Close()(ctx) } return nil }).If(rc.isEnabled) } 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] = &stepResult{ Outcome: stepStatusSuccess, Conclusion: stepStatusSuccess, Outputs: make(map[string]string), } runStep, err := sc.isEnabled(ctx) if err != nil { rc.StepResults[rc.CurrentStep].Conclusion = stepStatusFailure rc.StepResults[rc.CurrentStep].Outcome = stepStatusFailure return err } if !runStep { log.Debugf("Skipping step '%s' due to '%s'", sc.Step.String(), sc.Step.If.Value) 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) 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 = stepStatusFailure if sc.Step.ContinueOnError { common.Logger(ctx).Infof("Failed but continue next step") err = nil rc.StepResults[rc.CurrentStep].Conclusion = stepStatusSuccess } else { rc.StepResults[rc.CurrentStep].Conclusion = stepStatusFailure } } return err } } func (rc *RunContext) platformImage() string { job := rc.Run.Job() c := job.Container() if c != nil { return rc.ExprEval.Interpolate(c.Image) } if job.RunsOn() == nil { log.Errorf("'runs-on' key not defined in %s", rc.String()) } for _, runnerLabel := range job.RunsOn() { platformName := rc.ExprEval.Interpolate(runnerLabel) image := rc.Config.Platforms[strings.ToLower(platformName)] if image != "" { return image } } return "" } func (rc *RunContext) hostname() string { job := rc.Run.Job() c := job.Container() if c == nil { return "" } optionsFlags := pflag.NewFlagSet("container_options", pflag.ContinueOnError) hostname := optionsFlags.StringP("hostname", "h", "", "") optionsArgs, err := shlex.Split(c.Options) if err != nil { log.Warnf("Cannot parse container options: %s", c.Options) return "" } err = optionsFlags.Parse(optionsArgs) if err != nil { log.Warnf("Cannot parse container options: %s", c.Options) return "" } return *hostname } func (rc *RunContext) isEnabled(ctx context.Context) bool { job := rc.Run.Job() l := common.Logger(ctx) runJob, err := EvalBool(rc.ExprEval, job.If.Value) if err != nil { common.Logger(ctx).Errorf(" \u274C Error in if: expression - %s", job.Name) return false } if !runJob { l.Debugf("Skipping job '%s' due to '%s'", job.Name, job.If.Value) return false } img := rc.platformImage() if img == "" { if job.RunsOn() == nil { log.Errorf("'runs-on' key not defined in %s", rc.String()) } for _, runnerLabel := range job.RunsOn() { platformName := rc.ExprEval.Interpolate(runnerLabel) l.Infof("\U0001F6A7 Skipping unsupported platform -- Try running with `-P %+v=...`", platformName) } return false } return true } var splitPattern *regexp.Regexp func previousOrNextPartIsAnOperator(i int, parts []string) bool { operator := false if i > 0 { operator = operatorPattern.MatchString(parts[i-1]) } if i+1 < len(parts) { operator = operator || operatorPattern.MatchString(parts[i+1]) } return operator } func mergeMaps(maps ...map[string]string) map[string]string { rtnMap := make(map[string]string) for _, m := range maps { for k, v := range m { rtnMap[k] = v } } return rtnMap } func createContainerName(parts ...string) string { name := make([]string, 0) pattern := regexp.MustCompile("[^a-zA-Z0-9]") partLen := (30 / len(parts)) - 1 for i, part := range parts { if i == len(parts)-1 { name = append(name, pattern.ReplaceAllString(part, "-")) } else { // If any part has a '-' on the end it is likely part of a matrix job. // Let's preserve the number to prevent clashes in container names. re := regexp.MustCompile("-[0-9]+$") num := re.FindStringSubmatch(part) if len(num) > 0 { name = append(name, trimToLen(pattern.ReplaceAllString(part, "-"), partLen-len(num[0]))) name = append(name, num[0]) } else { name = append(name, trimToLen(pattern.ReplaceAllString(part, "-"), partLen)) } } } return strings.ReplaceAll(strings.Trim(strings.Join(name, "-"), "-"), "--", "-") } func trimToLen(s string, l int) string { if l < 0 { l = 0 } if len(s) > l { return s[:l] } return s } type jobContext struct { Status string `json:"status"` Container struct { ID string `json:"id"` Network string `json:"network"` } `json:"container"` Services map[string]struct { ID string `json:"id"` } `json:"services"` } func (rc *RunContext) getJobContext() *jobContext { jobStatus := "success" for _, stepStatus := range rc.StepResults { if stepStatus.Conclusion == stepStatusFailure { jobStatus = "failure" break } } return &jobContext{ Status: jobStatus, } } func (rc *RunContext) getStepsContext() map[string]*stepResult { return rc.StepResults } func (rc *RunContext) getNeedsTransitive(job *model.Job) []string { needs := job.Needs() for _, need := range needs { parentNeeds := rc.getNeedsTransitive(rc.Run.Workflow.GetJob(need)) needs = append(needs, parentNeeds...) } return needs } type githubContext struct { Event map[string]interface{} `json:"event"` EventPath string `json:"event_path"` Workflow string `json:"workflow"` RunID string `json:"run_id"` RunNumber string `json:"run_number"` Actor string `json:"actor"` Repository string `json:"repository"` EventName string `json:"event_name"` Sha string `json:"sha"` Ref string `json:"ref"` HeadRef string `json:"head_ref"` BaseRef string `json:"base_ref"` Token string `json:"token"` Workspace string `json:"workspace"` Action string `json:"action"` ActionPath string `json:"action_path"` ActionRef string `json:"action_ref"` ActionRepository string `json:"action_repository"` Job string `json:"job"` JobName string `json:"job_name"` RepositoryOwner string `json:"repository_owner"` RetentionDays string `json:"retention_days"` RunnerPerflog string `json:"runner_perflog"` RunnerTrackingID string `json:"runner_tracking_id"` } func (rc *RunContext) getGithubContext() *githubContext { ghc := &githubContext{ Event: make(map[string]interface{}), EventPath: ActPath + "/workflow/event.json", Workflow: rc.Run.Workflow.Name, RunID: rc.Config.Env["GITHUB_RUN_ID"], RunNumber: rc.Config.Env["GITHUB_RUN_NUMBER"], Actor: rc.Config.Actor, EventName: rc.Config.EventName, Workspace: rc.Config.ContainerWorkdir(), Action: rc.CurrentStep, Token: rc.Config.Secrets["GITHUB_TOKEN"], ActionPath: rc.Config.Env["GITHUB_ACTION_PATH"], ActionRef: rc.Config.Env["RUNNER_ACTION_REF"], ActionRepository: rc.Config.Env["RUNNER_ACTION_REPOSITORY"], RepositoryOwner: rc.Config.Env["GITHUB_REPOSITORY_OWNER"], RetentionDays: rc.Config.Env["GITHUB_RETENTION_DAYS"], RunnerPerflog: rc.Config.Env["RUNNER_PERFLOG"], RunnerTrackingID: rc.Config.Env["RUNNER_TRACKING_ID"], } if ghc.RunID == "" { ghc.RunID = "1" } if ghc.RunNumber == "" { ghc.RunNumber = "1" } if ghc.RetentionDays == "" { ghc.RetentionDays = "0" } if ghc.RunnerPerflog == "" { ghc.RunnerPerflog = "/dev/null" } // Backwards compatibility for configs that require // a default rather than being run as a cmd if ghc.Actor == "" { ghc.Actor = "nektos/act" } repoPath := rc.Config.Workdir repo, err := common.FindGithubRepo(repoPath, rc.Config.GitHubInstance) if err != nil { log.Warningf("unable to get git repo: %v", err) } else { ghc.Repository = repo if ghc.RepositoryOwner == "" { ghc.RepositoryOwner = strings.Split(repo, "/")[0] } } _, sha, err := common.FindGitRevision(repoPath) if err != nil { log.Warningf("unable to get git revision: %v", err) } else { ghc.Sha = sha } if rc.EventJSON != "" { err = json.Unmarshal([]byte(rc.EventJSON), &ghc.Event) if err != nil { log.Errorf("Unable to Unmarshal event '%s': %v", rc.EventJSON, err) } } maybeRef := nestedMapLookup(ghc.Event, ghc.EventName, "ref") if maybeRef != nil { log.Debugf("using github ref from event: %s", maybeRef) ghc.Ref = maybeRef.(string) } else { ref, err := common.FindGitRef(repoPath) if err != nil { log.Warningf("unable to get git ref: %v", err) } else { log.Debugf("using github ref: %s", ref) ghc.Ref = ref } // set the branch in the event data if rc.Config.DefaultBranch != "" { ghc.Event = withDefaultBranch(rc.Config.DefaultBranch, ghc.Event) } else { ghc.Event = withDefaultBranch("master", ghc.Event) } } if ghc.EventName == "pull_request" { ghc.BaseRef = asString(nestedMapLookup(ghc.Event, "pull_request", "base", "ref")) ghc.HeadRef = asString(nestedMapLookup(ghc.Event, "pull_request", "head", "ref")) } return ghc } func (ghc *githubContext) isLocalCheckout(step *model.Step) bool { if step.Type() == model.StepTypeInvalid { // This will be errored out by the executor later, we need this here to avoid a null panic though return false } if step.Type() != model.StepTypeUsesActionRemote { return false } remoteAction := newRemoteAction(step.Uses) if remoteAction == nil { // IsCheckout() will nil panic if we dont bail out early return false } if !remoteAction.IsCheckout() { return false } if repository, ok := step.With["repository"]; ok && repository != ghc.Repository { return false } if repository, ok := step.With["ref"]; ok && repository != ghc.Ref { return false } return true } func asString(v interface{}) string { if v == nil { return "" } else if s, ok := v.(string); ok { return s } return "" } func nestedMapLookup(m map[string]interface{}, ks ...string) (rval interface{}) { var ok bool if len(ks) == 0 { // degenerate input return nil } if rval, ok = m[ks[0]]; !ok { return nil } else if len(ks) == 1 { // we've reached the final key return rval } else if m, ok = rval.(map[string]interface{}); !ok { return nil } else { // 1+ more keys return nestedMapLookup(m, ks[1:]...) } } func withDefaultBranch(b string, event map[string]interface{}) map[string]interface{} { repoI, ok := event["repository"] if !ok { repoI = make(map[string]interface{}) } repo, ok := repoI.(map[string]interface{}) if !ok { log.Warnf("unable to set default branch to %v", b) return event } // if the branch is already there return with no changes if _, ok = repo["default_branch"]; ok { return event } repo["default_branch"] = b event["repository"] = repo return event } func (rc *RunContext) withGithubEnv(env map[string]string) map[string]string { github := rc.getGithubContext() env["CI"] = "true" env["GITHUB_ENV"] = ActPath + "/workflow/envs.txt" env["GITHUB_PATH"] = ActPath + "/workflow/paths.txt" env["GITHUB_WORKFLOW"] = github.Workflow env["GITHUB_RUN_ID"] = github.RunID env["GITHUB_RUN_NUMBER"] = github.RunNumber env["GITHUB_ACTION"] = github.Action if github.ActionPath != "" { env["GITHUB_ACTION_PATH"] = github.ActionPath } env["GITHUB_ACTIONS"] = "true" env["GITHUB_ACTOR"] = github.Actor env["GITHUB_REPOSITORY"] = github.Repository env["GITHUB_EVENT_NAME"] = github.EventName env["GITHUB_EVENT_PATH"] = github.EventPath env["GITHUB_WORKSPACE"] = github.Workspace env["GITHUB_SHA"] = github.Sha env["GITHUB_REF"] = github.Ref env["GITHUB_TOKEN"] = github.Token env["GITHUB_SERVER_URL"] = "https://github.com" env["GITHUB_API_URL"] = "https://api.github.com" env["GITHUB_GRAPHQL_URL"] = "https://api.github.com/graphql" env["GITHUB_ACTION_REF"] = github.ActionRef env["GITHUB_ACTION_REPOSITORY"] = github.ActionRepository env["GITHUB_BASE_REF"] = github.BaseRef env["GITHUB_HEAD_REF"] = github.HeadRef env["GITHUB_JOB"] = rc.JobName env["GITHUB_REPOSITORY_OWNER"] = github.RepositoryOwner env["GITHUB_RETENTION_DAYS"] = github.RetentionDays env["RUNNER_PERFLOG"] = github.RunnerPerflog env["RUNNER_TRACKING_ID"] = github.RunnerTrackingID if rc.Config.GitHubInstance != "github.com" { env["GITHUB_SERVER_URL"] = fmt.Sprintf("https://%s", rc.Config.GitHubInstance) env["GITHUB_API_URL"] = fmt.Sprintf("https://%s/api/v3", rc.Config.GitHubInstance) env["GITHUB_GRAPHQL_URL"] = fmt.Sprintf("https://%s/api/graphql", rc.Config.GitHubInstance) } if rc.Config.ArtifactServerPath != "" { setActionRuntimeVars(rc, env) } job := rc.Run.Job() if job.RunsOn() != nil { for _, runnerLabel := range job.RunsOn() { platformName := rc.ExprEval.Interpolate(runnerLabel) if platformName != "" { if platformName == "ubuntu-latest" { // hardcode current ubuntu-latest since we have no way to check that 'on the fly' env["ImageOS"] = "ubuntu20" } else { platformName = strings.SplitN(strings.Replace(platformName, `-`, ``, 1), `.`, 2)[0] env["ImageOS"] = platformName } } } } return env } func setActionRuntimeVars(rc *RunContext, env map[string]string) { actionsRuntimeURL := os.Getenv("ACTIONS_RUNTIME_URL") if actionsRuntimeURL == "" { actionsRuntimeURL = fmt.Sprintf("http://%s:%s/", common.GetOutboundIP().String(), rc.Config.ArtifactServerPort) } env["ACTIONS_RUNTIME_URL"] = actionsRuntimeURL actionsRuntimeToken := os.Getenv("ACTIONS_RUNTIME_TOKEN") if actionsRuntimeToken == "" { actionsRuntimeToken = "token" } env["ACTIONS_RUNTIME_TOKEN"] = actionsRuntimeToken } func (rc *RunContext) localCheckoutPath() (string, bool) { ghContext := rc.getGithubContext() for _, step := range rc.Run.Job().Steps { if ghContext.isLocalCheckout(step) { return step.With["path"], true } } return "", false } func (rc *RunContext) handleCredentials() (username, password string, err error) { // TODO: remove below 2 lines when we can release act with breaking changes username = rc.Config.Secrets["DOCKER_USERNAME"] password = rc.Config.Secrets["DOCKER_PASSWORD"] container := rc.Run.Job().Container() if container == nil || container.Credentials == nil { return } if container.Credentials != nil && len(container.Credentials) != 2 { err = fmt.Errorf("invalid property count for key 'credentials:'") return } ee := rc.NewExpressionEvaluator() var ok bool if username, ok = ee.InterpolateWithStringCheck(container.Credentials["username"]); !ok { err = fmt.Errorf("failed to interpolate container.credentials.username") return } if password, ok = ee.InterpolateWithStringCheck(container.Credentials["password"]); !ok { err = fmt.Errorf("failed to interpolate container.credentials.password") return } if container.Credentials["username"] == "" || container.Credentials["password"] == "" { err = fmt.Errorf("container.credentials cannot be empty") return } return username, password, err }