Skip to content

Commit

Permalink
refactor actions to improve testability
Browse files Browse the repository at this point in the history
  • Loading branch information
cplee committed Jan 17, 2019
1 parent 19d1d0c commit 317a305
Show file tree
Hide file tree
Showing 10 changed files with 583 additions and 845 deletions.
80 changes: 80 additions & 0 deletions 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
}
49 changes: 49 additions & 0 deletions 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)
}
129 changes: 129 additions & 0 deletions 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]
}
}
}
36 changes: 1 addition & 35 deletions actions/parser.go
Expand Up @@ -5,53 +5,19 @@ import (
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"

"github.com/hashicorp/hcl"
"github.com/hashicorp/hcl/hcl/ast"
"github.com/hashicorp/hcl/hcl/token"
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 {
Expand Down

0 comments on commit 317a305

Please sign in to comment.