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