v0.4.4 edcdd4c

actions_docker.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close