1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "context"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11
12 "bnema.dev/ermite-actions/action"
13 "bnema.dev/ermite-actions/expr"
14 "bnema.dev/ermite-actions/template"
15)
16
17// pathMap is ContainerInfo's path translation for a docker action: the host
18// directories mounted below /github. The runner inserts each mapping first, so
19// the last added is tried first (ContainerInfo.AddPathTranslateMapping).
20type pathMap struct{ host, container string }
21
22func translatePath(maps []pathMap, p string) string {
23 if p == "" {
24 return p
25 }
26 for _, m := range maps {
27 if p == m.host {
28 return m.container
29 }
30 if strings.HasPrefix(p, m.host+"/") {
31 return m.container + p[len(m.host):]
32 }
33 }
34 return p
35}
36
37// ctx is the context of the job's own operations (downloads, resolutions): it
38// ends when the job is cancelled.
39func (j *job) ctx() context.Context {
40 if j.jobCtx == nil {
41 return context.Background()
42 }
43 return j.jobCtx
44}
45
46// dockerEnvContext evaluates against the contexts of the step with the inputs
47// context replaced: the arguments and the env of a container action see its
48// inputs, with the defaults (ContainerActionHandler.cs:122-131).
49func (j *job) dockerEnvContext(env *ordered, in *inputSet) template.Env {
50 te := j.evalEnv(env)
51 ctxs := make(map[string]expr.Value, len(te.Contexts)+1)
52 for k, v := range te.Contexts {
53 ctxs[k] = v
54 }
55 d := expr.NewDictionary()
56 for _, k := range in.keys {
57 v, _ := in.Get(k)
58 d.Add(k, expr.String(v))
59 }
60 ctxs["inputs"] = d
61 te.Contexts = ctxs
62 return te
63}
64
65// quoteArg is the quoting of one argument of the entrypoint
66// (ContainerActionHandler.cs:139-148): in double quotes, a quote preceded by a
67// backslash; an empty argument is "".
68func quoteArg(a string) string {
69 if a == "" {
70 return `""`
71 }
72 return `"` + strings.ReplaceAll(a, `"`, `\"`) + `"`
73}
74
75// dockerBuildCmd is docker build (DockerCommandManager.DockerBuild).
76func (j *job) dockerBuildCmd(tag, dockerfile, context string) dockerCmd {
77 return j.newDockerCmd("build",
78 dockerPart{line: "-t " + tag, args: []string{"-t", tag}},
79 dockerPart{line: `-f "` + dockerfile + `"`, args: []string{"-f", dockerfile}},
80 dockerPart{line: `"` + context + `"`, args: []string{context}})
81}
82
83// buildImage builds an image from a Dockerfile, in the step.
84func (j *job) buildImage(s *stepRun, tag, dockerfile, workDir string, cancel <-chan struct{}) error {
85 // The context is Directory.GetParent(dockerfile).FullName (see buildActionImage).
86 c := j.dockerBuildCmd(tag, dockerfile, filepath.Dir(dockerfile))
87 code, err := j.runDockerCmd(s, c, dirOrCwd(workDir), nil, cancel)
88 if err != nil {
89 return err
90 }
91 if code != 0 {
92 return ghErrorf("Docker build failed with exit code %d", code)
93 }
94 return nil
95}
96
97// runDocker is ContainerActionHandler.RunAsync (ContainerActionHandler.cs:
98// 34-277) in a sandbox that has the docker CLI: the same mounts, variables and
99// options as the runner's docker run.
100func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered, cancel <-chan struct{}) stepResult {
101 num, n := s.num, s.n
102 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
103 for _, k := range in.keys {
104 v, _ := in.Get(k)
105 env.Set(inputEnvName(k), v)
106 }
107 j.mu.Lock()
108 workspace := j.state.GitHub("workspace")
109 temp := j.state.Layout().Temp
110 j.mu.Unlock()
111 image := n.image
112 if image == "" {
113 var build string
114 if def.act == nil && def.dockerfile != "" {
115 build = def.dockerfile
116 } else if def.act == nil {
117 image = def.image
118 } else {
119 im, err := action.ParseImage(def.image)
120 if err != nil {
121 return fail(err)
122 }
123 if im.Kind == action.ImageRegistry {
124 image = im.Ref
125 } else {
126 build = pathCombine(def.dir, filepath.FromSlash(im.Path))
127 }
128 }
129 if build != "" {
130 if _, err := os.Stat(build); err != nil {
131 return fail(ghErrorf("File not found: '%s'", build))
132 }
133 j.out(num, "##[group]Building docker image")
134 j.out(num, fmt.Sprintf("Dockerfile for action: '%s'.", build))
135 image = newImageTag(j.dockerLabel())
136 err := j.buildImage(s, image, build, workspace, cancel)
137 j.out(num, "##[endgroup]")
138 if err != nil {
139 if err == errCancelled {
140 return j.cancelledStep(s)
141 }
142 return fail(err)
143 }
144 }
145 }
146 var entry string
147 r := def.runs()
148 switch s.stage {
149 case stageMain:
150 if r != nil && r.Entrypoint != "" {
151 entry = r.Entrypoint
152 } else if v, ok := in.Get("entryPoint"); ok {
153 entry = v
154 }
155 case stagePre:
156 if r != nil {
157 entry = r.PreEntrypoint
158 }
159 case stagePost:
160 if r != nil {
161 entry = r.PostEntrypoint
162 }
163 }
164 te := j.dockerEnvContext(env, in)
165 argLine, argList, err := j.dockerArgs(r, in, te)
166 if err != nil {
167 j.errorf(num, "%s", templateNotValid(err, true))
168 return resFailed
169 }
170 if r != nil && r.Env != nil {
171 v, err := template.Evaluate(context.Background(), r.Env, te)
172 if err != nil {
173 j.errorf(num, "%s", templateNotValid(err, true))
174 return resFailed
175 }
176 if d, ok := v.(*expr.Dictionary); ok {
177 for _, p := range d.Pairs() {
178 if _, has := env.Get(p.Key); !has {
179 env.Set(p.Key, expr.CoerceString(p.Value))
180 }
181 }
182 }
183 }
184 // The directories the container sees (ContainerActionHandler.cs:171-198).
185 home := filepath.Join(temp, "_github_home")
186 workflowDir := filepath.Join(temp, "_github_workflow")
187 fcDir := filepath.Join(temp, "_runner_file_commands")
188 for _, d := range []string{home, workflowDir} {
189 if err := os.MkdirAll(d, 0o755); err != nil {
190 return fail(err)
191 }
192 j.own(d)
193 }
194 env.Set("HOME", home)
195 maps := []pathMap{ // last added first
196 {workspace, "/github/workspace"}, {fcDir, "/github/file_commands"}, {workflowDir, "/github/workflow"},
197 {home, "/github/home"}, {temp, "/github/runner_temp"},
198 }
199 // The runtime variables of the contexts (GITHUB_*, RUNNER_*).
200 j.mu.Lock()
201 runtime := j.state.RuntimeEnv()
202 j.mu.Unlock()
203 for _, k := range runtime.Keys() {
204 v, _ := runtime.Get(k)
205 env.Set(k, v)
206 }
207 if err := checkEnvNames(env.Keys()); err != nil {
208 return fail(err)
209 }
210 cenv := newOrdered()
211 for _, k := range env.Keys() {
212 v, _ := env.Get(k)
213 cenv.Set(k, translatePath(maps, v))
214 }
215 parts := []dockerPart{
216 raw("--name", sanitizeName(image)+"_"+shortID()),
217 raw("--label", j.dockerLabel()),
218 raw("--workdir", "/github/workspace"),
219 raw("--rm"),
220 }
221 for _, k := range cenv.Keys() {
222 parts = append(parts, envOpt(k))
223 }
224 parts = append(parts, raw("-e", "GITHUB_ACTIONS=true"))
225 if _, ok := cenv.Get("CI"); !ok {
226 parts = append(parts, raw("-e", "CI=true"))
227 }
228 if entry != "" {
229 parts = append(parts, entrypointOpt(entry))
230 }
231 // A docker action joins the network of the job's containers, so that it
232 // reaches the services by their aliases (ContainerActionHandler.cs:172-175
233 // reads job.container.network; the read creates job.container, which is why
234 // the context of any job that ran a docker action has "container": {}).
235 j.mu.Lock()
236 j.state.TouchJobContainer()
237 network := j.state.JobContainerNetwork()
238 j.mu.Unlock()
239 if network != "" {
240 parts = append(parts, raw("--network", network))
241 }
242 parts = append(parts,
243 volumeOpt("/var/run/docker.sock", "/var/run/docker.sock"),
244 volumeOpt(temp, "/github/runner_temp"),
245 volumeOpt(home, "/github/home"),
246 volumeOpt(workflowDir, "/github/workflow"),
247 volumeOpt(fcDir, "/github/file_commands"),
248 volumeOpt(workspace, "/github/workspace"),
249 raw(image),
250 dockerPart{line: argLine, args: argList})
251 cmd := j.newDockerCmd("run", parts...)
252 j.out(num, "##[command]"+cmd.line)
253 // The docker process gets the container variables on top of its own
254 // environment: "-e NAME" reads them from there.
255 // (ProcessInvoker gets ContainerEnvironmentVariables: the translated values,
256 // and nothing from the contexts on top.)
257 s.rawEnvList = j.dockerProcessEnv(cenv)
258 return j.runProc(s, cmd.argv[0], workspace, cmd.argv, "Docker Action run completed with exit code %d", cancel)
259}
260
261// dockerArgs is the "args" of the entrypoint: the manifest's list, evaluated and
262// quoted, or the "args" input as it is (ContainerActionHandler.cs:133-161).
263func (j *job) dockerArgs(r *action.Runs, in *inputSet, te template.Env) (line string, list []string, err error) {
264 if r != nil && r.Args != nil {
265 v, err := template.Evaluate(context.Background(), r.Args, te)
266 if err != nil {
267 return "", nil, err
268 }
269 a, _ := v.(*expr.Array)
270 if a != nil {
271 for _, it := range a.Items() {
272 s := expr.CoerceString(it)
273 line += " " + quoteArg(s)
274 list = append(list, s)
275 }
276 }
277 return line, list, nil
278 }
279 raw, _ := in.Get("args")
280 return raw, splitArgs(raw), nil
281}
282
283// cancelledStep logs the cancellation of a step that was running docker, unless
284// the step timed out: then StepsRunner logs the timeout message instead
285// (stepRun.logCanceled).
286func (j *job) cancelledStep(s *stepRun) stepResult {
287 s.logCanceled()
288 return resCanceled
289}
290
291// done reports whether a cancellation channel is closed (a nil one never is).
292func done(c <-chan struct{}) bool {
293 select {
294 case <-c:
295 return true
296 default:
297 return false
298 }
299}
300
301func shortID() string { return strings.ReplaceAll(newImageTag(""), ":", "")[:6] }
302
303// dockerProcessEnv is the environment of the docker CLI: the one of the runner
304// and the translated container variables (DockerCommandManager.DockerRun passes
305// ContainerEnvironmentVariables as the environment of the process).
306func (j *job) dockerProcessEnv(cenv *ordered) []string {
307 penv := newOrdered()
308 for _, kv := range j.baseEnv {
309 if k, v, ok := strings.Cut(kv, "="); ok {
310 penv.Set(k, v)
311 }
312 }
313 for _, k := range cenv.Keys() {
314 v, _ := cenv.Get(k)
315 penv.Set(k, v)
316 }
317 penv.Set(ProcessTrackingID, j.trackID)
318 var list []string
319 for _, k := range penv.Keys() {
320 v, _ := penv.Get(k)
321 list = append(list, k+"="+v)
322 }
323 return list
324}