1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "context"
7 "fmt"
8 "io"
9 "io/fs"
10 "os"
11 "strings"
12 "sync/atomic"
13 "time"
14
15 "bnema.dev/ermite-actions/template"
16)
17
18// What a step leaves besides its log: annotations, summary, outcome, saved
19// state. The pieces that the steps of phase F share (embedded steps of a
20// composite action, state of an action) are the fields marked below.
21
22// stepScope is what a step that runs inside another one (a step of a
23// composite action) shares with the step of the workflow that uses the action.
24// The zero value is a step of the workflow.
25type stepScope struct {
26 // issues, when set, is the collector of the outer step: the issues of an
27 // embedded step count against its limits (ExecutionContext.AddIssue,
28 // :838). Embedded is true then.
29 issues *issueCollector
30 embedded bool
31 // stateKey names the intra-action state the step saves to (SaveState). Empty
32 // means the step's own number.
33 stateKey string
34}
35
36// AddIssue is ExecutionContext.AddIssue for the step.
37func (s *stepRun) AddIssue(is issue) { s.j.addIssueFor(s, is) }
38
39func (j *job) addIssueFor(s *stepRun, is issue) {
40 j.addIssue(s.num, s.scope.issues, s.scope.embedded, is)
41}
42
43// Workspace is github.workspace.
44func (s *stepRun) Workspace() string { return s.j.githubValue("workspace") }
45
46// HostPath is TranslateToHostPath of the job container.
47func (s *stepRun) HostPath(p string) string {
48 if c := s.j.stepContainer(); c != nil {
49 return c.translateToHost(p)
50 }
51 return p
52}
53
54func (j *job) githubValue(k string) string {
55 j.mu.Lock()
56 defer j.mu.Unlock()
57 return j.state.GitHub(k)
58}
59
60// AddMask is SecretMasker.AddValue.
61func (s *stepRun) AddMask(v string) { s.j.addMask(v) }
62
63// addMask registers values with the masker of the job, with their encodings
64// (SecretMasker.AddValue). It is the one way to add a secret after the job
65// started (add-mask, a stop-commands token, the token of an action archive): it
66// only adds, the masker is never replaced, so the text that other goroutines
67// mask meanwhile loses no value.
68func (j *job) addMask(values ...string) {
69 m := j.masker.Load()
70 for _, v := range values {
71 m.add(v)
72 }
73}
74
75// Matchers is the root of the problem matchers of the job.
76func (s *stepRun) Matchers() *matcherRoot { return s.j.matchers }
77
78// maxMatcherFile bounds a problem matcher file.
79const maxMatcherFile = 1 << 20
80
81// ReadMatcherFile reads a problem matcher file.
82func (s *stepRun) ReadMatcherFile(path string) ([]byte, error) {
83 // With a job container the path came from a command of the container: it
84 // is read only when it is a file of a directory the container shares with
85 // the host, never the host's own file of the same name.
86 if c := s.j.stepContainer(); c != nil && !c.insideMappings(path) {
87 return nil, &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist}
88 }
89 f, err := os.Open(path)
90 if err != nil {
91 return nil, err
92 }
93 defer func() { _ = f.Close() }()
94 b, err := io.ReadAll(io.LimitReader(f, maxMatcherFile+1))
95 if err != nil {
96 return nil, err
97 }
98 if len(b) > maxMatcherFile {
99 return nil, ghErrorf("The problem matcher file is larger than %d bytes", maxMatcherFile)
100 }
101 return b, nil
102}
103
104func (j *job) getenv(k string) string {
105 if j.cfg.Getenv != nil {
106 return j.cfg.Getenv(k)
107 }
108 return os.Getenv(k)
109}
110
111// SaveState is the state of an action step (save-state and GITHUB_STATE): kept
112// per step so that the post step of the action can read it as STATE_<name>
113// (ActionCommandManager.cs:384-398). A step that is not an action only fills
114// it; the runner does the same.
115func (s *stepRun) SaveState(name, value string) { s.j.saveState(s.stateKey(), name, value) }
116
117func (s *stepRun) stateKey() string {
118 if s.scope.stateKey != "" {
119 return s.scope.stateKey
120 }
121 return fmt.Sprint(s.num)
122}
123
124// intraState is IntraActionState: a Dictionary<string, string> with an
125// OrdinalIgnoreCase comparer. A name that is already there keeps the spelling
126// it was first saved with and takes the new value (the indexer of Dictionary
127// replaces the value, not the key), and the entries enumerate in the order
128// they were added: ActionRunner.cs:255-258 sets STATE_<key> from that
129// enumeration, so the spelling of the first save-state is the one the action
130// sees.
131type intraState struct {
132 keys []string // spelling and order of first insertion
133 vals map[string]string
134}
135
136func (st *intraState) set(name, value string) {
137 if st.vals == nil {
138 st.vals = map[string]string{}
139 }
140 lo := strings.ToLower(name)
141 if _, ok := st.vals[lo]; !ok {
142 st.keys = append(st.keys, name)
143 }
144 st.vals[lo] = value
145}
146
147// Pair is one saved name (as first spelled) and its value.
148func (st *intraState) pairs() []Pair {
149 out := make([]Pair, 0, len(st.keys))
150 for _, k := range st.keys {
151 out = append(out, Pair{k, st.vals[strings.ToLower(k)]})
152 }
153 return out
154}
155
156func (j *job) saveState(key, name, value string) {
157 j.issuesMu.Lock()
158 defer j.issuesMu.Unlock()
159 if j.states == nil {
160 j.states = map[string]*intraState{}
161 }
162 st := j.states[key]
163 if st == nil {
164 st = &intraState{}
165 j.states[key] = st
166 }
167 st.set(name, value)
168}
169
170// intraActionState returns the state saved under key (the number of the step,
171// or the stateKey of its scope) as the runner enumerates it: names as first
172// spelled, in the order they were first saved. Phase F sets STATE_<name> from
173// it for the pre, main and post stages of the action.
174func (j *job) intraActionState(key string) []Pair {
175 j.issuesMu.Lock()
176 defer j.issuesMu.Unlock()
177 if st := j.states[key]; st != nil {
178 return st.pairs()
179 }
180 return nil
181}
182
183// Summary receives GITHUB_STEP_SUMMARY after the step (see processStepSummary).
184//
185// The server keeps one summary per step of the workflow. The steps inside a
186// composite action each have a summary file in the runner (uploaded under the
187// id of the embedded step); here they are concatenated, in order, under the
188// step that uses the action.
189func (s *stepRun) Summary(markdown string) {
190 j := s.j
191 j.issuesMu.Lock()
192 defer j.issuesMu.Unlock()
193 if j.summaries == nil {
194 j.summaries = map[int]string{}
195 }
196 if s.scope.embedded {
197 markdown = j.summaries[s.num] + markdown
198 if len(markdown) > stepSummaryLimit {
199 return // over the limit of the contract: the first ones stay
200 }
201 }
202 j.summaries[s.num] = markdown
203}
204
205// MaskText masks text with the secrets known now.
206func (s *stepRun) MaskText(t string) string { return s.j.mask(t) }
207
208// summarySink is implemented by the Sink of a step that keeps summaries.
209type summarySink interface{ Summary(markdown string) }
210
211// recordOutcome keeps steps.<id>.outcome of a step for StepResults.
212func (j *job) recordOutcome(num int, c Conclusion) {
213 j.issuesMu.Lock()
214 defer j.issuesMu.Unlock()
215 if j.outcomes == nil {
216 j.outcomes = map[int]Conclusion{}
217 }
218 j.outcomes[num] = c
219}
220
221// flushStepResults hands the results of step n to the reporter, before the step
222// completes. Called with the serialisation of the reporter not held.
223func (j *job) flushStepResults(next Reporter, n int, c Conclusion) {
224 rr, ok := next.(StepResultReporter)
225 if !ok {
226 return
227 }
228 j.issuesMu.Lock()
229 outcome, has := j.outcomes[n]
230 summary, hasSummary := j.summaries[n]
231 delete(j.summaries, n)
232 coll := j.collectors[n]
233 j.issuesMu.Unlock()
234 if !has {
235 outcome = c
236 }
237 r := StepResults{Outcome: outcome}
238 if coll != nil && j.cfg.SendAnnotations {
239 r.Annotations = coll.result()
240 }
241 if hasSummary && j.cfg.SendSummaries {
242 r.Summary, r.HasSummary = summary, true
243 }
244 rr.StepResults(n, r)
245}
246
247// logCanceled logs the cancellation of the process of a step
248// (StepsRunner.cs:333-337 logs the exception of a cancelled step), unless the
249// step timed out: then StepsRunner logs the timeout message instead.
250func (s *stepRun) logCanceled() {
251 if s.timedOut.Load() && !s.j.cancelRequested() {
252 return
253 }
254 s.j.errorf(s.num, "The operation was canceled.")
255}
256
257func (j *job) cancelRequested() bool {
258 select {
259 case <-j.cancel.Done():
260 return true
261 default:
262 return false
263 }
264}
265
266// ---- timeout-minutes
267
268// timedOutFlag is the marker of a step that reached its timeout.
269type timedOutFlag = atomic.Bool
270
271// stepTimeoutMessage is StepsRunner.cs:328.
272func stepTimeoutMessage(name string, minutes int) string {
273 return fmt.Sprintf("The action '%s' has timed out after %d minutes.", name, minutes)
274}
275
276// ---- step summary
277
278// summaryContent is what CreateStepSummaryCommand uploads: the lines of the
279// file, each masked, each ended by a line feed (StreamReader.ReadLine and
280// StreamWriter.WriteLine; ProcessCommand, FileCommandManager.cs:253-261).
281func summaryContent(text string, mask func(string) string) string {
282 var sb strings.Builder
283 for _, l := range splitLines(text) {
284 sb.WriteString(mask(l))
285 sb.WriteByte('\n')
286 }
287 return sb.String()
288}
289
290// maxStepTimeoutMinutes bounds the timeout so that the duration fits.
291const maxStepTimeoutMinutes = 1 << 24
292
293// stepTimeout is PipelineTemplateEvaluator.EvaluateStepTimeout and
294// ConvertToStepTimeout: the number is truncated to an int; zero, negative and
295// absent values mean no timeout. An expression that fails logs the runner's
296// two errors and the step runs without timeout (StepsRunner.cs:266-273).
297func (j *job) stepTimeout(s *stepRun) int {
298 tok := s.st.TimeoutMinutes
299 if tok == nil {
300 return 0
301 }
302 j.mu.Lock()
303 f, err := template.EvaluateNumber(context.Background(), tok, j.evalEnv(s.env))
304 j.mu.Unlock()
305 if err != nil {
306 j.errorf(s.num, "An error occurred when attempting to determine the step timeout.")
307 j.errorf(s.num, "%s", templateNotValid(err, true))
308 return 0
309 }
310 switch {
311 case f != f || f < 1:
312 return 0
313 case f > maxStepTimeoutMinutes:
314 return maxStepTimeoutMinutes
315 }
316 return int(f)
317}
318
319// ---- hook points for steps that contain steps (phase F)
320//
321// A step of the workflow is run by runStep; a composite action runs its inner
322// steps itself. What an inner step shares with the step that contains it is
323// named by a stepScope, and what its lifecycle needs from the commands is:
324//
325// - embeddedScope(outer, stateKey): the scope of an inner step. Its issues
326// go to the collector of the outer step (counted against its limits when
327// the outer step completes) and its summary is appended to the outer
328// step's; its save-state goes to the state of stateKey.
329// - newOutputManager(s, cancel): one per process of a step (inner or not),
330// with the cancellation channel the process gets; call dispose when the
331// process is over.
332// - armStepTimeout / stepTimedOut: the timeout of a step, inner or not,
333// around the run of its body (see runStep).
334// - addMask: add a secret.
335
336// embeddedScope is the scope of a step inside the step outer.
337func (j *job) embeddedScope(outer *stepRun, stateKey string) stepScope {
338 return stepScope{issues: j.collector(outer.num), embedded: true, stateKey: stateKey}
339}
340
341// armStepTimeout starts the timeout-minutes timer of the step (StepsRunner.
342// RunStepAsync, :280-296). When it fires, the step is marked timed out and
343// cancel is called (the same cancellation as a job cancel: SIGINT, SIGTERM,
344// SIGKILL). It returns the function that stops the timer and the number of
345// minutes, 0 for none.
346func (j *job) armStepTimeout(s *stepRun, cancel func()) (stop func(), minutes int) {
347 minutes = j.stepTimeout(s)
348 if minutes <= 0 {
349 return func() {}, 0
350 }
351 t := time.AfterFunc(time.Duration(minutes)*time.Minute+j.cfg.StepTimeoutSkew, func() {
352 s.timedOut.Store(true)
353 cancel()
354 })
355 return func() { t.Stop() }, minutes
356}
357
358// stepTimedOut turns the result of a step whose own timer fired (and not the
359// job's cancellation) into a failure with the runner's message
360// (StepsRunner.cs:323-330). Other results pass through.
361func (j *job) stepTimedOut(s *stepRun, r stepResult, minutes int) stepResult {
362 if r == resCanceled && s.timedOut.Load() && !j.cancelRequested() {
363 j.errorf(s.num, "%s", stepTimeoutMessage(s.name, minutes))
364 return resFailed
365 }
366 return r
367}