1
0
Fork 0
mirror of https://code.forgejo.org/forgejo/runner.git synced 2025-09-15 18:57:01 +00:00

refactor actions to improve testability

This commit is contained in:
Casey Lee 2019-01-17 00:15:35 -08:00
parent b23fbbcc94
commit f26a1a3f0c
8 changed files with 601 additions and 523 deletions

80
actions/action.go Normal file
View file

@ -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
}

49
actions/api.go Normal file
View file

@ -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)
}

129
actions/model.go Normal file
View file

@ -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]
}
}
}

View file

@ -5,9 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"os"
"path/filepath"
"github.com/hashicorp/hcl" "github.com/hashicorp/hcl"
"github.com/hashicorp/hcl/hcl/ast" "github.com/hashicorp/hcl/hcl/ast"
@ -15,43 +12,12 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// ParseWorkflows will read in the set of actions from the workflow file func parseWorkflowsFile(workflowReader io.Reader) (*workflowsFile, error) {
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
}
// TODO: add validation logic // TODO: add validation logic
// - check for circular dependencies // - check for circular dependencies
// - check for valid local path refs // - check for valid local path refs
// - check for valid dependencies // - check for valid dependencies
return workflows, nil
}
func parseWorkflowsFile(workflowReader io.Reader) (*workflowsFile, error) {
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
_, err := buf.ReadFrom(workflowReader) _, err := buf.ReadFrom(workflowReader)
if err != nil { if err != nil {

View file

@ -1,31 +1,94 @@
package actions package actions
import ( import (
"archive/tar" "io/ioutil"
"bytes"
"context"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"github.com/howeyc/gopass"
"github.com/nektos/act/common" "github.com/nektos/act/common"
"github.com/nektos/act/container"
log "github.com/sirupsen/logrus" 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") log.Debugf("Listing all events")
events := make([]string, 0) events := make([]string, 0)
for _, w := range wFile.Workflow { for _, w := range runner.workflows.Workflow {
events = append(events, w.On) events = append(events, w.On)
} }
@ -37,380 +100,46 @@ func (wFile *workflowsFile) ListEvents() []string {
return events 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) log.Debugf("Listing actions for event '%s'", eventName)
workflow, _, err := wFile.getWorkflow(eventName) workflow, _, err := runner.workflows.getWorkflow(eventName)
if err != nil { if err != nil {
return nil, err 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 { // RunAction runs a set of actions in parallel, and their dependencies
log.Debugf("Running action '%s'", actionName) func (runner *runnerImpl) RunActions(actionNames ...string) error {
return wFile.newActionExecutor(ctx, dryrun, "", eventJSON, actionName)() log.Debugf("Running actions %+q", actionNames)
} graph := runner.workflows.newExecutionGraph(actionNames...)
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...)
pipeline := make([]common.Executor, 0) pipeline := make([]common.Executor, 0)
for _, actions := range graph { for _, actions := range graph {
stage := make([]common.Executor, 0) stage := make([]common.Executor, 0)
for _, actionName := range actions { for _, actionName := range actions {
action, err := wFile.getAction(actionName) stage = append(stage, runner.newActionExecutor(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)
} }
pipeline = append(pipeline, common.NewParallelExecutor(stage...)) 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 { // RunEvent runs the actions for a single event
logger := newActionLogger(actionName, dryrun) func (runner *runnerImpl) RunEvent() error {
log.Debugf("Using '%s' for action '%s'", action.Uses, actionName) log.Debugf("Running event '%s'", runner.config.EventName)
workflow, _, err := runner.workflows.getWorkflow(runner.config.EventName)
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)
if err != nil { 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 (runner *runnerImpl) Close() error {
return os.RemoveAll(runner.tempDir)
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
} }

185
actions/runner_exec.go Normal file
View file

@ -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)
}

View file

@ -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
}

View file

@ -3,9 +3,7 @@ package cmd
import ( import (
"context" "context"
"fmt" "fmt"
"io/ioutil"
"os" "os"
"path/filepath"
"github.com/nektos/act/actions" "github.com/nektos/act/actions"
"github.com/nektos/act/common" "github.com/nektos/act/common"
@ -13,82 +11,80 @@ import (
"github.com/spf13/cobra" "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 // Execute is the entry point to running the CLI
func Execute(ctx context.Context, version string) { func Execute(ctx context.Context, version string) {
runnerConfig := &actions.RunnerConfig{Ctx: ctx}
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "act [event name to run]", Use: "act [event name to run]",
Short: "Run Github actions locally by specifying the event name (e.g. `push`) or an action name directly.", Short: "Run Github actions locally by specifying the event name (e.g. `push`) or an action name directly.",
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
RunE: newRunAction(ctx), RunE: newRunCommand(runnerConfig),
Version: version, PersistentPreRun: setupLogging,
SilenceUsage: true, Version: version,
SilenceUsage: true,
} }
rootCmd.Flags().BoolVarP(&list, "list", "l", false, "list actions") rootCmd.Flags().BoolP("list", "l", false, "list actions")
rootCmd.Flags().StringVarP(&actionName, "action", "a", "", "run action") rootCmd.Flags().StringP("action", "a", "", "run action")
rootCmd.Flags().StringVarP(&eventPath, "event", "e", "", "path to event JSON file") rootCmd.Flags().StringVarP(&runnerConfig.EventPath, "event", "e", "", "path to event JSON file")
rootCmd.PersistentFlags().BoolVarP(&dryrun, "dryrun", "n", false, "dryrun mode") rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") rootCmd.PersistentFlags().BoolVarP(&runnerConfig.Dryrun, "dryrun", "n", false, "dryrun mode")
rootCmd.PersistentFlags().StringVarP(&workflowPath, "file", "f", "./.github/main.workflow", "path to workflow file") rootCmd.PersistentFlags().StringVarP(&runnerConfig.WorkflowPath, "file", "f", "./.github/main.workflow", "path to workflow file")
rootCmd.PersistentFlags().StringVarP(&workingDir, "directory", "C", ".", "working directory") rootCmd.PersistentFlags().StringVarP(&runnerConfig.WorkingDir, "directory", "C", ".", "working directory")
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
os.Exit(1) os.Exit(1)
} }
} }
func newRunAction(ctx context.Context) func(*cobra.Command, []string) error { func setupLogging(cmd *cobra.Command, args []string) {
return func(cmd *cobra.Command, args []string) error { verbose, _ := cmd.Flags().GetBool("verbose")
if verbose { if verbose {
log.SetLevel(log.DebugLevel) 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 listEvents(workflows actions.Workflows) error { func newRunCommand(runnerConfig *actions.RunnerConfig) func(*cobra.Command, []string) error {
eventNames := workflows.ListEvents() 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 { for _, eventName := range eventNames {
graph, err := workflows.GraphEvent(eventName) graph, err := runner.GraphEvent(eventName)
if err != nil { if err != nil {
return err return err
} }