diff options
Diffstat (limited to 'pkg/model/workflow.go')
-rw-r--r-- | pkg/model/workflow.go | 763 |
1 files changed, 763 insertions, 0 deletions
diff --git a/pkg/model/workflow.go b/pkg/model/workflow.go new file mode 100644 index 0000000..7bec766 --- /dev/null +++ b/pkg/model/workflow.go @@ -0,0 +1,763 @@ +package model + +import ( + "fmt" + "io" + "reflect" + "regexp" + "strconv" + "strings" + + "github.com/nektos/act/pkg/common" + log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" +) + +// Workflow is the structure of the files in .github/workflows +type Workflow struct { + File string + Name string `yaml:"name"` + RawOn yaml.Node `yaml:"on"` + Env map[string]string `yaml:"env"` + Jobs map[string]*Job `yaml:"jobs"` + Defaults Defaults `yaml:"defaults"` +} + +// On events for the workflow +func (w *Workflow) On() []string { + switch w.RawOn.Kind { + case yaml.ScalarNode: + var val string + err := w.RawOn.Decode(&val) + if err != nil { + log.Fatal(err) + } + return []string{val} + case yaml.SequenceNode: + var val []string + err := w.RawOn.Decode(&val) + if err != nil { + log.Fatal(err) + } + return val + case yaml.MappingNode: + var val map[string]interface{} + err := w.RawOn.Decode(&val) + if err != nil { + log.Fatal(err) + } + var keys []string + for k := range val { + keys = append(keys, k) + } + return keys + } + return nil +} + +func (w *Workflow) OnEvent(event string) interface{} { + if w.RawOn.Kind == yaml.MappingNode { + var val map[string]interface{} + if !decodeNode(w.RawOn, &val) { + return nil + } + return val[event] + } + return nil +} + +func (w *Workflow) OnSchedule() []string { + schedules := w.OnEvent("schedule") + if schedules == nil { + return []string{} + } + + switch val := schedules.(type) { + case []interface{}: + allSchedules := []string{} + for _, v := range val { + for k, cron := range v.(map[string]interface{}) { + if k != "cron" { + continue + } + allSchedules = append(allSchedules, cron.(string)) + } + } + return allSchedules + default: + } + + return []string{} +} + +type WorkflowDispatchInput struct { + Description string `yaml:"description"` + Required bool `yaml:"required"` + Default string `yaml:"default"` + Type string `yaml:"type"` + Options []string `yaml:"options"` +} + +type WorkflowDispatch struct { + Inputs map[string]WorkflowDispatchInput `yaml:"inputs"` +} + +func (w *Workflow) WorkflowDispatchConfig() *WorkflowDispatch { + switch w.RawOn.Kind { + case yaml.ScalarNode: + var val string + if !decodeNode(w.RawOn, &val) { + return nil + } + if val == "workflow_dispatch" { + return &WorkflowDispatch{} + } + case yaml.SequenceNode: + var val []string + if !decodeNode(w.RawOn, &val) { + return nil + } + for _, v := range val { + if v == "workflow_dispatch" { + return &WorkflowDispatch{} + } + } + case yaml.MappingNode: + var val map[string]yaml.Node + if !decodeNode(w.RawOn, &val) { + return nil + } + + n, found := val["workflow_dispatch"] + var workflowDispatch WorkflowDispatch + if found && decodeNode(n, &workflowDispatch) { + return &workflowDispatch + } + default: + return nil + } + return nil +} + +type WorkflowCallInput struct { + Description string `yaml:"description"` + Required bool `yaml:"required"` + Default string `yaml:"default"` + Type string `yaml:"type"` +} + +type WorkflowCallOutput struct { + Description string `yaml:"description"` + Value string `yaml:"value"` +} + +type WorkflowCall struct { + Inputs map[string]WorkflowCallInput `yaml:"inputs"` + Outputs map[string]WorkflowCallOutput `yaml:"outputs"` +} + +type WorkflowCallResult struct { + Outputs map[string]string +} + +func (w *Workflow) WorkflowCallConfig() *WorkflowCall { + if w.RawOn.Kind != yaml.MappingNode { + // The callers expect for "on: workflow_call" and "on: [ workflow_call ]" a non nil return value + return &WorkflowCall{} + } + + var val map[string]yaml.Node + if !decodeNode(w.RawOn, &val) { + return &WorkflowCall{} + } + + var config WorkflowCall + node := val["workflow_call"] + if !decodeNode(node, &config) { + return &WorkflowCall{} + } + + return &config +} + +// Job is the structure of one job in a workflow +type Job struct { + Name string `yaml:"name"` + RawNeeds yaml.Node `yaml:"needs"` + RawRunsOn yaml.Node `yaml:"runs-on"` + Env yaml.Node `yaml:"env"` + If yaml.Node `yaml:"if"` + Steps []*Step `yaml:"steps"` + TimeoutMinutes string `yaml:"timeout-minutes"` + Services map[string]*ContainerSpec `yaml:"services"` + Strategy *Strategy `yaml:"strategy"` + RawContainer yaml.Node `yaml:"container"` + Defaults Defaults `yaml:"defaults"` + Outputs map[string]string `yaml:"outputs"` + Uses string `yaml:"uses"` + With map[string]interface{} `yaml:"with"` + RawSecrets yaml.Node `yaml:"secrets"` + Result string +} + +// Strategy for the job +type Strategy struct { + FailFast bool + MaxParallel int + FailFastString string `yaml:"fail-fast"` + MaxParallelString string `yaml:"max-parallel"` + RawMatrix yaml.Node `yaml:"matrix"` +} + +// Default settings that will apply to all steps in the job or workflow +type Defaults struct { + Run RunDefaults `yaml:"run"` +} + +// Defaults for all run steps in the job or workflow +type RunDefaults struct { + Shell string `yaml:"shell"` + WorkingDirectory string `yaml:"working-directory"` +} + +// GetMaxParallel sets default and returns value for `max-parallel` +func (s Strategy) GetMaxParallel() int { + // MaxParallel default value is `GitHub will maximize the number of jobs run in parallel depending on the available runners on GitHub-hosted virtual machines` + // So I take the liberty to hardcode default limit to 4 and this is because: + // 1: tl;dr: self-hosted does only 1 parallel job - https://github.com/actions/runner/issues/639#issuecomment-825212735 + // 2: GH has 20 parallel job limit (for free tier) - https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/usage-limits-billing-and-administration.md?plain=1#L45 + // 3: I want to add support for MaxParallel to act and 20! parallel jobs is a bit overkill IMHO + maxParallel := 4 + if s.MaxParallelString != "" { + var err error + if maxParallel, err = strconv.Atoi(s.MaxParallelString); err != nil { + log.Errorf("Failed to parse 'max-parallel' option: %v", err) + } + } + return maxParallel +} + +// GetFailFast sets default and returns value for `fail-fast` +func (s Strategy) GetFailFast() bool { + // FailFast option is true by default: https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=1#L1107 + failFast := true + log.Debug(s.FailFastString) + if s.FailFastString != "" { + var err error + if failFast, err = strconv.ParseBool(s.FailFastString); err != nil { + log.Errorf("Failed to parse 'fail-fast' option: %v", err) + } + } + return failFast +} + +func (j *Job) InheritSecrets() bool { + if j.RawSecrets.Kind != yaml.ScalarNode { + return false + } + + var val string + if !decodeNode(j.RawSecrets, &val) { + return false + } + + return val == "inherit" +} + +func (j *Job) Secrets() map[string]string { + if j.RawSecrets.Kind != yaml.MappingNode { + return nil + } + + var val map[string]string + if !decodeNode(j.RawSecrets, &val) { + return nil + } + + return val +} + +// Container details for the job +func (j *Job) Container() *ContainerSpec { + var val *ContainerSpec + switch j.RawContainer.Kind { + case yaml.ScalarNode: + val = new(ContainerSpec) + if !decodeNode(j.RawContainer, &val.Image) { + return nil + } + case yaml.MappingNode: + val = new(ContainerSpec) + if !decodeNode(j.RawContainer, val) { + return nil + } + } + return val +} + +// Needs list for Job +func (j *Job) Needs() []string { + switch j.RawNeeds.Kind { + case yaml.ScalarNode: + var val string + if !decodeNode(j.RawNeeds, &val) { + return nil + } + return []string{val} + case yaml.SequenceNode: + var val []string + if !decodeNode(j.RawNeeds, &val) { + return nil + } + return val + } + return nil +} + +// RunsOn list for Job +func (j *Job) RunsOn() []string { + switch j.RawRunsOn.Kind { + case yaml.MappingNode: + var val struct { + Group string + Labels yaml.Node + } + + if !decodeNode(j.RawRunsOn, &val) { + return nil + } + + labels := nodeAsStringSlice(val.Labels) + + if val.Group != "" { + labels = append(labels, val.Group) + } + + return labels + default: + return nodeAsStringSlice(j.RawRunsOn) + } +} + +func nodeAsStringSlice(node yaml.Node) []string { + switch node.Kind { + case yaml.ScalarNode: + var val string + if !decodeNode(node, &val) { + return nil + } + return []string{val} + case yaml.SequenceNode: + var val []string + if !decodeNode(node, &val) { + return nil + } + return val + } + return nil +} + +func environment(yml yaml.Node) map[string]string { + env := make(map[string]string) + if yml.Kind == yaml.MappingNode { + if !decodeNode(yml, &env) { + return nil + } + } + return env +} + +// Environments returns string-based key=value map for a job +func (j *Job) Environment() map[string]string { + return environment(j.Env) +} + +// Matrix decodes RawMatrix YAML node +func (j *Job) Matrix() map[string][]interface{} { + if j.Strategy.RawMatrix.Kind == yaml.MappingNode { + var val map[string][]interface{} + if !decodeNode(j.Strategy.RawMatrix, &val) { + return nil + } + return val + } + return nil +} + +// GetMatrixes returns the matrix cross product +// It skips includes and hard fails excludes for non-existing keys +// +//nolint:gocyclo +func (j *Job) GetMatrixes() ([]map[string]interface{}, error) { + matrixes := make([]map[string]interface{}, 0) + if j.Strategy != nil { + j.Strategy.FailFast = j.Strategy.GetFailFast() + j.Strategy.MaxParallel = j.Strategy.GetMaxParallel() + + if m := j.Matrix(); m != nil { + includes := make([]map[string]interface{}, 0) + extraIncludes := make([]map[string]interface{}, 0) + for _, v := range m["include"] { + switch t := v.(type) { + case []interface{}: + for _, i := range t { + i := i.(map[string]interface{}) + extraInclude := true + for k := range i { + if _, ok := m[k]; ok { + includes = append(includes, i) + extraInclude = false + break + } + } + if extraInclude { + extraIncludes = append(extraIncludes, i) + } + } + case interface{}: + v := v.(map[string]interface{}) + extraInclude := true + for k := range v { + if _, ok := m[k]; ok { + includes = append(includes, v) + extraInclude = false + break + } + } + if extraInclude { + extraIncludes = append(extraIncludes, v) + } + } + } + delete(m, "include") + + excludes := make([]map[string]interface{}, 0) + for _, e := range m["exclude"] { + e := e.(map[string]interface{}) + for k := range e { + if _, ok := m[k]; ok { + excludes = append(excludes, e) + } else { + // We fail completely here because that's what GitHub does for non-existing matrix keys, fail on exclude, silent skip on include + return nil, fmt.Errorf("the workflow is not valid. Matrix exclude key %q does not match any key within the matrix", k) + } + } + } + delete(m, "exclude") + + matrixProduct := common.CartesianProduct(m) + MATRIX: + for _, matrix := range matrixProduct { + for _, exclude := range excludes { + if commonKeysMatch(matrix, exclude) { + log.Debugf("Skipping matrix '%v' due to exclude '%v'", matrix, exclude) + continue MATRIX + } + } + matrixes = append(matrixes, matrix) + } + for _, include := range includes { + matched := false + for _, matrix := range matrixes { + if commonKeysMatch2(matrix, include, m) { + matched = true + log.Debugf("Adding include values '%v' to existing entry", include) + for k, v := range include { + matrix[k] = v + } + } + } + if !matched { + extraIncludes = append(extraIncludes, include) + } + } + for _, include := range extraIncludes { + log.Debugf("Adding include '%v'", include) + matrixes = append(matrixes, include) + } + if len(matrixes) == 0 { + matrixes = append(matrixes, make(map[string]interface{})) + } + } else { + matrixes = append(matrixes, make(map[string]interface{})) + } + } else { + matrixes = append(matrixes, make(map[string]interface{})) + log.Debugf("Empty Strategy, matrixes=%v", matrixes) + } + return matrixes, nil +} + +func commonKeysMatch(a map[string]interface{}, b map[string]interface{}) bool { + for aKey, aVal := range a { + if bVal, ok := b[aKey]; ok && !reflect.DeepEqual(aVal, bVal) { + return false + } + } + return true +} + +func commonKeysMatch2(a map[string]interface{}, b map[string]interface{}, m map[string][]interface{}) bool { + for aKey, aVal := range a { + _, useKey := m[aKey] + if bVal, ok := b[aKey]; useKey && ok && !reflect.DeepEqual(aVal, bVal) { + return false + } + } + return true +} + +// JobType describes what type of job we are about to run +type JobType int + +const ( + // JobTypeDefault is all jobs that have a `run` attribute + JobTypeDefault JobType = iota + + // JobTypeReusableWorkflowLocal is all jobs that have a `uses` that is a local workflow in the .github/workflows directory + JobTypeReusableWorkflowLocal + + // JobTypeReusableWorkflowRemote is all jobs that have a `uses` that references a workflow file in a github repo + JobTypeReusableWorkflowRemote + + // JobTypeInvalid represents a job which is not configured correctly + JobTypeInvalid +) + +func (j JobType) String() string { + switch j { + case JobTypeDefault: + return "default" + case JobTypeReusableWorkflowLocal: + return "local-reusable-workflow" + case JobTypeReusableWorkflowRemote: + return "remote-reusable-workflow" + } + return "unknown" +} + +// Type returns the type of the job +func (j *Job) Type() (JobType, error) { + isReusable := j.Uses != "" + + if isReusable { + isYaml, _ := regexp.MatchString(`\.(ya?ml)(?:$|@)`, j.Uses) + + if isYaml { + isLocalPath := strings.HasPrefix(j.Uses, "./") + isRemotePath, _ := regexp.MatchString(`^[^.](.+?/){2,}.+\.ya?ml@`, j.Uses) + hasVersion, _ := regexp.MatchString(`\.ya?ml@`, j.Uses) + + if isLocalPath { + return JobTypeReusableWorkflowLocal, nil + } else if isRemotePath && hasVersion { + return JobTypeReusableWorkflowRemote, nil + } + } + + return JobTypeInvalid, fmt.Errorf("`uses` key references invalid workflow path '%s'. Must start with './' if it's a local workflow, or must start with '<org>/<repo>/' and include an '@' if it's a remote workflow", j.Uses) + } + + return JobTypeDefault, nil +} + +// ContainerSpec is the specification of the container to use for the job +type ContainerSpec struct { + Image string `yaml:"image"` + Env map[string]string `yaml:"env"` + Ports []string `yaml:"ports"` + Volumes []string `yaml:"volumes"` + Options string `yaml:"options"` + Credentials map[string]string `yaml:"credentials"` + Entrypoint string + Args string + Name string + Reuse bool + + // Gitea specific + Cmd []string `yaml:"cmd"` +} + +// Step is the structure of one step in a job +type Step struct { + Number int `yaml:"-"` + ID string `yaml:"id"` + If yaml.Node `yaml:"if"` + Name string `yaml:"name"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + WorkingDirectory string `yaml:"working-directory"` + Shell string `yaml:"shell"` + Env yaml.Node `yaml:"env"` + With map[string]string `yaml:"with"` + RawContinueOnError string `yaml:"continue-on-error"` + TimeoutMinutes string `yaml:"timeout-minutes"` +} + +// String gets the name of step +func (s *Step) String() string { + if s.Name != "" { + return s.Name + } else if s.Uses != "" { + return s.Uses + } else if s.Run != "" { + return s.Run + } + return s.ID +} + +// Environments returns string-based key=value map for a step +func (s *Step) Environment() map[string]string { + return environment(s.Env) +} + +// GetEnv gets the env for a step +func (s *Step) GetEnv() map[string]string { + env := s.Environment() + + for k, v := range s.With { + envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(k), "_") + envKey = fmt.Sprintf("INPUT_%s", strings.ToUpper(envKey)) + env[envKey] = v + } + return env +} + +// ShellCommand returns the command for the shell +func (s *Step) ShellCommand() string { + shellCommand := "" + + // Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L9-L17 + switch s.Shell { + case "", "bash": + shellCommand = "bash --noprofile --norc -e -o pipefail {0}" + case "pwsh": + shellCommand = "pwsh -command . '{0}'" + case "python": + shellCommand = "python {0}" + case "sh": + shellCommand = "sh -e {0}" + case "cmd": + shellCommand = "cmd /D /E:ON /V:OFF /S /C \"CALL \"{0}\"\"" + case "powershell": + shellCommand = "powershell -command . '{0}'" + default: + shellCommand = s.Shell + } + return shellCommand +} + +// StepType describes what type of step we are about to run +type StepType int + +const ( + // StepTypeRun is all steps that have a `run` attribute + StepTypeRun StepType = iota + + // StepTypeUsesDockerURL is all steps that have a `uses` that is of the form `docker://...` + StepTypeUsesDockerURL + + // StepTypeUsesActionLocal is all steps that have a `uses` that is a local action in a subdirectory + StepTypeUsesActionLocal + + // StepTypeUsesActionRemote is all steps that have a `uses` that is a reference to a github repo + StepTypeUsesActionRemote + + // StepTypeReusableWorkflowLocal is all steps that have a `uses` that is a local workflow in the .github/workflows directory + StepTypeReusableWorkflowLocal + + // StepTypeReusableWorkflowRemote is all steps that have a `uses` that references a workflow file in a github repo + StepTypeReusableWorkflowRemote + + // StepTypeInvalid is for steps that have invalid step action + StepTypeInvalid +) + +func (s StepType) String() string { + switch s { + case StepTypeInvalid: + return "invalid" + case StepTypeRun: + return "run" + case StepTypeUsesActionLocal: + return "local-action" + case StepTypeUsesActionRemote: + return "remote-action" + case StepTypeUsesDockerURL: + return "docker" + case StepTypeReusableWorkflowLocal: + return "local-reusable-workflow" + case StepTypeReusableWorkflowRemote: + return "remote-reusable-workflow" + } + return "unknown" +} + +// Type returns the type of the step +func (s *Step) Type() StepType { + if s.Run == "" && s.Uses == "" { + return StepTypeInvalid + } + + if s.Run != "" { + if s.Uses != "" { + return StepTypeInvalid + } + return StepTypeRun + } else if strings.HasPrefix(s.Uses, "docker://") { + return StepTypeUsesDockerURL + } else if strings.HasPrefix(s.Uses, "./.github/workflows") && (strings.HasSuffix(s.Uses, ".yml") || strings.HasSuffix(s.Uses, ".yaml")) { + return StepTypeReusableWorkflowLocal + } else if !strings.HasPrefix(s.Uses, "./") && strings.Contains(s.Uses, ".github/workflows") && (strings.Contains(s.Uses, ".yml@") || strings.Contains(s.Uses, ".yaml@")) { + return StepTypeReusableWorkflowRemote + } else if strings.HasPrefix(s.Uses, "./") { + return StepTypeUsesActionLocal + } + return StepTypeUsesActionRemote +} + +// ReadWorkflow returns a list of jobs for a given workflow file reader +func ReadWorkflow(in io.Reader) (*Workflow, error) { + w := new(Workflow) + err := yaml.NewDecoder(in).Decode(w) + return w, err +} + +// GetJob will get a job by name in the workflow +func (w *Workflow) GetJob(jobID string) *Job { + for id, j := range w.Jobs { + if jobID == id { + if j.Name == "" { + j.Name = id + } + if j.If.Value == "" { + j.If.Value = "success()" + } + return j + } + } + return nil +} + +// GetJobIDs will get all the job names in the workflow +func (w *Workflow) GetJobIDs() []string { + ids := make([]string, 0) + for id := range w.Jobs { + ids = append(ids, id) + } + return ids +} + +var OnDecodeNodeError = func(node yaml.Node, out interface{}, err error) { + log.Errorf("Failed to decode node %v into %T: %v", node, out, err) +} + +func decodeNode(node yaml.Node, out interface{}) bool { + if err := node.Decode(out); err != nil { + if OnDecodeNodeError != nil { + OnDecodeNodeError(node, out, err) + } + return false + } + return true +} |