2020-02-04 18:38:41 -06:00
package model
import (
2021-04-02 09:01:45 -05:00
"fmt"
2020-08-31 16:50:01 -05:00
"io"
2022-10-29 12:15:38 -05:00
"io/fs"
2020-02-04 18:38:41 -06:00
"math"
"os"
"path/filepath"
2021-04-02 09:01:45 -05:00
"regexp"
2020-02-04 18:38:41 -06:00
"sort"
log "github.com/sirupsen/logrus"
)
// WorkflowPlanner contains methods for creating plans
type WorkflowPlanner interface {
2023-02-16 10:41:59 -06:00
PlanEvent ( eventName string ) ( * Plan , error )
PlanJob ( jobName string ) ( * Plan , error )
PlanAll ( ) ( * Plan , error )
2020-02-04 18:38:41 -06:00
GetEvents ( ) [ ] string
}
// Plan contains a list of stages to run in series
type Plan struct {
Stages [ ] * Stage
}
// Stage contains a list of runs to execute in parallel
type Stage struct {
Runs [ ] * Run
}
// Run represents a job from a workflow that needs to be run
type Run struct {
Workflow * Workflow
JobID string
}
2020-02-07 00:17:58 -06:00
func ( r * Run ) String ( ) string {
jobName := r . Job ( ) . Name
if jobName == "" {
jobName = r . JobID
}
2020-02-27 01:29:43 -06:00
return jobName
2020-02-07 00:17:58 -06:00
}
// Job returns the job for this Run
func ( r * Run ) Job ( ) * Job {
return r . Workflow . GetJob ( r . JobID )
}
2021-05-03 09:57:24 -05:00
type WorkflowFiles struct {
2022-10-29 12:15:38 -05:00
workflowDirEntry os . DirEntry
2021-05-03 09:57:24 -05:00
dirPath string
}
// NewWorkflowPlanner will load a specific workflow, all workflows from a directory or all workflows from a directory and its subdirectories
2022-10-06 17:09:43 -05:00
//
//nolint:gocyclo
2021-05-03 09:57:24 -05:00
func NewWorkflowPlanner ( path string , noWorkflowRecurse bool ) ( WorkflowPlanner , error ) {
path , err := filepath . Abs ( path )
if err != nil {
return nil , err
}
2020-05-26 22:29:50 -05:00
fi , err := os . Stat ( path )
if err != nil {
return nil , err
}
2021-05-03 09:57:24 -05:00
var workflows [ ] WorkflowFiles
2020-05-26 22:29:50 -05:00
if fi . IsDir ( ) {
log . Debugf ( "Loading workflows from '%s'" , path )
2021-05-03 09:57:24 -05:00
if noWorkflowRecurse {
2022-10-29 12:15:38 -05:00
files , err := os . ReadDir ( path )
2021-05-03 09:57:24 -05:00
if err != nil {
return nil , err
}
for _ , v := range files {
workflows = append ( workflows , WorkflowFiles {
dirPath : path ,
2022-10-29 12:15:38 -05:00
workflowDirEntry : v ,
2021-05-03 09:57:24 -05:00
} )
}
} else {
log . Debug ( "Loading workflows recursively" )
if err := filepath . Walk ( path ,
func ( p string , f os . FileInfo , err error ) error {
if err != nil {
return err
}
if ! f . IsDir ( ) {
log . Debugf ( "Found workflow '%s' in '%s'" , f . Name ( ) , p )
workflows = append ( workflows , WorkflowFiles {
dirPath : filepath . Dir ( p ) ,
2022-10-29 12:15:38 -05:00
workflowDirEntry : fs . FileInfoToDirEntry ( f ) ,
2021-05-03 09:57:24 -05:00
} )
}
return nil
} ) ; err != nil {
return nil , err
}
}
2020-05-26 22:29:50 -05:00
} else {
log . Debugf ( "Loading workflow '%s'" , path )
2021-05-03 09:57:24 -05:00
dirname := filepath . Dir ( path )
workflows = append ( workflows , WorkflowFiles {
dirPath : dirname ,
2022-10-29 12:15:38 -05:00
workflowDirEntry : fs . FileInfoToDirEntry ( fi ) ,
2021-05-03 09:57:24 -05:00
} )
2020-05-26 22:29:50 -05:00
}
2020-02-04 18:38:41 -06:00
if err != nil {
return nil , err
}
wp := new ( workflowPlanner )
2021-05-03 09:57:24 -05:00
for _ , wf := range workflows {
2022-10-29 12:15:38 -05:00
ext := filepath . Ext ( wf . workflowDirEntry . Name ( ) )
2020-02-04 18:38:41 -06:00
if ext == ".yml" || ext == ".yaml" {
2022-10-29 12:15:38 -05:00
f , err := os . Open ( filepath . Join ( wf . dirPath , wf . workflowDirEntry . Name ( ) ) )
2020-02-04 18:38:41 -06:00
if err != nil {
return nil , err
}
2020-02-17 00:04:13 -06:00
log . Debugf ( "Reading workflow '%s'" , f . Name ( ) )
2020-02-04 18:38:41 -06:00
workflow , err := ReadWorkflow ( f )
if err != nil {
2022-06-10 16:16:42 -05:00
_ = f . Close ( )
2020-08-31 16:50:01 -05:00
if err == io . EOF {
2022-10-29 12:15:38 -05:00
return nil , fmt . Errorf ( "unable to read workflow '%s': file is empty: %w" , wf . workflowDirEntry . Name ( ) , err )
2020-08-31 16:50:01 -05:00
}
2022-10-29 12:15:38 -05:00
return nil , fmt . Errorf ( "workflow is not valid. '%s': %w" , wf . workflowDirEntry . Name ( ) , err )
2020-02-04 18:38:41 -06:00
}
2021-05-05 15:04:03 -05:00
_ , err = f . Seek ( 0 , 0 )
if err != nil {
2022-06-10 16:16:42 -05:00
_ = f . Close ( )
2022-10-29 12:15:38 -05:00
return nil , fmt . Errorf ( "error occurring when resetting io pointer in '%s': %w" , wf . workflowDirEntry . Name ( ) , err )
2021-05-05 15:04:03 -05:00
}
2021-05-03 09:57:24 -05:00
2022-10-29 12:15:38 -05:00
workflow . File = wf . workflowDirEntry . Name ( )
2020-02-07 00:17:58 -06:00
if workflow . Name == "" {
2022-10-29 12:15:38 -05:00
workflow . Name = wf . workflowDirEntry . Name ( )
2020-02-07 00:17:58 -06:00
}
2021-05-03 09:57:24 -05:00
2024-01-19 17:49:35 -06:00
err = validateJobName ( workflow )
if err != nil {
_ = f . Close ( )
return nil , err
2021-04-02 09:01:45 -05:00
}
2021-05-03 09:57:24 -05:00
2020-02-04 18:38:41 -06:00
wp . workflows = append ( wp . workflows , workflow )
2022-06-10 16:16:42 -05:00
_ = f . Close ( )
2020-02-04 18:38:41 -06:00
}
}
return wp , nil
}
2022-09-21 01:26:19 -05:00
// CombineWorkflowPlanner combines workflows to a WorkflowPlanner
func CombineWorkflowPlanner ( workflows ... * Workflow ) WorkflowPlanner {
return & workflowPlanner {
workflows : workflows ,
}
}
2024-01-19 17:49:35 -06:00
func NewSingleWorkflowPlanner ( name string , f io . Reader ) ( WorkflowPlanner , error ) {
wp := new ( workflowPlanner )
log . Debugf ( "Reading workflow %s" , name )
workflow , err := ReadWorkflow ( f )
if err != nil {
if err == io . EOF {
return nil , fmt . Errorf ( "unable to read workflow '%s': file is empty: %w" , name , err )
}
return nil , fmt . Errorf ( "workflow is not valid. '%s': %w" , name , err )
}
workflow . File = name
if workflow . Name == "" {
workflow . Name = name
}
err = validateJobName ( workflow )
if err != nil {
return nil , err
}
wp . workflows = append ( wp . workflows , workflow )
return wp , nil
}
func validateJobName ( workflow * Workflow ) error {
jobNameRegex := regexp . MustCompile ( ` ^([[:alpha:]_][[:alnum:]_\-]*)$ ` )
for k := range workflow . Jobs {
if ok := jobNameRegex . MatchString ( k ) ; ! ok {
return fmt . Errorf ( "workflow is not valid. '%s': Job name '%s' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'" , workflow . Name , k )
}
}
return nil
}
2020-02-04 18:38:41 -06:00
type workflowPlanner struct {
workflows [ ] * Workflow
}
// PlanEvent builds a new list of runs to execute in parallel for an event name
2023-02-16 10:41:59 -06:00
func ( wp * workflowPlanner ) PlanEvent ( eventName string ) ( * Plan , error ) {
2020-02-04 18:38:41 -06:00
plan := new ( Plan )
2020-10-09 00:28:01 -05:00
if len ( wp . workflows ) == 0 {
2023-02-16 10:41:59 -06:00
log . Debug ( "no workflows found by planner" )
return plan , nil
2020-10-09 00:28:01 -05:00
}
2023-02-16 10:41:59 -06:00
var lastErr error
2020-10-09 00:28:01 -05:00
2020-02-04 18:38:41 -06:00
for _ , w := range wp . workflows {
2023-02-16 10:41:59 -06:00
events := w . On ( )
if len ( events ) == 0 {
log . Debugf ( "no events found for workflow: %s" , w . File )
continue
}
for _ , e := range events {
2020-02-10 18:35:00 -06:00
if e == eventName {
2023-02-16 10:41:59 -06:00
stages , err := createStages ( w , w . GetJobIDs ( ) ... )
if err != nil {
log . Warn ( err )
lastErr = err
} else {
plan . mergeStages ( stages )
}
2020-02-10 18:35:00 -06:00
}
2020-02-04 18:38:41 -06:00
}
}
2023-02-16 10:41:59 -06:00
return plan , lastErr
2020-02-04 18:38:41 -06:00
}
// PlanJob builds a new run to execute in parallel for a job name
2023-02-16 10:41:59 -06:00
func ( wp * workflowPlanner ) PlanJob ( jobName string ) ( * Plan , error ) {
2020-02-04 18:38:41 -06:00
plan := new ( Plan )
2020-10-09 00:28:01 -05:00
if len ( wp . workflows ) == 0 {
log . Debugf ( "no jobs found for workflow: %s" , jobName )
}
2023-02-16 10:41:59 -06:00
var lastErr error
2020-10-09 00:28:01 -05:00
2020-02-04 18:38:41 -06:00
for _ , w := range wp . workflows {
2023-02-16 10:41:59 -06:00
stages , err := createStages ( w , jobName )
if err != nil {
log . Warn ( err )
lastErr = err
} else {
plan . mergeStages ( stages )
}
2020-02-04 18:38:41 -06:00
}
2023-02-16 10:41:59 -06:00
return plan , lastErr
2020-02-04 18:38:41 -06:00
}
2022-09-29 00:59:52 -05:00
// PlanAll builds a new run to execute in parallel all
2023-02-16 10:41:59 -06:00
func ( wp * workflowPlanner ) PlanAll ( ) ( * Plan , error ) {
2022-09-29 00:59:52 -05:00
plan := new ( Plan )
if len ( wp . workflows ) == 0 {
2023-02-16 10:41:59 -06:00
log . Debug ( "no workflows found by planner" )
return plan , nil
2022-09-29 00:59:52 -05:00
}
2023-02-16 10:41:59 -06:00
var lastErr error
2022-09-29 00:59:52 -05:00
for _ , w := range wp . workflows {
2023-02-16 10:41:59 -06:00
stages , err := createStages ( w , w . GetJobIDs ( ) ... )
if err != nil {
log . Warn ( err )
lastErr = err
} else {
plan . mergeStages ( stages )
}
2022-09-29 00:59:52 -05:00
}
2023-02-16 10:41:59 -06:00
return plan , lastErr
2022-09-29 00:59:52 -05:00
}
2020-02-04 18:38:41 -06:00
// GetEvents gets all the events in the workflows file
func ( wp * workflowPlanner ) GetEvents ( ) [ ] string {
events := make ( [ ] string , 0 )
for _ , w := range wp . workflows {
found := false
for _ , e := range events {
2020-02-10 18:35:00 -06:00
for _ , we := range w . On ( ) {
if e == we {
found = true
break
}
}
if found {
2020-02-04 18:38:41 -06:00
break
}
}
if ! found {
2020-02-10 18:35:00 -06:00
events = append ( events , w . On ( ) ... )
2020-02-04 18:38:41 -06:00
}
}
// sort the list based on depth of dependencies
sort . Slice ( events , func ( i , j int ) bool {
return events [ i ] < events [ j ]
} )
return events
}
2020-02-17 12:30:52 -06:00
// MaxRunNameLen determines the max name length of all jobs
func ( p * Plan ) MaxRunNameLen ( ) int {
maxRunNameLen := 0
for _ , stage := range p . Stages {
for _ , run := range stage . Runs {
runNameLen := len ( run . String ( ) )
if runNameLen > maxRunNameLen {
maxRunNameLen = runNameLen
}
}
}
return maxRunNameLen
}
2020-02-04 18:38:41 -06:00
// GetJobIDs will get all the job names in the stage
func ( s * Stage ) GetJobIDs ( ) [ ] string {
names := make ( [ ] string , 0 )
for _ , r := range s . Runs {
names = append ( names , r . JobID )
}
return names
}
// Merge stages with existing stages in plan
func ( p * Plan ) mergeStages ( stages [ ] * Stage ) {
newStages := make ( [ ] * Stage , int ( math . Max ( float64 ( len ( p . Stages ) ) , float64 ( len ( stages ) ) ) ) )
for i := 0 ; i < len ( newStages ) ; i ++ {
newStages [ i ] = new ( Stage )
if i >= len ( p . Stages ) {
2020-02-10 18:53:14 -06:00
newStages [ i ] . Runs = append ( newStages [ i ] . Runs , stages [ i ] . Runs ... )
2020-02-04 18:38:41 -06:00
} else if i >= len ( stages ) {
2020-02-10 18:53:14 -06:00
newStages [ i ] . Runs = append ( newStages [ i ] . Runs , p . Stages [ i ] . Runs ... )
2020-02-04 18:38:41 -06:00
} else {
2020-02-10 18:53:14 -06:00
newStages [ i ] . Runs = append ( newStages [ i ] . Runs , p . Stages [ i ] . Runs ... )
newStages [ i ] . Runs = append ( newStages [ i ] . Runs , stages [ i ] . Runs ... )
2020-02-04 18:38:41 -06:00
}
}
p . Stages = newStages
}
2023-02-16 10:41:59 -06:00
func createStages ( w * Workflow , jobIDs ... string ) ( [ ] * Stage , error ) {
2020-02-04 18:38:41 -06:00
// first, build a list of all the necessary jobs to run, and their dependencies
jobDependencies := make ( map [ string ] [ ] string )
for len ( jobIDs ) > 0 {
newJobIDs := make ( [ ] string , 0 )
for _ , jID := range jobIDs {
// make sure we haven't visited this job yet
if _ , ok := jobDependencies [ jID ] ; ! ok {
if job := w . GetJob ( jID ) ; job != nil {
2020-02-10 18:35:00 -06:00
jobDependencies [ jID ] = job . Needs ( )
newJobIDs = append ( newJobIDs , job . Needs ( ) ... )
2020-02-04 18:38:41 -06:00
}
}
}
jobIDs = newJobIDs
}
// next, build an execution graph
stages := make ( [ ] * Stage , 0 )
for len ( jobDependencies ) > 0 {
stage := new ( Stage )
for jID , jDeps := range jobDependencies {
// make sure all deps are in the graph already
if listInStages ( jDeps , stages ... ) {
stage . Runs = append ( stage . Runs , & Run {
Workflow : w ,
JobID : jID ,
} )
delete ( jobDependencies , jID )
}
}
if len ( stage . Runs ) == 0 {
2023-02-16 10:41:59 -06:00
return nil , fmt . Errorf ( "unable to build dependency graph for %s (%s)" , w . Name , w . File )
2020-02-04 18:38:41 -06:00
}
stages = append ( stages , stage )
}
2023-08-21 12:53:47 -05:00
if len ( stages ) == 0 {
return nil , fmt . Errorf ( "Could not find any stages to run. View the valid jobs with `act --list`. Use `act --help` to find how to filter by Job ID/Workflow/Event Name" )
2023-02-16 10:41:59 -06:00
}
return stages , nil
2020-02-04 18:38:41 -06:00
}
// return true iff all strings in srcList exist in at least one of the stages
func listInStages ( srcList [ ] string , stages ... * Stage ) bool {
for _ , src := range srcList {
found := false
for _ , stage := range stages {
for _ , search := range stage . GetJobIDs ( ) {
if src == search {
found = true
}
}
}
if ! found {
return false
}
}
return true
}