1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "context"
7
8 "bnema.dev/ermite-actions/expr"
9)
10
11// compositeGitHubKeys are the keys of the github context that a composite
12// action's steps change: the runner gives them a copy of the context
13// (CompositeActionHandler.cs:152-161), the executor restores these afterwards.
14func compositeGitHubKeys() []string {
15 keys := []string{"action_path", "action_status", "action_repository", "action_ref", "event_path"}
16 for _, c := range fileCommands {
17 keys = append(keys, c.ContextName)
18 }
19 return keys
20}
21
22// maxLocalDepth is the nesting of composite actions the executor allows. It is
23// ermite's guard against a local composite action that uses itself, not a limit
24// of GitHub's, whose runner has none for local actions.
25const maxLocalDepth = 100
26
27// prepareLocalComposite is the first part of ActionRunner.RunAsync for a local
28// composite action (ActionRunner.cs:77-96): its steps are prepared when the
29// step runs, in the log of the step, and the pulls and builds they need run
30// first as embedded steps.
31func (j *job) prepareLocalComposite(s *stepRun, def *actionDef, cancel <-chan struct{}) error {
32 n := s.n
33 if n.children != nil {
34 return nil // pre and post stages reuse the nodes of the main stage
35 }
36 // Local composite actions have no depth limit on GitHub: the recording of
37 // ermite-conformance corpus/act-composite-depth has composites of 9, 10 and 12
38 // levels, all of which succeed. The runner prepares the steps of a local
39 // composite when its step runs, with the depth restarting at 1
40 // (ActionRunner.cs:77-96), which is why. This port does the same, and the
41 // resolutions it needs are cached and budgeted by the host (host/resolve.go).
42 //
43 // The one guard that is ermite's own, not GitHub's: a local composite that
44 // uses itself (directly or in a cycle) would recurse until the executor
45 // crashes, so the nesting of composite actions is capped at maxLocalDepth.
46 if d := s.depth(); d >= maxLocalDepth {
47 return ghErrorf("Composite action depth exceeded max depth %d", maxLocalDepth)
48 }
49 kids, err := newNodes(def.act.Runs.Steps, n, n.scope)
50 if err != nil {
51 return err
52 }
53 for _, k := range kids {
54 k.runtimePrepared = true
55 }
56 n.children = kids
57 n.preSteps, n.postSteps = nil, nil
58 j.out(s.num, "Prepare all required actions")
59 if j.actions == nil {
60 j.actions = newActionState()
61 }
62 saved := *j.actions
63 j.actions.pullOrder, j.actions.buildOrder = nil, nil
64 j.actions.pullUsers, j.actions.buildUsers = map[string][]*node{}, map[string][]*node{}
65 // The preparation stops with the step, and only with it: its context ends when
66 // the cancel channel of the step closes (job cancellation for a step that does
67 // not run on always(), timeout-minutes). A step that keeps running after the
68 // job was cancelled keeps the means to prepare its composite.
69 ctx, stop := context.WithCancel(context.Background())
70 defer stop()
71 if cancel != nil {
72 go func() {
73 select {
74 case <-cancel:
75 stop()
76 case <-ctx.Done():
77 }
78 }()
79 }
80 if err := j.prepare(ctx, s.num, kids, 1, n); err != nil {
81 return err
82 }
83 setups := j.setupSteps()
84 j.actions.pullOrder, j.actions.buildOrder = saved.pullOrder, saved.buildOrder
85 j.actions.pullUsers, j.actions.buildUsers = saved.pullUsers, saved.buildUsers
86 for _, q := range setups {
87 if r := q.setup(s, cancel); r != resSucceeded {
88 if r == resCanceled {
89 return errSetupCancelled
90 }
91 return errSetupFailed
92 }
93 }
94 return nil
95}
96
97// errSetupFailed ends a composite whose image setup failed; the cause is in the
98// log already.
99// errSetupCancelled: the step was cancelled while its images were prepared; the
100// cancellation is logged by the docker command already.
101var errSetupCancelled = ghErrorf("The operation was canceled.")
102
103var errSetupFailed = ghErrorf("A container setup step of the composite action failed.")
104
105// runComposite is CompositeActionHandler.RunAsync (CompositeActionHandler.cs:
106// 30-207) and RunStepsAsync: the steps of the stage run in their own steps
107// context with the inputs of the action, and the outputs are mapped from them.
108func (j *job) runComposite(s *stepRun, def *actionDef, in *inputSet, env *ordered) stepResult {
109 n := s.n
110 var kids []*node
111 switch s.stage {
112 case stagePre:
113 kids = n.preSteps
114 case stagePost:
115 // Only the steps that registered a post, last registered first.
116 for i := len(n.postSteps) - 1; i >= 0; i-- {
117 if c := n.postSteps[i]; c.postRegistered {
118 kids = append(kids, c)
119 }
120 }
121 default:
122 kids = n.children
123 }
124 inputs := expr.NewDictionary()
125 for _, k := range in.keys {
126 v, _ := in.Get(k)
127 inputs.Add(k, expr.String(v))
128 }
129 // The pre stage has no context name in the runner, so its steps context is a
130 // random one; post shares the scope of main (siblingScopeName).
131 scope := n.scope
132 if s.stage == stagePre {
133 scope = "__pre_" + n.scope
134 }
135 j.mu.Lock()
136 restoreGH := j.state.GitHubKeys(compositeGitHubKeys()...)
137 restoreScope := j.state.PushScope(scope, inputs)
138 j.state.SetGitHub("action_path", def.dir)
139 j.mu.Unlock()
140 restored := false
141 restore := func() {
142 if !restored {
143 restored = true
144 restoreScope()
145 restoreGH()
146 }
147 }
148 defer func() {
149 j.mu.Lock()
150 restore()
151 j.mu.Unlock()
152 }()
153 ok := resSucceeded
154 s.result = &ok
155 for _, c := range kids {
156 j.runEmbedded(s, c, scope)
157 }
158 // Outputs are evaluated in the steps context of the action and belong to the
159 // step that uses it (ExecutionContext.SetOutput of the composite step).
160 var outs []Pair
161 if s.stage == stageMain {
162 outs = j.compositeOutputs(s, def)
163 }
164 j.mu.Lock()
165 restore()
166 for _, o := range outs {
167 j.state.SetOutput(s.st.ID, o.Key, o.Value)
168 }
169 j.mu.Unlock()
170 return *s.result
171}
172
173// compositeOutputs is ProcessOutputs (CompositeActionHandler.cs:192-228): the
174// "value" of each output of the action, evaluated with the steps and inputs
175// of the action.
176func (j *job) compositeOutputs(s *stepRun, def *actionDef) []Pair {
177 var out []Pair
178 j.mu.Lock()
179 defer j.mu.Unlock()
180 for _, o := range def.act.Outputs {
181 if o.Value == nil {
182 continue
183 }
184 v, err := j.evalString(o.Value, s.env)
185 if err != nil {
186 j.mu.Unlock()
187 j.errorf(s.num, "%s", templateNotValid(err, true))
188 j.mu.Lock()
189 m := resFailed
190 s.result = &m
191 return nil
192 }
193 out = append(out, Pair{o.Name, v})
194 }
195 return out
196}
197
198// runEmbedded is one iteration of CompositeActionHandler.RunStepsAsync: the
199// step of the composite action runs inside the step that uses it, with its own
200// environment, condition, cancellation watch and result. Its log lines belong
201// to the outer step.
202//
203// scope is the steps context of the action; with the id of the step it is the
204// id of the step's markers (the fully qualified context name).
205func (j *job) runEmbedded(parent *stepRun, c *node, scope string) {
206 s := &stepRun{j: j, num: parent.num, st: c.st, n: c, stage: parent.stage, parent: parent, fc: j.fc.Fork()}
207 // The issues, summaries and state of an inner step belong to the step that
208 // uses the action, whose collector counts them (10 per level, the outer
209 // step's own first).
210 s.scope = j.embeddedScope(parent, c.stateKey)
211 defer s.fc.Cleanup()
212 s.cmds = newCommandManager(s, j.cfg.Debug)
213 j.mu.Lock()
214 // github.action_status: the result of the composite action so far.
215 j.state.SetGitHub("action_status", parent.actionStatus())
216 j.mu.Unlock()
217 env, envErr := j.stepEnvironment(s)
218 s.env = env
219 s.name = j.displayNameOf(c.st, env, true)
220 // The display name is masked before it is cut and escaped. The runner masks
221 // the finished line (ExecutionContext.Write), after SanitizeDisplayName and
222 // EscapeValue, so a secret holding ";", "]" or "%" (escaped by then) or cut by
223 // the 1000-character limit would escape its masker. Masking first is stricter;
224 // the line is masked again when it is written, as every line is.
225 id := scope + "." + c.st.ID
226 if envErr != nil {
227 // The error of the environment is logged and the step marked failed
228 // (CompositeActionHandler.cs:296-302, SetStepConclusion :529-533), then the
229 // runner does not stop: it prints the start marker (:308-324), evaluates
230 // the condition (:372-390; success() of an embedded main step reads
231 // github.action_status, set from the composite's result before the env was
232 // evaluated, :246-248, SuccessFunction.cs:28, so the default condition
233 // still holds) and runs the step (RunStepAsync :481-527) without the env
234 // that failed. Its result stays Failed (nothing resets it), and the end
235 // marker (:431-440) carries it with the elapsed time.
236 j.errorf(s.num, "%s", templateNotValid(envErr, true))
237 s.envFailed = true
238 // SetStepConclusion publishes Failed to the steps context at once
239 // (:529-533, UpdateGlobalStepsContext), so the condition of the step, and
240 // of the steps after it, see steps.<id>.outcome == 'failure'. Nothing is
241 // merged into the composite's result yet: that is done when the step ends
242 // (:505-512, finishStep), which also replaces this entry with the final
243 // outcome and conclusion.
244 j.mu.Lock()
245 j.state.SetStepResult(c.st.ID, string(resFailed.conclusion()), string(resFailed.conclusion()))
246 j.mu.Unlock()
247 }
248 q := qstep{n: c, stage: parent.stage}
249 switch parent.stage {
250 case stagePre:
251 if c.def != nil {
252 q.cond = c.def.preIf()
253 }
254 case stagePost:
255 q.cond = c.postCond
256 }
257 j.out(parent.num, startMarker(j.mask(s.name), id))
258 start := j.now()
259 r := j.runGuarded(s, q, true)
260 // Outcome is the result before continue-on-error, conclusion the final one;
261 // a skipped step takes no time (CompositeActionHandler.cs:415, :448).
262 outcome := r
263 if s.outcome != nil {
264 outcome = *s.outcome
265 }
266 ms := j.now().Sub(start).Milliseconds()
267 if r == resSkipped {
268 ms = 0
269 }
270 j.out(parent.num, endMarker(id, markerResult(outcome), markerResult(r), ms))
271}