From be75ee20b1a9f0c2117ede35f7f37b1f59cdee35 Mon Sep 17 00:00:00 2001 From: Casey Lee Date: Sun, 9 Feb 2020 23:03:12 -0800 Subject: [PATCH] local actions Signed-off-by: Casey Lee --- .github/workflows/basic.yml | 8 + .github/workflows/docker-url/action.yml | 16 ++ go.sum | 1 + pkg/model/action.go | 57 +++++++ pkg/model/workflow.go | 31 ++++ pkg/runner/run_context.go | 139 +--------------- pkg/runner/step.go | 205 ++++++++++++++++++++++++ 7 files changed, 320 insertions(+), 137 deletions(-) create mode 100644 .github/workflows/docker-url/action.yml create mode 100644 pkg/model/action.go create mode 100644 pkg/runner/step.go diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml index b570a86..34d3c23 100644 --- a/.github/workflows/basic.yml +++ b/.github/workflows/basic.yml @@ -21,3 +21,11 @@ jobs: - run: cp $GITHUB_EVENT_PATH $HOME/foo.json - run: ls $HOME - run: cat $HOME/foo.json + - uses: docker://alpine:3.8 + with: + args: uname -a + local-action: + runs-on: ubuntu-latest + steps: + - uses: ./.github/workflows/docker-url + diff --git a/.github/workflows/docker-url/action.yml b/.github/workflows/docker-url/action.yml new file mode 100644 index 0000000..a463b91 --- /dev/null +++ b/.github/workflows/docker-url/action.yml @@ -0,0 +1,16 @@ +name: docker-url +author: nektos +description: testing +inputs: + who-to-greet: + description: who to greet + required: true + default: World +runs: + using: docker + image: docker://alpine:3.8 + env: + TEST: enabled + args: + - echo + - ${INPUT_WHO_TO_GREET} diff --git a/go.sum b/go.sum index 3bc8655..08e7c20 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,7 @@ github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BU github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/engine v0.0.0-20181106193140-f5749085e9cb h1:PyjxRdW1mqCmSoxy/6uP01P7CGbsD+woX+oOWbaUPwQ= github.com/docker/engine v0.0.0-20181106193140-f5749085e9cb/go.mod h1:3CPr2caMgTHxxIAZgEMd3uLYPDlRvPqCpyeRf6ncPcY= +github.com/docker/engine v1.13.1 h1:Cks33UT9YBW5Xyc3MtGDq2IPgqfJtJ+qkFaxc2b0Euc= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.3.3 h1:Xk8S3Xj5sLGlG5g67hJmYMmUgXv5N4PhkjJHHqrwnTk= diff --git a/pkg/model/action.go b/pkg/model/action.go new file mode 100644 index 0000000..4987483 --- /dev/null +++ b/pkg/model/action.go @@ -0,0 +1,57 @@ +package model + +import ( + "io" + + "gopkg.in/yaml.v2" +) + +// ActionRunsUsing is the type of runner for the action +type ActionRunsUsing string + +const ( + // ActionRunsUsingNode12 for running with node12 + ActionRunsUsingNode12 = "node12" + // ActionRunsUsingDocker for running with docker + ActionRunsUsingDocker = "docker" +) + +// Action describes a metadata file for GitHub actions. The metadata filename must be either action.yml or action.yaml. The data in the metadata file defines the inputs, outputs and main entrypoint for your action. +type Action struct { + Name string `yaml:"name"` + Author string `yaml:"author"` + Description string `yaml:"description"` + Inputs map[string]Input `yaml:"inputs"` + Outputs map[string]Output `yaml:"outputs"` + Runs struct { + Using ActionRunsUsing `yaml:"using"` + Env map[string]string `yaml:"env"` + Main string `yaml:"main"` + Image string `yaml:"image"` + Entrypoint []string `yaml:"entrypoint"` + Args []string `yaml:"args"` + } `yaml:"runs"` + Branding struct { + Color string `yaml:"color"` + Icon string `yaml:"icon"` + } `yaml:"branding"` +} + +// Input parameters allow you to specify data that the action expects to use during runtime. GitHub stores input parameters as environment variables. Input ids with uppercase letters are converted to lowercase during runtime. We recommended using lowercase input ids. +type Input struct { + Description string `yaml:"description"` + Required bool `yaml:"required"` + Default string `yaml:"default"` +} + +// Output parameters allow you to declare data that an action sets. Actions that run later in a workflow can use the output data set in previously run actions. For example, if you had an action that performed the addition of two inputs (x + y = z), the action could output the sum (z) for other actions to use as an input. +type Output struct { + Description string `yaml:"description"` +} + +// ReadAction reads an action from a reader +func ReadAction(in io.Reader) (*Action, error) { + a := new(Action) + err := yaml.NewDecoder(in).Decode(a) + return a, err +} diff --git a/pkg/model/workflow.go b/pkg/model/workflow.go index 616867b..d1c7bfd 100644 --- a/pkg/model/workflow.go +++ b/pkg/model/workflow.go @@ -3,6 +3,7 @@ package model import ( "fmt" "io" + "regexp" "strings" "gopkg.in/yaml.v2" @@ -63,11 +64,41 @@ func (s *Step) GetEnv() map[string]string { } for k, v := range s.With { envKey := fmt.Sprintf("INPUT_%s", strings.ToUpper(k)) + envKey = regexp.MustCompile("[^A-Z0-9]").ReplaceAllString(envKey, "_") rtnEnv[envKey] = v } return rtnEnv } +// StepType describes what type of step we are about to run +type StepType int + +const ( + // StepTypeRun is all steps that have a `run` attribute + StepTypeRun StepType = iota + + //StepTypeUsesDockerURL is all steps that have a `uses` that is of the form `docker://...` + StepTypeUsesDockerURL + + //StepTypeUsesActionLocal is all steps that have a `uses` that is a reference to a github repo + StepTypeUsesActionLocal + + //StepTypeUsesActionRemote is all steps that have a `uses` that is a local action in a subdirectory + StepTypeUsesActionRemote +) + +// Type returns the type of the step +func (s *Step) Type() StepType { + if s.Run != "" { + return StepTypeRun + } else if strings.HasPrefix(s.Uses, "docker://") { + return StepTypeUsesDockerURL + } else if strings.HasPrefix(s.Uses, "./") { + return StepTypeUsesActionLocal + } + return StepTypeUsesActionRemote +} + // ReadWorkflow returns a list of jobs for a given workflow file reader func ReadWorkflow(in io.Reader) (*Workflow, error) { w := new(Workflow) diff --git a/pkg/runner/run_context.go b/pkg/runner/run_context.go index fcbfeb9..8e225c9 100644 --- a/pkg/runner/run_context.go +++ b/pkg/runner/run_context.go @@ -38,48 +38,6 @@ func (rc *RunContext) GetEnv() map[string]string { return rc.Env } -// StepEnv returns the env for a step -func (rc *RunContext) StepEnv(step *model.Step) map[string]string { - env := make(map[string]string) - env["HOME"] = "/github/home" - env["GITHUB_WORKFLOW"] = rc.Run.Workflow.Name - env["GITHUB_RUN_ID"] = "1" - env["GITHUB_RUN_NUMBER"] = "1" - env["GITHUB_ACTION"] = step.ID - env["GITHUB_ACTOR"] = "nektos/act" - - repoPath := rc.Config.Workdir - repo, err := common.FindGithubRepo(repoPath) - if err != nil { - log.Warningf("unable to get git repo: %v", err) - } else { - env["GITHUB_REPOSITORY"] = repo - } - env["GITHUB_EVENT_NAME"] = rc.Config.EventName - env["GITHUB_EVENT_PATH"] = "/github/workflow/event.json" - env["GITHUB_WORKSPACE"] = "/github/workspace" - - _, rev, err := common.FindGitRevision(repoPath) - if err != nil { - log.Warningf("unable to get git revision: %v", err) - } else { - env["GITHUB_SHA"] = rev - } - - ref, err := common.FindGitRef(repoPath) - if err != nil { - log.Warningf("unable to get git ref: %v", err) - } else { - log.Infof("using github ref: %s", ref) - env["GITHUB_REF"] = ref - } - job := rc.Run.Job() - if job.Container != nil { - return mergeMaps(rc.GetEnv(), job.Container.Env, step.GetEnv(), env) - } - return mergeMaps(rc.GetEnv(), step.GetEnv(), env) -} - // Close cleans up temp dir func (rc *RunContext) Close(ctx context.Context) error { return os.RemoveAll(rc.Tempdir) @@ -91,105 +49,11 @@ func (rc *RunContext) Executor() common.Executor { steps = append(steps, rc.setupTempDir()) for _, step := range rc.Run.Job().Steps { - containerSpec := new(model.ContainerSpec) - - var stepExecutor common.Executor - if step.Run != "" { - stepExecutor = common.NewPipelineExecutor( - rc.setupContainerSpec(step, containerSpec), - rc.pullImage(containerSpec), - rc.runContainer(containerSpec), - ) - } else if step.Uses != "" { - stepExecutor = common.NewErrorExecutor(fmt.Errorf("Not yet implemented - job:%s step:%+v", rc.Run, step)) - // clone action repo - // read action.yaml - // if runs.using == node12, start node12 container and run `main` - // if runs.using == docker, pull `image` and run - // caputre output/commands - } else { - stepExecutor = common.NewErrorExecutor(fmt.Errorf("Unable to determine how to run job:%s step:%+v", rc.Run, step)) - } - steps = append(steps, stepExecutor) + steps = append(steps, rc.newStepExecutor(step)) } return common.NewPipelineExecutor(steps...).Finally(rc.Close) } -func (rc *RunContext) setupContainerSpec(step *model.Step, containerSpec *model.ContainerSpec) common.Executor { - return func(ctx context.Context) error { - job := rc.Run.Job() - - containerSpec.Env = rc.StepEnv(step) - - if step.Uses != "" { - containerSpec.Image = step.Uses - } else if job.Container != nil { - containerSpec.Image = job.Container.Image - containerSpec.Args = rc.shellCommand(step.Shell, step.Run) - containerSpec.Ports = job.Container.Ports - containerSpec.Volumes = job.Container.Volumes - containerSpec.Options = job.Container.Options - } else if step.Run != "" { - containerSpec.Image = platformImage(job.RunsOn) - containerSpec.Args = rc.shellCommand(step.Shell, step.Run) - } else { - return fmt.Errorf("Unable to setup container for %s", step) - } - return nil - } -} - -func (rc *RunContext) shellCommand(shell string, run string) string { - shellCommand := "" - - switch shell { - case "", "bash": - shellCommand = "bash --noprofile --norc -eo pipefail {0}" - case "pwsh": - shellCommand = "pwsh -command \"& '{0}'\"" - case "python": - shellCommand = "python {0}" - case "sh": - shellCommand = "sh -e -c {0}" - case "cmd": - shellCommand = "%ComSpec% /D /E:ON /V:OFF /S /C \"CALL \"{0}\"\"" - case "powershell": - shellCommand = "powershell -command \"& '{0}'\"" - default: - shellCommand = shell - } - - tempScript, err := ioutil.TempFile(rc.Tempdir, ".temp-script-") - if err != nil { - log.Fatalf("Unable to create temp script %v", err) - } - - if _, err := tempScript.Write([]byte(run)); err != nil { - log.Fatal(err) - } - log.Debugf("Wrote command '%s' to '%s'", run, tempScript.Name()) - if err := tempScript.Close(); err != nil { - log.Fatal(err) - } - containerPath := fmt.Sprintf("/github/home/%s", filepath.Base(tempScript.Name())) - cmd := strings.Replace(shellCommand, "{0}", containerPath, 1) - log.Debugf("about to run %s", cmd) - return cmd -} - -func platformImage(platform string) string { - switch platform { - case "ubuntu-latest", "ubuntu-18.04": - return "ubuntu:18.04" - case "ubuntu-16.04": - return "ubuntu:16.04" - case "windows-latest", "windows-2019", "macos-latest", "macos-10.15": - return "" - default: - return "" - } -} - func mergeMaps(maps ...map[string]string) map[string]string { rtnMap := make(map[string]string) for _, m := range maps { @@ -208,6 +72,7 @@ func (rc *RunContext) setupTempDir() common.Executor { tempBase = "/tmp" } rc.Tempdir, err = ioutil.TempDir(tempBase, "act-") + log.Debugf("Setup tempdir %s", rc.Tempdir) return err } } diff --git a/pkg/runner/step.go b/pkg/runner/step.go new file mode 100644 index 0000000..f5620e7 --- /dev/null +++ b/pkg/runner/step.go @@ -0,0 +1,205 @@ +package runner + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/nektos/act/pkg/common" + "github.com/nektos/act/pkg/model" + log "github.com/sirupsen/logrus" +) + +func (rc *RunContext) newStepExecutor(step *model.Step) common.Executor { + job := rc.Run.Job() + containerSpec := new(model.ContainerSpec) + containerSpec.Env = rc.StepEnv(step) + + switch step.Type() { + case model.StepTypeRun: + if job.Container != nil { + containerSpec.Image = job.Container.Image + containerSpec.Ports = job.Container.Ports + containerSpec.Volumes = job.Container.Volumes + containerSpec.Options = job.Container.Options + } else { + containerSpec.Image = platformImage(job.RunsOn) + } + return common.NewPipelineExecutor( + rc.setupShellCommand(containerSpec, step.Shell, step.Run), + rc.pullImage(containerSpec), + rc.runContainer(containerSpec), + ) + + case model.StepTypeUsesDockerURL: + containerSpec.Image = strings.TrimPrefix(step.Uses, "docker://") + containerSpec.Entrypoint = step.With["entrypoint"] + containerSpec.Args = step.With["args"] + return common.NewPipelineExecutor( + rc.pullImage(containerSpec), + rc.runContainer(containerSpec), + ) + + case model.StepTypeUsesActionLocal: + return common.NewPipelineExecutor( + rc.setupAction(containerSpec, filepath.Join(rc.Config.Workdir, step.Uses)), + rc.pullImage(containerSpec), + rc.runContainer(containerSpec), + ) + case model.StepTypeUsesActionRemote: + return common.NewPipelineExecutor( + rc.cloneAction(step.Uses), + rc.setupAction(containerSpec, step.Uses), + rc.pullImage(containerSpec), + rc.runContainer(containerSpec), + ) + } + + return common.NewErrorExecutor(fmt.Errorf("Unable to determine how to run job:%s step:%+v", rc.Run, step)) +} + +// StepEnv returns the env for a step +func (rc *RunContext) StepEnv(step *model.Step) map[string]string { + env := make(map[string]string) + env["HOME"] = "/github/home" + env["GITHUB_WORKFLOW"] = rc.Run.Workflow.Name + env["GITHUB_RUN_ID"] = "1" + env["GITHUB_RUN_NUMBER"] = "1" + env["GITHUB_ACTION"] = step.ID + env["GITHUB_ACTOR"] = "nektos/act" + + repoPath := rc.Config.Workdir + repo, err := common.FindGithubRepo(repoPath) + if err != nil { + log.Warningf("unable to get git repo: %v", err) + } else { + env["GITHUB_REPOSITORY"] = repo + } + env["GITHUB_EVENT_NAME"] = rc.Config.EventName + env["GITHUB_EVENT_PATH"] = "/github/workflow/event.json" + env["GITHUB_WORKSPACE"] = "/github/workspace" + + _, rev, err := common.FindGitRevision(repoPath) + if err != nil { + log.Warningf("unable to get git revision: %v", err) + } else { + env["GITHUB_SHA"] = rev + } + + ref, err := common.FindGitRef(repoPath) + if err != nil { + log.Warningf("unable to get git ref: %v", err) + } else { + log.Infof("using github ref: %s", ref) + env["GITHUB_REF"] = ref + } + job := rc.Run.Job() + if job.Container != nil { + return mergeMaps(rc.GetEnv(), job.Container.Env, step.GetEnv(), env) + } + return mergeMaps(rc.GetEnv(), step.GetEnv(), env) +} + +func (rc *RunContext) setupShellCommand(containerSpec *model.ContainerSpec, shell string, run string) common.Executor { + return func(ctx context.Context) error { + shellCommand := "" + + switch shell { + case "", "bash": + shellCommand = "bash --noprofile --norc -eo pipefail {0}" + case "pwsh": + shellCommand = "pwsh -command \"& '{0}'\"" + case "python": + shellCommand = "python {0}" + case "sh": + shellCommand = "sh -e -c {0}" + case "cmd": + shellCommand = "%ComSpec% /D /E:ON /V:OFF /S /C \"CALL \"{0}\"\"" + case "powershell": + shellCommand = "powershell -command \"& '{0}'\"" + default: + shellCommand = shell + } + + tempScript, err := ioutil.TempFile(rc.Tempdir, ".temp-script-") + if err != nil { + return err + } + + if _, err := tempScript.Write([]byte(run)); err != nil { + return err + } + log.Debugf("Wrote command '%s' to '%s'", run, tempScript.Name()) + if err := tempScript.Close(); err != nil { + return err + } + containerPath := fmt.Sprintf("/github/home/%s", filepath.Base(tempScript.Name())) + containerSpec.Args = strings.Replace(shellCommand, "{0}", containerPath, 1) + return nil + } +} + +func platformImage(platform string) string { + switch platform { + case "ubuntu-latest", "ubuntu-18.04": + return "ubuntu:18.04" + case "ubuntu-16.04": + return "ubuntu:16.04" + case "windows-latest", "windows-2019", "macos-latest", "macos-10.15": + return "" + default: + return "" + } +} + +func (rc *RunContext) setupAction(containerSpec *model.ContainerSpec, actionDir string) common.Executor { + return func(ctx context.Context) error { + f, err := os.Open(filepath.Join(actionDir, "action.yml")) + if os.IsNotExist(err) { + f, err = os.Open(filepath.Join(actionDir, "action.yaml")) + if err != nil { + return err + } + } else if err != nil { + return err + } + + action, err := model.ReadAction(f) + if err != nil { + return err + } + + for inputID, input := range action.Inputs { + envKey := fmt.Sprintf("INPUT_%s", strings.ToUpper(inputID)) + envKey = regexp.MustCompile("[^A-Z0-9]").ReplaceAllString(envKey, "_") + if _, ok := containerSpec.Env[envKey]; !ok { + containerSpec.Env[envKey] = input.Default + } + } + + switch action.Runs.Using { + case model.ActionRunsUsingNode12: + containerSpec.Image = "node:12" + containerSpec.Args = action.Runs.Main + case model.ActionRunsUsingDocker: + if strings.HasPrefix(action.Runs.Image, "docker://") { + containerSpec.Image = strings.TrimPrefix(action.Runs.Image, "docker://") + containerSpec.Entrypoint = strings.Join(action.Runs.Entrypoint, " ") + containerSpec.Args = strings.Join(action.Runs.Args, " ") + } else { + // TODO: docker build + } + } + return nil + } +} + +func (rc *RunContext) cloneAction(action string) common.Executor { + return func(ctx context.Context) error { + return nil + } +}