1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "strings"
10 "sync"
11
12 "bnema.dev/ermite-actions/expr"
13 "bnema.dev/ermite-actions/template"
14 "bnema.dev/ermite-actions/workflow"
15)
16
17// ---------------------------------------------------------------- steps
18
19// stepRun is the state of one step being run; it implements Sink and
20// commandHost.
21type stepRun struct {
22 j *job
23 num int
24 st *workflow.Step
25 env *ordered // the step's environment: job env + step env
26 cmdResult *stepResult
27 outcome *stepResult
28 cmds *commandManager
29}
30
31func (s *stepRun) Out(text string) { s.j.out(s.num, text) }
32func (s *stepRun) Tagged(tag, msg string) { s.j.tagged(s.num, tag, msg) }
33func (s *stepRun) Debug(msg string) { s.j.debug(s.num, msg) }
34func (s *stepRun) Error(msg string) { s.j.errorf(s.num, "%s", msg) }
35func (s *stepRun) CommandFailed() { f := resFailed; s.cmdResult = &f }
36func (s *stepRun) SetOutput(name, value string) {
37 s.j.mu.Lock()
38 s.j.state.SetOutput(s.st.ID, name, value)
39 s.j.mu.Unlock()
40}
41func (s *stepRun) SaveState(name, value string) {}
42func (s *stepRun) SetEnv(name, value string) {
43 s.j.mu.Lock()
44 s.j.state.SetEnv(name, value)
45 s.j.mu.Unlock()
46}
47func (s *stepRun) AddPath(p string) {
48 s.j.mu.Lock()
49 s.j.state.AddPath(p)
50 s.j.mu.Unlock()
51}
52
53// EnvFlag reads a boolean variable of the env context, then of the process
54// (ActionCommandManager.cs:236-246).
55func (s *stepRun) EnvFlag(name string) bool {
56 v := strings.ToLower(envValue(s.j.baseEnv, name))
57 if v != "true" {
58 if e, ok := s.env.Get(name); ok {
59 v = strings.ToLower(e)
60 }
61 }
62 return v == "true"
63}
64
65func firstLine(contents string) string {
66 l := strings.TrimLeft(contents, " \t\r\n")
67 if i := strings.IndexAny(l, "\r\n"); i >= 0 {
68 l = l[:i]
69 }
70 return l
71}
72
73// displayName is the name shown for the step (ActionRunner.GenerateDisplayName,
74// ActionRunner.cs:363): the evaluated name, or "Run " and the first line of
75// the script or the action reference.
76func (j *job) displayName(st *workflow.Step, env *ordered) string {
77 tok := st.Name
78 prefix := ""
79 switch {
80 case tok != nil:
81 case st.Run != nil:
82 tok, prefix = st.Run, "Run "
83 case st.Uses != nil:
84 tok, prefix = st.Uses, "Run "
85 default:
86 return ""
87 }
88 // JobExtension evaluates the display names once, when the job starts, with
89 // the contexts the server sent (github.job is empty there) and without the
90 // step functions; ActionRunner.GenerateDisplayName only does it when every
91 // context and function the name uses is available (CheckHasRequiredContext).
92 // A name that is fully evaluated then stays as it is (recorded:
93 // ctx-github-names, "Run echo \"job=\""); the others are evaluated again,
94 // with the contexts of the step, when the step starts.
95 var name string
96 var err error
97 if serverEvaluable(tok) {
98 j.mu.Lock()
99 sc := j.state.ServerContexts()
100 j.mu.Unlock()
101 name, err = template.EvaluateString(context.Background(), tok, template.Env{Contexts: sc})
102 } else {
103 name, err = j.evalString(tok, env)
104 }
105 if err != nil {
106 name = tokenSource(tok)
107 }
108 return formatStepName(prefix, name)
109}
110
111// serverContextNames are the contexts of the job message (ContextData) when
112// display names are first evaluated.
113var serverContextNames = map[string]bool{"github": true, "needs": true, "strategy": true, "matrix": true, "inputs": true, "vars": true}
114
115// wellKnownFuncs are the functions available without the step functions.
116var wellKnownFuncs = map[string]bool{"case": true, "contains": true, "endswith": true, "format": true, "join": true, "startswith": true, "tojson": true, "fromjson": true}
117
118// serverEvaluable is TemplateTokenExtensions.CheckHasRequiredContext for the
119// first evaluation of a display name: every expression of the token only
120// reads contexts of the job message and calls well-known functions.
121func serverEvaluable(tok template.Token) bool {
122 ok := true
123 template.Walk(tok, func(t template.Token) bool {
124 if be, isExpr := t.(*template.BasicExpression); isExpr {
125 ok = ok && nodeServerEvaluable(be.Node())
126 }
127 return ok
128 })
129 return ok
130}
131
132func nodeServerEvaluable(n expr.Node) bool {
133 switch n := n.(type) {
134 case *expr.ContextAccess:
135 return serverContextNames[strings.ToLower(n.Name.Lexeme)]
136 case *expr.Unary:
137 return nodeServerEvaluable(n.Expr)
138 case *expr.Binary:
139 return nodeServerEvaluable(n.Left) && nodeServerEvaluable(n.Right)
140 case *expr.Grouping:
141 return nodeServerEvaluable(n.Group)
142 case *expr.IndexAccess:
143 return nodeServerEvaluable(n.Expr) && nodeServerEvaluable(n.Index)
144 case *expr.Logical:
145 for _, a := range n.Args {
146 if !nodeServerEvaluable(a) {
147 return false
148 }
149 }
150 case *expr.FunctionCall:
151 if !wellKnownFuncs[strings.ToLower(n.Name.Lexeme)] {
152 return false
153 }
154 for _, a := range n.Args {
155 if !nodeServerEvaluable(a) {
156 return false
157 }
158 }
159 }
160 return true
161}
162
163// exceptionClasser is implemented by the errors of package expr that stand for
164// a .NET exception (expr.JSONError).
165type exceptionClasser interface{ ExceptionClass() string }
166
167// templateNotValid is the message of the TemplateValidationException the
168// runner throws when the evaluation of a template fails
169// (TemplateStrings.TemplateNotValidWithErrors): "The template is not valid. "
170// and the errors. With a position the error reads "file (Line: l, Col: c):
171// message" (recorded: expr-runtime-errors, step env and run). A step condition
172// has no position: the error is the exception itself, "Class: message"
173// (withLocation is false).
174func templateNotValid(err error, withLocation bool) string {
175 var te *template.Error
176 if !errors.As(err, &te) {
177 return err.Error()
178 }
179 if withLocation {
180 return "The template is not valid. " + te.Error()
181 }
182 var c exceptionClasser
183 if errors.As(err, &c) {
184 return "The template is not valid. " + c.ExceptionClass() + ": " + te.Raw
185 }
186 return te.Error()
187}
188
189func tokenSource(t template.Token) string {
190 if be, ok := t.(*template.BasicExpression); ok && be.Source != "" {
191 return be.Source
192 }
193 return t.String()
194}
195
196// formatStepName is ActionRunner.FormatStepName (ActionRunner.cs:447).
197func formatStepName(prefix, name string) string {
198 if name == "" {
199 return ""
200 }
201 return prefix + firstLine(name)
202}
203
204func (j *job) statusFuncs() workflow.StatusFuncs {
205 st := j.state.Status()
206 return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
207}
208
209// markCancelled records the cancellation of the job: job result Canceled and
210// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
211func (j *job) markCancelledLocked() {
212 if j.cancelled {
213 return
214 }
215 j.cancelled = true
216 c := resCanceled
217 j.result = &c
218 j.state.SetStatus("cancelled")
219}
220
221func (j *job) runStep(num int, st *workflow.Step) {
222 rep := j.cfg.Reporter
223 s := &stepRun{j: j, num: num, st: st}
224 s.cmds = newCommandManager(s, j.cfg.Debug)
225
226 // Environment of the step (StepsRunner.cs:108-139): job env, then the step's
227 // own, evaluated against the job env.
228 j.mu.Lock()
229 // github.action is the id of the step, set before the step's env is
230 // evaluated (StepsRunner.cs:118), so that the env and the if of a step see
231 // its own id (the dump step of the recordings reads __run_6, not __run_5).
232 j.state.SetGitHub("action", st.ID)
233 jobEnv := j.state.JobEnv()
234 j.mu.Unlock()
235 s.env = jobEnv.Clone()
236 pairs, envErr := j.evalMap(st.Env, jobEnv)
237 if envErr == nil {
238 for _, p := range pairs {
239 s.env.Set(p.Key, p.Value)
240 }
241 }
242 name := j.displayName(st, s.env)
243 rep.StepStarted(num, name, j.now())
244
245 complete := func(r stepResult) {
246 res := r
247 j.mu.Lock()
248 // Update the steps context (ExecutionContext.UpdateGlobalStepsContext).
249 outcome := res
250 if s.outcome != nil {
251 outcome = *s.outcome
252 }
253 j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
254 if res == resFailed {
255 m := merge(j.result, resFailed)
256 j.result = &m
257 j.state.SetStatus(statusName(m))
258 }
259 j.mu.Unlock()
260 rep.StepCompleted(num, name, res.conclusion(), j.now())
261 }
262
263 if envErr != nil {
264 j.errorf(num, "%s", templateNotValid(envErr, true))
265 complete(resFailed)
266 return
267 }
268
269 // Cancellation seen before the step starts (StepsRunner.cs:188-197).
270 cancelledAtStart := false
271 select {
272 case <-j.cancel.Done():
273 cancelledAtStart = true
274 j.mu.Lock()
275 j.markCancelledLocked()
276 j.mu.Unlock()
277 default:
278 }
279
280 // Condition (StepsRunner.cs:200-261). On runner shutdown conditions are not
281 // evaluated and the step is skipped.
282 cond := func() (bool, error) {
283 j.mu.Lock()
284 defer j.mu.Unlock()
285 return st.EvaluateIf(context.Background(), j.evalEnv(s.env), j.statusFuncs())
286 }
287 if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
288 complete(resSkipped)
289 return
290 }
291 ok, err := cond()
292 switch {
293 case err != nil:
294 j.errorf(num, "%s", templateNotValid(err, false))
295 complete(resFailed)
296 return
297 case !ok:
298 complete(resSkipped)
299 return
300 }
301
302 // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
303 // cancelled and the step is stopped unless its condition still holds
304 // (always()).
305 stepCancel := make(chan struct{})
306 stopWatch := make(chan struct{})
307 var watch sync.WaitGroup
308 if !cancelledAtStart {
309 watch.Add(1)
310 go func() {
311 defer watch.Done()
312 select {
313 case <-j.cancel.Done():
314 case <-stopWatch:
315 return
316 }
317 j.mu.Lock()
318 j.markCancelledLocked()
319 j.mu.Unlock()
320 keep := false
321 if j.cancel.Reason() != CancelShutdown {
322 keep, _ = cond()
323 }
324 if !keep {
325 close(stepCancel)
326 }
327 }()
328 }
329
330 r := j.execute(s, stepCancel)
331 close(stopWatch)
332 watch.Wait()
333
334 // Merge the result of the commands (StepsRunner.cs:347-351).
335 if s.cmdResult != nil {
336 m := merge(&r, *s.cmdResult)
337 r = m
338 }
339 // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
340 if r == resFailed && st.ContinueOnError != nil {
341 coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
342 if cerr != nil {
343 j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
344 j.errorf(num, "%s", cerr.Error())
345 }
346 if coe && cerr == nil {
347 o := resFailed
348 s.outcome = &o
349 r = resSucceeded
350 }
351 }
352 complete(r)
353}
354
355// statusName is the job.status for a result (TaskResult.ToActionResult).
356func statusName(r stepResult) string {
357 switch r {
358 case resFailed:
359 return "failure"
360 case resCanceled:
361 return "cancelled"
362 }
363 return "success"
364}
365
366// notSupported is the failure of a step that uses a feature of a later phase.
367func notSupported(st *workflow.Step) string {
368 switch {
369 case st.Uses != nil:
370 return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value)
371 case st.TimeoutMinutes != nil:
372 return "not supported yet: timeout-minutes"
373 }
374 return ""
375}