1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13 "sync/atomic"
14 "syscall"
15 "time"
16
17 "bnema.dev/ermite-actions/expr"
18 "bnema.dev/ermite-actions/template"
19 "bnema.dev/ermite-actions/workflow"
20 "github.com/bnema/zerowrap"
21 "github.com/google/uuid"
22)
23
24// Names of the implicit steps (JobExtension.cs:70 and :15).
25const (
26 SetUpJobName = "Set up job"
27 CompleteJobName = "Complete job"
28)
29
30// DefaultTimeoutMinutes is the timeout of a job without timeout-minutes.
31const DefaultTimeoutMinutes = 360
32
33// MaxOutputBytes is the limit of ermite on the job outputs, names and values
34// together (1 MiB).
35const MaxOutputBytes = 1 << 20
36
37// maxIssueMessageLength is ExecutionContext._maxIssueMessageLength.
38const maxIssueMessageLength = 4096
39
40// Config describes the job to run.
41type Config struct {
42 // WorkflowPath and WorkflowYAML are the workflow file, which the executor
43 // parses itself; JobKey selects the job.
44 WorkflowPath string
45 WorkflowYAML []byte
46 JobKey string
47 // JobName is the display name of the job, for "Complete job name: ...".
48 // Empty means the job key.
49 JobName string
50 // ContextsJSON and MatrixJSON are the fields of the job message.
51 ContextsJSON string
52 MatrixJSON string
53 // Repo is the repository name used when the github context lacks one.
54 Repo string
55 // TimeoutMinutes is the job timeout; 0 means DefaultTimeoutMinutes. It is
56 // used for the message and, without Deadline, for the timer.
57 TimeoutMinutes int
58 // Deadline is the time left of the job timeout when Run is called, when the
59 // timeout started counting earlier (at the receipt of the job by the host);
60 // 0 means TimeoutMinutes from now.
61 Deadline time.Duration
62
63 Runner RunnerInfo
64 RunnerVersion string
65 // Layout overrides the directory layout (tests); nil means the hosted layout
66 // under Home.
67 Layout *Layout
68 // Home is the home directory of the user of the steps; empty means
69 // /home/runner.
70 Home string
71 // ToolCache is RUNNER_TOOL_CACHE; empty means DefaultToolCache. The official
72 // runner reads it from its own environment (HostContext.cs:411).
73 ToolCache string
74 // BaseEnv is the environment steps inherit; nil means os.Environ().
75 BaseEnv []string
76 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
77 Debug bool
78
79 Reporter Reporter
80 // Log receives the runner's own diagnostics (not the job log); the zero value
81 // discards them.
82 Log zerowrap.Logger
83
84 // Credential, when set, is the user the steps run as.
85 Credential *syscall.Credential
86 // Cancellation timings and test hooks, see procSpec.
87 TermAfter, KillAfter, WaitDelay time.Duration
88 Signal func(pid int, sig syscall.Signal)
89 // Now returns the current time; nil means time.Now.
90 Now func() time.Time
91}
92
93type stepResult int
94
95// Values are ordered like TaskResult so that merge picks the worst
96// (TaskResultUtil.MergeTaskResults).
97const (
98 resSucceeded stepResult = iota
99 resFailed
100 resCanceled
101 resSkipped
102)
103
104func (r stepResult) conclusion() Conclusion {
105 switch r {
106 case resFailed:
107 return ConclusionFailure
108 case resCanceled:
109 return ConclusionCancelled
110 case resSkipped:
111 return ConclusionSkipped
112 }
113 return ConclusionSuccess
114}
115
116// merge is TaskResultUtil.MergeTaskResults (Runner.Common/Util/TaskResultUtil.cs:40).
117func merge(cur *stepResult, coming stepResult) stepResult {
118 if cur == nil {
119 return coming
120 }
121 if *cur > resFailed {
122 return *cur
123 }
124 if coming >= *cur {
125 return coming
126 }
127 return *cur
128}
129
130type job struct {
131 cfg Config
132 cancel *Canceller
133 wf *workflow.Workflow
134 jb *workflow.Job
135 fc *FileCommands
136 // tempRoot is RUNNER_TEMP, opened before the first step (see FileCommands).
137 tempRoot *os.Root
138 trackID string
139
140 mu sync.Mutex // serialises the Reporter and guards state and result
141 state *State
142 result *stepResult // nil until a step fails or the job is cancelled (Global result)
143 cancelled bool
144 runDefaults struct{ shell, workingDirectory string }
145 baseEnv []string
146 lookupPath string
147 before map[string]bool
148 // masker hides the secrets in everything reported; it is set once the
149 // contexts are built, before any step runs.
150 masker atomic.Pointer[masker]
151}
152
153func (j *job) mask(s string) string { return j.masker.Load().mask(s) }
154
155func (j *job) now() time.Time {
156 if j.cfg.Now != nil {
157 return j.cfg.Now()
158 }
159 return time.Now()
160}
161
162// Run executes one job and reports to cfg.Reporter. It always ends with a
163// JobCompleted call and returns the same Result. cancel may be nil.
164func Run(cancel *Canceller, cfg Config) Result {
165 if cancel == nil {
166 cancel = NewCanceller()
167 }
168 j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString()}
169 j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter}
170 defer func() {
171 if j.fc != nil {
172 _ = j.fc.Close()
173 }
174 if j.tempRoot != nil {
175 _ = j.tempRoot.Close()
176 }
177 }()
178 res := j.run()
179 res.Error = j.mask(res.Error)
180 j.cfg.Reporter.JobCompleted(res)
181 return res
182}
183
184func (j *job) run() Result {
185 limit := j.cfg.Deadline
186 if limit <= 0 {
187 limit = time.Duration(j.timeoutMinutes()) * time.Minute
188 }
189 timer := time.AfterFunc(limit, func() { j.cancel.Cancel(CancelTimeout) })
190 defer timer.Stop()
191
192 // ---- Set up job (JobExtension.InitializeJob) ----
193 rep := j.cfg.Reporter
194 rep.StepStarted(1, SetUpJobName, j.now())
195 steps, err := j.setUp()
196 if err != nil {
197 j.errorf(1, "%s", err.Error())
198 rep.StepCompleted(1, SetUpJobName, ConclusionFailure, j.now())
199 // The job ends here, without "Complete job" (JobRunner.cs:107).
200 return Result{Conclusion: ConclusionFailure, Error: err.Error()}
201 }
202 rep.StepCompleted(1, SetUpJobName, ConclusionSuccess, j.now())
203
204 // ---- the steps (StepsRunner) ----
205 for i, st := range steps {
206 j.runStep(i+2, st)
207 }
208
209 // ---- Complete job (JobExtension.FinalizeJob) ----
210 last := len(steps) + 2
211 outputs := j.completeJob(last)
212
213 return j.finish(outputs)
214}
215
216func (j *job) finish(outputs map[string]string) Result {
217 res := Result{Outputs: outputs}
218 j.mu.Lock()
219 defer j.mu.Unlock()
220 r := resSucceeded
221 if j.result != nil {
222 r = *j.result
223 }
224 switch {
225 case j.cancel.Reason() == CancelTimeout && j.cancelled:
226 res.Conclusion = ConclusionTimedOut
227 res.Error = fmt.Sprintf("The job has exceeded the maximum execution time of %d minutes.", j.timeoutMinutes())
228 case r == resCanceled:
229 res.Conclusion = ConclusionCancelled
230 res.RunnerShutdown = j.cancel.Reason() == CancelShutdown
231 case r == resFailed:
232 res.Conclusion = ConclusionFailure
233 default:
234 res.Conclusion = ConclusionSuccess
235 }
236 return res
237}
238
239func (j *job) timeoutMinutes() int {
240 if j.cfg.TimeoutMinutes > 0 {
241 return j.cfg.TimeoutMinutes
242 }
243 return DefaultTimeoutMinutes
244}
245
246// ---------------------------------------------------------------- logging
247
248// write sends one log line to the Reporter, in order.
249func (j *job) write(num int, line string) {
250 j.mu.Lock()
251 defer j.mu.Unlock()
252 j.cfg.Reporter.Log(num, j.now(), line)
253}
254
255// out writes a message that may hold several lines (ExecutionContext.Output).
256func (j *job) out(num int, text string) {
257 for _, l := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
258 j.write(num, l)
259 }
260}
261
262// tagged writes tag+message; only the first line carries the tag.
263func (j *job) tagged(num int, tag, msg string) {
264 msg = j.mask(msg) // before the cut: a secret split by it would leak its start
265 if len(msg) > maxIssueMessageLength && tag != "" {
266 msg = msg[:maxIssueMessageLength]
267 }
268 lines := strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n")
269 j.write(num, tag+lines[0])
270 for _, l := range lines[1:] {
271 j.write(num, l)
272 }
273}
274
275func (j *job) errorf(num int, format string, a ...any) {
276 j.tagged(num, "##[error]", fmt.Sprintf(format, a...))
277}
278
279func (j *job) debug(num int, msg string) {
280 if j.cfg.Debug {
281 for _, l := range strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n") {
282 j.write(num, "##[debug]"+l)
283 }
284 }
285}
286
287// ---------------------------------------------------------------- set up
288
289// evalEnv is the environment for expressions of the job and step tokens.
290func (j *job) evalEnv(env *ordered) template.Env {
291 return template.Env{
292 Contexts: j.state.Values(env),
293 Funcs: map[string]expr.Func{"hashFiles": j.hashFilesFunc()},
294 }
295}
296
297func (j *job) evalString(tok template.Token, env *ordered) (string, error) {
298 if tok == nil {
299 return "", nil
300 }
301 return template.EvaluateString(context.Background(), tok, j.evalEnv(env))
302}
303
304// evalMap evaluates a mapping of strings (EvaluateStepEnvironment): values
305// are converted like GitHub converts them (null is the empty string).
306func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) {
307 if m == nil {
308 return nil, nil
309 }
310 v, err := template.Evaluate(context.Background(), m, j.evalEnv(env))
311 if err != nil {
312 return nil, err
313 }
314 d, ok := v.(*expr.Dictionary)
315 if !ok {
316 return nil, nil
317 }
318 var out []Pair
319 for _, p := range d.Pairs() {
320 if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary {
321 return nil, ghErrorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k)
322 }
323 out = append(out, Pair{p.Key, expr.CoerceString(p.Value)})
324 }
325 return out, nil
326}
327
328// setUp parses the workflow and prepares the job: JobExtension.InitializeJob
329// (JobExtension.cs:63-182) and the job-start code of JobRunner.cs:142-177. Its
330// log lines belong to step 1.
331func (j *job) setUp() ([]*workflow.Step, error) {
332 cfg := j.cfg
333 j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion))
334 // Only a self-hosted runner prints its name and machine (JobExtension.cs:
335 // 88-98: the runner is identified by its OAuth client id); the recordings
336 // of github-hosted runners have neither line. The runner group name
337 // (system.runnerGroupName) is not known to ermite.
338 if !cfg.Runner.githubHosted() {
339 if cfg.Runner.Name != "" {
340 j.out(1, fmt.Sprintf("Runner name: '%s'", cfg.Runner.Name))
341 }
342 if host, err := os.Hostname(); err == nil {
343 j.out(1, fmt.Sprintf("Machine name: '%s'", host))
344 }
345 }
346
347 wf, errs := workflow.Parse(cfg.WorkflowPath, cfg.WorkflowYAML)
348 if len(errs) > 0 {
349 var msgs []string
350 for _, e := range errs {
351 msgs = append(msgs, e.Error())
352 }
353 return nil, ghErrorf("The workflow is not valid. %s", strings.Join(msgs, "\n"))
354 }
355 jb := wf.Job(cfg.JobKey)
356 if jb == nil {
357 return nil, ghErrorf("The workflow has no job '%s'.", cfg.JobKey)
358 }
359 j.wf, j.jb = wf, jb
360
361 state, layout, err := NewState(cfg.ContextsJSON, cfg.MatrixJSON, cfg.JobKey, cfg.Runner, cfg.Repo)
362 if err != nil {
363 return nil, err
364 }
365 switch {
366 case cfg.Layout != nil:
367 state.WithLayout(*cfg.Layout)
368 layout = *cfg.Layout
369 case (cfg.Home != "" && cfg.Home != layout.Home) || cfg.ToolCache != "":
370 home := cfg.Home
371 if home == "" {
372 home = layout.Home
373 }
374 layout = NewLayout(home, filepath.Base(layout.Workspace), cfg.ToolCache)
375 state.WithLayout(layout)
376 }
377 j.state = state
378 j.masker.Store(newMasker(state.Secrets()...))
379
380 // Features that arrive in later phases fail the job with a clear message.
381 switch {
382 case jb.IsReusable():
383 return nil, errors.New("not supported yet: reusable workflows (jobs.<id>.uses)")
384 case jb.Container != nil:
385 return nil, errors.New("not supported yet: jobs.<id>.container")
386 case jb.Services != nil:
387 return nil, errors.New("not supported yet: jobs.<id>.services")
388 }
389
390 j.printTokenInfo()
391
392 // Directories (TempDirectoryManager, PipelineDirectoryManager).
393 j.out(1, "Prepare workflow directory")
394 for _, d := range []string{layout.Temp, layout.Workspace, layout.ToolCache} {
395 if err := os.MkdirAll(d, 0o755); err != nil {
396 return nil, fmt.Errorf("create %s: %w", d, err)
397 }
398 }
399 clearDir(layout.Temp)
400 if err := os.MkdirAll(layout.Temp, 0o755); err != nil {
401 return nil, err
402 }
403 fc, err := NewFileCommands(filepath.Join(layout.Temp, "_runner_file_commands"))
404 if err != nil {
405 return nil, err
406 }
407 if c := cfg.Credential; c != nil {
408 fc.SetOwner(int(c.Uid), int(c.Gid))
409 }
410 j.fc = fc
411 // Everything the executor writes below the temp directory later goes through
412 // this root (see FileCommands): the user of the steps owns the directory.
413 if j.tempRoot, err = os.OpenRoot(layout.Temp); err != nil {
414 return nil, err
415 }
416 for _, d := range []string{layout.WorkRoot, layout.RunnerWorkspace, layout.Workspace, layout.Temp, fc.dir} {
417 j.own(d)
418 }
419
420 j.baseEnv = cfg.BaseEnv
421 if j.baseEnv == nil {
422 j.baseEnv = os.Environ()
423 }
424 if !hasEnv(j.baseEnv, "HOME") {
425 j.baseEnv = append(append([]string(nil), j.baseEnv...), "HOME="+layout.Home)
426 }
427 j.lookupPath = envValue(j.baseEnv, "PATH")
428
429 // Job-level environment: workflow env, then job env, each evaluated with an
430 // empty env context (JobExtension.cs:38-49, ExecutionContext.cs:1048).
431 for _, m := range []*template.Mapping{wf.Env, jb.Env} {
432 pairs, err := j.evalMap(m, newOrdered())
433 if err != nil {
434 return nil, err
435 }
436 for _, p := range pairs {
437 j.state.SetEnv(p.Key, p.Value)
438 }
439 }
440
441 // defaults.run: a job-level "run" replaces the workflow's whole (JobExtension.cs:78-96).
442 for _, d := range []*workflow.Defaults{wf.Defaults, jb.Defaults} {
443 if d == nil || d.Run == nil {
444 continue
445 }
446 shell, err := j.evalString(d.Run.Shell, newOrdered())
447 if err != nil {
448 return nil, err
449 }
450 wd, err := j.evalString(d.Run.WorkingDirectory, newOrdered())
451 if err != nil {
452 return nil, err
453 }
454 j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd
455 }
456
457 j.out(1, "Prepare all required actions")
458 // JobExtension.cs:418-422: the display name of the job, which the server
459 // computes there (message.JobDisplayName). ermite's Job message has no such
460 // field, so the executor evaluates it (see jobDisplayName).
461 name := cfg.JobName
462 if name == "" {
463 name = j.jobDisplayName()
464 }
465 if strings.TrimSpace(name) != "" {
466 j.out(1, "Complete job name: "+name)
467 }
468
469 // Process tracking for the orphan cleanup of "Complete job".
470 j.before = map[string]bool{}
471 for _, p := range snapshotProcesses() {
472 j.before[p.key()] = true
473 }
474 return jb.Steps, nil
475}
476
477// own gives a file or directory created by the executor to the user the steps
478// run as, so that scripts can write to RUNNER_TEMP, the workspace and the file
479// command files. Best effort: without Credential nothing is done.
480func (j *job) own(path string) {
481 if c := j.cfg.Credential; c != nil {
482 _ = os.Lchown(path, int(c.Uid), int(c.Gid))
483 }
484}
485
486func clearDir(dir string) {
487 ents, err := os.ReadDir(dir)
488 if err != nil {
489 return
490 }
491 for _, e := range ents {
492 _ = os.RemoveAll(filepath.Join(dir, e.Name()))
493 }
494}
495
496func hasEnv(env []string, key string) bool {
497 for _, kv := range env {
498 if strings.HasPrefix(kv, key+"=") {
499 return true
500 }
501 }
502 return false
503}
504
505func envValue(env []string, key string) string {
506 v := ""
507 for _, kv := range env {
508 if k, val, ok := strings.Cut(kv, "="); ok && k == key {
509 v = val
510 }
511 }
512 return v
513}
514
515// ---------------------------------------------------------------- complete
516
517// completeJob is JobExtension.FinalizeJob (JobExtension.cs:9-190): job outputs
518// and the cleanup of orphan processes.
519func (j *job) completeJob(num int) map[string]string {
520 rep := j.cfg.Reporter
521 rep.StepStarted(num, CompleteJobName, j.now())
522 result := resSucceeded
523 outputs := map[string]string{}
524
525 if j.jb.Outputs != nil {
526 // The runner of the official service prints nothing here: the recordings
527 // of github.com show only "Cleaning up orphan processes" in "Complete
528 // job", although the jobs have outputs (the log lines of
529 // JobExtension.cs:48,68 belong to the legacy message that carries
530 // JobOutputs).
531 j.mu.Lock()
532 env := j.state.JobEnv()
533 j.mu.Unlock()
534 pairs, err := j.evalMap(j.jb.Outputs, env)
535 if err != nil {
536 result = resFailed
537 j.errorf(num, "Fail to evaluate job outputs")
538 j.errorf(num, "%s", err.Error())
539 j.mu.Lock()
540 m := merge(j.result, resFailed)
541 j.result = &m
542 j.mu.Unlock()
543 }
544 total := 0
545 for _, p := range pairs {
546 if p.Value == "" {
547 j.debug(num, fmt.Sprintf("Skip output '%s' since it's empty", p.Key))
548 continue
549 }
550 // An output that holds a secret is dropped (JobExtension.cs:819-824).
551 if j.masker.Load().has(p.Value) {
552 j.tagged(num, "##[warning]", fmt.Sprintf("Skip output '%s' since it may contain secret.", p.Key))
553 continue
554 }
555 // ermite refuses outputs above 1 MiB in total (runner.proto,
556 // CompleteJobRequest.outputs), and they would not fit a frame of the
557 // stream either: the job fails without outputs.
558 if total += len(p.Key) + len(p.Value); total > MaxOutputBytes {
559 result = resFailed
560 j.errorf(num, "Fail to evaluate job outputs")
561 j.errorf(num, "The job outputs are larger than 1 MiB in total.")
562 j.mu.Lock()
563 m := merge(j.result, resFailed)
564 j.result = &m
565 j.mu.Unlock()
566 outputs = map[string]string{}
567 break
568 }
569 outputs[p.Key] = p.Value
570 }
571 }
572
573 j.out(num, "Cleaning up orphan processes")
574 killOrphans(j.trackID, j.before, func(l string) { j.out(num, l) })
575
576 rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now())
577 return outputs
578}
579
580// Defaults of the lines "Set up job" prints between the runner version and
581// "Prepare workflow directory" (JobExtension.cs:150-182). The server gives
582// GitHub's runner these as the variables system.github.token.permissions and
583// actions_cache_mode and the github context key secret_source; ermite's Job
584// message carries none of them, so the values recorded on github.com for a
585// push to a public repository are used. Every one of the 221 recorded jobs
586// has exactly these lines.
587const (
588 defaultSecretSource = "Actions"
589 defaultCacheMode = "write"
590)
591
592// printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source
593// and the cache mode.
594//
595// GitHub's runner prints what the server sends as
596// system.github.token.permissions (JobExtension.cs:154-166); ermite's Job
597// message does not carry it, so the group is worked out from the workflow
598// with the rules recorded on github.com (tokenPermissionLines). It is always
599// printed: Metadata is listed even for "permissions: {}".
600func (j *job) printTokenInfo() {
601 j.out(1, "##[group]GITHUB_TOKEN Permissions")
602 for _, p := range tokenPermissionLines(j.wf, j.jb) {
603 j.out(1, p.name+": "+p.level)
604 }
605 j.out(1, "##[endgroup]")
606 // secret_source is a github context key (JobExtension.cs:172); the context
607 // of a job from ermite lacks it, so it is added with the recorded value.
608 src := j.state.GitHub("secret_source")
609 if src == "" {
610 src = defaultSecretSource
611 j.state.SetGitHub("secret_source", src)
612 }
613 j.out(1, "Secret source: "+src)
614 j.out(1, "Cache mode: "+defaultCacheMode)
615}
616
617// jobDisplayName is the name of the job as GitHub shows it and the runner
618// prints it after "Complete job name:": the "name" of the job (the job key when
619// it has none) evaluated with github, needs, strategy, matrix, vars and inputs
620// as the server has them (State.ServerContexts: github without the keys the
621// runner adds), followed by the matrix values in parentheses unless the name
622// reads the matrix (recorded: matrix-job-name, see workflow.Job.DisplayName).
623// When the name cannot be worked out the job key is used and a warning is
624// logged: the name is not worth failing the job for.
625func (j *job) jobDisplayName() string {
626 ctx := context.Background()
627 fallback := func(why string, err error) string {
628 j.cfg.Log.Warn().Err(err).Str("job_key", j.cfg.JobKey).Msg("job display name: " + why + ", using the job key")
629 return j.cfg.JobKey
630 }
631 j.mu.Lock()
632 all := j.state.ServerContexts()
633 j.mu.Unlock()
634 env := j.evalEnv(newOrdered())
635 env.Contexts = all
636 // The strategy itself sees neither the strategy nor the matrix context.
637 senv := env
638 senv.Contexts = map[string]expr.Value{}
639 for _, k := range []string{"github", "needs", "vars", "inputs"} {
640 senv.Contexts[k] = all[k]
641 }
642 st, err := j.jb.EvaluateStrategy(ctx, senv)
643 if err != nil {
644 return fallback("the strategy does not evaluate", err)
645 }
646 idx, ok := comboIndex(st, all["strategy"], all["matrix"])
647 if !ok {
648 return fallback("the matrix of the job is not one of the combinations of its strategy", nil)
649 }
650 name, err := j.jb.DisplayName(ctx, env, st, idx)
651 if err != nil {
652 return fallback("the name does not evaluate", err)
653 }
654 // GitHub shows "Display name of", not "Display name of " (recorded:
655 // matrix-include-exclude, an empty last matrix value). The recordings only
656 // show trailing spaces; both ends are trimmed, as ermite's server does.
657 return strings.TrimSpace(name)
658}
659
660// comboIndex finds the combination of the strategy that is this job. Without a
661// matrix it is the only one. The index of the strategy context is trusted only
662// when that combination has the matrix of the job; otherwise the first
663// combination with equal values is taken. ok is false when there is none.
664func comboIndex(st *workflow.Strategy, strategyCtx, matrix expr.Value) (idx int, ok bool) {
665 if !st.HasMatrix {
666 return 0, true
667 }
668 m, isDict := matrix.(*expr.Dictionary)
669 if !isDict {
670 return 0, false
671 }
672 if d, isDict := strategyCtx.(*expr.Dictionary); isDict {
673 if n, found := d.Get("job-index"); found {
674 if f, isNum := n.(expr.Number); isNum && f >= 0 && int(f) < len(st.Combos) && sameMatrix(st.Combos[int(f)].Values, m) {
675 return int(f), true
676 }
677 }
678 }
679 for i, c := range st.Combos {
680 if sameMatrix(c.Values, m) {
681 return i, true
682 }
683 }
684 return 0, false
685}
686
687// sameMatrix compares two matrix values: the same keys (ignoring case and
688// order) with equal values.
689func sameMatrix(a, b *expr.Dictionary) bool {
690 if a.Len() != b.Len() {
691 return false
692 }
693 for _, p := range a.Pairs() {
694 v, found := b.Get(p.Key)
695 if !found || expr.ToJSON(v) != expr.ToJSON(p.Value) {
696 return false
697 }
698 }
699 return true
700}