v0.2.0 43088fd

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	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}

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