diff --git a/actions/action.go b/actions/action.go new file mode 100644 index 00000000..79162e99 --- /dev/null +++ b/actions/action.go @@ -0,0 +1,80 @@ +package actions + +import ( + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + + "github.com/nektos/act/common" + log "github.com/sirupsen/logrus" +) + +// imageURL is the directory where a `Dockerfile` should exist +func parseImageLocal(workingDir string, contextDir string) (contextDirOut string, tag string, ok bool) { + if !filepath.IsAbs(contextDir) { + contextDir = filepath.Join(workingDir, contextDir) + } + if _, err := os.Stat(filepath.Join(contextDir, "Dockerfile")); os.IsNotExist(err) { + log.Debugf("Ignoring missing Dockerfile '%s/Dockerfile'", contextDir) + return "", "", false + } + + sha, _, err := common.FindGitRevision(contextDir) + if err != nil { + log.Warnf("Unable to determine git revision: %v", err) + sha = "latest" + } + return contextDir, fmt.Sprintf("%s:%s", filepath.Base(contextDir), sha), true +} + +// imageURL is the URL for a docker repo +func parseImageReference(image string) (ref string, ok bool) { + imageURL, err := url.Parse(image) + if err != nil { + log.Debugf("Unable to parse image as url: %v", err) + return "", false + } + if imageURL.Scheme != "docker" { + log.Debugf("Ignoring non-docker ref '%s'", imageURL.String()) + return "", false + } + + return fmt.Sprintf("%s%s", imageURL.Host, imageURL.Path), true +} + +// imageURL is the directory where a `Dockerfile` should exist +func parseImageGithub(image string) (cloneURL *url.URL, ref string, path string, ok bool) { + re := regexp.MustCompile("^([^/@]+)/([^/@]+)(/([^@]*))?(@(.*))?$") + matches := re.FindStringSubmatch(image) + + if matches == nil { + return nil, "", "", false + } + + cloneURL, err := url.Parse(fmt.Sprintf("https://github.com/%s/%s", matches[1], matches[2])) + if err != nil { + log.Debugf("Unable to parse as URL: %v", err) + return nil, "", "", false + } + + resp, err := http.Head(cloneURL.String()) + if resp.StatusCode >= 400 || err != nil { + log.Debugf("Unable to HEAD URL %s status=%v err=%v", cloneURL.String(), resp.StatusCode, err) + return nil, "", "", false + } + + ref = matches[6] + if ref == "" { + ref = "master" + } + + path = matches[4] + if path == "" { + path = "." + } + + return cloneURL, ref, path, true +} diff --git a/actions/api.go b/actions/api.go new file mode 100644 index 00000000..0680165a --- /dev/null +++ b/actions/api.go @@ -0,0 +1,49 @@ +package actions + +import ( + "context" + "io" +) + +// Runner provides capabilities to run GitHub actions +type Runner interface { + EventGrapher + EventLister + EventRunner + ActionRunner + io.Closer +} + +// EventGrapher to list the actions +type EventGrapher interface { + GraphEvent(eventName string) ([][]string, error) +} + +// EventLister to list the events +type EventLister interface { + ListEvents() []string +} + +// EventRunner to run the actions for a given event +type EventRunner interface { + RunEvent() error +} + +// ActionRunner to run a specific actions +type ActionRunner interface { + RunActions(actionNames ...string) error +} + +// RunnerConfig contains the config for a new runner +type RunnerConfig struct { + Ctx context.Context // context to use for the run + Dryrun bool // don't start any of the containers + WorkingDir string // base directory to use + WorkflowPath string // path to load main.workflow file, relative to WorkingDir + EventName string // name of event to run + EventPath string // path to JSON file to use for event.json in containers, relative to WorkingDir +} + +type environmentApplier interface { + applyEnvironment(map[string]string) +} diff --git a/actions/model.go b/actions/model.go new file mode 100644 index 00000000..bf720d8f --- /dev/null +++ b/actions/model.go @@ -0,0 +1,129 @@ +package actions + +import ( + "fmt" + "log" + "os" + + "github.com/howeyc/gopass" +) + +type workflowModel struct { + On string + Resolves []string +} + +type actionModel struct { + Needs []string + Uses string + Runs []string + Args []string + Env map[string]string + Secrets []string +} + +type workflowsFile struct { + Workflow map[string]workflowModel + Action map[string]actionModel +} + +func (wFile *workflowsFile) getWorkflow(eventName string) (*workflowModel, string, error) { + var rtn workflowModel + for wName, w := range wFile.Workflow { + if w.On == eventName { + rtn = w + return &rtn, wName, nil + } + } + return nil, "", fmt.Errorf("unsupported event: %v", eventName) +} + +func (wFile *workflowsFile) getAction(actionName string) (*actionModel, error) { + if a, ok := wFile.Action[actionName]; ok { + return &a, nil + } + return nil, fmt.Errorf("unsupported action: %v", actionName) +} + +// return a pipeline that is run in series. pipeline is a list of steps to run in parallel +func (wFile *workflowsFile) newExecutionGraph(actionNames ...string) [][]string { + // first, build a list of all the necessary actions to run, and their dependencies + actionDependencies := make(map[string][]string) + for len(actionNames) > 0 { + newActionNames := make([]string, 0) + for _, aName := range actionNames { + // make sure we haven't visited this action yet + if _, ok := actionDependencies[aName]; !ok { + actionDependencies[aName] = wFile.Action[aName].Needs + newActionNames = append(newActionNames, wFile.Action[aName].Needs...) + } + } + actionNames = newActionNames + } + + // next, build an execution graph + graph := make([][]string, 0) + for len(actionDependencies) > 0 { + stage := make([]string, 0) + for aName, aDeps := range actionDependencies { + // make sure all deps are in the graph already + if listInLists(aDeps, graph...) { + stage = append(stage, aName) + delete(actionDependencies, aName) + } + } + if len(stage) == 0 { + log.Fatalf("Unable to build dependency graph!") + } + graph = append(graph, stage) + } + + return graph +} + +// return true iff all strings in srcList exist in at least one of the searchLists +func listInLists(srcList []string, searchLists ...[]string) bool { + for _, src := range srcList { + found := false + for _, searchList := range searchLists { + for _, search := range searchList { + if src == search { + found = true + } + } + } + if !found { + return false + } + } + return true +} + +var secretCache map[string]string + +func (action *actionModel) applyEnvironment(env map[string]string) { + for envKey, envValue := range action.Env { + env[envKey] = envValue + } + + for _, secret := range action.Secrets { + if secretVal, ok := os.LookupEnv(secret); ok { + env[secret] = secretVal + } else { + if secretCache == nil { + secretCache = make(map[string]string) + } + + if _, ok := secretCache[secret]; !ok { + fmt.Printf("Provide value for '%s': ", secret) + val, err := gopass.GetPasswdMasked() + if err != nil { + log.Fatal("abort") + } + + secretCache[secret] = string(val) + } + env[secret] = secretCache[secret] + } + } +} diff --git a/actions/parser.go b/actions/parser.go index 1458d88d..6eedd635 100644 --- a/actions/parser.go +++ b/actions/parser.go @@ -5,9 +5,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" - "os" - "path/filepath" "github.com/hashicorp/hcl" "github.com/hashicorp/hcl/hcl/ast" @@ -15,43 +12,12 @@ import ( log "github.com/sirupsen/logrus" ) -// ParseWorkflows will read in the set of actions from the workflow file -func ParseWorkflows(workingDir string, workflowPath string) (Workflows, error) { - workingDir, err := filepath.Abs(workingDir) - if err != nil { - return nil, err - } - log.Debugf("Setting working dir to %s", workingDir) - - if !filepath.IsAbs(workflowPath) { - workflowPath = filepath.Join(workingDir, workflowPath) - } - log.Debugf("Loading workflow config from %s", workflowPath) - workflowReader, err := os.Open(workflowPath) - if err != nil { - return nil, err - } - - workflows, err := parseWorkflowsFile(workflowReader) - if err != nil { - return nil, err - } - workflows.WorkingDir = workingDir - workflows.WorkflowPath = workflowPath - workflows.TempDir, err = ioutil.TempDir("", "act-") - if err != nil { - return nil, err - } - +func parseWorkflowsFile(workflowReader io.Reader) (*workflowsFile, error) { // TODO: add validation logic // - check for circular dependencies // - check for valid local path refs // - check for valid dependencies - return workflows, nil -} -func parseWorkflowsFile(workflowReader io.Reader) (*workflowsFile, error) { - buf := new(bytes.Buffer) _, err := buf.ReadFrom(workflowReader) if err != nil { diff --git a/actions/runner.go b/actions/runner.go index 055c77d3..fa92d955 100644 --- a/actions/runner.go +++ b/actions/runner.go @@ -1,31 +1,94 @@ package actions import ( - "archive/tar" - "bytes" - "context" - "fmt" - "io" - "math/rand" - "net/http" - "net/url" + "io/ioutil" "os" "path/filepath" - "regexp" "sort" - "github.com/howeyc/gopass" "github.com/nektos/act/common" - "github.com/nektos/act/container" log "github.com/sirupsen/logrus" ) -var secretCache map[string]string +type runnerImpl struct { + config *RunnerConfig + workflows *workflowsFile + tempDir string + eventJSON string +} -func (wFile *workflowsFile) ListEvents() []string { +// NewRunner Creates a new Runner +func NewRunner(runnerConfig *RunnerConfig) (Runner, error) { + runner := &runnerImpl{ + config: runnerConfig, + } + + init := common.NewPipelineExecutor( + runner.setupTempDir, + runner.setupWorkingDir, + runner.setupWorkflows, + runner.setupEvent, + ) + + return runner, init() +} + +func (runner *runnerImpl) setupTempDir() error { + var err error + runner.tempDir, err = ioutil.TempDir("", "act-") + return err +} + +func (runner *runnerImpl) setupWorkingDir() error { + var err error + runner.config.WorkingDir, err = filepath.Abs(runner.config.WorkingDir) + log.Debugf("Setting working dir to %s", runner.config.WorkingDir) + return err +} + +func (runner *runnerImpl) setupWorkflows() error { + runner.config.WorkflowPath = runner.resolvePath(runner.config.WorkflowPath) + log.Debugf("Loading workflow config from %s", runner.config.WorkflowPath) + workflowReader, err := os.Open(runner.config.WorkflowPath) + if err != nil { + return err + } + + defer workflowReader.Close() + + runner.workflows, err = parseWorkflowsFile(workflowReader) + return err +} + +func (runner *runnerImpl) setupEvent() error { + runner.eventJSON = "{}" + if runner.config.EventPath != "" { + runner.config.EventPath = runner.resolvePath(runner.config.EventPath) + log.Debugf("Reading event.json from %s", runner.config.EventPath) + eventJSONBytes, err := ioutil.ReadFile(runner.config.EventPath) + if err != nil { + return err + } + runner.eventJSON = string(eventJSONBytes) + } + return nil +} + +func (runner *runnerImpl) resolvePath(path string) string { + if path == "" { + return path + } + if !filepath.IsAbs(path) { + path = filepath.Join(runner.config.WorkingDir, path) + } + return path +} + +// ListEvents gets all the events in the workflows file +func (runner *runnerImpl) ListEvents() []string { log.Debugf("Listing all events") events := make([]string, 0) - for _, w := range wFile.Workflow { + for _, w := range runner.workflows.Workflow { events = append(events, w.On) } @@ -37,380 +100,46 @@ func (wFile *workflowsFile) ListEvents() []string { return events } -func (wFile *workflowsFile) GraphEvent(eventName string) ([][]string, error) { +// GraphEvent builds an execution path +func (runner *runnerImpl) GraphEvent(eventName string) ([][]string, error) { log.Debugf("Listing actions for event '%s'", eventName) - workflow, _, err := wFile.getWorkflow(eventName) + workflow, _, err := runner.workflows.getWorkflow(eventName) if err != nil { return nil, err } - return wFile.newExecutionGraph(workflow.Resolves...), nil + return runner.workflows.newExecutionGraph(workflow.Resolves...), nil } -func (wFile *workflowsFile) RunAction(ctx context.Context, dryrun bool, actionName string, eventJSON string) error { - log.Debugf("Running action '%s'", actionName) - return wFile.newActionExecutor(ctx, dryrun, "", eventJSON, actionName)() -} - -func (wFile *workflowsFile) RunEvent(ctx context.Context, dryrun bool, eventName string, eventJSON string) error { - log.Debugf("Running event '%s'", eventName) - workflow, _, err := wFile.getWorkflow(eventName) - if err != nil { - return err - } - - log.Debugf("Running actions %s -> %s", eventName, workflow.Resolves) - return wFile.newActionExecutor(ctx, dryrun, eventName, eventJSON, workflow.Resolves...)() -} - -func (wFile *workflowsFile) getWorkflow(eventName string) (*workflowDef, string, error) { - var rtn workflowDef - for wName, w := range wFile.Workflow { - if w.On == eventName { - rtn = w - return &rtn, wName, nil - } - } - return nil, "", fmt.Errorf("unsupported event: %v", eventName) -} - -func (wFile *workflowsFile) getAction(actionName string) (*actionDef, error) { - if a, ok := wFile.Action[actionName]; ok { - return &a, nil - } - return nil, fmt.Errorf("unsupported action: %v", actionName) -} - -func (wFile *workflowsFile) Close() { - os.RemoveAll(wFile.TempDir) -} - -// return a pipeline that is run in series. pipeline is a list of steps to run in parallel -func (wFile *workflowsFile) newExecutionGraph(actionNames ...string) [][]string { - // first, build a list of all the necessary actions to run, and their dependencies - actionDependencies := make(map[string][]string) - for len(actionNames) > 0 { - newActionNames := make([]string, 0) - for _, aName := range actionNames { - // make sure we haven't visited this action yet - if _, ok := actionDependencies[aName]; !ok { - actionDependencies[aName] = wFile.Action[aName].Needs - newActionNames = append(newActionNames, wFile.Action[aName].Needs...) - } - } - actionNames = newActionNames - } - - // next, build an execution graph - graph := make([][]string, 0) - for len(actionDependencies) > 0 { - stage := make([]string, 0) - for aName, aDeps := range actionDependencies { - // make sure all deps are in the graph already - if listInLists(aDeps, graph...) { - stage = append(stage, aName) - delete(actionDependencies, aName) - } - } - if len(stage) == 0 { - log.Fatalf("Unable to build dependency graph!") - } - graph = append(graph, stage) - } - - return graph -} - -// return true iff all strings in srcList exist in at least one of the searchLists -func listInLists(srcList []string, searchLists ...[]string) bool { - for _, src := range srcList { - found := false - for _, searchList := range searchLists { - for _, search := range searchList { - if src == search { - found = true - } - } - } - if !found { - return false - } - } - return true -} - -func (wFile *workflowsFile) newActionExecutor(ctx context.Context, dryrun bool, eventName string, eventJSON string, actionNames ...string) common.Executor { - graph := wFile.newExecutionGraph(actionNames...) +// RunAction runs a set of actions in parallel, and their dependencies +func (runner *runnerImpl) RunActions(actionNames ...string) error { + log.Debugf("Running actions %+q", actionNames) + graph := runner.workflows.newExecutionGraph(actionNames...) pipeline := make([]common.Executor, 0) for _, actions := range graph { stage := make([]common.Executor, 0) for _, actionName := range actions { - action, err := wFile.getAction(actionName) - if err != nil { - return common.NewErrorExecutor(err) - } - actionExecutor := action.asExecutor(ctx, dryrun, wFile.WorkingDir, wFile.TempDir, actionName, wFile.setupEnvironment(eventName, actionName, dryrun), eventJSON) - stage = append(stage, actionExecutor) + stage = append(stage, runner.newActionExecutor(actionName)) } pipeline = append(pipeline, common.NewParallelExecutor(stage...)) } - return common.NewPipelineExecutor(pipeline...) + executor := common.NewPipelineExecutor(pipeline...) + return executor() } -func (action *actionDef) asExecutor(ctx context.Context, dryrun bool, workingDir string, tempDir string, actionName string, env []string, eventJSON string) common.Executor { - logger := newActionLogger(actionName, dryrun) - log.Debugf("Using '%s' for action '%s'", action.Uses, actionName) - - in := container.DockerExecutorInput{ - Ctx: ctx, - Logger: logger, - Dryrun: dryrun, - } - - var image string - executors := make([]common.Executor, 0) - if imageRef, ok := parseImageReference(action.Uses); ok { - executors = append(executors, container.NewDockerPullExecutor(container.NewDockerPullExecutorInput{ - DockerExecutorInput: in, - Image: imageRef, - })) - image = imageRef - } else if contextDir, imageTag, ok := parseImageLocal(workingDir, action.Uses); ok { - executors = append(executors, container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{ - DockerExecutorInput: in, - ContextDir: contextDir, - ImageTag: imageTag, - })) - image = imageTag - } else if cloneURL, ref, path, ok := parseImageGithub(action.Uses); ok { - cloneDir := filepath.Join(os.TempDir(), "act", action.Uses) - executors = append(executors, common.NewGitCloneExecutor(common.NewGitCloneExecutorInput{ - URL: cloneURL, - Ref: ref, - Dir: cloneDir, - Logger: logger, - Dryrun: dryrun, - })) - - contextDir := filepath.Join(cloneDir, path) - imageTag := fmt.Sprintf("%s:%s", filepath.Base(cloneURL.Path), ref) - - executors = append(executors, container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{ - DockerExecutorInput: in, - ContextDir: contextDir, - ImageTag: imageTag, - })) - image = imageTag - } else { - return common.NewErrorExecutor(fmt.Errorf("unable to determine executor type for image '%s'", action.Uses)) - } - - ghReader, err := action.createGithubTarball(eventJSON) +// RunEvent runs the actions for a single event +func (runner *runnerImpl) RunEvent() error { + log.Debugf("Running event '%s'", runner.config.EventName) + workflow, _, err := runner.workflows.getWorkflow(runner.config.EventName) if err != nil { - return common.NewErrorExecutor(err) + return err } - randSuffix := randString(6) - containerName := regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(actionName, "-") - if len(containerName)+len(randSuffix)+1 > 30 { - containerName = containerName[:(30 - (len(randSuffix) + 1))] - } - executors = append(executors, container.NewDockerRunExecutor(container.NewDockerRunExecutorInput{ - DockerExecutorInput: in, - Cmd: action.Args, - Entrypoint: action.Runs, - Image: image, - WorkingDir: "/github/workspace", - Env: env, - Name: fmt.Sprintf("%s-%s", containerName, randSuffix), - Binds: []string{ - fmt.Sprintf("%s:%s", workingDir, "/github/workspace"), - fmt.Sprintf("%s:%s", tempDir, "/github/home"), - fmt.Sprintf("%s:%s", "/var/run/docker.sock", "/var/run/docker.sock"), - }, - Content: map[string]io.Reader{"/github": ghReader}, - })) - return common.NewPipelineExecutor(executors...) + log.Debugf("Running actions %s -> %s", runner.config.EventName, workflow.Resolves) + return runner.RunActions(workflow.Resolves...) } -const letterBytes = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" - -func randString(slen int) string { - b := make([]byte, slen) - for i := range b { - b[i] = letterBytes[rand.Int63()%int64(len(letterBytes))] - } - return string(b) -} - -func (action *actionDef) createGithubTarball(eventJSON string) (io.Reader, error) { - var buf bytes.Buffer - tw := tar.NewWriter(&buf) - var files = []struct { - Name string - Mode int64 - Body string - }{ - {"workflow/event.json", 0644, eventJSON}, - } - for _, file := range files { - log.Debugf("Writing entry to tarball %s len:%d from %v", file.Name, len(eventJSON), eventJSON) - hdr := &tar.Header{ - Name: file.Name, - Mode: file.Mode, - Size: int64(len(eventJSON)), - } - if err := tw.WriteHeader(hdr); err != nil { - return nil, err - } - if _, err := tw.Write([]byte(eventJSON)); err != nil { - return nil, err - } - } - if err := tw.Close(); err != nil { - return nil, err - } - - return &buf, nil - -} - -func (wFile *workflowsFile) setupEnvironment(eventName string, actionName string, dryrun bool) []string { - env := make([]string, 0) - repoPath := wFile.WorkingDir - - _, workflowName, _ := wFile.getWorkflow(eventName) - - env = append(env, fmt.Sprintf("HOME=/github/home")) - env = append(env, fmt.Sprintf("GITHUB_ACTOR=nektos/act")) - env = append(env, fmt.Sprintf("GITHUB_EVENT_PATH=/github/workflow/event.json")) - env = append(env, fmt.Sprintf("GITHUB_WORKSPACE=/github/workspace")) - env = append(env, fmt.Sprintf("GITHUB_WORKFLOW=%s", workflowName)) - env = append(env, fmt.Sprintf("GITHUB_EVENT_NAME=%s", eventName)) - env = append(env, fmt.Sprintf("GITHUB_ACTION=%s", actionName)) - - _, rev, err := common.FindGitRevision(repoPath) - if err != nil { - log.Warningf("unable to get git revision: %v", err) - } else { - env = append(env, fmt.Sprintf("GITHUB_SHA=%s", rev)) - } - - repo, err := common.FindGithubRepo(repoPath) - if err != nil { - log.Warningf("unable to get git repo: %v", err) - } else { - env = append(env, fmt.Sprintf("GITHUB_REPOSITORY=%s", repo)) - } - - branch, err := common.FindGitBranch(repoPath) - if err != nil { - log.Warningf("unable to get git branch: %v", err) - } else { - env = append(env, fmt.Sprintf("GITHUB_REF=refs/heads/%s", branch)) - } - - action, err := wFile.getAction(actionName) - if err == nil && !dryrun { - action.applyEnvironmentSecrets(&env) - } - - return env -} - -func (action *actionDef) applyEnvironmentSecrets(env *[]string) { - if action != nil { - for envKey, envValue := range action.Env { - *env = append(*env, fmt.Sprintf("%s=%s", envKey, envValue)) - } - - for _, secret := range action.Secrets { - if secretVal, ok := os.LookupEnv(secret); ok { - *env = append(*env, fmt.Sprintf("%s=%s", secret, secretVal)) - } else { - if secretCache == nil { - secretCache = make(map[string]string) - } - - if _, ok := secretCache[secret]; !ok { - fmt.Printf("Provide value for '%s': ", secret) - val, err := gopass.GetPasswdMasked() - if err != nil { - log.Fatal("abort") - } - - secretCache[secret] = string(val) - } - *env = append(*env, fmt.Sprintf("%s=%s", secret, secretCache[secret])) - } - } - } -} - -// imageURL is the directory where a `Dockerfile` should exist -func parseImageLocal(workingDir string, contextDir string) (contextDirOut string, tag string, ok bool) { - if !filepath.IsAbs(contextDir) { - contextDir = filepath.Join(workingDir, contextDir) - } - if _, err := os.Stat(filepath.Join(contextDir, "Dockerfile")); os.IsNotExist(err) { - log.Debugf("Ignoring missing Dockerfile '%s/Dockerfile'", contextDir) - return "", "", false - } - - sha, _, err := common.FindGitRevision(contextDir) - if err != nil { - log.Warnf("Unable to determine git revision: %v", err) - sha = "latest" - } - return contextDir, fmt.Sprintf("%s:%s", filepath.Base(contextDir), sha), true -} - -// imageURL is the URL for a docker repo -func parseImageReference(image string) (ref string, ok bool) { - imageURL, err := url.Parse(image) - if err != nil { - log.Debugf("Unable to parse image as url: %v", err) - return "", false - } - if imageURL.Scheme != "docker" { - log.Debugf("Ignoring non-docker ref '%s'", imageURL.String()) - return "", false - } - - return fmt.Sprintf("%s%s", imageURL.Host, imageURL.Path), true -} - -// imageURL is the directory where a `Dockerfile` should exist -func parseImageGithub(image string) (cloneURL *url.URL, ref string, path string, ok bool) { - re := regexp.MustCompile("^([^/@]+)/([^/@]+)(/([^@]*))?(@(.*))?$") - matches := re.FindStringSubmatch(image) - - if matches == nil { - return nil, "", "", false - } - - cloneURL, err := url.Parse(fmt.Sprintf("https://github.com/%s/%s", matches[1], matches[2])) - if err != nil { - log.Debugf("Unable to parse as URL: %v", err) - return nil, "", "", false - } - - resp, err := http.Head(cloneURL.String()) - if resp.StatusCode >= 400 || err != nil { - log.Debugf("Unable to HEAD URL %s status=%v err=%v", cloneURL.String(), resp.StatusCode, err) - return nil, "", "", false - } - - ref = matches[6] - if ref == "" { - ref = "master" - } - - path = matches[4] - if path == "" { - path = "." - } - - return cloneURL, ref, path, true +func (runner *runnerImpl) Close() error { + return os.RemoveAll(runner.tempDir) } diff --git a/actions/runner_exec.go b/actions/runner_exec.go new file mode 100644 index 00000000..2e5e2d49 --- /dev/null +++ b/actions/runner_exec.go @@ -0,0 +1,185 @@ +package actions + +import ( + "archive/tar" + "bytes" + "fmt" + "io" + "math/rand" + "os" + "path/filepath" + "regexp" + + "github.com/nektos/act/common" + "github.com/nektos/act/container" + log "github.com/sirupsen/logrus" +) + +func (runner *runnerImpl) newActionExecutor(actionName string) common.Executor { + action, err := runner.workflows.getAction(actionName) + if err != nil { + return common.NewErrorExecutor(err) + } + + env := make(map[string]string) + for _, applier := range []environmentApplier{action, runner} { + applier.applyEnvironment(env) + } + env["GITHUB_ACTION"] = actionName + + logger := newActionLogger(actionName, runner.config.Dryrun) + log.Debugf("Using '%s' for action '%s'", action.Uses, actionName) + + in := container.DockerExecutorInput{ + Ctx: runner.config.Ctx, + Logger: logger, + Dryrun: runner.config.Dryrun, + } + + var image string + executors := make([]common.Executor, 0) + if imageRef, ok := parseImageReference(action.Uses); ok { + executors = append(executors, container.NewDockerPullExecutor(container.NewDockerPullExecutorInput{ + DockerExecutorInput: in, + Image: imageRef, + })) + image = imageRef + } else if contextDir, imageTag, ok := parseImageLocal(runner.config.WorkingDir, action.Uses); ok { + executors = append(executors, container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{ + DockerExecutorInput: in, + ContextDir: contextDir, + ImageTag: imageTag, + })) + image = imageTag + } else if cloneURL, ref, path, ok := parseImageGithub(action.Uses); ok { + cloneDir := filepath.Join(os.TempDir(), "act", action.Uses) + executors = append(executors, common.NewGitCloneExecutor(common.NewGitCloneExecutorInput{ + URL: cloneURL, + Ref: ref, + Dir: cloneDir, + Logger: logger, + Dryrun: runner.config.Dryrun, + })) + + contextDir := filepath.Join(cloneDir, path) + imageTag := fmt.Sprintf("%s:%s", filepath.Base(cloneURL.Path), ref) + + executors = append(executors, container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{ + DockerExecutorInput: in, + ContextDir: contextDir, + ImageTag: imageTag, + })) + image = imageTag + } else { + return common.NewErrorExecutor(fmt.Errorf("unable to determine executor type for image '%s'", action.Uses)) + } + + ghReader, err := runner.createGithubTarball() + if err != nil { + return common.NewErrorExecutor(err) + } + randSuffix := randString(6) + containerName := regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(actionName, "-") + if len(containerName)+len(randSuffix)+1 > 30 { + containerName = containerName[:(30 - (len(randSuffix) + 1))] + } + + envList := make([]string, 0) + for k, v := range env { + envList = append(envList, fmt.Sprintf("%s=%s", k, v)) + } + executors = append(executors, container.NewDockerRunExecutor(container.NewDockerRunExecutorInput{ + DockerExecutorInput: in, + Cmd: action.Args, + Entrypoint: action.Runs, + Image: image, + WorkingDir: "/github/workspace", + Env: envList, + Name: fmt.Sprintf("%s-%s", containerName, randSuffix), + Binds: []string{ + fmt.Sprintf("%s:%s", runner.config.WorkingDir, "/github/workspace"), + fmt.Sprintf("%s:%s", runner.tempDir, "/github/home"), + fmt.Sprintf("%s:%s", "/var/run/docker.sock", "/var/run/docker.sock"), + }, + Content: map[string]io.Reader{"/github": ghReader}, + })) + + return common.NewPipelineExecutor(executors...) +} + +func (runner *runnerImpl) applyEnvironment(env map[string]string) { + repoPath := runner.config.WorkingDir + + _, workflowName, _ := runner.workflows.getWorkflow(runner.config.EventName) + + env["HOME"] = "/github/home" + env["GITHUB_ACTOR"] = "nektos/act" + env["GITHUB_EVENT_PATH"] = "/github/workflow/event.json" + env["GITHUB_WORKSPACE"] = "/github/workspace" + env["GITHUB_WORKFLOW"] = workflowName + env["GITHUB_EVENT_NAME"] = runner.config.EventName + + _, rev, err := common.FindGitRevision(repoPath) + if err != nil { + log.Warningf("unable to get git revision: %v", err) + } else { + env["GITHUB_SHA"] = rev + } + + repo, err := common.FindGithubRepo(repoPath) + if err != nil { + log.Warningf("unable to get git repo: %v", err) + } else { + env["GITHUB_REPOSITORY"] = repo + } + + branch, err := common.FindGitBranch(repoPath) + if err != nil { + log.Warningf("unable to get git branch: %v", err) + } else { + env["GITHUB_REF"] = fmt.Sprintf("refs/heads/%s", branch) + } + +} + +func (runner *runnerImpl) createGithubTarball() (io.Reader, error) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + var files = []struct { + Name string + Mode int64 + Body string + }{ + {"workflow/event.json", 0644, runner.eventJSON}, + } + for _, file := range files { + log.Debugf("Writing entry to tarball %s len:%d", file.Name, len(runner.eventJSON)) + hdr := &tar.Header{ + Name: file.Name, + Mode: file.Mode, + Size: int64(len(runner.eventJSON)), + } + if err := tw.WriteHeader(hdr); err != nil { + return nil, err + } + if _, err := tw.Write([]byte(runner.eventJSON)); err != nil { + return nil, err + } + } + if err := tw.Close(); err != nil { + return nil, err + } + + return &buf, nil + +} + +const letterBytes = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func randString(slen int) string { + b := make([]byte, slen) + for i := range b { + b[i] = letterBytes[rand.Int63()%int64(len(letterBytes))] + } + return string(b) +} diff --git a/actions/types.go b/actions/types.go deleted file mode 100644 index abd17c13..00000000 --- a/actions/types.go +++ /dev/null @@ -1,56 +0,0 @@ -package actions - -import ( - "context" -) - -// Workflows provides capabilities to work with the workflow file -type Workflows interface { - EventGrapher - EventLister - ActionRunner - EventRunner - Close() -} - -// EventGrapher to list the actions -type EventGrapher interface { - GraphEvent(eventName string) ([][]string, error) -} - -// EventLister to list the events -type EventLister interface { - ListEvents() []string -} - -// ActionRunner to run an action -type ActionRunner interface { - RunAction(ctx context.Context, dryrun bool, action string, eventJSON string) error -} - -// EventRunner to run an event -type EventRunner interface { - RunEvent(ctx context.Context, dryrun bool, event string, eventJSON string) error -} - -type workflowDef struct { - On string - Resolves []string -} - -type actionDef struct { - Needs []string - Uses string - Runs []string - Args []string - Env map[string]string - Secrets []string -} - -type workflowsFile struct { - TempDir string - WorkingDir string - WorkflowPath string - Workflow map[string]workflowDef - Action map[string]actionDef -} diff --git a/cmd/root.go b/cmd/root.go index 5c8afc5e..932689fe 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,9 +3,7 @@ package cmd import ( "context" "fmt" - "io/ioutil" "os" - "path/filepath" "github.com/nektos/act/actions" "github.com/nektos/act/common" @@ -13,82 +11,80 @@ import ( "github.com/spf13/cobra" ) -var verbose bool -var workflowPath string -var workingDir string -var list bool -var actionName string -var dryrun bool -var eventPath string - // Execute is the entry point to running the CLI func Execute(ctx context.Context, version string) { + runnerConfig := &actions.RunnerConfig{Ctx: ctx} var rootCmd = &cobra.Command{ - Use: "act [event name to run]", - Short: "Run Github actions locally by specifying the event name (e.g. `push`) or an action name directly.", - Args: cobra.MaximumNArgs(1), - RunE: newRunAction(ctx), - Version: version, - SilenceUsage: true, + Use: "act [event name to run]", + Short: "Run Github actions locally by specifying the event name (e.g. `push`) or an action name directly.", + Args: cobra.MaximumNArgs(1), + RunE: newRunCommand(runnerConfig), + PersistentPreRun: setupLogging, + Version: version, + SilenceUsage: true, } - rootCmd.Flags().BoolVarP(&list, "list", "l", false, "list actions") - rootCmd.Flags().StringVarP(&actionName, "action", "a", "", "run action") - rootCmd.Flags().StringVarP(&eventPath, "event", "e", "", "path to event JSON file") - rootCmd.PersistentFlags().BoolVarP(&dryrun, "dryrun", "n", false, "dryrun mode") - rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") - rootCmd.PersistentFlags().StringVarP(&workflowPath, "file", "f", "./.github/main.workflow", "path to workflow file") - rootCmd.PersistentFlags().StringVarP(&workingDir, "directory", "C", ".", "working directory") + rootCmd.Flags().BoolP("list", "l", false, "list actions") + rootCmd.Flags().StringP("action", "a", "", "run action") + rootCmd.Flags().StringVarP(&runnerConfig.EventPath, "event", "e", "", "path to event JSON file") + rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output") + rootCmd.PersistentFlags().BoolVarP(&runnerConfig.Dryrun, "dryrun", "n", false, "dryrun mode") + rootCmd.PersistentFlags().StringVarP(&runnerConfig.WorkflowPath, "file", "f", "./.github/main.workflow", "path to workflow file") + rootCmd.PersistentFlags().StringVarP(&runnerConfig.WorkingDir, "directory", "C", ".", "working directory") if err := rootCmd.Execute(); err != nil { os.Exit(1) } } -func newRunAction(ctx context.Context) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - if verbose { - log.SetLevel(log.DebugLevel) - } - - workflows, err := actions.ParseWorkflows(workingDir, workflowPath) - if err != nil { - return err - } - - defer workflows.Close() - - if list { - return listEvents(workflows) - } - - eventJSON := "{}" - if eventPath != "" { - if !filepath.IsAbs(eventPath) { - eventPath = filepath.Join(workingDir, eventPath) - } - log.Debugf("Reading event.json from %s", eventPath) - eventJSONBytes, err := ioutil.ReadFile(eventPath) - if err != nil { - return err - } - eventJSON = string(eventJSONBytes) - } - - if actionName != "" { - return workflows.RunAction(ctx, dryrun, actionName, eventJSON) - } - - if len(args) == 0 { - return workflows.RunEvent(ctx, dryrun, "push", eventJSON) - } - return workflows.RunEvent(ctx, dryrun, args[0], eventJSON) +func setupLogging(cmd *cobra.Command, args []string) { + verbose, _ := cmd.Flags().GetBool("verbose") + if verbose { + log.SetLevel(log.DebugLevel) } } -func listEvents(workflows actions.Workflows) error { - eventNames := workflows.ListEvents() +func newRunCommand(runnerConfig *actions.RunnerConfig) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + runnerConfig.EventName = "push" + } else { + runnerConfig.EventName = args[0] + } + + // create the runner + runner, err := actions.NewRunner(runnerConfig) + if err != nil { + return err + } + defer runner.Close() + + // check if we should just print the graph + list, err := cmd.Flags().GetBool("list") + if err != nil { + return err + } + if list { + return drawGraph(runner) + } + + // check if we are running just a single action + actionName, err := cmd.Flags().GetString("action") + if err != nil { + return err + } + if actionName != "" { + return runner.RunActions(actionName) + } + + // run the event in the RunnerRonfig + return runner.RunEvent() + } +} + +func drawGraph(runner actions.Runner) error { + eventNames := runner.ListEvents() for _, eventName := range eventNames { - graph, err := workflows.GraphEvent(eventName) + graph, err := runner.GraphEvent(eventName) if err != nil { return err }