detached 33a243c

containers_docker.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"fmt"
7	"strings"
8	"sync"
9	"sync/atomic"
10	"time"
11)
12
13// Helpers for the docker commands of the container steps. The runner builds a
14// command line as a string, echoes it (ExecutionContext.Command, "##[command]")
15// and lets .NET split it into arguments. Here the echoed line and the
16// arguments are built together (dockerPart), so that nothing a workflow wrote
17// can end up as an argument of its own, except through `options:` which the
18// runner splits too (ParseArguments).
19
20// quotedOpt is `flag "value"` in the echoed line and two arguments.
21func quotedOpt(flag, value string) dockerPart {
22	return dockerPart{line: flag + ` "` + value + `"`, args: []string{flag, value}}
23}
24
25// eqOpt is `flag="value"` (docker inspect --format="...").
26func eqOpt(flag, value string) dockerPart {
27	return dockerPart{line: flag + `="` + value + `"`, args: []string{flag + "=" + value}}
28}
29
30// Accepted risk (review of the pull request, GO-004-R1/R2): a workflow can set
31// DOCKER_*, proxy, PATH or SSH_AUTH_SOCK variables for its container, and the
32// ones listed here travel as NAME=value on the arguments of the docker command,
33// which the docker CLI reads. The CLI runs in the job's own sandbox, which is
34// the containment boundary: the job can already run docker with any
35// environment and read its own process arguments, and the C# runner has the
36// same exposure. Values still go through the masker in the logs.
37//
38// cliSensitive are the variables the docker CLI itself reads (its daemon, its
39// configuration, its proxy and TLS settings, the loader): handing one of them
40// to the process of docker as the value of a container variable would change
41// what the CLI does, so these are the only values that stay in the arguments.
42func cliSensitive(name string) bool {
43	u := strings.ToUpper(name)
44	switch u {
45	case "HOME", "TMPDIR", "USER", "LOGNAME", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY",
46		"GODEBUG", "GOMAXPROCS", "GOGC", "GOMEMLIMIT", "GOTRACEBACK", "KUBECONFIG", ProcessTrackingID:
47		return true
48	}
49	for _, pre := range []string{"DOCKER_", "XDG_", "LD_", "SSL_CERT", "BUILDKIT_", "COMPOSE_", "CONTAINERD_", "REGISTRY_"} {
50		if strings.HasPrefix(u, pre) {
51			return true
52		}
53	}
54	return false
55}
56
57// envForCLI is how one container variable reaches `docker create`/`exec`:
58// "-e NAME" with the value in the environment of the docker process, so that
59// it is on no command line (visible to every user of the sandbox in /proc) —
60// except for the variables the CLI reads itself, unless their value is the one
61// the process has anyway. An empty value is set explicitly, so that the CLI
62// cannot inherit one from the executor. echo is the text the runner logs.
63func (j *job) envForCLI(key, value, echo string) dockerPart {
64	if cliSensitive(key) {
65		if v, ok := envLookup(j.baseEnv, key); ok && v == value {
66			return dockerPart{line: echo, args: []string{"-e", key}}
67		}
68		return dockerPart{line: echo, args: []string{"-e", key + "=" + value}}
69	}
70	return dockerPart{line: echo, args: []string{"-e", key}, env: []string{key + "=" + value}}
71}
72
73func envLookup(env []string, key string) (string, bool) {
74	for i := len(env) - 1; i >= 0; i-- {
75		if k, v, ok := strings.Cut(env[i], "="); ok && k == key {
76			return v, true
77		}
78	}
79	return "", false
80}
81
82// checkEnvNames refuses the names `docker -e NAME` would take for NAME=value:
83// an empty one, or one holding "=" or NUL. Such a key would let a workflow set
84// a variable of the docker CLI itself ("HTTP_PROXY=http://x/?y") past the exact
85// name check of cliSensitive. The runner passes them on and docker decides; a
86// name like that is never valid in the environment of a process.
87func checkEnvNames(keys []string) error {
88	for _, k := range keys {
89		if k == "" || strings.ContainsAny(k, "=\x00") {
90			return ghErrorf("Invalid environment variable name '%s': a name cannot be empty or contain '=' or NUL", strings.ReplaceAll(k, "\x00", `\0`))
91		}
92	}
93	return nil
94}
95
96// envKV is DockerUtil.CreateEscapedOption("-e", key, value), or ("-e", key)
97// for an empty value, as the runner echoes it.
98func (j *job) envKV(key, value string) dockerPart {
99	if value == "" {
100		return j.envForCLI(key, "", "-e "+escapeDockerArg(key))
101	}
102	return j.envForCLI(key, value, "-e "+escapeDockerArg(key+"="+value))
103}
104
105// mountOpt is the -v option of DockerCommandManager.DockerCreate (:164-185).
106func mountOpt(m mount) dockerPart {
107	esc := func(s string) string { return strings.ReplaceAll(s, `"`, `\"`) }
108	var p dockerPart
109	if m.source == "" {
110		p = dockerPart{line: `-v "` + esc(m.target) + `"`, args: []string{"-v", m.target}}
111	} else {
112		p = dockerPart{line: `-v "` + esc(m.source) + `":"` + esc(m.target) + `"`, args: []string{"-v", m.source + ":" + m.target}}
113	}
114	if m.readOnly {
115		p.line += ":ro"
116		p.args[1] += ":ro"
117	}
118	return p
119}
120
121// optionsParts is the user's `options:` text: echoed as written, split into
122// arguments like .NET splits a command line. An empty text still leaves its
123// place in the line (the runner joins an empty string: two spaces).
124func optionsParts(options string) dockerPart {
125	return dockerPart{line: options, args: ParseArguments(options)}
126}
127
128// dockerArgString is the arguments of the command as ProcessExitCodeException
129// words them: the echoed line without the path of docker.
130func (j *job) dockerArgString(c dockerCmd) string {
131	return strings.TrimPrefix(c.line, j.dockerPath()+" ")
132}
133
134// exitError is ProcessExitCodeException: docker ran and said no.
135type exitError struct {
136	code int
137	msg  string
138}
139
140func (e *exitError) Error() string { return e.msg }
141
142// Time limits of the docker commands of the container steps. The runner has
143// none (its commands end with the job); a sandbox whose daemon stalls must not
144// hold the job until its timeout, nor the cleanup forever.
145type ContainerTimeouts struct {
146	// Command bounds one docker command (default 2 minutes); Pull bounds a
147	// docker pull (30 minutes).
148	Command, Pull time.Duration
149	// CleanupCommand bounds one command of "Stop containers" (30 seconds) and
150	// CleanupBudget the whole cleanup (3 minutes), whatever happens to the job.
151	CleanupCommand, CleanupBudget time.Duration
152}
153
154func (j *job) timeouts() ContainerTimeouts {
155	t := j.cfg.Actions.Containers
156	def := func(v *time.Duration, d time.Duration) {
157		if *v <= 0 {
158			*v = d
159		}
160	}
161	def(&t.Command, 2*time.Minute)
162	def(&t.Pull, 30*time.Minute)
163	def(&t.CleanupCommand, 30*time.Second)
164	def(&t.CleanupBudget, 3*time.Minute)
165	return t
166}
167
168// cmdTimeoutError is a docker command stopped by its time limit.
169type cmdTimeoutError struct {
170	cmd string
171	d   time.Duration
172}
173
174func (e *cmdTimeoutError) Error() string {
175	return fmt.Sprintf("The docker command timed out after %s: %s", e.d, e.cmd)
176}
177
178// bound returns a channel that closes when cancel does (nil never) or after d,
179// and a function that tells whether it was the time, with the one that
180// releases the timer.
181func bound(cancel <-chan struct{}, d time.Duration) (ch <-chan struct{}, timedOut func() bool, stop func()) {
182	out := make(chan struct{})
183	var once sync.Once
184	var fired atomic.Bool
185	closeOut := func() { once.Do(func() { close(out) }) }
186	t := time.AfterFunc(d, func() { fired.Store(true); closeOut() })
187	done := make(chan struct{})
188	if cancel != nil {
189		go func() {
190			select {
191			case <-cancel:
192				closeOut()
193			case <-done:
194			}
195		}()
196	}
197	return out, fired.Load, func() { t.Stop(); close(done) }
198}
199
200// dockerOpts are the options of dockerDo.
201type dockerOpts struct {
202	// s is the step whose log gets the command and its output; nil runs the
203	// command silently (the fallback cleanup).
204	s *stepRun
205	// cancel stops the command (the escalation of runProcess); always set.
206	cancel <-chan struct{}
207	// timeout bounds the command; 0 means the command limit.
208	timeout time.Duration
209	// quiet keeps the echoed command and the output out of the log.
210	quiet bool
211	// all logs the empty lines too (the overloads that return a code).
212	all bool
213	// errToOut also collects the lines of stderr.
214	errToOut bool
215}
216
217// do runs the command and returns the non-empty lines of its stdout (and
218// stderr with errToOut), its exit code and an error for what is not an exit
219// code: a start failure, a cancellation, a time limit.
220func (j *job) dockerDo(c dockerCmd, o dockerOpts) (lines []string, code int, err error) {
221	log := o.s != nil && !o.quiet
222	if log {
223		j.out(o.s.num, "##[command]"+c.line)
224	}
225	d := o.timeout
226	if d <= 0 {
227		d = j.timeouts().Command
228	}
229	cancel, timedOut, stop := bound(o.cancel, d)
230	defer stop()
231	var mu sync.Mutex
232	res, perr := runProcess(procSpec{
233		Argv: c.argv, Dir: dirOrCwd(j.githubValue("workspace")), Env: append(j.processEnv(newOrdered()), c.env...), Credential: j.cfg.Credential,
234		OnLine: func(l string) {
235			mu.Lock()
236			if l != "" {
237				lines = append(lines, l)
238			}
239			mu.Unlock()
240			if log && (o.all || l != "") {
241				j.write(o.s.num, l)
242			}
243		},
244		OnErrLine: func(l string) {
245			if o.errToOut && l != "" {
246				mu.Lock()
247				lines = append(lines, l)
248				mu.Unlock()
249			}
250			if log && (o.all || l != "") {
251				j.write(o.s.num, l)
252			}
253		},
254		Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
255	})
256	mu.Lock()
257	defer mu.Unlock()
258	switch {
259	case perr != nil:
260		return lines, 0, perr
261	case res.Cancelled && timedOut():
262		return lines, res.ExitCode, &cmdTimeoutError{cmd: j.dockerArgString(c), d: d}
263	case res.Cancelled:
264		return lines, res.ExitCode, errCancelled
265	}
266	return lines, res.ExitCode, nil
267}
268
269// dockerList is the overload of ExecuteDockerCommandAsync that returns the
270// output (DockerCommandManager.cs:462-492): non-empty lines of stdout are kept
271// and logged, non-empty lines of stderr are logged, and a non-zero exit is an
272// error.
273func (j *job) dockerList(s *stepRun, c dockerCmd, cancel <-chan struct{}) ([]string, error) {
274	lines, code, err := j.dockerDo(c, dockerOpts{s: s, cancel: cancel})
275	if err != nil {
276		return lines, err
277	}
278	if code != 0 {
279		return lines, &exitError{code: code, msg: fmt.Sprintf("Exit code %d returned from process: file name '%s', arguments '%s'.", code, j.dockerPath(), j.dockerArgString(c))}
280	}
281	return lines, nil
282}
283
284// dockerCode is the overload that logs every line and returns the exit code.
285func (j *job) dockerCode(s *stepRun, c dockerCmd, cancel <-chan struct{}) (int, error) {
286	_, code, err := j.dockerDo(c, dockerOpts{s: s, cancel: cancel, all: true})
287	return code, err
288}

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