github.com/nektos/act@v0.2.83/pkg/model/planner.go (about)

     1  package model
     2  
     3  import (
     4  	"fmt"
     5  	"io"
     6  	"io/fs"
     7  	"math"
     8  	"os"
     9  	"path/filepath"
    10  	"regexp"
    11  	"sort"
    12  
    13  	log "github.com/sirupsen/logrus"
    14  )
    15  
    16  // WorkflowPlanner contains methods for creating plans
    17  type WorkflowPlanner interface {
    18  	PlanEvent(eventName string) (*Plan, error)
    19  	PlanJob(jobName string) (*Plan, error)
    20  	PlanAll() (*Plan, error)
    21  	GetEvents() []string
    22  }
    23  
    24  // Plan contains a list of stages to run in series
    25  type Plan struct {
    26  	Stages []*Stage
    27  }
    28  
    29  // Stage contains a list of runs to execute in parallel
    30  type Stage struct {
    31  	Runs []*Run
    32  }
    33  
    34  // Run represents a job from a workflow that needs to be run
    35  type Run struct {
    36  	Workflow *Workflow
    37  	JobID    string
    38  }
    39  
    40  func (r *Run) String() string {
    41  	jobName := r.Job().Name
    42  	if jobName == "" {
    43  		jobName = r.JobID
    44  	}
    45  	return jobName
    46  }
    47  
    48  // Job returns the job for this Run
    49  func (r *Run) Job() *Job {
    50  	return r.Workflow.GetJob(r.JobID)
    51  }
    52  
    53  type WorkflowFiles struct {
    54  	workflowDirEntry os.DirEntry
    55  	dirPath          string
    56  }
    57  
    58  // NewWorkflowPlanner will load a specific workflow, all workflows from a directory or all workflows from a directory and its subdirectories
    59  func NewWorkflowPlanner(path string, noWorkflowRecurse, strict bool) (WorkflowPlanner, error) {
    60  	path, err := filepath.Abs(path)
    61  	if err != nil {
    62  		return nil, err
    63  	}
    64  
    65  	fi, err := os.Stat(path)
    66  	if err != nil {
    67  		return nil, err
    68  	}
    69  
    70  	var workflows []WorkflowFiles
    71  
    72  	if fi.IsDir() {
    73  		log.Debugf("Loading workflows from '%s'", path)
    74  		if noWorkflowRecurse {
    75  			files, err := os.ReadDir(path)
    76  			if err != nil {
    77  				return nil, err
    78  			}
    79  
    80  			for _, v := range files {
    81  				workflows = append(workflows, WorkflowFiles{
    82  					dirPath:          path,
    83  					workflowDirEntry: v,
    84  				})
    85  			}
    86  		} else {
    87  			log.Debug("Loading workflows recursively")
    88  			if err := filepath.Walk(path,
    89  				func(p string, f os.FileInfo, err error) error {
    90  					if err != nil {
    91  						return err
    92  					}
    93  
    94  					if !f.IsDir() {
    95  						log.Debugf("Found workflow '%s' in '%s'", f.Name(), p)
    96  						workflows = append(workflows, WorkflowFiles{
    97  							dirPath:          filepath.Dir(p),
    98  							workflowDirEntry: fs.FileInfoToDirEntry(f),
    99  						})
   100  					}
   101  
   102  					return nil
   103  				}); err != nil {
   104  				return nil, err
   105  			}
   106  		}
   107  	} else {
   108  		log.Debugf("Loading workflow '%s'", path)
   109  		dirname := filepath.Dir(path)
   110  
   111  		workflows = append(workflows, WorkflowFiles{
   112  			dirPath:          dirname,
   113  			workflowDirEntry: fs.FileInfoToDirEntry(fi),
   114  		})
   115  	}
   116  
   117  	wp := new(workflowPlanner)
   118  	for _, wf := range workflows {
   119  		ext := filepath.Ext(wf.workflowDirEntry.Name())
   120  		if ext == ".yml" || ext == ".yaml" {
   121  			f, err := os.Open(filepath.Join(wf.dirPath, wf.workflowDirEntry.Name()))
   122  			if err != nil {
   123  				return nil, err
   124  			}
   125  
   126  			log.Debugf("Reading workflow '%s'", f.Name())
   127  			workflow, err := ReadWorkflow(f, strict)
   128  			if err != nil {
   129  				_ = f.Close()
   130  				if err == io.EOF {
   131  					return nil, fmt.Errorf("unable to read workflow '%s': file is empty: %w", wf.workflowDirEntry.Name(), err)
   132  				}
   133  				return nil, fmt.Errorf("workflow is not valid. '%s': %w", wf.workflowDirEntry.Name(), err)
   134  			}
   135  			_, err = f.Seek(0, 0)
   136  			if err != nil {
   137  				_ = f.Close()
   138  				return nil, fmt.Errorf("error occurring when resetting io pointer in '%s': %w", wf.workflowDirEntry.Name(), err)
   139  			}
   140  
   141  			workflow.File = wf.workflowDirEntry.Name()
   142  			if workflow.Name == "" {
   143  				workflow.Name = wf.workflowDirEntry.Name()
   144  			}
   145  
   146  			err = validateJobName(workflow)
   147  			if err != nil {
   148  				_ = f.Close()
   149  				return nil, err
   150  			}
   151  
   152  			wp.workflows = append(wp.workflows, workflow)
   153  			_ = f.Close()
   154  		}
   155  	}
   156  
   157  	return wp, nil
   158  }
   159  
   160  func NewSingleWorkflowPlanner(name string, f io.Reader) (WorkflowPlanner, error) {
   161  	wp := new(workflowPlanner)
   162  
   163  	log.Debugf("Reading workflow %s", name)
   164  	workflow, err := ReadWorkflow(f, false)
   165  	if err != nil {
   166  		if err == io.EOF {
   167  			return nil, fmt.Errorf("unable to read workflow '%s': file is empty: %w", name, err)
   168  		}
   169  		return nil, fmt.Errorf("workflow is not valid. '%s': %w", name, err)
   170  	}
   171  	workflow.File = name
   172  	if workflow.Name == "" {
   173  		workflow.Name = name
   174  	}
   175  
   176  	err = validateJobName(workflow)
   177  	if err != nil {
   178  		return nil, err
   179  	}
   180  
   181  	wp.workflows = append(wp.workflows, workflow)
   182  
   183  	return wp, nil
   184  }
   185  
   186  func validateJobName(workflow *Workflow) error {
   187  	jobNameRegex := regexp.MustCompile(`^([[:alpha:]_][[:alnum:]_\-]*)$`)
   188  	for k := range workflow.Jobs {
   189  		if ok := jobNameRegex.MatchString(k); !ok {
   190  			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)
   191  		}
   192  	}
   193  	return nil
   194  }
   195  
   196  type workflowPlanner struct {
   197  	workflows []*Workflow
   198  }
   199  
   200  // PlanEvent builds a new list of runs to execute in parallel for an event name
   201  func (wp *workflowPlanner) PlanEvent(eventName string) (*Plan, error) {
   202  	plan := new(Plan)
   203  	if len(wp.workflows) == 0 {
   204  		log.Debug("no workflows found by planner")
   205  		return plan, nil
   206  	}
   207  	var lastErr error
   208  
   209  	for _, w := range wp.workflows {
   210  		events := w.On()
   211  		if len(events) == 0 {
   212  			log.Debugf("no events found for workflow: %s", w.File)
   213  			continue
   214  		}
   215  
   216  		for _, e := range events {
   217  			if e == eventName {
   218  				stages, err := createStages(w, w.GetJobIDs()...)
   219  				if err != nil {
   220  					log.Warn(err)
   221  					lastErr = err
   222  				} else {
   223  					plan.mergeStages(stages)
   224  				}
   225  			}
   226  		}
   227  	}
   228  	return plan, lastErr
   229  }
   230  
   231  // PlanJob builds a new run to execute in parallel for a job name
   232  func (wp *workflowPlanner) PlanJob(jobName string) (*Plan, error) {
   233  	plan := new(Plan)
   234  	if len(wp.workflows) == 0 {
   235  		log.Debugf("no jobs found for workflow: %s", jobName)
   236  	}
   237  	var lastErr error
   238  
   239  	for _, w := range wp.workflows {
   240  		stages, err := createStages(w, jobName)
   241  		if err != nil {
   242  			log.Warn(err)
   243  			lastErr = err
   244  		} else {
   245  			plan.mergeStages(stages)
   246  		}
   247  	}
   248  	return plan, lastErr
   249  }
   250  
   251  // PlanAll builds a new run to execute in parallel all
   252  func (wp *workflowPlanner) PlanAll() (*Plan, error) {
   253  	plan := new(Plan)
   254  	if len(wp.workflows) == 0 {
   255  		log.Debug("no workflows found by planner")
   256  		return plan, nil
   257  	}
   258  	var lastErr error
   259  
   260  	for _, w := range wp.workflows {
   261  		stages, err := createStages(w, w.GetJobIDs()...)
   262  		if err != nil {
   263  			log.Warn(err)
   264  			lastErr = err
   265  		} else {
   266  			plan.mergeStages(stages)
   267  		}
   268  	}
   269  
   270  	return plan, lastErr
   271  }
   272  
   273  // GetEvents gets all the events in the workflows file
   274  func (wp *workflowPlanner) GetEvents() []string {
   275  	events := make([]string, 0)
   276  	for _, w := range wp.workflows {
   277  		found := false
   278  		for _, e := range events {
   279  			for _, we := range w.On() {
   280  				if e == we {
   281  					found = true
   282  					break
   283  				}
   284  			}
   285  			if found {
   286  				break
   287  			}
   288  		}
   289  
   290  		if !found {
   291  			events = append(events, w.On()...)
   292  		}
   293  	}
   294  
   295  	// sort the list based on depth of dependencies
   296  	sort.Slice(events, func(i, j int) bool {
   297  		return events[i] < events[j]
   298  	})
   299  
   300  	return events
   301  }
   302  
   303  // MaxRunNameLen determines the max name length of all jobs
   304  func (p *Plan) MaxRunNameLen() int {
   305  	maxRunNameLen := 0
   306  	for _, stage := range p.Stages {
   307  		for _, run := range stage.Runs {
   308  			runNameLen := len(run.String())
   309  			if runNameLen > maxRunNameLen {
   310  				maxRunNameLen = runNameLen
   311  			}
   312  		}
   313  	}
   314  	return maxRunNameLen
   315  }
   316  
   317  // GetJobIDs will get all the job names in the stage
   318  func (s *Stage) GetJobIDs() []string {
   319  	names := make([]string, 0)
   320  	for _, r := range s.Runs {
   321  		names = append(names, r.JobID)
   322  	}
   323  	return names
   324  }
   325  
   326  // Merge stages with existing stages in plan
   327  func (p *Plan) mergeStages(stages []*Stage) {
   328  	newStages := make([]*Stage, int(math.Max(float64(len(p.Stages)), float64(len(stages)))))
   329  	for i := 0; i < len(newStages); i++ {
   330  		newStages[i] = new(Stage)
   331  		if i >= len(p.Stages) {
   332  			newStages[i].Runs = append(newStages[i].Runs, stages[i].Runs...)
   333  		} else if i >= len(stages) {
   334  			newStages[i].Runs = append(newStages[i].Runs, p.Stages[i].Runs...)
   335  		} else {
   336  			newStages[i].Runs = append(newStages[i].Runs, p.Stages[i].Runs...)
   337  			newStages[i].Runs = append(newStages[i].Runs, stages[i].Runs...)
   338  		}
   339  	}
   340  	p.Stages = newStages
   341  }
   342  
   343  func createStages(w *Workflow, jobIDs ...string) ([]*Stage, error) {
   344  	// first, build a list of all the necessary jobs to run, and their dependencies
   345  	jobDependencies := make(map[string][]string)
   346  	for len(jobIDs) > 0 {
   347  		newJobIDs := make([]string, 0)
   348  		for _, jID := range jobIDs {
   349  			// make sure we haven't visited this job yet
   350  			if _, ok := jobDependencies[jID]; !ok {
   351  				if job := w.GetJob(jID); job != nil {
   352  					jobDependencies[jID] = job.Needs()
   353  					newJobIDs = append(newJobIDs, job.Needs()...)
   354  				}
   355  			}
   356  		}
   357  		jobIDs = newJobIDs
   358  	}
   359  
   360  	// next, build an execution graph
   361  	stages := make([]*Stage, 0)
   362  	for len(jobDependencies) > 0 {
   363  		stage := new(Stage)
   364  		for jID, jDeps := range jobDependencies {
   365  			// make sure all deps are in the graph already
   366  			if listInStages(jDeps, stages...) {
   367  				stage.Runs = append(stage.Runs, &Run{
   368  					Workflow: w,
   369  					JobID:    jID,
   370  				})
   371  				delete(jobDependencies, jID)
   372  			}
   373  		}
   374  		if len(stage.Runs) == 0 {
   375  			return nil, fmt.Errorf("unable to build dependency graph for %s (%s)", w.Name, w.File)
   376  		}
   377  		stages = append(stages, stage)
   378  	}
   379  
   380  	return stages, nil
   381  }
   382  
   383  // return true iff all strings in srcList exist in at least one of the stages
   384  func listInStages(srcList []string, stages ...*Stage) bool {
   385  	for _, src := range srcList {
   386  		found := false
   387  		for _, stage := range stages {
   388  			for _, search := range stage.GetJobIDs() {
   389  				if src == search {
   390  					found = true
   391  				}
   392  			}
   393  		}
   394  		if !found {
   395  			return false
   396  		}
   397  	}
   398  	return true
   399  }