refactor actions to improve testability
This commit is contained in:
80
actions/action.go
Normal file
80
actions/action.go
Normal 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
49
actions/api.go
Normal 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
129
actions/model.go
Normal 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]
|
||||
}
|
||||
}
|
||||
}
|
@@ -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 {
|
||||
|
@@ -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)
|
||||
}
|
||||
|
185
actions/runner_exec.go
Normal file
185
actions/runner_exec.go
Normal 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)
|
||||
}
|
@@ -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
|
||||
}
|
Reference in New Issue
Block a user