1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "context"
7 "strings"
8
9 "bnema.dev/ermite-actions/action"
10)
11
12// actionState is what the pre-job phase leaves for the run.
13type actionState struct {
14 nodes []*node
15 // pulled and built map an image to what the setup steps produced.
16 pulled map[string]string // image -> image
17 built map[string]string // action repository -> tag
18 // setup are the steps that pull and build images, in the order of
19 // ActionManager.PrepareActionsAsync (pulls, then builds).
20 pullOrder, buildOrder []string
21 buildInfo map[string]buildInfo
22 pullUsers map[string][]*node
23 buildUsers map[string][]*node
24 // pre are the pre steps of the job's steps with a pre stage, by node.
25 pre map[*node]bool
26 // resolved caches the download info by "name@ref" (ActionManager's legacy
27 // path asks again at every level; ermite's server caches per run).
28 downloaded map[string]bool
29 // infos are the resolutions of the job by "Name@Ref": an action that a local
30 // composite action brings again is not asked for again.
31 infos map[string]ResolvedAction
32}
33
34type buildInfo struct{ repository, dockerfile, workDir string }
35
36func newActionState() *actionState {
37 return &actionState{
38 pulled: map[string]string{}, built: map[string]string{}, buildInfo: map[string]buildInfo{},
39 pullUsers: map[string][]*node{}, buildUsers: map[string][]*node{}, pre: map[*node]bool{},
40 downloaded: map[string]bool{}, infos: map[string]ResolvedAction{},
41 }
42}
43
44// lookupKey is ActionManager.GetDownloadInfoLookupKey: "Name@Ref" for a
45// repository action, "" for a local one.
46func lookupKey(n *node) (string, error) {
47 switch n.uses.Kind {
48 case action.UsesRepository:
49 return n.uses.Name + "@" + n.uses.Ref, nil
50 case action.UsesSelfRepository:
51 return "", ghErrorf("Unable to resolve self-reference '$/'. This can occur when the server does not support this syntax, the feature flag is disabled, or the workflow context (repository/SHA) is unavailable.")
52 }
53 return "", nil
54}
55
56// prepare is ActionManager.PrepareActionsRecursiveLegacyAsync
57// (ActionManager.cs:439-654): resolve and download the repository actions of
58// nodes, read their manifests, recurse into composite actions, and note the
59// images to pull or build and the pre and post stages. depth is 0 for the
60// steps of the job and 1 for the ones of a local composite action prepared when
61// it runs.
62func (j *job) prepare(ctx context.Context, num int, nodes []*node, depth int, parent *node) error {
63 if depth > compositeMaxDepth {
64 return ghErrorf("Composite action depth exceeded max depth %d", compositeMaxDepth)
65 }
66 st := j.actions
67 var repoNodes []*node
68 for _, n := range nodes {
69 switch n.uses.Kind {
70 case action.UsesDocker:
71 if n.uses.Image == "" {
72 return ghErrorf("Value cannot be null or empty. (Parameter 'Image')")
73 }
74 if _, ok := st.pullUsers[n.uses.Image]; !ok {
75 st.pullOrder = append(st.pullOrder, n.uses.Image)
76 }
77 st.pullUsers[n.uses.Image] = append(st.pullUsers[n.uses.Image], n)
78 case action.UsesRepository, action.UsesLocal, action.UsesSelfRepository:
79 repoNodes = append(repoNodes, n)
80 }
81 }
82 if len(repoNodes) == 0 {
83 return nil
84 }
85 infos, err := j.downloadInfo(ctx, num, repoNodes)
86 if err != nil {
87 return err
88 }
89 for _, n := range repoNodes {
90 key, err := lookupKey(n)
91 if err != nil {
92 return err
93 }
94 if key == "" {
95 continue
96 }
97 ra, ok := infos[key]
98 if !ok {
99 return ghErrorf("Missing download info for %s", key)
100 }
101 dir := j.actionDirOfRepo(n)
102 // The directory of the archive is the one of the repository itself: the
103 // action's path is below it.
104 root := j.state.Layout().Actions + "/" + n.uses.Name + "/" + n.uses.Ref
105 _ = dir
106 if err := j.downloadAction(ctx, num, ra, ra.NameWithOwner, n.uses.Ref, root); err != nil {
107 return err
108 }
109 }
110 for _, n := range repoNodes {
111 if err := j.prepareRepositoryAction(ctx, num, n, depth); err != nil {
112 return err
113 }
114 }
115 return nil
116}
117
118func (j *job) actionDirOfRepo(n *node) string { return j.actionDir(n) }
119
120// downloadInfo is ActionManager.GetDownloadInfoAsync (ActionManager.cs:1000-1135):
121// it asks the server, through the host, about the distinct repository actions
122// of nodes. The server's answer for one action may be an error in GitHub's
123// words, which is the message of the failure.
124func (j *job) downloadInfo(ctx context.Context, num int, nodes []*node) (map[string]ResolvedAction, error) {
125 j.out(num, "Getting action download info")
126 var uses, keys []string
127 seen := map[string]bool{}
128 for _, n := range nodes {
129 key, err := lookupKey(n)
130 if err != nil {
131 return nil, err
132 }
133 if key == "" || seen[key] {
134 continue
135 }
136 seen[key] = true
137 keys = append(keys, key)
138 uses = append(uses, refString(n.uses))
139 }
140 out := map[string]ResolvedAction{}
141 if len(uses) == 0 {
142 return out, nil
143 }
144 // What the job resolved already is not asked again: the host would not call
145 // the server for it either (host/resolve.go), but the round trip is saved.
146 var askUses, askKeys []string
147 for i, k := range keys {
148 if ra, ok := j.actions.infos[k]; ok {
149 out[k] = ra
150 continue
151 }
152 askUses, askKeys = append(askUses, uses[i]), append(askKeys, k)
153 }
154 if len(askUses) == 0 {
155 return out, nil
156 }
157 uses, keys = askUses, askKeys
158 if j.cfg.Actions.Resolver == nil {
159 return nil, ghErrorf("Failed to resolve action download info.")
160 }
161 res, err := j.cfg.Actions.Resolver(ctx, uses)
162 if err != nil {
163 return nil, ghErrorf("%s", err.Error())
164 }
165 if len(res) != len(uses) {
166 return nil, ghErrorf("Failed to resolve action download info.")
167 }
168 for i, ra := range res {
169 if ra.Error != "" {
170 return nil, ghErrorf("%s", ra.Error)
171 }
172 // The token is a secret from here on (ActionManager.cs:1130).
173 j.addTokenMask(ra.Token)
174 if ra.NameWithOwner == "" {
175 ra.NameWithOwner = strings.SplitN(keys[i], "@", 2)[0]
176 }
177 out[keys[i]] = ra
178 j.actions.infos[keys[i]] = ra
179 }
180 return out, nil
181}
182
183// prepareRepositoryAction is ActionManager.PrepareRepositoryActionAsync
184// (:1411-1550) and the code after it in the loop of the caller: read the
185// manifest, note an image to pull or build, recurse into a composite, and
186// register pre and post stages.
187func (j *job) prepareRepositoryAction(ctx context.Context, num int, n *node, depth int) error {
188 local := n.uses.Kind == action.UsesLocal
189 if local {
190 if depth > 0 {
191 // "if we're in a composite action and haven't loaded the local action
192 // yet we assume it has a post step" (ActionManager.cs:643-654).
193 n.parentPost()
194 }
195 return nil
196 }
197 dir := j.actionDir(n)
198 def, err := j.loadDef(num, dir)
199 if err == errNoAction {
200 return ghErrorf("Can't find 'action.yml', 'action.yaml' or 'Dockerfile' for action '%s'.", refString(n.uses))
201 }
202 if err != nil {
203 return err
204 }
205 n.def = def
206 repo := n.uses.Name + "@" + n.uses.Ref
207 if n.uses.Path != "" {
208 repo = n.uses.Name + "/" + n.uses.Path + "@" + n.uses.Ref
209 }
210 root := j.state.Layout().Actions + "/" + n.uses.Name + "/" + n.uses.Ref
211 st := j.actions
212 switch def.kind {
213 case kindDocker:
214 img, build, err := j.dockerImageOf(def)
215 if err != nil {
216 return err
217 }
218 if build != "" {
219 if _, ok := st.buildUsers[repo]; !ok {
220 st.buildOrder = append(st.buildOrder, repo)
221 }
222 st.buildUsers[repo] = append(st.buildUsers[repo], n)
223 st.buildInfo[repo] = buildInfo{repository: repo, dockerfile: build, workDir: root}
224 } else {
225 if _, ok := st.pullUsers[img]; !ok {
226 st.pullOrder = append(st.pullOrder, img)
227 }
228 st.pullUsers[img] = append(st.pullUsers[img], n)
229 }
230 case kindComposite:
231 if err := j.prepareComposite(ctx, num, n, depth); err != nil {
232 return err
233 }
234 }
235 j.registerStages(n, depth)
236 return nil
237}
238
239// dockerImageOf says what a container action needs: an image to pull, or a
240// Dockerfile to build (absolute path).
241func (j *job) dockerImageOf(def *actionDef) (pull, build string, err error) {
242 if def.act == nil {
243 return "", def.dockerfile, nil
244 }
245 im, err := action.ParseImage(def.image)
246 if err != nil {
247 return "", "", err
248 }
249 if im.Kind == action.ImageDockerfile {
250 return "", pathCombine(def.dir, im.Path), nil
251 }
252 return im.Ref, "", nil
253}
254
255// prepareComposite creates the nodes of a composite action's steps and prepares
256// them one level down.
257func (j *job) prepareComposite(ctx context.Context, num int, n *node, depth int) error {
258 steps := n.def.act.Runs.Steps
259 kids, err := newNodes(steps, n, n.scope)
260 if err != nil {
261 return err
262 }
263 n.children = kids
264 return j.prepare(ctx, num, kids, depth+1, n)
265}
266
267// registerStages notes the pre and post stages of a prepared action that is not
268// local (ActionManager.cs:600-640): for the steps of the job a pre step, for
269// the steps of a composite action a pre and post step of the composite.
270func (j *job) registerStages(n *node, depth int) {
271 pre, post := n.hasPre(), n.hasPost()
272 if depth < 1 {
273 if pre {
274 j.actions.pre[n] = true
275 }
276 return
277 }
278 if p := n.registrar(); p != nil {
279 if pre {
280 p.preSteps = append(p.preSteps, n)
281 }
282 if post {
283 p.postSteps = append(p.postSteps, n)
284 }
285 }
286}
287
288// registrar is the node whose pre and post steps the node is registered with:
289// its parent, except for the steps of a local composite action prepared when
290// the action runs. The runner keys what it registers there by the id of the
291// execution context (ActionManager.cs:92-113 and :424-430, rootStepId =
292// ExecutionContext.Id), and an inner step's context shares the record, hence
293// the id, of the step of the job that contains it: everything lands on that
294// step, and the inner composite actions themselves never get a post of their
295// own (recorded: one "Post job cleanup." for act-composite-depth, not one per
296// level).
297func (n *node) registrar() *node {
298 if n.runtimePrepared {
299 for p := n.parent; p != nil; p = p.parent {
300 if p.parent == nil {
301 return p
302 }
303 }
304 }
305 return n.parent
306}
307
308// parentPost is the assumption for a local action inside a composite action.
309func (n *node) parentPost() {
310 if p := n.registrar(); p != nil {
311 p.postSteps = append(p.postSteps, n)
312 }
313}
314
315// hasPre and hasPost are ActionExecutionData.HasPre and HasPost.
316func (n *node) hasPre() bool {
317 d := n.def
318 if d == nil {
319 return false
320 }
321 if d.kind == kindComposite {
322 return len(n.preSteps) > 0
323 }
324 if r := d.runs(); r != nil {
325 return r.HasPre()
326 }
327 return false
328}
329
330func (n *node) hasPost() bool {
331 d := n.def
332 if d == nil {
333 return false
334 }
335 if d.kind == kindComposite {
336 return len(n.postSteps) > 0
337 }
338 if r := d.runs(); r != nil {
339 return r.HasPost()
340 }
341 return false
342}