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}