1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "context"
7 "sync"
8 "time"
9
10 "bnema.dev/ermite-actions/action"
11 "bnema.dev/ermite-actions/expr"
12 "bnema.dev/ermite-actions/template"
13 "bnema.dev/ermite-actions/workflow"
14)
15
16// qstep is a step of the job's queue (StepsRunner's JobSteps): a main step, the
17// pre or post stage of an action, or a setup step (pull or build an image).
18type qstep struct {
19 n *node
20 stage stage
21 name string
22 // cond, for a pre or post stage, replaces the step's own condition (the
23 // action's pre-if or post-if); setup steps use "success()".
24 cond string
25 // setup, when set, is the whole step: it runs instead of the node.
26 setup func(s *stepRun, cancel <-chan struct{}) stepResult
27 // ctxID is the context name of a runner step of the job (a Guid in "N"
28 // format), assigned before the step runs: its final result, skipped
29 // included, is registered in the steps context (JobExtension.cs:434-440,
30 // StepsRunner.CompleteStep, ExecutionContext.UpdateGlobalStepsContext:644).
31 // Empty for the setup steps a composite action runs (CompositeActionHandler.cs:121-123).
32 ctxID string
33}
34
35func (j *job) statusFuncs() workflow.StatusFuncs {
36 st := j.state.Status()
37 return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
38}
39
40// actionStatus is github.action_status of the steps of a composite action: the
41// result of the action so far (CompositeActionHandler.cs:246), the job's
42// cancellation included (:340). j.mu is held.
43func (s *stepRun) actionStatus() string {
44 if s.j.cancelled {
45 return "cancelled"
46 }
47 if s.result != nil {
48 return statusName(*s.result)
49 }
50 return "success"
51}
52
53// statusFuncs are the status functions of the step: success() and failure()
54// of an embedded main step read action_status, the others the job's status
55// (SuccessFunction.cs:28-45). j.mu is held.
56func (s *stepRun) statusFuncs() workflow.StatusFuncs {
57 if s.parent != nil && s.stage == stageMain {
58 a := s.parent.actionStatus()
59 return workflow.StatusFuncs{Success: a == "success", Failure: a == "failure", Cancelled: s.j.state.Status() == "cancelled"}
60 }
61 return s.j.statusFuncs()
62}
63
64// markCancelled records the cancellation of the job: job result Canceled and
65// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
66func (j *job) markCancelledLocked() {
67 if j.cancelled {
68 return
69 }
70 j.cancelled = true
71 c := resCanceled
72 j.result = &c
73 j.state.SetStatus("cancelled")
74}
75
76// evalCondString evaluates the pre-if or post-if of an action (an expression
77// without ${{ }}, evaluated as written, EvaluateStepIf on a bare condition).
78func (j *job) evalCondString(cond string, env *ordered, st workflow.StatusFuncs) (bool, error) {
79 sch, err := action.Schema()
80 if err != nil {
81 return false, err
82 }
83 tok := template.NewExpression(template.Origin{}, cond, sch.Info("step-if").WithContexts("vars", "needs"))
84 te := j.evalEnv(env)
85 funcs := map[string]expr.Func{}
86 for k, f := range te.Funcs {
87 funcs[k] = f
88 }
89 for k, f := range st.Funcs() {
90 funcs[k] = f
91 }
92 te.Funcs = funcs
93 v, err := template.EvaluateExpression(context.Background(), tok, te)
94 if err != nil {
95 return false, err
96 }
97 return expr.Truthy(v), nil
98}
99
100// stepEnvironment builds the environment of a step (StepsRunner.cs:108-139 and
101// CompositeActionHandler.cs:233-262): the job's, plus for an embedded step the
102// env of the steps around it, then its own env, evaluated against that.
103func (j *job) stepEnvironment(s *stepRun) (*ordered, error) {
104 j.mu.Lock()
105 env := j.state.JobEnv()
106 j.mu.Unlock()
107 s.overrides = newOrdered()
108 if p := s.parent; p != nil {
109 for _, k := range p.overrides.Keys() {
110 v, _ := p.env.Get(k)
111 env.Set(k, v)
112 s.overrides.Set(k, v)
113 }
114 }
115 pairs, err := j.evalStepEnv(s.st, env)
116 if err != nil {
117 return env, err
118 }
119 for _, p := range pairs {
120 env.Set(p.Key, p.Value)
121 s.overrides.Set(p.Key, p.Value)
122 }
123 return env, nil
124}
125
126// evalStepEnv evaluates the env of a step: a mapping, or the expression that
127// stands for one (a step of a composite action may write env: ${{ ... }}).
128func (j *job) evalStepEnv(st *workflow.Step, env *ordered) ([]Pair, error) {
129 if st.Env != nil || st.EnvExpression == nil {
130 return j.evalMap(st.Env, env)
131 }
132 v, err := template.Evaluate(context.Background(), st.EnvExpression, j.evalEnv(env))
133 if err != nil {
134 return nil, err
135 }
136 return pairsOf(v)
137}
138
139func pairsOf(v expr.Value) ([]Pair, error) {
140 d, ok := v.(*expr.Dictionary)
141 if !ok {
142 return nil, nil
143 }
144 var out []Pair
145 for _, p := range d.Pairs() {
146 if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary {
147 return nil, ghErrorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k)
148 }
149 out = append(out, Pair{p.Key, expr.CoerceString(p.Value)})
150 }
151 return out, nil
152}
153
154// runStep runs one step of the queue as a step of the job (its own number, its
155// own record in the log).
156func (j *job) runStep(num int, q qstep) {
157 rep := j.cfg.Reporter
158 s := &stepRun{j: j, num: num, stage: q.stage}
159 if q.n != nil {
160 s.st, s.n = q.n.st, q.n
161 // The state saved by the action is the one of its node, whatever the stage.
162 s.scope.stateKey = q.n.stateKey
163 }
164 s.cmds = newCommandManager(s, j.cfg.Debug)
165 name := q.name
166 if q.setup != nil {
167 s.name = name
168 s.ctxID = q.ctxID
169 rep.StepStarted(num, name, j.now())
170 r := j.runGuarded(s, q, true)
171 rep.StepCompleted(num, name, r.conclusion(), j.now())
172 return
173 }
174 if q.n == nil {
175 return
176 }
177 // github.action is the id of the step, set before the step's env is
178 // evaluated (StepsRunner.cs:118), so that the env and the if of a step see
179 // its own id (the dump step of the recordings reads __run_6, not __run_5).
180 j.mu.Lock()
181 j.state.SetGitHub("action", q.n.st.ID)
182 j.mu.Unlock()
183 env, envErr := j.stepEnvironment(s)
184 s.env = env
185 if name == "" {
186 name = j.displayName(q.n.st, s.env)
187 if q.stage == stagePre {
188 name = "Pre " + name
189 }
190 }
191 s.name = name
192 rep.StepStarted(num, name, j.now())
193 r := j.runGuarded(s, q, envErr == nil)
194 if envErr != nil {
195 j.errorf(num, "%s", templateNotValid(envErr, true))
196 r = resFailed
197 j.finishStep(s, r)
198 }
199 rep.StepCompleted(num, name, r.conclusion(), j.now())
200}
201
202// finishStep records the result of the step in the contexts
203// (ExecutionContext.UpdateGlobalStepsContext) and, for a step of the job, in
204// the result of the job.
205func (j *job) finishStep(s *stepRun, res stepResult) {
206 j.mu.Lock()
207 defer j.mu.Unlock()
208 st := s.st
209 outcome := res
210 if s.outcome != nil {
211 outcome = *s.outcome
212 }
213 // Pre and post stages of a step of the job have no context name, and
214 // generated ids are hidden: only the main stage and embedded steps count.
215 if s.n != nil && (s.parent != nil || s.stage == stageMain) && st != nil {
216 j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
217 }
218 if s.ctxID != "" {
219 j.state.SetStepResult(s.ctxID, string(outcome.conclusion()), string(res.conclusion()))
220 }
221 if s.parent == nil {
222 j.recordOutcome(s.num, outcome.conclusion())
223 }
224 if s.parent != nil {
225 if res == resFailed || res == resCanceled {
226 m := merge(s.parent.result, res)
227 s.parent.result = &m
228 }
229 return
230 }
231 if res == resFailed {
232 m := merge(j.result, resFailed)
233 j.result = &m
234 j.state.SetStatus(statusName(m))
235 }
236}
237
238// runGuarded is the body of a step between its start and its end: the
239// condition, the run with its cancellation watch, the commands' result,
240// continue-on-error. ok is false when the env of the step did not evaluate (the
241// error is reported by the caller).
242func (j *job) runGuarded(s *stepRun, q qstep, ok bool) stepResult {
243 num, st := s.num, s.st
244 complete := func(r stepResult) stepResult { j.finishStep(s, r); return r }
245 if !ok {
246 return resFailed
247 }
248 // Cancellation seen before the step starts (StepsRunner.cs:188-197).
249 cancelledAtStart := false
250 select {
251 case <-j.cancel.Done():
252 cancelledAtStart = true
253 j.mu.Lock()
254 j.markCancelledLocked()
255 j.mu.Unlock()
256 default:
257 }
258 cond := func() (bool, error) {
259 j.mu.Lock()
260 defer j.mu.Unlock()
261 switch {
262 case q.cond != "":
263 return j.evalCondString(q.cond, s.env, s.statusFuncs())
264 case q.setup != nil:
265 return j.evalCondString("success()", s.env, j.statusFuncs())
266 }
267 return st.EvaluateIf(context.Background(), j.evalEnv(s.env), s.statusFuncs())
268 }
269 // On runner shutdown conditions are not evaluated and the step is skipped.
270 if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
271 return complete(resSkipped)
272 }
273 run, err := cond()
274 switch {
275 case err != nil:
276 j.errorf(num, "%s", templateNotValid(err, false))
277 return complete(resFailed)
278 case !run:
279 return complete(resSkipped)
280 }
281 // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
282 // cancelled and the step is stopped unless its condition still holds
283 // (always()).
284 stepCancel := make(chan struct{})
285 var cancelOnce sync.Once
286 closeCancel := func() { cancelOnce.Do(func() { close(stepCancel) }) }
287 stopWatch := make(chan struct{})
288 var watch sync.WaitGroup
289 if !cancelledAtStart {
290 watch.Add(1)
291 go func() {
292 defer watch.Done()
293 select {
294 case <-j.cancel.Done():
295 case <-stopWatch:
296 return
297 }
298 j.mu.Lock()
299 j.markCancelledLocked()
300 j.mu.Unlock()
301 keep := false
302 if j.cancel.Reason() != CancelShutdown {
303 keep, _ = cond()
304 }
305 if !keep {
306 closeCancel()
307 }
308 }()
309 }
310 // timeout-minutes (StepsRunner.RunStepAsync): the timer cancels the body like
311 // a job cancellation does, and does not cancel the job. An embedded step
312 // has its own timer, the step around it has its own.
313 var r stepResult
314 stopTimeout, minutes := func() (func(), int) { return func() {}, 0 }()
315 if q.setup == nil {
316 stopTimeout, minutes = j.armStepTimeout(s, closeCancel)
317 if minutes > 0 {
318 s.deadline = j.now().Add(time.Duration(minutes)*time.Minute + j.cfg.StepTimeoutSkew)
319 }
320 if p := s.parent; p != nil && !p.deadline.IsZero() {
321 // The inner step has the time that is left to the step around it, and
322 // no timeout of its own (the schema refuses one).
323 s.deadline = p.deadline
324 t := time.AfterFunc(time.Until(p.deadline), func() { s.timedOut.Store(true); closeCancel() })
325 stopTimeout = func() { t.Stop() }
326 }
327 }
328 if q.setup != nil {
329 r = q.setup(s, stepCancel)
330 } else {
331 r = j.execute(s, stepCancel)
332 }
333 stopTimeout()
334 close(stopWatch)
335 watch.Wait()
336 if minutes > 0 {
337 r = j.stepTimedOut(s, r, minutes)
338 }
339 if s.parent != nil && r == resCanceled && s.timedOut.Load() && !j.cancelRequested() {
340 // CompositeActionHandler.RunStepAsync (:497-506): an inner step stopped by
341 // the timeout it inherited fails with a message that names no step.
342 j.errorf(num, "The action has timed out.")
343 r = resFailed
344 }
345
346 // The step was marked failed before it ran (CompositeActionHandler.cs:302):
347 // nothing resets that when it goes well (RunStepAsync :481-527).
348 if s.envFailed && r == resSucceeded {
349 r = resFailed
350 }
351 // Merge the result of the commands (StepsRunner.cs:347-351).
352 if s.cmdResult != nil {
353 r = merge(&r, *s.cmdResult)
354 }
355 // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
356 if r == resFailed && st != nil && st.ContinueOnError != nil {
357 coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
358 if cerr != nil {
359 j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
360 j.errorf(num, "%s", cerr.Error())
361 }
362 if coe && cerr == nil {
363 o := resFailed
364 s.outcome = &o
365 r = resSucceeded
366 }
367 }
368 return complete(r)
369}