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 }