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 // A docker action always runs in a container of its own.
108 j.addRuntimeEnv(env, true)
109 j.mu.Lock()
110 workspace := j.state.GitHub("workspace")
111 temp := j.state.Layout().Temp
112 j.mu.Unlock()
113 image := n.image
114 if image == "" {
115 var build string
116 if def.act == nil && def.dockerfile != "" {
117 build = def.dockerfile
118 } else if def.act == nil {
119 image = def.image
120 } else {
121 im, err := action.ParseImage(def.image)
122 if err != nil {
123 return fail(err)
124 }
125 if im.Kind == action.ImageRegistry {
126 image = im.Ref
127 } else {
128 build = pathCombine(def.dir, filepath.FromSlash(im.Path))
129 }
130 }
131 if build != "" {
132 if _, err := os.Stat(build); err != nil {
133 return fail(ghErrorf("File not found: '%s'", build))
134 }
135 j.out(num, "##[group]Building docker image")
136 j.out(num, fmt.Sprintf("Dockerfile for action: '%s'.", build))
137 image = newImageTag(j.dockerLabel())
138 err := j.buildImage(s, image, build, workspace, cancel)
139 j.out(num, "##[endgroup]")
140 if err != nil {
141 if err == errCancelled {
142 return j.cancelledStep(s)
143 }
144 return fail(err)
145 }
146 }
147 }
148 var entry string
149 r := def.runs()
150 switch s.stage {
151 case stageMain:
152 if r != nil && r.Entrypoint != "" {
153 entry = r.Entrypoint
154 } else if v, ok := in.Get("entryPoint"); ok {
155 entry = v
156 }
157 case stagePre:
158 if r != nil {
159 entry = r.PreEntrypoint
160 }
161 case stagePost:
162 if r != nil {
163 entry = r.PostEntrypoint
164 }
165 }
166 te := j.dockerEnvContext(env, in)
167 argLine, argList, err := j.dockerArgs(r, in, te)
168 if err != nil {
169 j.errorf(num, "%s", templateNotValid(err, true))
170 return resFailed
171 }
172 if r != nil && r.Env != nil {
173 v, err := template.Evaluate(context.Background(), r.Env, te)
174 if err != nil {
175 j.errorf(num, "%s", templateNotValid(err, true))
176 return resFailed
177 }
178 if d, ok := v.(*expr.Dictionary); ok {
179 for _, p := range d.Pairs() {
180 if _, has := env.Get(p.Key); !has {
181 env.Set(p.Key, expr.CoerceString(p.Value))
182 }
183 }
184 }
185 }
186 // The directories the container sees (ContainerActionHandler.cs:171-198).
187 home := filepath.Join(temp, "_github_home")
188 workflowDir := filepath.Join(temp, "_github_workflow")
189 fcDir := filepath.Join(temp, "_runner_file_commands")
190 for _, d := range []string{home, workflowDir} {
191 if err := os.MkdirAll(d, 0o755); err != nil {
192 return fail(err)
193 }
194 j.own(d)
195 }
196 env.Set("HOME", home)
197 maps := []pathMap{ // last added first
198 {workspace, "/github/workspace"}, {fcDir, "/github/file_commands"}, {workflowDir, "/github/workflow"},
199 {home, "/github/home"}, {temp, "/github/runner_temp"},
200 }
201 // The runtime variables of the contexts (GITHUB_*, RUNNER_*).
202 j.mu.Lock()
203 runtime := j.state.RuntimeEnv()
204 j.mu.Unlock()
205 for _, k := range runtime.Keys() {
206 v, _ := runtime.Get(k)
207 env.Set(k, v)
208 }
209 if err := checkEnvNames(env.Keys()); err != nil {
210 return fail(err)
211 }
212 cenv := newOrdered()
213 for _, k := range env.Keys() {
214 v, _ := env.Get(k)
215 cenv.Set(k, translatePath(maps, v))
216 }
217 parts := []dockerPart{
218 raw("--name", sanitizeName(image)+"_"+shortID()),
219 raw("--label", j.dockerLabel()),
220 raw("--workdir", "/github/workspace"),
221 raw("--rm"),
222 }
223 for _, k := range cenv.Keys() {
224 parts = append(parts, envOpt(k))
225 }
226 parts = append(parts, raw("-e", "GITHUB_ACTIONS=true"))
227 if _, ok := cenv.Get("CI"); !ok {
228 parts = append(parts, raw("-e", "CI=true"))
229 }
230 if entry != "" {
231 parts = append(parts, entrypointOpt(entry))
232 }
233 // A docker action joins the network of the job's containers, so that it
234 // reaches the services by their aliases (ContainerActionHandler.cs:172-175
235 // reads job.container.network; the read creates job.container, which is why
236 // the context of any job that ran a docker action has "container": {}).
237 j.mu.Lock()
238 j.state.TouchJobContainer()
239 network := j.state.JobContainerNetwork()
240 j.mu.Unlock()
241 if network != "" {
242 parts = append(parts, raw("--network", network))
243 }
244 parts = append(parts,
245 volumeOpt("/var/run/docker.sock", "/var/run/docker.sock"),
246 volumeOpt(temp, "/github/runner_temp"),
247 volumeOpt(home, "/github/home"),
248 volumeOpt(workflowDir, "/github/workflow"),
249 volumeOpt(fcDir, "/github/file_commands"),
250 volumeOpt(workspace, "/github/workspace"),
251 raw(image),
252 dockerPart{line: argLine, args: argList})
253 cmd := j.newDockerCmd("run", parts...)
254 j.out(num, "##[command]"+cmd.line)
255 // The docker process gets the container variables on top of its own
256 // environment: "-e NAME" reads them from there.
257 // (ProcessInvoker gets ContainerEnvironmentVariables: the translated values,
258 // and nothing from the contexts on top.)
259 s.rawEnvList = j.dockerProcessEnv(cenv)
260 return j.runProc(s, cmd.argv[0], workspace, cmd.argv, "Docker Action run completed with exit code %d", cancel)
261}
262
263// dockerArgs is the "args" of the entrypoint: the manifest's list, evaluated and
264// quoted, or the "args" input as it is (ContainerActionHandler.cs:133-161).
265func (j *job) dockerArgs(r *action.Runs, in *inputSet, te template.Env) (line string, list []string, err error) {
266 if r != nil && r.Args != nil {
267 v, err := template.Evaluate(context.Background(), r.Args, te)
268 if err != nil {
269 return "", nil, err
270 }
271 a, _ := v.(*expr.Array)
272 if a != nil {
273 for _, it := range a.Items() {
274 s := expr.CoerceString(it)
275 line += " " + quoteArg(s)
276 list = append(list, s)
277 }
278 }
279 return line, list, nil
280 }
281 raw, _ := in.Get("args")
282 return raw, splitArgs(raw), nil
283}
284
285// cancelledStep logs the cancellation of a step that was running docker, unless
286// the step timed out: then StepsRunner logs the timeout message instead
287// (stepRun.logCanceled).
288func (j *job) cancelledStep(s *stepRun) stepResult {
289 s.logCanceled()
290 return resCanceled
291}
292
293// done reports whether a cancellation channel is closed (a nil one never is).
294func done(c <-chan struct{}) bool {
295 select {
296 case <-c:
297 return true
298 default:
299 return false
300 }
301}
302
303func shortID() string { return strings.ReplaceAll(newImageTag(""), ":", "")[:6] }
304
305// dockerProcessEnv is the environment of the docker CLI: the one of the runner
306// and the translated container variables (DockerCommandManager.DockerRun passes
307// ContainerEnvironmentVariables as the environment of the process).
308func (j *job) dockerProcessEnv(cenv *ordered) []string {
309 penv := newOrdered()
310 for _, kv := range j.baseEnv {
311 if k, v, ok := strings.Cut(kv, "="); ok {
312 penv.Set(k, v)
313 }
314 }
315 for _, k := range cenv.Keys() {
316 v, _ := cenv.Get(k)
317 penv.Set(k, v)
318 }
319 penv.Set(ProcessTrackingID, j.trackID)
320 var list []string
321 for _, k := range penv.Keys() {
322 v, _ := penv.Get(k)
323 list = append(list, k+"="+v)
324 }
325 return list
326}