detached 5278df2

ci_plan.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"bytes"
7	"context"
8	"encoding/json"
9	"fmt"
10	"math"
11	"slices"
12	"strconv"
13	"strings"
14	"sync"
15
16	"bnema.dev/ermite-actions/expr"
17	"bnema.dev/ermite-actions/template"
18	"bnema.dev/ermite-actions/workflow"
19	"bnema.dev/ermite/internal/domain"
20)
21
22// maxJobError bounds JobError text (4 KiB, run.proto).
23const maxJobError = 4 << 10
24
25// wfCache keeps the parsed workflows of the runs in flight, so that planning a
26// job does not parse the file again.
27type wfCache struct {
28	mu  sync.Mutex
29	max int
30	m   map[int64]*workflow.Workflow
31	// uses keeps which secrets each job of a run refers to (the names only,
32	// never a value), per run then job key: the workflow file of a run does not
33	// change, so the walk of its YAML is done once per job.
34	uses map[int64]map[string]secretUse
35	// called keeps the parsed reusable workflows, by hash of path and content.
36	called map[string]*workflow.Workflow
37}
38
39func newWfCache(max int) *wfCache {
40	return &wfCache{max: max, m: map[int64]*workflow.Workflow{}, uses: map[int64]map[string]secretUse{}, called: map[string]*workflow.Workflow{}}
41}
42
43// secretUse returns the secrets the job refers to, walking the YAML once per
44// run and job.
45func (w *wfCache) secretUse(runID int64, yaml, jobKey string) secretUse {
46	w.mu.Lock()
47	if u, ok := w.uses[runID][jobKey]; ok {
48		w.mu.Unlock()
49		return u
50	}
51	w.mu.Unlock()
52	u := secretsUsedBy(yaml, jobKey)
53	w.mu.Lock()
54	defer w.mu.Unlock()
55	if w.uses[runID] == nil {
56		if len(w.uses) >= w.max {
57			clear(w.uses)
58		}
59		w.uses[runID] = map[string]secretUse{}
60	}
61	w.uses[runID][jobKey] = u
62	return u
63}
64
65func (w *wfCache) get(runID int64, path, yaml string) (*workflow.Workflow, error) {
66	w.mu.Lock()
67	if wf, ok := w.m[runID]; ok {
68		w.mu.Unlock()
69		return wf, nil
70	}
71	w.mu.Unlock()
72	wf, errs := workflow.Parse(path, []byte(yaml))
73	if len(errs) > 0 {
74		return nil, fmt.Errorf("the workflow file no longer parses: %s", errs[0].Error())
75	}
76	w.mu.Lock()
77	defer w.mu.Unlock()
78	if len(w.m) >= w.max {
79		clear(w.m)
80	}
81	w.m[runID] = wf
82	return wf, nil
83}
84
85// instances returns the indexes of the records of the job key, in order.
86func (rs *runState) instances(key string) []int {
87	var out []int
88	for i, j := range rs.jobs {
89		if j.Key == key {
90			out = append(out, i)
91		}
92	}
93	return out
94}
95
96func (rs *runState) sortJobs() {
97	slices.SortStableFunc(rs.jobs, func(a, b domain.CIJob) int {
98		if a.Index != b.Index {
99			return a.Index - b.Index
100		}
101		return a.Combo - b.Combo
102	})
103}
104
105// result is the aggregate conclusion of the instances of a job as the needs
106// context shows it: success, failure, cancelled or skipped.
107func result(insts []domain.CIJob) string {
108	var failed, cancelled, ok bool
109	for _, j := range insts {
110		if j.ContinueOnError && j.Conclusion.IsFailure() {
111			ok = true // recorded: needs.<job>.result is success
112			continue
113		}
114		switch j.Conclusion {
115		case domain.CIFailure, domain.CITimedOut, domain.CIActionRequired:
116			failed = true
117		case domain.CICancelled:
118			cancelled = true
119		case domain.CISkipped:
120		default:
121			ok = true
122		}
123	}
124	switch {
125	case failed:
126		return "failure"
127	case cancelled:
128		return "cancelled"
129	case ok:
130		return "success"
131	}
132	return "skipped"
133}
134
135// needsOf computes the needs context and the status functions of a job from
136// the jobs it needs, and whether they all have ended.
137//
138// The needs context holds the direct needs only, but the status functions look
139// at the whole chain (wf.Ancestors): a job whose need ran through always()
140// after a failure further up is skipped by its default success() (recorded:
141// needs-failed-dependency, chain-after-always), and failure() is true when an
142// ancestor failed (documented, not recorded). An ancestor beyond the direct
143// needs only changes success() and failure(): cancelled() is not recorded for
144// them, and a skipped ancestor behind a need that did run is not either.
145func (rs *runState) needsOf(wf *workflow.Workflow, j *workflow.Job, scope string) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) {
146	needs = expr.NewDictionary()
147	st = workflow.StatusFuncs{Success: true, Cancelled: rs.run.CancelRequested}
148	for _, n := range wf.Ancestors(j.ID) {
149		var insts []domain.CIJob
150		for _, i := range rs.instancesIn(scope, n) {
151			if rs.jobs[i].Ended() {
152				insts = append(insts, rs.jobs[i])
153			}
154		}
155		switch result(insts) {
156		case "failure":
157			st.Failure, st.Success = true, false
158		case "cancelled":
159			st.Success = false
160		}
161	}
162	for _, n := range j.Needs {
163		var insts []domain.CIJob
164		for _, i := range rs.instancesIn(scope, n) {
165			if !rs.jobs[i].Ended() {
166				return nil, st, false
167			}
168			insts = append(insts, rs.jobs[i])
169		}
170		if len(insts) == 0 {
171			return nil, st, false
172		}
173		res := result(insts)
174		switch res {
175		case "failure":
176			st.Failure, st.Success = true, false
177		case "cancelled":
178			st.Cancelled, st.Success = true, false
179		case "skipped":
180			st.Success = false
181		}
182		outs := expr.NewDictionary()
183		for _, in := range insts {
184			for _, k := range sortedKeys(in.Outputs) {
185				// Instances of a matrix share names: the last non-empty value wins.
186				if v := in.Outputs[k]; v != "" || !hasKey(outs, k) {
187					setKey(outs, k, expr.String(v))
188				}
189			}
190		}
191		d := expr.NewDictionary()
192		d.Add("result", expr.String(res))
193		d.Add("outputs", outs)
194		needs.Add(n, d)
195	}
196	return needs, st, true
197}
198
199func hasKey(d *expr.Dictionary, k string) bool { _, ok := d.Get(k); return ok }
200
201// setKey sets a key of a dictionary, replacing an existing value.
202func setKey(d *expr.Dictionary, k string, v expr.Value) {
203	if !d.Add(k, v) {
204		pairs := d.Pairs()
205		*d = *expr.NewDictionary()
206		for _, p := range pairs {
207			if strings.EqualFold(p.Key, k) {
208				d.Add(p.Key, v)
209			} else {
210				d.Add(p.Key, p.Value)
211			}
212		}
213	}
214}
215
216// githubValue is the github context of the run with the keys of the job.
217func (rs *runState) githubValue(key string, attempt int32) (*expr.Dictionary, error) {
218	v, err := expr.FromJSON([]byte(rs.src.GithubJSON))
219	if err != nil {
220		return nil, fmt.Errorf("the stored github context is not valid: %w", err)
221	}
222	d, ok := v.(*expr.Dictionary)
223	if !ok {
224		return nil, fmt.Errorf("the stored github context is not an object")
225	}
226	setKey(d, "job", expr.String(key))
227	setKey(d, "run_attempt", expr.String(strconv.Itoa(int(attempt))))
228	return d, nil
229}
230
231// trim cuts a message for a job error.
232func trim(s string) string { return cutText(s, maxJobError) }
233
234// end ends a job with a conclusion and an error text, completes its steps like
235// the server does, and applies fail-fast to its siblings.
236func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg string) {
237	now := rs.c.now()
238	cancelish := conclusion == domain.CICancelled || conclusion == domain.CITimedOut
239	for i := range j.Steps {
240		s := &j.Steps[i]
241		if s.State == domain.CIStepCompleted {
242			continue
243		}
244		s.Conclusion = domain.CICancelled
245		if s.State == domain.CIStepPending && !cancelish {
246			s.Conclusion = domain.CISkipped
247		}
248		s.Outcome = s.Conclusion
249		s.State, s.CompletedAt = domain.CIStepCompleted, now
250	}
251	j.Phase, j.Conclusion, j.CompletedAt = domain.CIPhaseEnded, conclusion, now
252	if msg != "" {
253		j.Error = trim(msg)
254	}
255	if j.HasToken() {
256		j.TokenValidUntil = now.Add(domain.CITokenGrace)
257	}
258	rs.revokeTokens(j)
259	rs.touch(j)
260	rs.c.mu.Lock()
261	delete(rs.c.heartbeats, jobRef(*j))
262	rs.c.mu.Unlock()
263
264	if j.FailFast && !j.ContinueOnError && conclusion.IsFailure() {
265		for i := range rs.jobs {
266			s := &rs.jobs[i]
267			if s.Key != j.Key || s.Scope != j.Scope || s.ID == j.ID || s.Ended() {
268				continue
269			}
270			if s.Active() {
271				rs.requestCancel(s, domain.CICancelFailFast, "Canceled because another job of the matrix failed (fail-fast)")
272			} else {
273				rs.end(s, domain.CICancelled, "")
274			}
275		}
276	}
277}
278
279// requestCancel asks the runner of a job to stop, once: the first reason wins.
280func (rs *runState) requestCancel(j *domain.CIJob, reason domain.CICancelReason, msg string) {
281	if j.CancelReason != domain.CICancelNone || j.Ended() {
282		return
283	}
284	j.CancelReason, j.CancelMessage, j.CancelRequestedAt = reason, msg, rs.c.now()
285	rs.touch(j)
286}
287
288// cancelAll cancels what is left of the run: jobs that no runner has are over
289// at once, the ones a runner has get a cancel request.
290func (rs *runState) cancelAll(msg string) {
291	for i := range rs.jobs {
292		j := &rs.jobs[i]
293		switch {
294		case j.Ended():
295		case j.Active():
296			rs.requestCancel(j, domain.CICancelUser, msg)
297		default:
298			rs.end(j, domain.CICancelled, "")
299		}
300	}
301}
302
303// advance plans the jobs that became ready and promotes queued matrix
304// instances, until nothing changes.
305func (rs *runState) advance(ctx context.Context) error {
306	if !rs.latest() {
307		return nil
308	}
309	if rs.run.CancelRequested {
310		rs.cancelAll("Canceled by the owner")
311		return nil
312	}
313	wf, err := rs.c.flows.get(rs.run.ID, rs.run.WorkflowPath, rs.src.WorkflowYAML)
314	if err != nil {
315		for i := range rs.jobs {
316			if !rs.jobs[i].Ended() && !rs.jobs[i].Active() {
317				rs.end(&rs.jobs[i], domain.CIFailure, err.Error())
318			}
319		}
320		return nil
321	}
322	if ok, err := rs.acquireRun(ctx, wf); err != nil || !ok {
323		return err // waits for its group, or was failed by it
324	}
325	for changed := true; changed; {
326		changed = false
327		for _, wj := range wf.Jobs {
328			insts := rs.instances(wj.ID)
329			if len(insts) != 1 {
330				continue
331			}
332			rec := rs.jobs[insts[0]]
333			if rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting {
334				continue
335			}
336			needs, st, ready := rs.needsOf(wf, wj, "")
337			if !ready {
338				continue
339			}
340			rs.planJob(ctx, wf, wj, insts[0], needs, st)
341			changed = true
342		}
343		// The jobs of called workflows, and the calls that have ended.
344		ch, err := rs.advanceCalls(ctx)
345		if err != nil {
346			return err
347		}
348		if ch {
349			changed = true
350		}
351	}
352	return rs.promote(ctx)
353}
354
355// promote makes planned instances ready while max-parallel allows it and the
356// concurrency group of the job, if it has one of its own, gives it the turn.
357// An instance that waits for its group is pending.
358func (rs *runState) promote(ctx context.Context) error {
359	now := rs.c.now()
360	counts := map[string]int{}
361	for _, j := range rs.jobs {
362		if j.Phase == domain.CIPhaseReady || j.Active() {
363			counts[j.Scope+"\x00"+j.Key]++
364		}
365	}
366	for i := range rs.jobs {
367		j := &rs.jobs[i]
368		if !j.Planned || j.Phase != domain.CIPhaseWaiting || j.Call != nil {
369			continue // a call never runs: it ends with the jobs of its scope
370		}
371		if j.MaxParallel > 0 && counts[j.Scope+"\x00"+j.Key] >= j.MaxParallel {
372			continue
373		}
374		if g := j.Concurrency; g.Group != "" && !g.FromRun {
375			st, err := rs.acquire(ctx, g.Group, domain.CIConcurrencyEntry{RunID: j.RunID, Attempt: j.RunAttempt, JobID: j.ID, Cancel: g.Cancel, Max: g.Max})
376			if err != nil {
377				return err
378			}
379			if st != g.State {
380				j.Concurrency.State = st
381				rs.touch(j)
382			}
383			if st != domain.CIConcurrencyHolding {
384				continue
385			}
386		}
387		j.Phase, j.ReadyAt = domain.CIPhaseReady, now
388		counts[j.Scope+"\x00"+j.Key]++
389		rs.touch(j)
390	}
391	return nil
392}
393
394// plan evaluates a job whose needs have ended: its condition, its strategy and
395// the parts of every instance the server decides (name, labels, timeout,
396// contexts). The record at idx becomes the first instance.
397func (rs *runState) planJob(ctx context.Context, wf *workflow.Workflow, wj *workflow.Job, idx int, needs *expr.Dictionary, st workflow.StatusFuncs) {
398	rec := rs.jobs[idx]
399	fail := func(msg string) { rs.end(&rs.jobs[idx], domain.CIFailure, msg) }
400
401	// github.job is empty while the job is evaluated (if, strategy, name,
402	// runs-on...): the runner sets it. Recorded: ctx-github-names, where
403	// "name: Display name of ${{ github.job }}" shows as "Display name of".
404	gh, err := rs.githubValue("", rs.run.Attempt)
405	if err != nil {
406		fail(err.Error())
407		return
408	}
409	vars, err := rs.c.varsContext(ctx, rs.run.Repo)
410	if err != nil {
411		fail(err.Error())
412		return
413	}
414	env := template.Env{Contexts: map[string]expr.Value{
415		"github": gh, "needs": needs, "inputs": rs.inputsOf(rec), "vars": vars,
416	}}
417	run, err := wj.EvaluateIf(ctx, env, st)
418	switch {
419	case err != nil:
420		fail(err.Error())
421		return
422	case !run:
423		rs.jobs[idx].Name = rs.skippedName(ctx, wj, env)
424		if call := rs.scopeOf(rec); call != nil {
425			rs.jobs[idx].Name = call.Name + " / " + rs.jobs[idx].Name
426		}
427		rs.end(&rs.jobs[idx], domain.CISkipped, "")
428		return
429	}
430	if wj.IsReusable() {
431		if err := rs.planCall(ctx, wf, wj, idx, env); err != nil {
432			if text, ok := isCallFailure(err); ok {
433				fail(text)
434			} else {
435				fail("reusable workflow " + wj.Uses.Value + ": " + err.Error())
436			}
437		}
438		return
439	}
440	plan, err := wj.EvaluateStrategy(ctx, env)
441	if err != nil {
442		fail(err.Error())
443		return
444	}
445
446	if err := rs.checkJobBudget(len(plan.Combos) - 1); err != nil {
447		fail(err.Error())
448		return
449	}
450	insts := make([]domain.CIJob, 0, len(plan.Combos))
451	for i := range plan.Combos {
452		menv, err := plan.MatrixEnv(env, i)
453		if err != nil {
454			fail(err.Error())
455			return
456		}
457		inst, err := rs.instance(ctx, wf, wj, plan, rec, i, menv, needs)
458		if err != nil {
459			fail(err.Error())
460			return
461		}
462		insts = append(insts, inst)
463	}
464	rs.jobs[idx] = insts[0]
465	rs.touch(&rs.jobs[idx])
466	for _, inst := range insts[1:] {
467		rs.jobs = append(rs.jobs, inst)
468		rs.dirty[inst.ID] = true
469	}
470	rs.sortJobs()
471}
472
473// skippedName is the best name for a job that did not run: its evaluated name
474// when that does not read the matrix, else its key.
475func (rs *runState) skippedName(ctx context.Context, wj *workflow.Job, env template.Env) string {
476	if wj.HasName {
477		if name, err := template.EvaluateString(ctx, wj.Name, env); err == nil && name != "" {
478			return name
479		}
480	}
481	return wj.ID
482}
483
484// instance builds the planned record of combination i of a job.
485func (rs *runState) instance(ctx context.Context, wf *workflow.Workflow, wj *workflow.Job, plan *workflow.Strategy, base domain.CIJob, i int, env template.Env, needs *expr.Dictionary) (domain.CIJob, error) {
486	j := base
487	j.Combo = i
488	j.ID = domain.NewJobID(rs.run.ID, base.Index, i)
489	if i > 0 {
490		j.Version = 0
491	}
492	name, err := wj.DisplayName(ctx, env, plan, i)
493	if err != nil {
494		return j, err
495	}
496	name = strings.TrimSpace(name) // GitHub shows "Display name of", not "Display name of "
497	call := rs.scopeOf(base)
498	if call != nil {
499		name = call.Name + " / " + name // "<caller job name> / <callee job name>"
500	}
501	labels, _, err := wj.EvaluateRunsOn(ctx, env)
502	if err != nil {
503		return j, err
504	}
505	if len(labels) == 0 {
506		return j, fmt.Errorf("job '%s' has no runs-on labels", wj.ID)
507	}
508	timeout := int32(domain.CIDefaultTimeoutMinutes)
509	if wj.TimeoutMinutes != nil {
510		n, err := template.EvaluateNumber(ctx, wj.TimeoutMinutes, env)
511		if err != nil {
512			return j, err
513		}
514		timeout = int32(min(max(math.Round(n), 1), 525600)) //nolint:gosec // clamped
515	}
516	gh, err := rs.githubValue(wj.ID, rs.run.Attempt)
517	if err != nil {
518		return j, err
519	}
520	matrix := plan.Combos[i].Values
521	var matrixCtx expr.Value = matrix
522	if !plan.HasMatrix {
523		matrixCtx = expr.Null{} // GitHub: "matrix": null for a job without a matrix
524	}
525	environment, err := environmentOf(ctx, wj, env)
526	if err != nil {
527		return j, err
528	}
529	// The vars of planning (see planJob), not read again for each leg.
530	vars, _ := env.Contexts["vars"].(*expr.Dictionary)
531	if vars == nil {
532		vars = expr.NewDictionary()
533	}
534	contexts, err := contextsJSON(gh, needs, plan.Context(i), matrixCtx, vars, rs.inputsOf(base), rs.jobKeysOf(ctx, base, i))
535	if err != nil {
536		return j, err
537	}
538	j.Environment = environment
539	if call != nil {
540		if j.Permissions, err = rs.scopedPermissions(wf, wj, call); err != nil {
541			return j, err
542		}
543	} else {
544		j.Permissions = domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions))
545	}
546	j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout
547	j.MatrixJSON = compactJSON(expr.ToJSON(matrix))
548	j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos)
549	j.FailFast, j.MaxParallel = plan.FailFast, plan.MaxParallel
550	j.ContinueOnError = false
551	if wj.ContinueOnError != nil {
552		// Evaluated with github, needs, strategy, matrix, vars and inputs.
553		coe, err := template.EvaluateBool(ctx, wj.ContinueOnError, env)
554		if err != nil {
555			return j, err
556		}
557		j.ContinueOnError = coe
558	}
559	if wj.Concurrency != nil {
560		// Evaluated with github, needs, strategy, matrix, inputs and vars;
561		// this leg's own group, which it takes before it may run.
562		conc, err := evalConcurrency(ctx, wj.Concurrency, env)
563		if err != nil {
564			return j, err
565		}
566		conc.Evaluated = true
567		j.Concurrency = conc
568	}
569	j.Planned, j.Phase = true, domain.CIPhaseWaiting
570	return j, nil
571}
572
573func compactJSON(s string) string {
574	var b bytes.Buffer
575	if err := json.Compact(&b, []byte(s)); err != nil {
576		return "{}"
577	}
578	return b.String()
579}
580
581// contextsJSON is the JSON object of the contexts the server computes for a
582// job (Job.contexts in runner.proto).
583//
584// "job" holds the keys of the job context the server knows (check_run_id,
585// workflow_ref, workflow_sha, workflow_repository, workflow_file_path); the
586// runner adds status. The runner copies the keys of this "job" into its job
587// context (executor.State.initRest), whatever they are.
588func contextsJSON(github, needs, strategy *expr.Dictionary, matrix expr.Value, vars, inputs *expr.Dictionary, job ordered) (string, error) {
589	out := map[string]json.RawMessage{}
590	for k, v := range map[string]expr.Value{
591		"github": github, "needs": needs, "strategy": strategy, "matrix": matrix,
592		"inputs": inputs, "vars": vars,
593	} {
594		out[k] = json.RawMessage(compactJSON(expr.ToJSON(v)))
595	}
596	jb, err := json.Marshal(job)
597	if err != nil {
598		return "", err
599	}
600	out["job"] = jb
601	b, err := json.Marshal(out)
602	if err != nil {
603		return "", err
604	}
605	return string(b), nil
606}
607
608// permissionSpec reads the "permissions:" of a workflow or a job, nil for none.
609func permissionSpec(p *workflow.Permissions) *domain.PermissionSpec {
610	if p == nil {
611		return nil
612	}
613	spec := &domain.PermissionSpec{All: p.All, Levels: make(map[string]string, len(p.Scopes))}
614	for _, sc := range p.Scopes {
615		spec.Levels[sc.Scope] = sc.Level
616	}
617	return spec
618}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close