detached f09c6ed

Merge pull request 'feat(executor): job containers and service containers (CI-2 phase H)' (#13) from feat/ci2-containers into main

commit
f09c6edc69ca60e2fd8753caac237444caaadced
author
bnema · · 2026-10-14 06:55 UTC
merge of
3e5ad18 + ce2fb4f · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
33 files · +3324 -17
M README.md +42 -0
@@ -256,6 +256,48 @@ image:
256256 tzdata, zip/unzip. Full parity with the hosted images (`actions/runner-images`)
257257 is a later phase.
258258
259+## Job containers and service containers
260+
261+`container:` and `services:` work as on GitHub-hosted Linux runners
262+(`internal/executor/containers_*.go`, a port of `ContainerOperationProvider`,
263+`ContainerInfo`, `DockerCommandManager` and `ContainerStepHost` of
264+actions/runner, with their line numbers in the comments). The containers are
265+created in the **sandbox's own `dockerd`**, so nothing reaches the host: in `vm`
266+mode (the mode of hosts without Docker Engine) the daemon runs inside the
267+micro-VM, in `container` mode it is the nested daemon of the job.
268+
269+- **Initialize containers** (a step after "Set up job"): docker version check
270+ (API ≥ 1.35), cleanup of labelled leftovers, network `github_network_<32 hex>`,
271+ for each container `docker pull` (3 tries, random 1–10 s back off),
272+ `docker create`/`start`, `docker port` and the health checks of the services
273+ (exponential back off 2–32 s, "`<alias>` service is healthy."). Registry
274+ `credentials:` go to a private `docker --config` directory under RUNNER_TEMP
275+ (password through stdin, masked in every log, directory removed after the pull).
276+- **Stop containers** (post step, `always()`): service logs, `docker rm --force`,
277+ `docker network rm`. It runs after a failure, a cancellation, a step timeout
278+ and the job timeout.
279+- **Steps in the job container**: `run:` steps and node actions go through
280+ `docker exec -i --workdir … -e NAME… <id> …` (values are passed in the
281+ environment of the docker process, translated to `/__w`, `/__t`, `/__e`,
282+ `/github/home`, `/github/workflow`, never on the command line). The shell is
283+ looked up by the container: `sh -e {0}` unless the step says otherwise. Node
284+ actions use the externals mounted read-only at `/__e`, `node<N>_alpine` when
285+ `/etc/*release` says alpine. Docker actions run as siblings on the job network.
286+- Contexts: `job.container.{id,network}`, `job.services.<alias>.{id,network,ports}`
287+ (host ports from `docker port`).
288+- **Untrusted input.** `options:` is split into arguments like .NET splits a
289+ command line and passed to `docker create` as an argument vector, never through
290+ a shell, so a workflow cannot inject a second command; it can still pass any
291+ `docker create` flag (`--privileged`, `--pid host`, `--device`, …) and bind
292+ volumes (`volumes: /:/host`), exactly as on a self-hosted GitHub runner. That
293+ is contained by the sandbox (its own daemon, its own disk), not by the runner:
294+ **host paths in `volumes:` and `options:` are paths of the sandbox**. A job
295+ that mounts `/` exposes the sandbox's filesystem to its own container only.
296+ The runner warns, as GitHub does, about a volume that mounts `/`.
297+- Not ported: container hooks (`ACTIONS_RUNNER_CONTAINER_HOOKS`), the
298+ "already running in a container" refusal (the sandbox's daemon is local to the
299+ executor).
300+
259301 ## Job limits
260302
261303 Each job gets its own resources, set for the runner as a whole:
@@ -256,6 +256,48 @@ image:
256 tzdata, zip/unzip. Full parity with the hosted images (`actions/runner-images`)256 tzdata, zip/unzip. Full parity with the hosted images (`actions/runner-images`)
257 is a later phase.257 is a later phase.
258 258
259+## Job containers and service containers
260+
261+`container:` and `services:` work as on GitHub-hosted Linux runners
262+(`internal/executor/containers_*.go`, a port of `ContainerOperationProvider`,
263+`ContainerInfo`, `DockerCommandManager` and `ContainerStepHost` of
264+actions/runner, with their line numbers in the comments). The containers are
265+created in the **sandbox's own `dockerd`**, so nothing reaches the host: in `vm`
266+mode (the mode of hosts without Docker Engine) the daemon runs inside the
267+micro-VM, in `container` mode it is the nested daemon of the job.
268+
269+- **Initialize containers** (a step after "Set up job"): docker version check
270+ (API ≥ 1.35), cleanup of labelled leftovers, network `github_network_<32 hex>`,
271+ for each container `docker pull` (3 tries, random 1–10 s back off),
272+ `docker create`/`start`, `docker port` and the health checks of the services
273+ (exponential back off 2–32 s, "`<alias>` service is healthy."). Registry
274+ `credentials:` go to a private `docker --config` directory under RUNNER_TEMP
275+ (password through stdin, masked in every log, directory removed after the pull).
276+- **Stop containers** (post step, `always()`): service logs, `docker rm --force`,
277+ `docker network rm`. It runs after a failure, a cancellation, a step timeout
278+ and the job timeout.
279+- **Steps in the job container**: `run:` steps and node actions go through
280+ `docker exec -i --workdir … -e NAME… <id> …` (values are passed in the
281+ environment of the docker process, translated to `/__w`, `/__t`, `/__e`,
282+ `/github/home`, `/github/workflow`, never on the command line). The shell is
283+ looked up by the container: `sh -e {0}` unless the step says otherwise. Node
284+ actions use the externals mounted read-only at `/__e`, `node<N>_alpine` when
285+ `/etc/*release` says alpine. Docker actions run as siblings on the job network.
286+- Contexts: `job.container.{id,network}`, `job.services.<alias>.{id,network,ports}`
287+ (host ports from `docker port`).
288+- **Untrusted input.** `options:` is split into arguments like .NET splits a
289+ command line and passed to `docker create` as an argument vector, never through
290+ a shell, so a workflow cannot inject a second command; it can still pass any
291+ `docker create` flag (`--privileged`, `--pid host`, `--device`, …) and bind
292+ volumes (`volumes: /:/host`), exactly as on a self-hosted GitHub runner. That
293+ is contained by the sandbox (its own daemon, its own disk), not by the runner:
294+ **host paths in `volumes:` and `options:` are paths of the sandbox**. A job
295+ that mounts `/` exposes the sandbox's filesystem to its own container only.
296+ The runner warns, as GitHub does, about a volume that mounts `/`.
297+- Not ported: container hooks (`ACTIONS_RUNNER_CONTAINER_HOOKS`), the
298+ "already running in a container" refusal (the sandbox's daemon is local to the
299+ executor).
300+
259 ## Job limits301 ## Job limits
260 302
261 Each job gets its own resources, set for the runner as a whole:303 Each job gets its own resources, set for the runner as a whole:
M internal/executor/actions_docker.go +14 -0
@@ -203,6 +203,9 @@ func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered,
203203 v, _ := runtime.Get(k)
204204 env.Set(k, v)
205205 }
206+ if err := checkEnvNames(env.Keys()); err != nil {
207+ return fail(err)
208+ }
206209 cenv := newOrdered()
207210 for _, k := range env.Keys() {
208211 v, _ := env.Get(k)
@@ -224,6 +227,17 @@ func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered,
224227 if entry != "" {
225228 parts = append(parts, entrypointOpt(entry))
226229 }
230+ // A docker action joins the network of the job's containers, so that it
231+ // reaches the services by their aliases (ContainerActionHandler.cs:172-175
232+ // reads job.container.network; the read creates job.container, which is why
233+ // the context of any job that ran a docker action has "container": {}).
234+ j.mu.Lock()
235+ j.state.TouchJobContainer()
236+ network := j.state.JobContainerNetwork()
237+ j.mu.Unlock()
238+ if network != "" {
239+ parts = append(parts, raw("--network", network))
240+ }
227241 parts = append(parts,
228242 volumeOpt("/var/run/docker.sock", "/var/run/docker.sock"),
229243 volumeOpt(temp, "/github/runner_temp"),
@@ -203,6 +203,9 @@ func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered,
203 v, _ := runtime.Get(k)203 v, _ := runtime.Get(k)
204 env.Set(k, v)204 env.Set(k, v)
205 }205 }
206+ if err := checkEnvNames(env.Keys()); err != nil {
207+ return fail(err)
208+ }
206 cenv := newOrdered()209 cenv := newOrdered()
207 for _, k := range env.Keys() {210 for _, k := range env.Keys() {
208 v, _ := env.Get(k)211 v, _ := env.Get(k)
@@ -224,6 +227,17 @@ func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered,
224 if entry != "" {227 if entry != "" {
225 parts = append(parts, entrypointOpt(entry))228 parts = append(parts, entrypointOpt(entry))
226 }229 }
230+ // A docker action joins the network of the job's containers, so that it
231+ // reaches the services by their aliases (ContainerActionHandler.cs:172-175
232+ // reads job.container.network; the read creates job.container, which is why
233+ // the context of any job that ran a docker action has "container": {}).
234+ j.mu.Lock()
235+ j.state.TouchJobContainer()
236+ network := j.state.JobContainerNetwork()
237+ j.mu.Unlock()
238+ if network != "" {
239+ parts = append(parts, raw("--network", network))
240+ }
227 parts = append(parts,241 parts = append(parts,
228 volumeOpt("/var/run/docker.sock", "/var/run/docker.sock"),242 volumeOpt("/var/run/docker.sock", "/var/run/docker.sock"),
229 volumeOpt(temp, "/github/runner_temp"),243 volumeOpt(temp, "/github/runner_temp"),
M internal/executor/actions_docker_cmd.go +10 -1
@@ -8,6 +8,7 @@ import (
88 "os"
99 "os/exec"
1010 "regexp"
11+ "slices"
1112 "strings"
1213
1314 "github.com/google/uuid"
@@ -54,6 +55,10 @@ func escapeDockerArg(v string) string {
5455 type dockerCmd struct {
5556 line string
5657 argv []string
58+ // env is added to the environment of the docker process, after its own:
59+ // "-e NAME" options read their values there, so that they are neither on
60+ // the command line nor in the echoed text.
61+ env []string
5762 }
5863
5964 // newDockerCmd builds "docker <command> <options>" the way the runner does
@@ -62,12 +67,14 @@ type dockerCmd struct {
6267 func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd {
6368 lines := []string{}
6469 argv := []string{j.dockerPath(), command}
70+ var env []string
6571 for _, p := range parts {
6672 lines = append(lines, p.line)
6773 argv = append(argv, p.args...)
74+ env = append(env, p.env...)
6875 }
6976 line := strings.TrimSpace(command + " " + strings.Join(lines, " "))
70- return dockerCmd{line: j.dockerPath() + " " + line, argv: argv}
77+ return dockerCmd{line: j.dockerPath() + " " + line, argv: argv, env: env}
7178 }
7279
7380 // dockerPart is one piece of a docker command: its text in the echoed line and
@@ -75,6 +82,7 @@ func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd {
7582 type dockerPart struct {
7683 line string
7784 args []string
85+ env []string // NAME=value for the process of docker, see dockerCmd.env
7886 }
7987
8088 func raw(args ...string) dockerPart { return dockerPart{line: strings.Join(args, " "), args: args} }
@@ -172,6 +180,7 @@ func (j *job) runDockerCmd(s *stepRun, c dockerCmd, dir string, env []string, ca
172180 if env == nil {
173181 env = j.processEnv(newOrdered())
174182 }
183+ env = append(slices.Clone(env), c.env...)
175184 res, err := runProcess(procSpec{
176185 Argv: c.argv, Dir: dir, Env: env, Credential: j.cfg.Credential,
177186 OnLine: func(line string) { j.write(s.num, line) },
@@ -8,6 +8,7 @@ import (
8 "os"8 "os"
9 "os/exec"9 "os/exec"
10 "regexp"10 "regexp"
11+ "slices"
11 "strings"12 "strings"
12 13
13 "github.com/google/uuid"14 "github.com/google/uuid"
@@ -54,6 +55,10 @@ func escapeDockerArg(v string) string {
54 type dockerCmd struct {55 type dockerCmd struct {
55 line string56 line string
56 argv []string57 argv []string
58+ // env is added to the environment of the docker process, after its own:
59+ // "-e NAME" options read their values there, so that they are neither on
60+ // the command line nor in the echoed text.
61+ env []string
57 }62 }
58 63
59 // newDockerCmd builds "docker <command> <options>" the way the runner does64 // newDockerCmd builds "docker <command> <options>" the way the runner does
@@ -62,12 +67,14 @@ type dockerCmd struct {
62 func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd {67 func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd {
63 lines := []string{}68 lines := []string{}
64 argv := []string{j.dockerPath(), command}69 argv := []string{j.dockerPath(), command}
70+ var env []string
65 for _, p := range parts {71 for _, p := range parts {
66 lines = append(lines, p.line)72 lines = append(lines, p.line)
67 argv = append(argv, p.args...)73 argv = append(argv, p.args...)
74+ env = append(env, p.env...)
68 }75 }
69 line := strings.TrimSpace(command + " " + strings.Join(lines, " "))76 line := strings.TrimSpace(command + " " + strings.Join(lines, " "))
70- return dockerCmd{line: j.dockerPath() + " " + line, argv: argv}77+ return dockerCmd{line: j.dockerPath() + " " + line, argv: argv, env: env}
71 }78 }
72 79
73 // dockerPart is one piece of a docker command: its text in the echoed line and80 // dockerPart is one piece of a docker command: its text in the echoed line and
@@ -75,6 +82,7 @@ func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd {
75 type dockerPart struct {82 type dockerPart struct {
76 line string83 line string
77 args []string84 args []string
85+ env []string // NAME=value for the process of docker, see dockerCmd.env
78 }86 }
79 87
80 func raw(args ...string) dockerPart { return dockerPart{line: strings.Join(args, " "), args: args} }88 func raw(args ...string) dockerPart { return dockerPart{line: strings.Join(args, " "), args: args} }
@@ -172,6 +180,7 @@ func (j *job) runDockerCmd(s *stepRun, c dockerCmd, dir string, env []string, ca
172 if env == nil {180 if env == nil {
173 env = j.processEnv(newOrdered())181 env = j.processEnv(newOrdered())
174 }182 }
183+ env = append(slices.Clone(env), c.env...)
175 res, err := runProcess(procSpec{184 res, err := runProcess(procSpec{
176 Argv: c.argv, Dir: dir, Env: env, Credential: j.cfg.Credential,185 Argv: c.argv, Dir: dir, Env: env, Credential: j.cfg.Credential,
177 OnLine: func(line string) { j.write(s.num, line) },186 OnLine: func(line string) { j.write(s.num, line) },
M internal/executor/actions_job.go +5 -0
@@ -25,6 +25,11 @@ func (j *job) prepareJob() ([]qstep, error) {
2525 return nil, j.setupError(err)
2626 }
2727 queue := j.setupSteps()
28+ // JobExtension.cs:317-331: the images of the actions are pulled first, then
29+ // the containers start.
30+ if j.cs != nil {
31+ queue = append(queue, j.initContainersStep())
32+ }
2833 for _, n := range nodes {
2934 if j.actions.pre[n] {
3035 queue = append(queue, qstep{n: n, stage: stagePre, cond: n.def.preIf()})
@@ -25,6 +25,11 @@ func (j *job) prepareJob() ([]qstep, error) {
25 return nil, j.setupError(err)25 return nil, j.setupError(err)
26 }26 }
27 queue := j.setupSteps()27 queue := j.setupSteps()
28+ // JobExtension.cs:317-331: the images of the actions are pulled first, then
29+ // the containers start.
30+ if j.cs != nil {
31+ queue = append(queue, j.initContainersStep())
32+ }
28 for _, n := range nodes {33 for _, n := range nodes {
29 if j.actions.pre[n] {34 if j.actions.pre[n] {
30 queue = append(queue, qstep{n: n, stage: stagePre, cond: n.def.preIf()})35 queue = append(queue, qstep{n: n, stage: stagePre, cond: n.def.preIf()})
M internal/executor/actions_node.go +3 -0
@@ -175,6 +175,9 @@ func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, ca
175175 if st, err := os.Stat(script); err != nil || st.IsDir() {
176176 return fail(ghErrorf("File not found: '%s'", script))
177177 }
178+ if c := j.stepContainer(); c != nil {
179+ return j.runNodeInContainer(s, c, script, env, cancel)
180+ }
178181 j.mu.Lock()
179182 wd := j.state.GitHub("workspace")
180183 j.mu.Unlock()
@@ -175,6 +175,9 @@ func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, ca
175 if st, err := os.Stat(script); err != nil || st.IsDir() {175 if st, err := os.Stat(script); err != nil || st.IsDir() {
176 return fail(ghErrorf("File not found: '%s'", script))176 return fail(ghErrorf("File not found: '%s'", script))
177 }177 }
178+ if c := j.stepContainer(); c != nil {
179+ return j.runNodeInContainer(s, c, script, env, cancel)
180+ }
178 j.mu.Lock()181 j.mu.Lock()
179 wd := j.state.GitHub("workspace")182 wd := j.state.GitHub("workspace")
180 j.mu.Unlock()183 j.mu.Unlock()
M internal/executor/actions_run.go +4 -0
@@ -175,6 +175,10 @@ func (j *job) runAction(s *stepRun, cancel <-chan struct{}) stepResult {
175175 j.registerPost(s, def)
176176 }
177177 j.setActionRepository(n)
178+ // ActionRunner.cs:157-164: with a job container, the container must exist.
179+ if err := j.containerReady(); err != nil {
180+ return fail(err)
181+ }
178182 if err := j.prepareFileCommands(s); err != nil {
179183 return fail(err)
180184 }
@@ -175,6 +175,10 @@ func (j *job) runAction(s *stepRun, cancel <-chan struct{}) stepResult {
175 j.registerPost(s, def)175 j.registerPost(s, def)
176 }176 }
177 j.setActionRepository(n)177 j.setActionRepository(n)
178+ // ActionRunner.cs:157-164: with a job container, the container must exist.
179+ if err := j.containerReady(); err != nil {
180+ return fail(err)
181+ }
178 if err := j.prepareFileCommands(s); err != nil {182 if err := j.prepareFileCommands(s); err != nil {
179 return fail(err)183 return fail(err)
180 }184 }
M internal/executor/actions_types.go +3 -0
@@ -46,6 +46,9 @@ type ActionsConfig struct {
4646 // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no
4747 // feature flags, so the default is pinned here.
4848 Node24ByDefault bool
49+ // Containers are the time limits of the docker commands of job containers
50+ // and services; the zero value means the defaults.
51+ Containers ContainerTimeouts
4952 // Backoff, when set, replaces the random 10 to 30 s wait between download
5053 // attempts (tests).
5154 Backoff func() time.Duration
@@ -46,6 +46,9 @@ type ActionsConfig struct {
46 // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no46 // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no
47 // feature flags, so the default is pinned here.47 // feature flags, so the default is pinned here.
48 Node24ByDefault bool48 Node24ByDefault bool
49+ // Containers are the time limits of the docker commands of job containers
50+ // and services; the zero value means the defaults.
51+ Containers ContainerTimeouts
49 // Backoff, when set, replaces the random 10 to 30 s wait between download52 // Backoff, when set, replaces the random 10 to 30 s wait between download
50 // attempts (tests).53 // attempts (tests).
51 Backoff func() time.Duration54 Backoff func() time.Duration
M internal/executor/commands.go +5 -0
@@ -144,6 +144,10 @@ type commandHost interface {
144144 AddIssue(issue)
145145 // Workspace is github.workspace.
146146 Workspace() string
147+ // HostPath translates a path printed by a process in the job container
148+ // back to the host's (ContainerInfo.TranslateToHostPath); without a
149+ // container it is the identity.
150+ HostPath(p string) string
147151 // AddMask is SecretMasker.AddValue.
148152 AddMask(value string)
149153 // Matchers is the root of the problem matchers of the job.
@@ -339,6 +343,7 @@ func (m *commandManager) addMask(c *actionCommand) {
339343
340344 // matcherPath roots a path of add-matcher and remove-matcher in the workspace.
341345 func (m *commandManager) matcherPath(file string) string {
346+ file = m.host.HostPath(file) // ActionCommandManager.cs:513-517, :575-580
342347 if !filepath.IsAbs(file) {
343348 return filepath.Join(m.host.Workspace(), file)
344349 }
@@ -144,6 +144,10 @@ type commandHost interface {
144 AddIssue(issue)144 AddIssue(issue)
145 // Workspace is github.workspace.145 // Workspace is github.workspace.
146 Workspace() string146 Workspace() string
147+ // HostPath translates a path printed by a process in the job container
148+ // back to the host's (ContainerInfo.TranslateToHostPath); without a
149+ // container it is the identity.
150+ HostPath(p string) string
147 // AddMask is SecretMasker.AddValue.151 // AddMask is SecretMasker.AddValue.
148 AddMask(value string)152 AddMask(value string)
149 // Matchers is the root of the problem matchers of the job.153 // Matchers is the root of the problem matchers of the job.
@@ -339,6 +343,7 @@ func (m *commandManager) addMask(c *actionCommand) {
339 343
340 // matcherPath roots a path of add-matcher and remove-matcher in the workspace.344 // matcherPath roots a path of add-matcher and remove-matcher in the workspace.
341 func (m *commandManager) matcherPath(file string) string {345 func (m *commandManager) matcherPath(file string) string {
346+ file = m.host.HostPath(file) // ActionCommandManager.cs:513-517, :575-580
342 if !filepath.IsAbs(file) {347 if !filepath.IsAbs(file) {
343 return filepath.Join(m.host.Workspace(), file)348 return filepath.Join(m.host.Workspace(), file)
344 }349 }
A internal/executor/containers_cleanup_test.go +380 -0
@@ -0,0 +1,380 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+func fastTimeouts(c *Config) {
14+ c.Actions.Containers = ContainerTimeouts{Command: 400 * time.Millisecond, Pull: 400 * time.Millisecond, CleanupCommand: 400 * time.Millisecond, CleanupBudget: 3 * time.Second}
15+}
16+
17+func names(l [][]string) string {
18+ var v []string
19+ for _, c := range l {
20+ v = append(v, c[0])
21+ }
22+ return strings.Join(v, " ")
23+}
24+
25+// GO-001: a docker command that stalls is stopped by its own limit, the job
26+// does not wait for its timeout, and the cleanup still finds the container
27+// that the daemon did create, by the label of the job.
28+func TestStalledCreateIsBoundedAndCleanedByLabel(t *testing.T) {
29+ f := newFakeCtr(t)
30+ f.set(t, "hang_create_after", "1") // created in the daemon, the CLI never returns
31+ start := time.Now()
32+ rec, res := ctrRun(t, f, ctrJob, " - run: echo never\n", fastTimeouts)
33+ if res.Conclusion != ConclusionFailure || time.Since(start) > 20*time.Second {
34+ t.Fatalf("%+v after %s", res, time.Since(start))
35+ }
36+ log := logOf(rec.step(t, "Initialize containers"))
37+ if !strings.Contains(log, "##[error]The docker command timed out after 400ms: create --name ") {
38+ t.Errorf("init:\n%s", log)
39+ }
40+ if b, _ := os.ReadFile(filepath.Join(f.dir, "created")); strings.TrimSpace(string(b)) != "" {
41+ t.Errorf("container left in the daemon: %q", b)
42+ }
43+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network: github_network_") {
44+ t.Errorf("stop:\n%s", got)
45+ }
46+}
47+
48+// GO-003-R: options: may carry another --name (the runner passes it on). The
49+// container is then not named as recorded; it is found by the label.
50+func TestOverridingNameAndTimedOutCreateAreCleanedByLabel(t *testing.T) {
51+ f := newFakeCtr(t)
52+ f.set(t, "hang_create_after", "1")
53+ rec, _ := ctrRun(t, f, " container:\n image: ubuntu:24.04\n options: --name my-own-name\n", " - run: echo never\n", fastTimeouts)
54+ var ps, rm []string
55+ for _, c := range f.calls(t) {
56+ if c[0] == "ps" && strings.Contains(strings.Join(c, " "), "--quiet") {
57+ ps = c
58+ }
59+ if c[0] == "rm" {
60+ rm = c
61+ }
62+ }
63+ if ps == nil || !strings.Contains(strings.Join(ps, "\n"), "label=") {
64+ t.Errorf("no listing by label: %v", ps)
65+ }
66+ if len(rm) < 3 || !containerIDRe.MatchString(rm[2]) {
67+ t.Errorf("rm by id expected: %v", rm)
68+ }
69+ if b, _ := os.ReadFile(filepath.Join(f.dir, "created")); strings.TrimSpace(string(b)) != "" {
70+ t.Errorf("container left: %q", b)
71+ }
72+ _ = rec
73+}
74+
75+// GO-001: the cancellation of the job reaches every docker call of the init.
76+func TestCancelDuringInitStopsTheCommandAndCleans(t *testing.T) {
77+ for _, verb := range []string{"version", "inspect", "port", "start", "ps"} {
78+ f := newFakeCtr(t)
79+ f.set(t, "hang_"+verb, "1")
80+ cancel := NewCanceller()
81+ go func() { time.Sleep(500 * time.Millisecond); cancel.Cancel(CancelUser) }()
82+ start := time.Now()
83+ rec, res := ctrRunC(t, f, cancel, " container: ubuntu:24.04\n services:\n s:\n image: redis:7\n", " - run: echo never\n", func(c *Config) {
84+ c.Actions.Containers.Command = time.Minute
85+ })
86+ if res.Conclusion != ConclusionCancelled || time.Since(start) > 15*time.Second {
87+ t.Fatalf("%s: %+v after %s %v", verb, res, time.Since(start), rec.names())
88+ }
89+ if got := rec.concl("Stop containers"); got != ConclusionSuccess {
90+ t.Errorf("%s: Stop containers %s", verb, got)
91+ }
92+ }
93+}
94+
95+// GO-002: one removal that stalls does not keep the others and the network from
96+// being removed; the logs of a service never prevent its removal; the
97+// failures are reported by the step.
98+func TestCleanupAttemptsEverythingWithItsOwnBudget(t *testing.T) {
99+ f := newFakeCtr(t)
100+ f.set(t, "hang_logs", "1")
101+ f.set(t, "hang_for", "rm --force "+strings.Repeat("0", 63)+"1") // the rm of the job container (first id)
102+ cancel := NewCanceller()
103+ rec, res := ctrRunC(t, f, cancel, " container: ubuntu:24.04\n services:\n a:\n image: redis:7\n b:\n image: redis:7\n", " - run: echo ok\n", fastTimeouts)
104+ if res.Conclusion == ConclusionSuccess {
105+ t.Fatalf("a failed cleanup must be visible: %+v", res)
106+ }
107+ stop := logOf(rec.step(t, "Stop containers"))
108+ for _, w := range []string{"Stop and remove container: ", "Print service container logs: ", "Remove container network: github_network_", "##[error]The docker command timed out after 400ms: rm --force ", "##[warning]Docker logs failed: The docker command timed out"} {
109+ if !strings.Contains(stop, w) {
110+ t.Errorf("stop lacks %q:\n%s", w, stop)
111+ }
112+ }
113+ rms, nets := 0, 0
114+ for _, c := range f.calls(t) {
115+ if c[0] == "rm" {
116+ rms++
117+ }
118+ if c[0] == "network" && len(c) > 1 && c[1] == "rm" {
119+ nets++
120+ }
121+ }
122+ // 3 removals, and the fallback of the job tries the one that timed out again.
123+ if rms < 3 || rms > 4 || nets != 1 {
124+ t.Errorf("rm=%d network rm=%d (want 3 or 4 and 1):\n%s", rms, nets, stop)
125+ }
126+ if rec.concl("Stop containers") != ConclusionFailure {
127+ t.Errorf("Stop containers: %s", rec.concl("Stop containers"))
128+ }
129+}
130+
131+// GO-003: a shutdown skips the queued post steps (StepsRunner); the job-level
132+// fallback removes the containers and the network all the same, and does not
133+// run twice what "Stop containers" did.
134+func TestShutdownStillCleansUpThroughTheFallback(t *testing.T) {
135+ f := newFakeCtr(t)
136+ f.set(t, "hang_exec", "1")
137+ cancel := NewCanceller()
138+ go func() { time.Sleep(600 * time.Millisecond); cancel.Cancel(CancelShutdown) }()
139+ rec, res := ctrRunC(t, f, cancel, ctrJob, " - name: long\n run: sleep 60\n", nil)
140+ _ = res
141+ if rec.concl("Stop containers") == ConclusionSuccess {
142+ t.Skip("the runner ran Stop containers on shutdown")
143+ }
144+ got := names(f.calls(t))
145+ if !strings.HasSuffix(got, "rm network") {
146+ t.Errorf("fallback did not remove the container and the network: %s", got)
147+ }
148+}
149+
150+func TestFallbackDoesNothingWhenStopContainersDidItAll(t *testing.T) {
151+ f := newFakeCtr(t)
152+ _, res := ctrRun(t, f, ctrJob, " - run: echo ok\n", nil)
153+ if res.Conclusion != ConclusionSuccess {
154+ t.Fatalf("%+v", res)
155+ }
156+ n := 0
157+ for _, c := range f.calls(t) {
158+ if c[0] == "rm" || (c[0] == "network" && len(c) > 1 && c[1] == "rm") {
159+ n++
160+ }
161+ }
162+ if n != 2 {
163+ t.Errorf("%d removals, want exactly 2 (container, network): %s", n, names(f.calls(t)))
164+ }
165+}
166+
167+// GO-003: a panic of the job still cleans up.
168+func TestPanicStillCleansUp(t *testing.T) {
169+ f := newFakeCtr(t)
170+ defer func() {
171+ _ = recover()
172+ got := names(f.calls(t))
173+ if !strings.Contains(got, "rm") || !strings.HasSuffix(got, "rm network") {
174+ t.Errorf("calls after the panic: %s", got)
175+ }
176+ }()
177+ ctrRun(t, f, ctrJob, " - run: echo ok\n", func(c *Config) {
178+ c.Reporter = &panicOnStep{Reporter: c.Reporter, name: "Run echo ok"}
179+ })
180+}
181+
182+type panicOnStep struct {
183+ Reporter
184+ name string
185+}
186+
187+func (p *panicOnStep) StepStarted(n int, name string, at time.Time) {
188+ if name == p.name {
189+ panic("boom")
190+ }
191+ p.Reporter.StepStarted(n, name, at)
192+}
193+
194+// GO-007: the credentials directory is removed even when the pull fails; a
195+// removal that fails is reported and left to the fallback.
196+func TestRegistryConfigRemovedAfterFailedPullAndRemovalFailureIsReported(t *testing.T) {
197+ f := newFakeCtr(t)
198+ f.set(t, "pull_fail", "9")
199+ var temp string
200+ rec, _ := ctrRun(t, f, " container:\n image: ghcr.io/o/i:1\n credentials:\n username: u\n password: pw-secret\n", " - run: echo\n", func(c *Config) { temp = c.Layout.Temp })
201+ if m, _ := filepath.Glob(filepath.Join(temp, ".docker_*")); len(m) != 0 {
202+ t.Errorf("config left after a failed pull: %v", m)
203+ }
204+ _ = rec
205+
206+ if os.Geteuid() == 0 {
207+ t.Skip("root removes anything")
208+ }
209+ f2 := newFakeCtr(t)
210+ f2.set(t, "lock_cfg", "1")
211+ rec2, res2 := ctrRun(t, f2, " container:\n image: ghcr.io/o/i:1\n credentials:\n username: u\n password: pw-secret\n", " - run: echo\n", func(c *Config) { temp = c.Layout.Temp })
212+ defer func() {
213+ m, _ := filepath.Glob(filepath.Join(temp, ".docker_*", "sub"))
214+ for _, d := range m {
215+ _ = os.Chmod(d, 0o755)
216+ }
217+ }()
218+ if res2.Conclusion != ConclusionFailure {
219+ t.Fatalf("%+v", res2)
220+ }
221+ if got := logOf(rec2.step(t, "Initialize containers")); !strings.Contains(got, "##[error]Failed to remove directory containing Docker client credentials:") {
222+ t.Errorf("init:\n%s", got)
223+ }
224+}
225+
226+// GO-001-R: the health inspection takes the cancellation too.
227+func TestCancelDuringHealthInspection(t *testing.T) {
228+ f := newFakeCtr(t)
229+ f.set(t, "hang_inspect", "1")
230+ cancel := NewCanceller()
231+ go func() { time.Sleep(500 * time.Millisecond); cancel.Cancel(CancelUser) }()
232+ start := time.Now()
233+ rec, res := ctrRunC(t, f, cancel, " services:\n s:\n image: redis:7\n", " - run: echo never\n", func(c *Config) {
234+ c.Actions.Containers.Command = time.Minute
235+ })
236+ if res.Conclusion != ConclusionCancelled || time.Since(start) > 15*time.Second {
237+ t.Fatalf("%+v after %s %v", res, time.Since(start), rec.names())
238+ }
239+ var health bool
240+ for _, c := range f.calls(t) {
241+ if c[0] == "inspect" && strings.Contains(strings.Join(c, " "), "Healthcheck") {
242+ health = true
243+ }
244+ }
245+ if !health {
246+ t.Errorf("the health inspection was not reached: %s", names(f.calls(t)))
247+ }
248+}
249+
250+// GO-002-R: a failed removal is not a removal. It stays pending and the job's
251+// fallback tries again; "No such container" is a removal.
252+func TestFailedRemovalStaysPendingAndAbsenceCounts(t *testing.T) {
253+ count := func(f *fakeCtr, verb string) int {
254+ n := 0
255+ for _, c := range f.calls(t) {
256+ if c[0] == verb || (verb == "network rm" && c[0] == "network" && len(c) > 1 && c[1] == "rm") {
257+ n++
258+ }
259+ }
260+ return n
261+ }
262+ f := newFakeCtr(t)
263+ f.set(t, "rm_fail", "1")
264+ f.set(t, "netrm_fail", "1")
265+ rec, _ := ctrRun(t, f, ctrJob, " - run: echo ok\n", fastTimeouts)
266+ if count(f, "rm") != 2 || count(f, "network rm") != 2 {
267+ t.Errorf("rm=%d network rm=%d, want the fallback to retry both once", count(f, "rm"), count(f, "network rm"))
268+ }
269+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Docker rm fail with exit code 1") || !strings.Contains(got, "Docker network rm fail with exit code 1") {
270+ t.Errorf("warnings:\n%s", got)
271+ }
272+
273+ f2 := newFakeCtr(t)
274+ f2.set(t, "rm_absent", "1")
275+ f2.set(t, "netrm_absent", "1")
276+ rec2, res2 := ctrRun(t, f2, ctrJob, " - run: echo ok\n", fastTimeouts)
277+ if res2.Conclusion != ConclusionSuccess || count(f2, "rm") != 1 || count(f2, "network rm") != 1 {
278+ t.Errorf("absent: %+v rm=%d network rm=%d\n%s", res2, count(f2, "rm"), count(f2, "network rm"), logOf(rec2.step(t, "Stop containers")))
279+ }
280+}
281+
282+// GO-007-R: the credentials directory is registered before the login; a panic
283+// in the login or in the pull leaves nothing behind.
284+func TestPanicDuringLoginOrPullLeavesNoCredentials(t *testing.T) {
285+ for _, during := range []string{" login ", " pull "} {
286+ f := newFakeCtr(t)
287+ var temp string
288+ func() {
289+ defer func() { _ = recover() }()
290+ ctrRun(t, f, " container:\n image: ghcr.io/o/i:1\n credentials:\n username: u\n password: pw-secret\n", " - run: echo\n", func(c *Config) {
291+ temp = c.Layout.Temp
292+ c.Reporter = &panicOnLog{Reporter: c.Reporter, contains: during}
293+ })
294+ }()
295+ if m, _ := filepath.Glob(filepath.Join(temp, ".docker_*")); len(m) != 0 {
296+ t.Errorf("panic during%s: credentials left: %v", during, m)
297+ }
298+ }
299+}
300+
301+type panicOnLog struct {
302+ Reporter
303+ contains string
304+}
305+
306+func (p *panicOnLog) Log(n int, at time.Time, line string) {
307+ if strings.HasPrefix(line, "##[command]") && strings.Contains(line, p.contains) {
308+ panic("boom")
309+ }
310+ p.Reporter.Log(n, at, line)
311+}
312+
313+// GO-004-R3: a variable name that holds "=" or NUL, or is empty, is refused
314+// for create and exec, before docker is called.
315+func TestInvalidEnvNamesAreRefused(t *testing.T) {
316+ if checkEnvNames([]string{"A", "b_c", "A.B", "A-B"}) != nil {
317+ t.Error("valid names refused")
318+ }
319+ for _, k := range []string{"", "A=B", "HTTP_PROXY=http://x/?y", "A\x00B", "="} {
320+ if err := checkEnvNames([]string{"OK", k}); err == nil || !strings.Contains(err.Error(), "Invalid environment variable name") {
321+ t.Errorf("%q: %v", k, err)
322+ }
323+ }
324+ f := newFakeCtr(t)
325+ rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n \"HTTP_PROXY=http://x/?y\": v\n", " - run: echo never\n", nil)
326+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "Set up job")), "##[error]Invalid environment variable name 'HTTP_PROXY=http://x/?y'") {
327+ t.Errorf("container env: %+v", res)
328+ }
329+ rec, res = ctrRun(t, f, ctrJob, " - name: s\n env:\n \"HTTP_PROXY=http://x/?y\": v\n run: echo never\n", nil)
330+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "s")), "##[error]Invalid environment variable name 'HTTP_PROXY=http://x/?y'") {
331+ t.Errorf("step env: %+v\n%s", res, logOf(rec.step(t, "s")))
332+ }
333+ if n := len(f.execCalls(t)); n != 0 {
334+ t.Errorf("docker exec called %d times", n)
335+ }
336+}
337+
338+// GO-008: the budget of the whole cleanup is checked before every command. With
339+// a budget spent by the first stalled removal, the others (and the retry of the
340+// fallback) are not started: they would each cost a signal escalation.
341+func TestCleanupBudgetIsCheckedBeforeEveryCommand(t *testing.T) {
342+ f := newFakeCtr(t)
343+ f.set(t, "hang_for", "rm --force")
344+ f.set(t, "hang_ignore", "1") // SIGINT is ignored: a started command costs TermAfter
345+ svcs := ""
346+ for _, n := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
347+ svcs += " " + n + ":\n image: redis:7\n"
348+ }
349+ start := time.Now()
350+ rec, res := ctrRun(t, f, " container: ubuntu:24.04\n services:\n"+svcs, " - run: echo ok\n", func(c *Config) {
351+ c.Actions.Containers = ContainerTimeouts{Command: time.Minute, Pull: time.Minute, CleanupCommand: time.Minute, CleanupBudget: time.Second}
352+ })
353+ total := time.Since(start)
354+ if res.Conclusion == ConclusionSuccess {
355+ t.Fatalf("an unfinished cleanup must be visible: %+v", res)
356+ }
357+ stop := rec.step(t, "Stop containers")
358+ took := stop.Completed.Sub(stop.Started)
359+ // The budget (1 s) and one escalation (TermAfter 200 ms in the tests, plus the kill delay).
360+ if took > 1900*time.Millisecond || total > 12*time.Second {
361+ t.Errorf("Stop containers took %s (run %s), want about the budget plus one escalation", took, total)
362+ }
363+ rms := 0
364+ for _, c := range f.calls(t) {
365+ if c[0] == "rm" {
366+ rms++
367+ }
368+ }
369+ if rms != 1 {
370+ t.Errorf("%d removals started, want 1 (the budget was spent by it)", rms)
371+ }
372+ log := logOf(stop)
373+ if !strings.Contains(log, "##[error]The cleanup of the containers ran out of time (1s): stopped: docker rm --force ") ||
374+ !strings.Contains(log, "ran out of time (1s): not run: docker ") {
375+ t.Errorf("log:\n%s", log)
376+ }
377+ if strings.Count(log, "not run: docker network rm") != 1 {
378+ t.Errorf("the network removal must be reported as not run once:\n%s", log)
379+ }
380+}
@@ -0,0 +1,380 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+func fastTimeouts(c *Config) {
14+ c.Actions.Containers = ContainerTimeouts{Command: 400 * time.Millisecond, Pull: 400 * time.Millisecond, CleanupCommand: 400 * time.Millisecond, CleanupBudget: 3 * time.Second}
15+}
16+
17+func names(l [][]string) string {
18+ var v []string
19+ for _, c := range l {
20+ v = append(v, c[0])
21+ }
22+ return strings.Join(v, " ")
23+}
24+
25+// GO-001: a docker command that stalls is stopped by its own limit, the job
26+// does not wait for its timeout, and the cleanup still finds the container
27+// that the daemon did create, by the label of the job.
28+func TestStalledCreateIsBoundedAndCleanedByLabel(t *testing.T) {
29+ f := newFakeCtr(t)
30+ f.set(t, "hang_create_after", "1") // created in the daemon, the CLI never returns
31+ start := time.Now()
32+ rec, res := ctrRun(t, f, ctrJob, " - run: echo never\n", fastTimeouts)
33+ if res.Conclusion != ConclusionFailure || time.Since(start) > 20*time.Second {
34+ t.Fatalf("%+v after %s", res, time.Since(start))
35+ }
36+ log := logOf(rec.step(t, "Initialize containers"))
37+ if !strings.Contains(log, "##[error]The docker command timed out after 400ms: create --name ") {
38+ t.Errorf("init:\n%s", log)
39+ }
40+ if b, _ := os.ReadFile(filepath.Join(f.dir, "created")); strings.TrimSpace(string(b)) != "" {
41+ t.Errorf("container left in the daemon: %q", b)
42+ }
43+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network: github_network_") {
44+ t.Errorf("stop:\n%s", got)
45+ }
46+}
47+
48+// GO-003-R: options: may carry another --name (the runner passes it on). The
49+// container is then not named as recorded; it is found by the label.
50+func TestOverridingNameAndTimedOutCreateAreCleanedByLabel(t *testing.T) {
51+ f := newFakeCtr(t)
52+ f.set(t, "hang_create_after", "1")
53+ rec, _ := ctrRun(t, f, " container:\n image: ubuntu:24.04\n options: --name my-own-name\n", " - run: echo never\n", fastTimeouts)
54+ var ps, rm []string
55+ for _, c := range f.calls(t) {
56+ if c[0] == "ps" && strings.Contains(strings.Join(c, " "), "--quiet") {
57+ ps = c
58+ }
59+ if c[0] == "rm" {
60+ rm = c
61+ }
62+ }
63+ if ps == nil || !strings.Contains(strings.Join(ps, "\n"), "label=") {
64+ t.Errorf("no listing by label: %v", ps)
65+ }
66+ if len(rm) < 3 || !containerIDRe.MatchString(rm[2]) {
67+ t.Errorf("rm by id expected: %v", rm)
68+ }
69+ if b, _ := os.ReadFile(filepath.Join(f.dir, "created")); strings.TrimSpace(string(b)) != "" {
70+ t.Errorf("container left: %q", b)
71+ }
72+ _ = rec
73+}
74+
75+// GO-001: the cancellation of the job reaches every docker call of the init.
76+func TestCancelDuringInitStopsTheCommandAndCleans(t *testing.T) {
77+ for _, verb := range []string{"version", "inspect", "port", "start", "ps"} {
78+ f := newFakeCtr(t)
79+ f.set(t, "hang_"+verb, "1")
80+ cancel := NewCanceller()
81+ go func() { time.Sleep(500 * time.Millisecond); cancel.Cancel(CancelUser) }()
82+ start := time.Now()
83+ rec, res := ctrRunC(t, f, cancel, " container: ubuntu:24.04\n services:\n s:\n image: redis:7\n", " - run: echo never\n", func(c *Config) {
84+ c.Actions.Containers.Command = time.Minute
85+ })
86+ if res.Conclusion != ConclusionCancelled || time.Since(start) > 15*time.Second {
87+ t.Fatalf("%s: %+v after %s %v", verb, res, time.Since(start), rec.names())
88+ }
89+ if got := rec.concl("Stop containers"); got != ConclusionSuccess {
90+ t.Errorf("%s: Stop containers %s", verb, got)
91+ }
92+ }
93+}
94+
95+// GO-002: one removal that stalls does not keep the others and the network from
96+// being removed; the logs of a service never prevent its removal; the
97+// failures are reported by the step.
98+func TestCleanupAttemptsEverythingWithItsOwnBudget(t *testing.T) {
99+ f := newFakeCtr(t)
100+ f.set(t, "hang_logs", "1")
101+ f.set(t, "hang_for", "rm --force "+strings.Repeat("0", 63)+"1") // the rm of the job container (first id)
102+ cancel := NewCanceller()
103+ rec, res := ctrRunC(t, f, cancel, " container: ubuntu:24.04\n services:\n a:\n image: redis:7\n b:\n image: redis:7\n", " - run: echo ok\n", fastTimeouts)
104+ if res.Conclusion == ConclusionSuccess {
105+ t.Fatalf("a failed cleanup must be visible: %+v", res)
106+ }
107+ stop := logOf(rec.step(t, "Stop containers"))
108+ for _, w := range []string{"Stop and remove container: ", "Print service container logs: ", "Remove container network: github_network_", "##[error]The docker command timed out after 400ms: rm --force ", "##[warning]Docker logs failed: The docker command timed out"} {
109+ if !strings.Contains(stop, w) {
110+ t.Errorf("stop lacks %q:\n%s", w, stop)
111+ }
112+ }
113+ rms, nets := 0, 0
114+ for _, c := range f.calls(t) {
115+ if c[0] == "rm" {
116+ rms++
117+ }
118+ if c[0] == "network" && len(c) > 1 && c[1] == "rm" {
119+ nets++
120+ }
121+ }
122+ // 3 removals, and the fallback of the job tries the one that timed out again.
123+ if rms < 3 || rms > 4 || nets != 1 {
124+ t.Errorf("rm=%d network rm=%d (want 3 or 4 and 1):\n%s", rms, nets, stop)
125+ }
126+ if rec.concl("Stop containers") != ConclusionFailure {
127+ t.Errorf("Stop containers: %s", rec.concl("Stop containers"))
128+ }
129+}
130+
131+// GO-003: a shutdown skips the queued post steps (StepsRunner); the job-level
132+// fallback removes the containers and the network all the same, and does not
133+// run twice what "Stop containers" did.
134+func TestShutdownStillCleansUpThroughTheFallback(t *testing.T) {
135+ f := newFakeCtr(t)
136+ f.set(t, "hang_exec", "1")
137+ cancel := NewCanceller()
138+ go func() { time.Sleep(600 * time.Millisecond); cancel.Cancel(CancelShutdown) }()
139+ rec, res := ctrRunC(t, f, cancel, ctrJob, " - name: long\n run: sleep 60\n", nil)
140+ _ = res
141+ if rec.concl("Stop containers") == ConclusionSuccess {
142+ t.Skip("the runner ran Stop containers on shutdown")
143+ }
144+ got := names(f.calls(t))
145+ if !strings.HasSuffix(got, "rm network") {
146+ t.Errorf("fallback did not remove the container and the network: %s", got)
147+ }
148+}
149+
150+func TestFallbackDoesNothingWhenStopContainersDidItAll(t *testing.T) {
151+ f := newFakeCtr(t)
152+ _, res := ctrRun(t, f, ctrJob, " - run: echo ok\n", nil)
153+ if res.Conclusion != ConclusionSuccess {
154+ t.Fatalf("%+v", res)
155+ }
156+ n := 0
157+ for _, c := range f.calls(t) {
158+ if c[0] == "rm" || (c[0] == "network" && len(c) > 1 && c[1] == "rm") {
159+ n++
160+ }
161+ }
162+ if n != 2 {
163+ t.Errorf("%d removals, want exactly 2 (container, network): %s", n, names(f.calls(t)))
164+ }
165+}
166+
167+// GO-003: a panic of the job still cleans up.
168+func TestPanicStillCleansUp(t *testing.T) {
169+ f := newFakeCtr(t)
170+ defer func() {
171+ _ = recover()
172+ got := names(f.calls(t))
173+ if !strings.Contains(got, "rm") || !strings.HasSuffix(got, "rm network") {
174+ t.Errorf("calls after the panic: %s", got)
175+ }
176+ }()
177+ ctrRun(t, f, ctrJob, " - run: echo ok\n", func(c *Config) {
178+ c.Reporter = &panicOnStep{Reporter: c.Reporter, name: "Run echo ok"}
179+ })
180+}
181+
182+type panicOnStep struct {
183+ Reporter
184+ name string
185+}
186+
187+func (p *panicOnStep) StepStarted(n int, name string, at time.Time) {
188+ if name == p.name {
189+ panic("boom")
190+ }
191+ p.Reporter.StepStarted(n, name, at)
192+}
193+
194+// GO-007: the credentials directory is removed even when the pull fails; a
195+// removal that fails is reported and left to the fallback.
196+func TestRegistryConfigRemovedAfterFailedPullAndRemovalFailureIsReported(t *testing.T) {
197+ f := newFakeCtr(t)
198+ f.set(t, "pull_fail", "9")
199+ var temp string
200+ rec, _ := ctrRun(t, f, " container:\n image: ghcr.io/o/i:1\n credentials:\n username: u\n password: pw-secret\n", " - run: echo\n", func(c *Config) { temp = c.Layout.Temp })
201+ if m, _ := filepath.Glob(filepath.Join(temp, ".docker_*")); len(m) != 0 {
202+ t.Errorf("config left after a failed pull: %v", m)
203+ }
204+ _ = rec
205+
206+ if os.Geteuid() == 0 {
207+ t.Skip("root removes anything")
208+ }
209+ f2 := newFakeCtr(t)
210+ f2.set(t, "lock_cfg", "1")
211+ rec2, res2 := ctrRun(t, f2, " container:\n image: ghcr.io/o/i:1\n credentials:\n username: u\n password: pw-secret\n", " - run: echo\n", func(c *Config) { temp = c.Layout.Temp })
212+ defer func() {
213+ m, _ := filepath.Glob(filepath.Join(temp, ".docker_*", "sub"))
214+ for _, d := range m {
215+ _ = os.Chmod(d, 0o755)
216+ }
217+ }()
218+ if res2.Conclusion != ConclusionFailure {
219+ t.Fatalf("%+v", res2)
220+ }
221+ if got := logOf(rec2.step(t, "Initialize containers")); !strings.Contains(got, "##[error]Failed to remove directory containing Docker client credentials:") {
222+ t.Errorf("init:\n%s", got)
223+ }
224+}
225+
226+// GO-001-R: the health inspection takes the cancellation too.
227+func TestCancelDuringHealthInspection(t *testing.T) {
228+ f := newFakeCtr(t)
229+ f.set(t, "hang_inspect", "1")
230+ cancel := NewCanceller()
231+ go func() { time.Sleep(500 * time.Millisecond); cancel.Cancel(CancelUser) }()
232+ start := time.Now()
233+ rec, res := ctrRunC(t, f, cancel, " services:\n s:\n image: redis:7\n", " - run: echo never\n", func(c *Config) {
234+ c.Actions.Containers.Command = time.Minute
235+ })
236+ if res.Conclusion != ConclusionCancelled || time.Since(start) > 15*time.Second {
237+ t.Fatalf("%+v after %s %v", res, time.Since(start), rec.names())
238+ }
239+ var health bool
240+ for _, c := range f.calls(t) {
241+ if c[0] == "inspect" && strings.Contains(strings.Join(c, " "), "Healthcheck") {
242+ health = true
243+ }
244+ }
245+ if !health {
246+ t.Errorf("the health inspection was not reached: %s", names(f.calls(t)))
247+ }
248+}
249+
250+// GO-002-R: a failed removal is not a removal. It stays pending and the job's
251+// fallback tries again; "No such container" is a removal.
252+func TestFailedRemovalStaysPendingAndAbsenceCounts(t *testing.T) {
253+ count := func(f *fakeCtr, verb string) int {
254+ n := 0
255+ for _, c := range f.calls(t) {
256+ if c[0] == verb || (verb == "network rm" && c[0] == "network" && len(c) > 1 && c[1] == "rm") {
257+ n++
258+ }
259+ }
260+ return n
261+ }
262+ f := newFakeCtr(t)
263+ f.set(t, "rm_fail", "1")
264+ f.set(t, "netrm_fail", "1")
265+ rec, _ := ctrRun(t, f, ctrJob, " - run: echo ok\n", fastTimeouts)
266+ if count(f, "rm") != 2 || count(f, "network rm") != 2 {
267+ t.Errorf("rm=%d network rm=%d, want the fallback to retry both once", count(f, "rm"), count(f, "network rm"))
268+ }
269+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Docker rm fail with exit code 1") || !strings.Contains(got, "Docker network rm fail with exit code 1") {
270+ t.Errorf("warnings:\n%s", got)
271+ }
272+
273+ f2 := newFakeCtr(t)
274+ f2.set(t, "rm_absent", "1")
275+ f2.set(t, "netrm_absent", "1")
276+ rec2, res2 := ctrRun(t, f2, ctrJob, " - run: echo ok\n", fastTimeouts)
277+ if res2.Conclusion != ConclusionSuccess || count(f2, "rm") != 1 || count(f2, "network rm") != 1 {
278+ t.Errorf("absent: %+v rm=%d network rm=%d\n%s", res2, count(f2, "rm"), count(f2, "network rm"), logOf(rec2.step(t, "Stop containers")))
279+ }
280+}
281+
282+// GO-007-R: the credentials directory is registered before the login; a panic
283+// in the login or in the pull leaves nothing behind.
284+func TestPanicDuringLoginOrPullLeavesNoCredentials(t *testing.T) {
285+ for _, during := range []string{" login ", " pull "} {
286+ f := newFakeCtr(t)
287+ var temp string
288+ func() {
289+ defer func() { _ = recover() }()
290+ ctrRun(t, f, " container:\n image: ghcr.io/o/i:1\n credentials:\n username: u\n password: pw-secret\n", " - run: echo\n", func(c *Config) {
291+ temp = c.Layout.Temp
292+ c.Reporter = &panicOnLog{Reporter: c.Reporter, contains: during}
293+ })
294+ }()
295+ if m, _ := filepath.Glob(filepath.Join(temp, ".docker_*")); len(m) != 0 {
296+ t.Errorf("panic during%s: credentials left: %v", during, m)
297+ }
298+ }
299+}
300+
301+type panicOnLog struct {
302+ Reporter
303+ contains string
304+}
305+
306+func (p *panicOnLog) Log(n int, at time.Time, line string) {
307+ if strings.HasPrefix(line, "##[command]") && strings.Contains(line, p.contains) {
308+ panic("boom")
309+ }
310+ p.Reporter.Log(n, at, line)
311+}
312+
313+// GO-004-R3: a variable name that holds "=" or NUL, or is empty, is refused
314+// for create and exec, before docker is called.
315+func TestInvalidEnvNamesAreRefused(t *testing.T) {
316+ if checkEnvNames([]string{"A", "b_c", "A.B", "A-B"}) != nil {
317+ t.Error("valid names refused")
318+ }
319+ for _, k := range []string{"", "A=B", "HTTP_PROXY=http://x/?y", "A\x00B", "="} {
320+ if err := checkEnvNames([]string{"OK", k}); err == nil || !strings.Contains(err.Error(), "Invalid environment variable name") {
321+ t.Errorf("%q: %v", k, err)
322+ }
323+ }
324+ f := newFakeCtr(t)
325+ rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n \"HTTP_PROXY=http://x/?y\": v\n", " - run: echo never\n", nil)
326+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "Set up job")), "##[error]Invalid environment variable name 'HTTP_PROXY=http://x/?y'") {
327+ t.Errorf("container env: %+v", res)
328+ }
329+ rec, res = ctrRun(t, f, ctrJob, " - name: s\n env:\n \"HTTP_PROXY=http://x/?y\": v\n run: echo never\n", nil)
330+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "s")), "##[error]Invalid environment variable name 'HTTP_PROXY=http://x/?y'") {
331+ t.Errorf("step env: %+v\n%s", res, logOf(rec.step(t, "s")))
332+ }
333+ if n := len(f.execCalls(t)); n != 0 {
334+ t.Errorf("docker exec called %d times", n)
335+ }
336+}
337+
338+// GO-008: the budget of the whole cleanup is checked before every command. With
339+// a budget spent by the first stalled removal, the others (and the retry of the
340+// fallback) are not started: they would each cost a signal escalation.
341+func TestCleanupBudgetIsCheckedBeforeEveryCommand(t *testing.T) {
342+ f := newFakeCtr(t)
343+ f.set(t, "hang_for", "rm --force")
344+ f.set(t, "hang_ignore", "1") // SIGINT is ignored: a started command costs TermAfter
345+ svcs := ""
346+ for _, n := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
347+ svcs += " " + n + ":\n image: redis:7\n"
348+ }
349+ start := time.Now()
350+ rec, res := ctrRun(t, f, " container: ubuntu:24.04\n services:\n"+svcs, " - run: echo ok\n", func(c *Config) {
351+ c.Actions.Containers = ContainerTimeouts{Command: time.Minute, Pull: time.Minute, CleanupCommand: time.Minute, CleanupBudget: time.Second}
352+ })
353+ total := time.Since(start)
354+ if res.Conclusion == ConclusionSuccess {
355+ t.Fatalf("an unfinished cleanup must be visible: %+v", res)
356+ }
357+ stop := rec.step(t, "Stop containers")
358+ took := stop.Completed.Sub(stop.Started)
359+ // The budget (1 s) and one escalation (TermAfter 200 ms in the tests, plus the kill delay).
360+ if took > 1900*time.Millisecond || total > 12*time.Second {
361+ t.Errorf("Stop containers took %s (run %s), want about the budget plus one escalation", took, total)
362+ }
363+ rms := 0
364+ for _, c := range f.calls(t) {
365+ if c[0] == "rm" {
366+ rms++
367+ }
368+ }
369+ if rms != 1 {
370+ t.Errorf("%d removals started, want 1 (the budget was spent by it)", rms)
371+ }
372+ log := logOf(stop)
373+ if !strings.Contains(log, "##[error]The cleanup of the containers ran out of time (1s): stopped: docker rm --force ") ||
374+ !strings.Contains(log, "ran out of time (1s): not run: docker ") {
375+ t.Errorf("log:\n%s", log)
376+ }
377+ if strings.Count(log, "not run: docker network rm") != 1 {
378+ t.Errorf("the network removal must be reported as not run once:\n%s", log)
379+ }
380+}
A internal/executor/containers_docker.go +288 -0
@@ -0,0 +1,288 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
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.
21+func 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="...").
26+func 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.
42+func 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.
63+func (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+
73+func 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.
87+func 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.
98+func (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).
106+func 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).
124+func 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.
130+func (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.
135+type exitError struct {
136+ code int
137+ msg string
138+}
139+
140+func (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.
145+type 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+
154+func (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.
169+type cmdTimeoutError struct {
170+ cmd string
171+ d time.Duration
172+}
173+
174+func (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.
181+func 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.
201+type 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.
220+func (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.
273+func (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.
285+func (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+}
@@ -0,0 +1,288 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
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.
21+func 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="...").
26+func 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.
42+func 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.
63+func (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+
73+func 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.
87+func 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.
98+func (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).
106+func 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).
124+func 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.
130+func (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.
135+type exitError struct {
136+ code int
137+ msg string
138+}
139+
140+func (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.
145+type 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+
154+func (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.
169+type cmdTimeoutError struct {
170+ cmd string
171+ d time.Duration
172+}
173+
174+func (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.
181+func 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.
201+type 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.
220+func (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.
273+func (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.
285+func (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+}
A internal/executor/containers_exec.go +111 -0
@@ -0,0 +1,111 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+)
8+
9+// ContainerStepHost (Runner.Worker/Handlers/StepHost.cs:102-296): the steps of
10+// a job that has a container run through `docker exec` in it.
11+
12+// stepContainer is the job container when steps run in it, else nil.
13+func (j *job) stepContainer() *container {
14+ if j.cs == nil {
15+ return nil
16+ }
17+ return j.cs.job
18+}
19+
20+// containerReady is the check of ActionRunner.cs:157-164: the container must
21+// exist. After a failed "Initialize containers" an always() step gets the
22+// runner's error.
23+func (j *job) containerReady() error {
24+ if c := j.stepContainer(); c != nil && c.id == "" {
25+ return ghErrorf("Value cannot be null. (Parameter 'ContainerId')")
26+ }
27+ return nil
28+}
29+
30+// hostEnv is the environment a handler gives its process: the step's
31+// variables then the runtime variables of the contexts (GITHUB_*, RUNNER_*)
32+// (ScriptHandler.cs:289-299). It is what `docker exec -e` passes on.
33+func (j *job) handlerEnv(stepEnv *ordered) *ordered {
34+ env := stepEnv.Clone()
35+ j.mu.Lock()
36+ rt := j.state.RuntimeEnv()
37+ j.mu.Unlock()
38+ for _, k := range rt.Keys() {
39+ v, _ := rt.Get(k)
40+ env.Set(k, v)
41+ }
42+ return env
43+}
44+
45+// execArgv is ContainerStepHost.ExecuteAsync (StepHost.cs:196-246):
46+// `docker exec -i --workdir <dir> -e NAME... [-e PATH="<prepend>:<PATH>"] <id>
47+// <command> <arguments>`. The values of the variables are not on the command
48+// line: -e NAME takes them from the environment of docker, which gets them
49+// with the host paths translated to the container's (:248-254). args are the
50+// arguments of the command, already split.
51+func (j *job) execArgv(c *container, workDir string, env *ordered, command string, args []string) (argv []string, procEnv []string) {
52+ argv = []string{j.dockerPath(), "exec", "-i", "--workdir", workDir}
53+ cenv := newOrdered()
54+ for _, k := range env.Keys() {
55+ v, _ := env.Get(k)
56+ v = c.translateToContainer(v)
57+ // PATH is set below. The docker process is started by its absolute
58+ // path, so the variables of the container are not read by it, except
59+ // those the CLI itself reads.
60+ p := j.envForCLI(k, v, "")
61+ argv = append(argv, p.args...)
62+ for _, kv := range p.env {
63+ cenv.Set(k, strings.TrimPrefix(kv, k+"="))
64+ }
65+ }
66+ j.mu.Lock()
67+ prepend := j.state.Prepend()
68+ j.mu.Unlock()
69+ if len(prepend) > 0 {
70+ rev := make([]string, len(prepend))
71+ for i, p := range prepend {
72+ rev[len(prepend)-1-i] = p
73+ }
74+ full := strings.Join(rev, ":")
75+ if c.runtimePath != "" {
76+ full += ":" + c.runtimePath
77+ }
78+ // The value is in the environment of docker.exec only: the process is
79+ // started by absolute path and `docker exec` runs nothing of its own.
80+ argv = append(argv, "-e", "PATH")
81+ cenv.Set("PATH", full)
82+ }
83+ argv = append(argv, c.id, command)
84+ argv = append(argv, args...)
85+ return argv, j.dockerProcessEnv(cenv)
86+}
87+
88+// containerScript prepares a run step for the container: the script is
89+// written on the host in RUNNER_TEMP, which the container has at /__w/_temp,
90+// and the shell gets that path.
91+func (j *job) containerScript(c *container, plan ShellPlan, in scriptIn, s *stepRun) (argv []string, err error) {
92+ sc, err := WriteScriptFor(j.tempRoot, plan, in.script, c.translateToContainer)
93+ if err != nil {
94+ return nil, err
95+ }
96+ j.mu.Lock()
97+ workspace := j.state.GitHub("workspace")
98+ j.mu.Unlock()
99+ wd := c.resolvePath(ResolveWorkingDirectory(workspace, in.workingDirectory))
100+ var rest []string
101+ if len(sc.Argv) > 1 {
102+ rest = sc.Argv[1:]
103+ }
104+ henv := j.handlerEnv(s.env)
105+ if err := checkEnvNames(henv.Keys()); err != nil {
106+ return nil, err
107+ }
108+ a, penv := j.execArgv(c, wd, henv, plan.CommandPath, rest)
109+ s.rawEnvList = penv
110+ return a, nil
111+}
@@ -0,0 +1,111 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+)
8+
9+// ContainerStepHost (Runner.Worker/Handlers/StepHost.cs:102-296): the steps of
10+// a job that has a container run through `docker exec` in it.
11+
12+// stepContainer is the job container when steps run in it, else nil.
13+func (j *job) stepContainer() *container {
14+ if j.cs == nil {
15+ return nil
16+ }
17+ return j.cs.job
18+}
19+
20+// containerReady is the check of ActionRunner.cs:157-164: the container must
21+// exist. After a failed "Initialize containers" an always() step gets the
22+// runner's error.
23+func (j *job) containerReady() error {
24+ if c := j.stepContainer(); c != nil && c.id == "" {
25+ return ghErrorf("Value cannot be null. (Parameter 'ContainerId')")
26+ }
27+ return nil
28+}
29+
30+// hostEnv is the environment a handler gives its process: the step's
31+// variables then the runtime variables of the contexts (GITHUB_*, RUNNER_*)
32+// (ScriptHandler.cs:289-299). It is what `docker exec -e` passes on.
33+func (j *job) handlerEnv(stepEnv *ordered) *ordered {
34+ env := stepEnv.Clone()
35+ j.mu.Lock()
36+ rt := j.state.RuntimeEnv()
37+ j.mu.Unlock()
38+ for _, k := range rt.Keys() {
39+ v, _ := rt.Get(k)
40+ env.Set(k, v)
41+ }
42+ return env
43+}
44+
45+// execArgv is ContainerStepHost.ExecuteAsync (StepHost.cs:196-246):
46+// `docker exec -i --workdir <dir> -e NAME... [-e PATH="<prepend>:<PATH>"] <id>
47+// <command> <arguments>`. The values of the variables are not on the command
48+// line: -e NAME takes them from the environment of docker, which gets them
49+// with the host paths translated to the container's (:248-254). args are the
50+// arguments of the command, already split.
51+func (j *job) execArgv(c *container, workDir string, env *ordered, command string, args []string) (argv []string, procEnv []string) {
52+ argv = []string{j.dockerPath(), "exec", "-i", "--workdir", workDir}
53+ cenv := newOrdered()
54+ for _, k := range env.Keys() {
55+ v, _ := env.Get(k)
56+ v = c.translateToContainer(v)
57+ // PATH is set below. The docker process is started by its absolute
58+ // path, so the variables of the container are not read by it, except
59+ // those the CLI itself reads.
60+ p := j.envForCLI(k, v, "")
61+ argv = append(argv, p.args...)
62+ for _, kv := range p.env {
63+ cenv.Set(k, strings.TrimPrefix(kv, k+"="))
64+ }
65+ }
66+ j.mu.Lock()
67+ prepend := j.state.Prepend()
68+ j.mu.Unlock()
69+ if len(prepend) > 0 {
70+ rev := make([]string, len(prepend))
71+ for i, p := range prepend {
72+ rev[len(prepend)-1-i] = p
73+ }
74+ full := strings.Join(rev, ":")
75+ if c.runtimePath != "" {
76+ full += ":" + c.runtimePath
77+ }
78+ // The value is in the environment of docker.exec only: the process is
79+ // started by absolute path and `docker exec` runs nothing of its own.
80+ argv = append(argv, "-e", "PATH")
81+ cenv.Set("PATH", full)
82+ }
83+ argv = append(argv, c.id, command)
84+ argv = append(argv, args...)
85+ return argv, j.dockerProcessEnv(cenv)
86+}
87+
88+// containerScript prepares a run step for the container: the script is
89+// written on the host in RUNNER_TEMP, which the container has at /__w/_temp,
90+// and the shell gets that path.
91+func (j *job) containerScript(c *container, plan ShellPlan, in scriptIn, s *stepRun) (argv []string, err error) {
92+ sc, err := WriteScriptFor(j.tempRoot, plan, in.script, c.translateToContainer)
93+ if err != nil {
94+ return nil, err
95+ }
96+ j.mu.Lock()
97+ workspace := j.state.GitHub("workspace")
98+ j.mu.Unlock()
99+ wd := c.resolvePath(ResolveWorkingDirectory(workspace, in.workingDirectory))
100+ var rest []string
101+ if len(sc.Argv) > 1 {
102+ rest = sc.Argv[1:]
103+ }
104+ henv := j.handlerEnv(s.env)
105+ if err := checkEnvNames(henv.Keys()); err != nil {
106+ return nil, err
107+ }
108+ a, penv := j.execArgv(c, wd, henv, plan.CommandPath, rest)
109+ s.rawEnvList = penv
110+ return a, nil
111+}
A internal/executor/containers_exec_test.go +153 -0
@@ -0,0 +1,153 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+// execCalls are the `docker exec` calls recorded by the fake.
11+func (f *fakeCtr) execCalls(t *testing.T) [][]string {
12+ var out [][]string
13+ for _, c := range f.calls(t) {
14+ if c[0] == "exec" {
15+ out = append(out, c)
16+ }
17+ }
18+ return out
19+}
20+
21+func TestRunStepThroughDockerExec(t *testing.T) {
22+ f := newFakeCtr(t)
23+ f.set(t, "exec_out", "hello from the container\n")
24+ var work string
25+ rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n CVAR: c\n",
26+ " - name: s\n working-directory: sub\n env:\n FOO: bar\n run: echo hi\n", func(c *Config) { work = c.Layout.WorkRoot })
27+ if res.Conclusion != ConclusionSuccess {
28+ t.Fatalf("%+v\n%s", res, logOf(rec.step(t, "Initialize containers")))
29+ }
30+ if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|s|Stop containers|Complete job" {
31+ t.Fatalf("steps %s", got)
32+ }
33+ ex := f.execCalls(t)
34+ if len(ex) != 1 {
35+ t.Fatalf("exec calls %v", ex)
36+ }
37+ c := strings.Join(ex[0], "\n")
38+ id := strings.Repeat("0", 63) + "1"
39+ for _, w := range []string{
40+ "exec\n-i\n--workdir\n/__w/hello/hello/sub\n-e\nFOO\n", "\n-e\nGITHUB_WORKSPACE\n", "\n-e\nGITHUB_OUTPUT\n",
41+ "\n" + id + "\nsh\n-e\n/__w/_temp/",
42+ } {
43+ if !strings.Contains(c, w) {
44+ t.Errorf("exec lacks %q:\n%s", w, c)
45+ }
46+ }
47+ if strings.Contains(c, "bar") || strings.Contains(c, work) {
48+ t.Errorf("values and host paths stay off the command line:\n%s", c)
49+ }
50+ log := logOf(rec.step(t, "s"))
51+ if !strings.Contains(log, "shell: sh -e {0}\n") || !strings.Contains(log, "hello from the container") {
52+ t.Errorf("log:\n%s", log)
53+ }
54+ // The create command of the job container.
55+ create := logOf(rec.step(t, "Initialize containers"))
56+ for _, w := range []string{
57+ "--workdir /__w/hello/hello --network github_network_", " -e \"CVAR=c\" -e \"HOME=/github/home\" -e GITHUB_ACTIONS=true -e CI=true -v \"/var/run/docker.sock\":\"/var/run/docker.sock\" -v \"" + work + "\":\"/__w\"",
58+ "\":\"/__e\":ro -v \"" + work + "/_temp\":\"/__w/_temp\"", "\":\"/__t\" -v \"" + work + "/_temp/_github_home\":\"/github/home\" -v \"" + work + "/_temp/_github_workflow\":\"/github/workflow\" --entrypoint \"tail\" ubuntu:24.04 \"-f\" \"/dev/null\"",
59+ } {
60+ if !strings.Contains(create, w) {
61+ t.Errorf("create lacks %q:\n%s", w, create)
62+ }
63+ }
64+}
65+
66+func TestRunEnvIsTranslatedForTheContainer(t *testing.T) {
67+ f := newFakeCtr(t)
68+ f.set(t, "exec_env", "1")
69+ rec, res := ctrRun(t, f, " container: ubuntu:24.04\n",
70+ " - name: s\n run: echo hi\n", nil)
71+ if res.Conclusion != ConclusionSuccess {
72+ t.Fatalf("%+v %v", res, rec.names())
73+ }
74+ // HOME is the container's own (/github/home): it is not passed with -e.
75+ if c := strings.Join(f.execCalls(t)[0], "\n"); strings.Contains(c, "\nHOME\n") {
76+ t.Errorf("HOME must not be passed:\n%s", c)
77+ }
78+ log := logOf(rec.step(t, "s"))
79+ for _, w := range []string{
80+ "ENV GITHUB_WORKSPACE=/__w/hello/hello", "RUNNER_TEMP=/__w/_temp",
81+ "GITHUB_OUTPUT=/__w/_temp/_runner_file_commands/set_output_", "GITHUB_EVENT_PATH=/github/workflow/event.json",
82+ } {
83+ if !strings.Contains(log, w) {
84+ t.Errorf("log lacks %q:\n%s", w, log)
85+ }
86+ }
87+}
88+
89+func TestNodeActionInContainerUsesExternalsAndAlpine(t *testing.T) {
90+ for _, tc := range []struct{ release, node string }{{"ID=ubuntu\nVERSION_ID=\"24.04\"\n", "node24"}, {"ID=alpine\nVERSION_ID=3.23.4\n", "node24_alpine"}} {
91+ f := newFakeCtr(t)
92+ f.set(t, "exec_out", tc.release)
93+ tree := files{
94+ ".github/actions/x/action.yml": "name: x\nruns:\n using: node24\n main: index.js\n",
95+ ".github/actions/x/index.js": "x\n",
96+ }
97+ rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - name: a\n uses: ./.github/actions/x\n", func(c *Config) {
98+ writeFiles(t, c.Layout.Workspace, tree)
99+ })
100+ if res.Conclusion != ConclusionSuccess {
101+ t.Fatalf("%s: %+v\n%s", tc.node, res, logOf(rec.step(t, "a")))
102+ }
103+ ex := f.execCalls(t)
104+ if len(ex) != 2 {
105+ t.Fatalf("%s: exec calls %v", tc.node, ex)
106+ }
107+ if got := strings.Join(ex[0][len(ex[0])-4:len(ex[0])-1], " "); got != "sh -c "+alpineCheck {
108+ t.Errorf("probe: %v", ex[0])
109+ }
110+ run := strings.Join(ex[1], "\n")
111+ if !strings.Contains(run, "\n/__e/"+tc.node+"/bin/node\n") || !strings.Contains(run, "\n/__w/hello/hello/.github/actions/x/index.js") {
112+ t.Errorf("%s: node call:\n%s", tc.node, run)
113+ }
114+ if got := logOf(rec.step(t, "a")); !strings.Contains(got, "exec "+strings.Repeat("0", 63)+"1 sh -c \"cat /etc/*release | grep ^ID\"") {
115+ t.Errorf("probe line missing:\n%s", got)
116+ }
117+ }
118+}
119+
120+func TestCommandPathsFromTheContainerAreTranslated(t *testing.T) {
121+ f := newFakeCtr(t)
122+ // The step prints an annotation for a file at its container path.
123+ f.set(t, "exec_out", "::error file=/__w/hello/hello/src/a.go,line=3::boom\n")
124+ rec, res := ctrRun(t, f, ctrJob, " - name: s\n run: echo\n", nil)
125+ if res.Conclusion != ConclusionSuccess {
126+ t.Fatalf("%+v", res)
127+ }
128+ log := logOf(rec.step(t, "s"))
129+ if !strings.Contains(log, "##[error]boom") {
130+ t.Errorf("log:\n%s", log)
131+ }
132+}
133+
134+func TestDockerActionJoinsTheJobNetworkAndContextHasContainerKey(t *testing.T) {
135+ f := newFakeCtr(t)
136+ rec, res := ctrRun(t, f, " services:\n redis:\n image: redis:7\n",
137+ " - uses: docker://alpine:3.20\n - name: ctx\n run: echo \"${{ toJSON(job.container) }}\"\n", nil)
138+ if res.Conclusion != ConclusionSuccess {
139+ t.Fatalf("%+v %v", res, rec.names())
140+ }
141+ var run []string
142+ for _, c := range f.calls(t) {
143+ if c[0] == "run" {
144+ run = c
145+ }
146+ }
147+ if run == nil || !strings.Contains(strings.Join(run, "\n"), "\n--network\ngithub_network_") {
148+ t.Errorf("docker run without the job network: %v", run)
149+ }
150+ if got := logOf(rec.step(t, "ctx")); !strings.Contains(got, "\"network\": \"github_network_") {
151+ t.Errorf("ctx:\n%s", got)
152+ }
153+}
@@ -0,0 +1,153 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+// execCalls are the `docker exec` calls recorded by the fake.
11+func (f *fakeCtr) execCalls(t *testing.T) [][]string {
12+ var out [][]string
13+ for _, c := range f.calls(t) {
14+ if c[0] == "exec" {
15+ out = append(out, c)
16+ }
17+ }
18+ return out
19+}
20+
21+func TestRunStepThroughDockerExec(t *testing.T) {
22+ f := newFakeCtr(t)
23+ f.set(t, "exec_out", "hello from the container\n")
24+ var work string
25+ rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n CVAR: c\n",
26+ " - name: s\n working-directory: sub\n env:\n FOO: bar\n run: echo hi\n", func(c *Config) { work = c.Layout.WorkRoot })
27+ if res.Conclusion != ConclusionSuccess {
28+ t.Fatalf("%+v\n%s", res, logOf(rec.step(t, "Initialize containers")))
29+ }
30+ if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|s|Stop containers|Complete job" {
31+ t.Fatalf("steps %s", got)
32+ }
33+ ex := f.execCalls(t)
34+ if len(ex) != 1 {
35+ t.Fatalf("exec calls %v", ex)
36+ }
37+ c := strings.Join(ex[0], "\n")
38+ id := strings.Repeat("0", 63) + "1"
39+ for _, w := range []string{
40+ "exec\n-i\n--workdir\n/__w/hello/hello/sub\n-e\nFOO\n", "\n-e\nGITHUB_WORKSPACE\n", "\n-e\nGITHUB_OUTPUT\n",
41+ "\n" + id + "\nsh\n-e\n/__w/_temp/",
42+ } {
43+ if !strings.Contains(c, w) {
44+ t.Errorf("exec lacks %q:\n%s", w, c)
45+ }
46+ }
47+ if strings.Contains(c, "bar") || strings.Contains(c, work) {
48+ t.Errorf("values and host paths stay off the command line:\n%s", c)
49+ }
50+ log := logOf(rec.step(t, "s"))
51+ if !strings.Contains(log, "shell: sh -e {0}\n") || !strings.Contains(log, "hello from the container") {
52+ t.Errorf("log:\n%s", log)
53+ }
54+ // The create command of the job container.
55+ create := logOf(rec.step(t, "Initialize containers"))
56+ for _, w := range []string{
57+ "--workdir /__w/hello/hello --network github_network_", " -e \"CVAR=c\" -e \"HOME=/github/home\" -e GITHUB_ACTIONS=true -e CI=true -v \"/var/run/docker.sock\":\"/var/run/docker.sock\" -v \"" + work + "\":\"/__w\"",
58+ "\":\"/__e\":ro -v \"" + work + "/_temp\":\"/__w/_temp\"", "\":\"/__t\" -v \"" + work + "/_temp/_github_home\":\"/github/home\" -v \"" + work + "/_temp/_github_workflow\":\"/github/workflow\" --entrypoint \"tail\" ubuntu:24.04 \"-f\" \"/dev/null\"",
59+ } {
60+ if !strings.Contains(create, w) {
61+ t.Errorf("create lacks %q:\n%s", w, create)
62+ }
63+ }
64+}
65+
66+func TestRunEnvIsTranslatedForTheContainer(t *testing.T) {
67+ f := newFakeCtr(t)
68+ f.set(t, "exec_env", "1")
69+ rec, res := ctrRun(t, f, " container: ubuntu:24.04\n",
70+ " - name: s\n run: echo hi\n", nil)
71+ if res.Conclusion != ConclusionSuccess {
72+ t.Fatalf("%+v %v", res, rec.names())
73+ }
74+ // HOME is the container's own (/github/home): it is not passed with -e.
75+ if c := strings.Join(f.execCalls(t)[0], "\n"); strings.Contains(c, "\nHOME\n") {
76+ t.Errorf("HOME must not be passed:\n%s", c)
77+ }
78+ log := logOf(rec.step(t, "s"))
79+ for _, w := range []string{
80+ "ENV GITHUB_WORKSPACE=/__w/hello/hello", "RUNNER_TEMP=/__w/_temp",
81+ "GITHUB_OUTPUT=/__w/_temp/_runner_file_commands/set_output_", "GITHUB_EVENT_PATH=/github/workflow/event.json",
82+ } {
83+ if !strings.Contains(log, w) {
84+ t.Errorf("log lacks %q:\n%s", w, log)
85+ }
86+ }
87+}
88+
89+func TestNodeActionInContainerUsesExternalsAndAlpine(t *testing.T) {
90+ for _, tc := range []struct{ release, node string }{{"ID=ubuntu\nVERSION_ID=\"24.04\"\n", "node24"}, {"ID=alpine\nVERSION_ID=3.23.4\n", "node24_alpine"}} {
91+ f := newFakeCtr(t)
92+ f.set(t, "exec_out", tc.release)
93+ tree := files{
94+ ".github/actions/x/action.yml": "name: x\nruns:\n using: node24\n main: index.js\n",
95+ ".github/actions/x/index.js": "x\n",
96+ }
97+ rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - name: a\n uses: ./.github/actions/x\n", func(c *Config) {
98+ writeFiles(t, c.Layout.Workspace, tree)
99+ })
100+ if res.Conclusion != ConclusionSuccess {
101+ t.Fatalf("%s: %+v\n%s", tc.node, res, logOf(rec.step(t, "a")))
102+ }
103+ ex := f.execCalls(t)
104+ if len(ex) != 2 {
105+ t.Fatalf("%s: exec calls %v", tc.node, ex)
106+ }
107+ if got := strings.Join(ex[0][len(ex[0])-4:len(ex[0])-1], " "); got != "sh -c "+alpineCheck {
108+ t.Errorf("probe: %v", ex[0])
109+ }
110+ run := strings.Join(ex[1], "\n")
111+ if !strings.Contains(run, "\n/__e/"+tc.node+"/bin/node\n") || !strings.Contains(run, "\n/__w/hello/hello/.github/actions/x/index.js") {
112+ t.Errorf("%s: node call:\n%s", tc.node, run)
113+ }
114+ if got := logOf(rec.step(t, "a")); !strings.Contains(got, "exec "+strings.Repeat("0", 63)+"1 sh -c \"cat /etc/*release | grep ^ID\"") {
115+ t.Errorf("probe line missing:\n%s", got)
116+ }
117+ }
118+}
119+
120+func TestCommandPathsFromTheContainerAreTranslated(t *testing.T) {
121+ f := newFakeCtr(t)
122+ // The step prints an annotation for a file at its container path.
123+ f.set(t, "exec_out", "::error file=/__w/hello/hello/src/a.go,line=3::boom\n")
124+ rec, res := ctrRun(t, f, ctrJob, " - name: s\n run: echo\n", nil)
125+ if res.Conclusion != ConclusionSuccess {
126+ t.Fatalf("%+v", res)
127+ }
128+ log := logOf(rec.step(t, "s"))
129+ if !strings.Contains(log, "##[error]boom") {
130+ t.Errorf("log:\n%s", log)
131+ }
132+}
133+
134+func TestDockerActionJoinsTheJobNetworkAndContextHasContainerKey(t *testing.T) {
135+ f := newFakeCtr(t)
136+ rec, res := ctrRun(t, f, " services:\n redis:\n image: redis:7\n",
137+ " - uses: docker://alpine:3.20\n - name: ctx\n run: echo \"${{ toJSON(job.container) }}\"\n", nil)
138+ if res.Conclusion != ConclusionSuccess {
139+ t.Fatalf("%+v %v", res, rec.names())
140+ }
141+ var run []string
142+ for _, c := range f.calls(t) {
143+ if c[0] == "run" {
144+ run = c
145+ }
146+ }
147+ if run == nil || !strings.Contains(strings.Join(run, "\n"), "\n--network\ngithub_network_") {
148+ t.Errorf("docker run without the job network: %v", run)
149+ }
150+ if got := logOf(rec.step(t, "ctx")); !strings.Contains(got, "\"network\": \"github_network_") {
151+ t.Errorf("ctx:\n%s", got)
152+ }
153+}
A internal/executor/containers_fake_test.go +130 -0
@@ -0,0 +1,130 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+)
11+
12+// fakeCtrDocker is a docker CLI for the container steps: it records every
13+// call (one argument per line between "--" markers, like fakeDocker) and
14+// answers like the real one. Behaviour is chosen by files in its directory:
15+//
16+// pull_fail N: the first N pulls fail
17+// health words, one per inspect: starting, healthy, unhealthy
18+// create_fail docker create exits 125
19+// exec_out what docker exec prints
20+// hang_exec docker exec waits until it gets a signal
21+type fakeCtr struct {
22+ dir, bin, log string
23+}
24+
25+func newFakeCtr(t *testing.T) *fakeCtr {
26+ t.Helper()
27+ d := t.TempDir()
28+ f := &fakeCtr{dir: d, bin: filepath.Join(d, "docker"), log: filepath.Join(d, "calls")}
29+ script := `#!/bin/sh
30+D=` + d + `
31+{ echo "--"; for a in "$@"; do printf "%s\n" "$a"; done; echo "ENV:CVAR=$CVAR:EMPTYV=${EMPTYV-unset}:PATH=$PATH"; } >> $D/calls
32+[ "$1" = "--config" ] && { CFG=$2; shift 2; }
33+hang() { if [ -f $D/hang_ignore ]; then trap '' INT; trap 'exit 143' TERM; else trap 'exit 130' INT TERM; fi; while :; do sleep 0.05; done; }
34+[ -f $D/hang_$1 ] && hang
35+if [ -f $D/hang_for ]; then case "$*" in *"$(cat $D/hang_for)"*) hang ;; esac; fi
36+case "$1" in
37+version) echo "'1.48'" ;;
38+ps) case "$*" in *--quiet*) [ -f $D/created ] && cat $D/created ;; *status=running*) id=$(echo "$*" | sed 's/.*id=\([^ ]*\).*/\1/'); echo "$id Up Less than a second" ;; esac ;;
39+network) if [ "$2" = rm ]; then
40+ [ -f $D/netrm_fail ] && { echo "Error response from daemon: network is busy"; exit 1; }
41+ [ -f $D/netrm_absent ] && { echo "Error response from daemon: network $3 not found"; exit 1; }
42+ fi ;;
43+login) cat > $D/login_stdin
44+ if [ -f $D/lock_cfg ]; then mkdir -p $CFG/sub; : > $CFG/sub/f; chmod 500 $CFG/sub; fi ;;
45+pull) if [ -f $D/pull_fail ]; then n=$(cat $D/pull_fail); if [ "$n" -gt 0 ]; then echo $((n-1)) > $D/pull_fail; echo "pull denied"; exit 1; fi; fi; echo "pulled $2" ;;
46+create) [ -f $D/create_fail ] && { echo "unknown flag"; exit 125; }
47+ [ -f $D/create_out ] && { cat $D/create_out; exit 0; }
48+ n=$(cat $D/seq 2>/dev/null || echo 0); n=$((n+1)); echo $n > $D/seq; id=$(printf "%064d" $n); echo $id >> $D/created
49+ [ -f $D/hang_create_after ] && hang
50+ echo $id ;;
51+start) echo "$2" ;;
52+inspect) case "$*" in
53+ *Healthcheck*) if [ -s $D/health ]; then w=$(head -1 $D/health); sed -i 1d $D/health; echo "$w"; fi ;;
54+ *Config.Env*) printf 'HOME=/github/home\nGITHUB_ACTIONS=true\nPATH=/usr/bin:/bin\n' ;;
55+ esac ;;
56+port) [ -f $D/ports ] && cat $D/ports ;;
57+logs) echo "service log of $3" ;;
58+rm) [ -f $D/rm_fail ] && { echo "Error response from daemon: cannot remove container: boom"; exit 1; }
59+ [ -f $D/rm_absent ] && { echo "Error response from daemon: No such container: $3"; exit 1; }
60+ [ -f $D/created ] && { grep -v "^$3$" $D/created > $D/created.n; mv $D/created.n $D/created; }
61+ echo "$3" ;;
62+exec) if [ -f $D/add_path ]; then f=$(echo "$GITHUB_PATH" | sed "s#^/__w#$(cat $D/add_path)#"); echo /added >> "$f"; fi
63+ [ -f $D/exec_env ] && echo "ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE RUNNER_TEMP=$RUNNER_TEMP HOME=${HOME:-<unset>} GITHUB_OUTPUT=$GITHUB_OUTPUT GITHUB_EVENT_PATH=$GITHUB_EVENT_PATH"
64+ [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; }
65+ [ -f $D/exec_out ] && cat $D/exec_out
66+ if [ -f $D/exec_exit ]; then n=$(cat $D/exec_exit); [ "$n" != 0 ] && echo 0 > $D/exec_exit && exit $n; fi ;;
67+esac
68+exit 0
69+`
70+ if err := os.WriteFile(f.bin, []byte(script), 0o755); err != nil {
71+ t.Fatal(err)
72+ }
73+ return f
74+}
75+
76+func (f *fakeCtr) set(t *testing.T, name, content string) {
77+ t.Helper()
78+ if err := os.WriteFile(filepath.Join(f.dir, name), []byte(content), 0o644); err != nil {
79+ t.Fatal(err)
80+ }
81+}
82+
83+func (f *fakeCtr) calls(t *testing.T) [][]string { return calls(t, f.log) }
84+
85+// verbs are the docker subcommands called, in order.
86+func (f *fakeCtr) verbs(t *testing.T) []string {
87+ var out []string
88+ for _, c := range f.calls(t) {
89+ if c[0] == "--config" && len(c) > 2 {
90+ c = c[2:]
91+ }
92+ v := c[0]
93+ if v == "network" && len(c) > 1 {
94+ v += " " + c[1]
95+ }
96+ out = append(out, v)
97+ }
98+ return out
99+}
100+
101+// ctrRun runs a workflow whose job has the given container/services YAML.
102+func ctrRun(t *testing.T, f *fakeCtr, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) {
103+ t.Helper()
104+ return ctrRunC(t, f, nil, jobYAML, steps, tweak)
105+}
106+
107+func ctrRunC(t *testing.T, f *fakeCtr, cancel *Canceller, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) {
108+ t.Helper()
109+ needTools(t, "bash")
110+ return runLocalC(t, cancel, wfHead+jobYAML+" steps:\n"+steps, "j", func(c *Config) {
111+ c.Actions.DockerPath = f.bin
112+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
113+ c.Actions.ExternalsDir = fakeNode(t)
114+ if tweak != nil {
115+ tweak(c)
116+ }
117+ })
118+}
119+
120+// concl is the conclusion of the step called name.
121+func (r *recorder) concl(name string) Conclusion {
122+ r.mu.Lock()
123+ defer r.mu.Unlock()
124+ for _, s := range r.steps {
125+ if s.Name == name {
126+ return s.Conclusion
127+ }
128+ }
129+ return ""
130+}
@@ -0,0 +1,130 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+)
11+
12+// fakeCtrDocker is a docker CLI for the container steps: it records every
13+// call (one argument per line between "--" markers, like fakeDocker) and
14+// answers like the real one. Behaviour is chosen by files in its directory:
15+//
16+// pull_fail N: the first N pulls fail
17+// health words, one per inspect: starting, healthy, unhealthy
18+// create_fail docker create exits 125
19+// exec_out what docker exec prints
20+// hang_exec docker exec waits until it gets a signal
21+type fakeCtr struct {
22+ dir, bin, log string
23+}
24+
25+func newFakeCtr(t *testing.T) *fakeCtr {
26+ t.Helper()
27+ d := t.TempDir()
28+ f := &fakeCtr{dir: d, bin: filepath.Join(d, "docker"), log: filepath.Join(d, "calls")}
29+ script := `#!/bin/sh
30+D=` + d + `
31+{ echo "--"; for a in "$@"; do printf "%s\n" "$a"; done; echo "ENV:CVAR=$CVAR:EMPTYV=${EMPTYV-unset}:PATH=$PATH"; } >> $D/calls
32+[ "$1" = "--config" ] && { CFG=$2; shift 2; }
33+hang() { if [ -f $D/hang_ignore ]; then trap '' INT; trap 'exit 143' TERM; else trap 'exit 130' INT TERM; fi; while :; do sleep 0.05; done; }
34+[ -f $D/hang_$1 ] && hang
35+if [ -f $D/hang_for ]; then case "$*" in *"$(cat $D/hang_for)"*) hang ;; esac; fi
36+case "$1" in
37+version) echo "'1.48'" ;;
38+ps) case "$*" in *--quiet*) [ -f $D/created ] && cat $D/created ;; *status=running*) id=$(echo "$*" | sed 's/.*id=\([^ ]*\).*/\1/'); echo "$id Up Less than a second" ;; esac ;;
39+network) if [ "$2" = rm ]; then
40+ [ -f $D/netrm_fail ] && { echo "Error response from daemon: network is busy"; exit 1; }
41+ [ -f $D/netrm_absent ] && { echo "Error response from daemon: network $3 not found"; exit 1; }
42+ fi ;;
43+login) cat > $D/login_stdin
44+ if [ -f $D/lock_cfg ]; then mkdir -p $CFG/sub; : > $CFG/sub/f; chmod 500 $CFG/sub; fi ;;
45+pull) if [ -f $D/pull_fail ]; then n=$(cat $D/pull_fail); if [ "$n" -gt 0 ]; then echo $((n-1)) > $D/pull_fail; echo "pull denied"; exit 1; fi; fi; echo "pulled $2" ;;
46+create) [ -f $D/create_fail ] && { echo "unknown flag"; exit 125; }
47+ [ -f $D/create_out ] && { cat $D/create_out; exit 0; }
48+ n=$(cat $D/seq 2>/dev/null || echo 0); n=$((n+1)); echo $n > $D/seq; id=$(printf "%064d" $n); echo $id >> $D/created
49+ [ -f $D/hang_create_after ] && hang
50+ echo $id ;;
51+start) echo "$2" ;;
52+inspect) case "$*" in
53+ *Healthcheck*) if [ -s $D/health ]; then w=$(head -1 $D/health); sed -i 1d $D/health; echo "$w"; fi ;;
54+ *Config.Env*) printf 'HOME=/github/home\nGITHUB_ACTIONS=true\nPATH=/usr/bin:/bin\n' ;;
55+ esac ;;
56+port) [ -f $D/ports ] && cat $D/ports ;;
57+logs) echo "service log of $3" ;;
58+rm) [ -f $D/rm_fail ] && { echo "Error response from daemon: cannot remove container: boom"; exit 1; }
59+ [ -f $D/rm_absent ] && { echo "Error response from daemon: No such container: $3"; exit 1; }
60+ [ -f $D/created ] && { grep -v "^$3$" $D/created > $D/created.n; mv $D/created.n $D/created; }
61+ echo "$3" ;;
62+exec) if [ -f $D/add_path ]; then f=$(echo "$GITHUB_PATH" | sed "s#^/__w#$(cat $D/add_path)#"); echo /added >> "$f"; fi
63+ [ -f $D/exec_env ] && echo "ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE RUNNER_TEMP=$RUNNER_TEMP HOME=${HOME:-<unset>} GITHUB_OUTPUT=$GITHUB_OUTPUT GITHUB_EVENT_PATH=$GITHUB_EVENT_PATH"
64+ [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; }
65+ [ -f $D/exec_out ] && cat $D/exec_out
66+ if [ -f $D/exec_exit ]; then n=$(cat $D/exec_exit); [ "$n" != 0 ] && echo 0 > $D/exec_exit && exit $n; fi ;;
67+esac
68+exit 0
69+`
70+ if err := os.WriteFile(f.bin, []byte(script), 0o755); err != nil {
71+ t.Fatal(err)
72+ }
73+ return f
74+}
75+
76+func (f *fakeCtr) set(t *testing.T, name, content string) {
77+ t.Helper()
78+ if err := os.WriteFile(filepath.Join(f.dir, name), []byte(content), 0o644); err != nil {
79+ t.Fatal(err)
80+ }
81+}
82+
83+func (f *fakeCtr) calls(t *testing.T) [][]string { return calls(t, f.log) }
84+
85+// verbs are the docker subcommands called, in order.
86+func (f *fakeCtr) verbs(t *testing.T) []string {
87+ var out []string
88+ for _, c := range f.calls(t) {
89+ if c[0] == "--config" && len(c) > 2 {
90+ c = c[2:]
91+ }
92+ v := c[0]
93+ if v == "network" && len(c) > 1 {
94+ v += " " + c[1]
95+ }
96+ out = append(out, v)
97+ }
98+ return out
99+}
100+
101+// ctrRun runs a workflow whose job has the given container/services YAML.
102+func ctrRun(t *testing.T, f *fakeCtr, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) {
103+ t.Helper()
104+ return ctrRunC(t, f, nil, jobYAML, steps, tweak)
105+}
106+
107+func ctrRunC(t *testing.T, f *fakeCtr, cancel *Canceller, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) {
108+ t.Helper()
109+ needTools(t, "bash")
110+ return runLocalC(t, cancel, wfHead+jobYAML+" steps:\n"+steps, "j", func(c *Config) {
111+ c.Actions.DockerPath = f.bin
112+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
113+ c.Actions.ExternalsDir = fakeNode(t)
114+ if tweak != nil {
115+ tweak(c)
116+ }
117+ })
118+}
119+
120+// concl is the conclusion of the step called name.
121+func (r *recorder) concl(name string) Conclusion {
122+ r.mu.Lock()
123+ defer r.mu.Unlock()
124+ for _, s := range r.steps {
125+ if s.Name == name {
126+ return s.Conclusion
127+ }
128+ }
129+ return ""
130+}
A internal/executor/containers_init.go +306 -0
@@ -0,0 +1,306 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "math/rand/v2"
8+ "os"
9+ "path/filepath"
10+ "regexp"
11+ "slices"
12+ "strconv"
13+ "strings"
14+ "sync"
15+ "time"
16+)
17+
18+// containerSet is the containers of a job: the job container, if any, then the
19+// services (JobExtension.cs:322-326: the order of the list the provider gets).
20+type containerSet struct {
21+ job *container
22+ services []*container
23+ network string
24+
25+ // What the cleanup needs, recorded BEFORE the command that creates it, so
26+ // that a command whose outcome is not known (a timeout, a cancellation, a
27+ // lost connection) can still be cleaned: the names are chosen here, not by
28+ // docker. mu guards them; cleanupMu serialises the cleanups ("Stop
29+ // containers" and the fallback of the job).
30+ mu sync.Mutex
31+ cleanupMu sync.Mutex
32+ networkTry string // network name, set before docker network create
33+ netRemoved bool // the network is gone
34+ dirs []string // docker --config directories that could not be removed
35+ // deadline is the end of the cleanup budget, fixed by the first cleanup of
36+ // the job: "Stop containers" and the fallback share it, so the cleanup of a
37+ // job is bounded as a whole.
38+ deadline time.Time
39+}
40+
41+func (cs *containerSet) tryNetwork(name string) { cs.mu.Lock(); cs.networkTry = name; cs.mu.Unlock() }
42+
43+func (cs *containerSet) addDir(d string) {
44+ cs.mu.Lock()
45+ defer cs.mu.Unlock()
46+ if !slices.Contains(cs.dirs, d) {
47+ cs.dirs = append(cs.dirs, d)
48+ }
49+}
50+
51+// delDir forgets a directory that is gone.
52+func (cs *containerSet) delDir(d string) {
53+ cs.mu.Lock()
54+ cs.dirs = slices.DeleteFunc(cs.dirs, func(x string) bool { return x == d })
55+ cs.mu.Unlock()
56+}
57+
58+func (cs *containerSet) all() []*container {
59+ var l []*container
60+ if cs.job != nil {
61+ l = append(l, cs.job)
62+ }
63+ return append(l, cs.services...)
64+}
65+
66+// minDockerAPI is requiredDockerEngineAPIVersion (ContainerOperationProvider.cs
67+// :591): Docker-CE 17.12.
68+var minDockerAPI = [2]int{1, 35}
69+
70+var (
71+ apiVersionRe = regexp.MustCompile(`(?i)\d+\.\d+(\.\d+)?`)
72+ portLineRe = regexp.MustCompile(`^(\d+)/(\w+) -> (.+):(\d+)$`)
73+)
74+
75+// initContainersStep is the "Initialize containers" step
76+// (JobExtension.cs:328-331), run if success().
77+func (j *job) initContainersStep() qstep {
78+ return qstep{name: "Initialize containers", setup: func(s *stepRun, cancel <-chan struct{}) stepResult {
79+ return j.initContainers(s, cancel)
80+ }}
81+}
82+
83+// result turns the error of a step body into the result StepsRunner gives it
84+// (StepsRunner.cs:314-340): a cancellation is logged and cancels the step,
85+// anything else is logged with its message and fails it.
86+func (j *job) containerResult(s *stepRun, err error) stepResult {
87+ if err == nil {
88+ return resSucceeded
89+ }
90+ if err == errCancelled {
91+ return j.cancelledStep(s)
92+ }
93+ j.errorf(s.num, "%s", err.Error())
94+ return resFailed
95+}
96+
97+// initContainers is ContainerOperationProvider.StartContainersAsync
98+// (:40-103). The step that stops the containers is registered first, so that
99+// it runs even when this one fails (:56-60).
100+func (j *job) initContainers(s *stepRun, cancel <-chan struct{}) stepResult {
101+ j.debug(s.num, "Register post job cleanup for stopping/deleting containers.")
102+ j.mu.Lock()
103+ j.postStack = append(j.postStack, qstep{name: "Stop containers", cond: "always()", setup: j.stopContainers})
104+ j.mu.Unlock()
105+ return j.containerResult(s, j.startContainers(s, cancel))
106+}
107+
108+func (j *job) startContainers(s *stepRun, cancel <-chan struct{}) error {
109+ num, cs := s.num, j.cs
110+ // AssertCompatibleOS (:559-571) refuses to run in a container because the
111+ // paths it mounts would be those of the container's host. The docker
112+ // daemon of a job runs next to the executor, in the same sandbox, so the
113+ // paths are right and the check is left out.
114+ if err := j.checkDockerVersion(s, cancel); err != nil {
115+ return err
116+ }
117+ j.out(num, "##[group]Clean up resources from previous jobs")
118+ label := j.dockerLabel()
119+ ps := j.newDockerCmd("ps", raw("--all", "--quiet", "--no-trunc"), quotedOpt("--filter", "label="+label))
120+ stale, err := j.dockerList(s, ps, cancel)
121+ if err != nil {
122+ return err
123+ }
124+ for _, id := range stale {
125+ code, err := j.dockerCode(s, j.newDockerCmd("rm", raw("--force", id)), cancel)
126+ if err != nil {
127+ return err
128+ }
129+ if code != 0 {
130+ j.tagged(num, "##[warning]", fmt.Sprintf("Delete stale containers failed, docker rm fail with exit code %d for container %s", code, id))
131+ }
132+ }
133+ prune := j.newDockerCmd("network", raw("prune", "--force"), quotedOpt("--filter", "label="+label))
134+ code, err := j.dockerCode(s, prune, cancel)
135+ if err != nil {
136+ return err
137+ }
138+ if code != 0 {
139+ j.tagged(num, "##[warning]", fmt.Sprintf("Delete stale container networks failed, docker network prune fail with exit code %d", code))
140+ }
141+ j.out(num, "##[endgroup]")
142+
143+ j.out(num, "##[group]Create local container network")
144+ network := "github_network_" + strings.ReplaceAll(newImageTag(""), ":", "")
145+ cs.tryNetwork(network)
146+ code, err = j.dockerCode(s, j.newDockerCmd("network", raw("create", "--label", label, network)), cancel)
147+ if err != nil {
148+ return err
149+ }
150+ if code != 0 {
151+ return ghErrorf("Docker network create failed with exit code %d", code)
152+ }
153+ j.mu.Lock()
154+ j.state.SetJobContainer("network", network)
155+ j.mu.Unlock()
156+ cs.network = network
157+ j.out(num, "##[endgroup]")
158+
159+ for _, c := range cs.all() {
160+ c.network = network
161+ if err := j.startContainer(s, c, cancel); err != nil {
162+ return err
163+ }
164+ }
165+ return j.healthchecks(s, cs.all(), cancel)
166+}
167+
168+// checkDockerVersion is AssertCompatibleOS' version check (:573-598) with
169+// DockerCommandManager.DockerVersion (:47-90), including its use of the
170+// server's text for the client version (:71, the runner's own slip).
171+func (j *job) checkDockerVersion(s *stepRun, cancel <-chan struct{}) error {
172+ num := s.num
173+ j.out(num, "##[group]Checking docker version")
174+ ver := func(which string) (string, error) {
175+ l, err := j.dockerList(s, j.newDockerCmd("version", raw("--format", "'{{."+which+".APIVersion}}'")), cancel)
176+ if err != nil {
177+ return "", err
178+ }
179+ if len(l) == 0 {
180+ return "", ghErrorf("Value cannot be null. (Parameter 'Docker.%s.Version')", which)
181+ }
182+ return l[0], nil
183+ }
184+ server, err := ver("Server")
185+ if err != nil {
186+ return err
187+ }
188+ j.out(num, "Docker daemon API version: "+server)
189+ client, err := ver("Client")
190+ if err != nil {
191+ return err
192+ }
193+ j.out(num, "Docker client API version: "+client)
194+ j.out(num, "##[endgroup]")
195+ v, ok := parseAPIVersion(server)
196+ if !ok {
197+ return ghErrorf("Value cannot be null. (Parameter 'ServerVersion')")
198+ }
199+ if v[0] < minDockerAPI[0] || (v[0] == minDockerAPI[0] && v[1] < minDockerAPI[1]) {
200+ return ghErrorf("Min required docker engine API server version is '%d.%d', your docker ('%s') server version is '%d.%d'",
201+ minDockerAPI[0], minDockerAPI[1], j.dockerPath(), v[0], v[1])
202+ }
203+ return nil
204+}
205+
206+func parseAPIVersion(s string) ([2]int, bool) {
207+ m := apiVersionRe.FindString(s)
208+ parts := strings.Split(m, ".")
209+ if len(parts) < 2 {
210+ return [2]int{}, false
211+ }
212+ a, e1 := strconv.Atoi(parts[0])
213+ b, e2 := strconv.Atoi(parts[1])
214+ return [2]int{a, b}, e1 == nil && e2 == nil
215+}
216+
217+// containerBackoff is BackoffTimerHelper.GetRandomBackoff(1 s, 10 s).
218+func (j *job) containerBackoff() time.Duration {
219+ if f := j.cfg.Actions.Backoff; f != nil {
220+ return f()
221+ }
222+ return time.Duration(1000+rand.IntN(9000)) * time.Millisecond
223+}
224+
225+// seconds is the way the runner prints a backoff, TimeSpan.TotalSeconds.
226+func seconds(d time.Duration) string { return strconv.FormatFloat(d.Seconds(), 'f', -1, 64) }
227+
228+// pullArgs builds `docker [--config dir] pull image`.
229+func (j *job) pullCmd(image, config string) dockerCmd {
230+ if config == "" {
231+ return j.newDockerCmd("pull", raw(image))
232+ }
233+ p := j.dockerPath()
234+ return dockerCmd{line: p + " --config " + config + " pull " + image, argv: []string{p, "--config", config, "pull", image}}
235+}
236+
237+// removeConfigDir is ContainerRegistryLogout (:457-470): the credentials
238+// directory goes, and a failure is the runner's error. A directory that
239+// cannot be removed is kept for the cleanup of the job, which tries again.
240+func (j *job) removeConfigDir(dir string) error {
241+ if dir == "" {
242+ return nil
243+ }
244+ if err := os.RemoveAll(dir); err != nil {
245+ j.cs.addDir(dir)
246+ return ghErrorf("Failed to remove directory containing Docker client credentials: %v", err)
247+ }
248+ j.cs.delDir(dir) // unregistered only once it is gone
249+ return nil
250+}
251+
252+// registryLogin is ContainerRegistryLogin (:394-455): the credentials go to a
253+// private docker config directory, the password through stdin. It returns the
254+// directory ("" without credentials); the caller removes it with
255+// removeConfigDir, whatever happens.
256+func (j *job) registryLogin(s *stepRun, c *container, cancel <-chan struct{}) (string, error) {
257+ if c.credUser == "" || c.credPass == "" {
258+ return "", nil
259+ }
260+ // The password is a secret for the logs of this job from here on.
261+ j.addMask(c.credPass)
262+ dir := filepath.Join(j.state.Layout().Temp, ".docker_"+strings.ReplaceAll(newImageTag(""), ":", ""))
263+ if err := os.MkdirAll(dir, 0o700); err != nil {
264+ return "", ghErrorf("Failed to create directory to store registry client credentials: %v", err)
265+ }
266+ // Registered with the cleanup of the job before anything can fail or panic:
267+ // from here on the fallback removes it whatever happens.
268+ j.cs.addDir(dir)
269+ j.own(dir)
270+ args := []string{j.dockerPath(), "--config", dir, "login"}
271+ line := "--config " + dir + " login"
272+ if c.registryServer != "" {
273+ args, line = append(args, c.registryServer), line+" "+c.registryServer
274+ }
275+ args, line = append(args, "-u", c.credUser, "--password-stdin"), line+" -u "+c.credUser+" --password-stdin"
276+ for try := 1; ; try++ {
277+ j.out(s.num, "##[command]"+j.dockerPath()+" "+line)
278+ bc, timedOut, stop := bound(cancel, j.timeouts().Command)
279+ res, err := runProcess(procSpec{
280+ Argv: args, Dir: dirOrCwd(j.githubValue("workspace")), Env: j.processEnv(newOrdered()), Credential: j.cfg.Credential,
281+ OnLine: func(string) {}, Stdin: c.credPass + "\n",
282+ Cancel: bc, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
283+ })
284+ stop()
285+ if err != nil {
286+ return dir, err
287+ }
288+ if res.Cancelled {
289+ if timedOut() {
290+ return dir, &cmdTimeoutError{cmd: "login", d: j.timeouts().Command}
291+ }
292+ return dir, errCancelled
293+ }
294+ if res.ExitCode == 0 {
295+ return dir, nil
296+ }
297+ if try == 3 {
298+ return dir, ghErrorf("Docker login for '%s' failed with exit code %d", c.registryServer, res.ExitCode)
299+ }
300+ d := j.containerBackoff()
301+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker login for '%s' failed with exit code %d, back off %s seconds before retry.", c.registryServer, res.ExitCode, seconds(d)))
302+ if !j.sleepOrCancel(d, cancel) {
303+ return dir, errCancelled
304+ }
305+ }
306+}
@@ -0,0 +1,306 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "math/rand/v2"
8+ "os"
9+ "path/filepath"
10+ "regexp"
11+ "slices"
12+ "strconv"
13+ "strings"
14+ "sync"
15+ "time"
16+)
17+
18+// containerSet is the containers of a job: the job container, if any, then the
19+// services (JobExtension.cs:322-326: the order of the list the provider gets).
20+type containerSet struct {
21+ job *container
22+ services []*container
23+ network string
24+
25+ // What the cleanup needs, recorded BEFORE the command that creates it, so
26+ // that a command whose outcome is not known (a timeout, a cancellation, a
27+ // lost connection) can still be cleaned: the names are chosen here, not by
28+ // docker. mu guards them; cleanupMu serialises the cleanups ("Stop
29+ // containers" and the fallback of the job).
30+ mu sync.Mutex
31+ cleanupMu sync.Mutex
32+ networkTry string // network name, set before docker network create
33+ netRemoved bool // the network is gone
34+ dirs []string // docker --config directories that could not be removed
35+ // deadline is the end of the cleanup budget, fixed by the first cleanup of
36+ // the job: "Stop containers" and the fallback share it, so the cleanup of a
37+ // job is bounded as a whole.
38+ deadline time.Time
39+}
40+
41+func (cs *containerSet) tryNetwork(name string) { cs.mu.Lock(); cs.networkTry = name; cs.mu.Unlock() }
42+
43+func (cs *containerSet) addDir(d string) {
44+ cs.mu.Lock()
45+ defer cs.mu.Unlock()
46+ if !slices.Contains(cs.dirs, d) {
47+ cs.dirs = append(cs.dirs, d)
48+ }
49+}
50+
51+// delDir forgets a directory that is gone.
52+func (cs *containerSet) delDir(d string) {
53+ cs.mu.Lock()
54+ cs.dirs = slices.DeleteFunc(cs.dirs, func(x string) bool { return x == d })
55+ cs.mu.Unlock()
56+}
57+
58+func (cs *containerSet) all() []*container {
59+ var l []*container
60+ if cs.job != nil {
61+ l = append(l, cs.job)
62+ }
63+ return append(l, cs.services...)
64+}
65+
66+// minDockerAPI is requiredDockerEngineAPIVersion (ContainerOperationProvider.cs
67+// :591): Docker-CE 17.12.
68+var minDockerAPI = [2]int{1, 35}
69+
70+var (
71+ apiVersionRe = regexp.MustCompile(`(?i)\d+\.\d+(\.\d+)?`)
72+ portLineRe = regexp.MustCompile(`^(\d+)/(\w+) -> (.+):(\d+)$`)
73+)
74+
75+// initContainersStep is the "Initialize containers" step
76+// (JobExtension.cs:328-331), run if success().
77+func (j *job) initContainersStep() qstep {
78+ return qstep{name: "Initialize containers", setup: func(s *stepRun, cancel <-chan struct{}) stepResult {
79+ return j.initContainers(s, cancel)
80+ }}
81+}
82+
83+// result turns the error of a step body into the result StepsRunner gives it
84+// (StepsRunner.cs:314-340): a cancellation is logged and cancels the step,
85+// anything else is logged with its message and fails it.
86+func (j *job) containerResult(s *stepRun, err error) stepResult {
87+ if err == nil {
88+ return resSucceeded
89+ }
90+ if err == errCancelled {
91+ return j.cancelledStep(s)
92+ }
93+ j.errorf(s.num, "%s", err.Error())
94+ return resFailed
95+}
96+
97+// initContainers is ContainerOperationProvider.StartContainersAsync
98+// (:40-103). The step that stops the containers is registered first, so that
99+// it runs even when this one fails (:56-60).
100+func (j *job) initContainers(s *stepRun, cancel <-chan struct{}) stepResult {
101+ j.debug(s.num, "Register post job cleanup for stopping/deleting containers.")
102+ j.mu.Lock()
103+ j.postStack = append(j.postStack, qstep{name: "Stop containers", cond: "always()", setup: j.stopContainers})
104+ j.mu.Unlock()
105+ return j.containerResult(s, j.startContainers(s, cancel))
106+}
107+
108+func (j *job) startContainers(s *stepRun, cancel <-chan struct{}) error {
109+ num, cs := s.num, j.cs
110+ // AssertCompatibleOS (:559-571) refuses to run in a container because the
111+ // paths it mounts would be those of the container's host. The docker
112+ // daemon of a job runs next to the executor, in the same sandbox, so the
113+ // paths are right and the check is left out.
114+ if err := j.checkDockerVersion(s, cancel); err != nil {
115+ return err
116+ }
117+ j.out(num, "##[group]Clean up resources from previous jobs")
118+ label := j.dockerLabel()
119+ ps := j.newDockerCmd("ps", raw("--all", "--quiet", "--no-trunc"), quotedOpt("--filter", "label="+label))
120+ stale, err := j.dockerList(s, ps, cancel)
121+ if err != nil {
122+ return err
123+ }
124+ for _, id := range stale {
125+ code, err := j.dockerCode(s, j.newDockerCmd("rm", raw("--force", id)), cancel)
126+ if err != nil {
127+ return err
128+ }
129+ if code != 0 {
130+ j.tagged(num, "##[warning]", fmt.Sprintf("Delete stale containers failed, docker rm fail with exit code %d for container %s", code, id))
131+ }
132+ }
133+ prune := j.newDockerCmd("network", raw("prune", "--force"), quotedOpt("--filter", "label="+label))
134+ code, err := j.dockerCode(s, prune, cancel)
135+ if err != nil {
136+ return err
137+ }
138+ if code != 0 {
139+ j.tagged(num, "##[warning]", fmt.Sprintf("Delete stale container networks failed, docker network prune fail with exit code %d", code))
140+ }
141+ j.out(num, "##[endgroup]")
142+
143+ j.out(num, "##[group]Create local container network")
144+ network := "github_network_" + strings.ReplaceAll(newImageTag(""), ":", "")
145+ cs.tryNetwork(network)
146+ code, err = j.dockerCode(s, j.newDockerCmd("network", raw("create", "--label", label, network)), cancel)
147+ if err != nil {
148+ return err
149+ }
150+ if code != 0 {
151+ return ghErrorf("Docker network create failed with exit code %d", code)
152+ }
153+ j.mu.Lock()
154+ j.state.SetJobContainer("network", network)
155+ j.mu.Unlock()
156+ cs.network = network
157+ j.out(num, "##[endgroup]")
158+
159+ for _, c := range cs.all() {
160+ c.network = network
161+ if err := j.startContainer(s, c, cancel); err != nil {
162+ return err
163+ }
164+ }
165+ return j.healthchecks(s, cs.all(), cancel)
166+}
167+
168+// checkDockerVersion is AssertCompatibleOS' version check (:573-598) with
169+// DockerCommandManager.DockerVersion (:47-90), including its use of the
170+// server's text for the client version (:71, the runner's own slip).
171+func (j *job) checkDockerVersion(s *stepRun, cancel <-chan struct{}) error {
172+ num := s.num
173+ j.out(num, "##[group]Checking docker version")
174+ ver := func(which string) (string, error) {
175+ l, err := j.dockerList(s, j.newDockerCmd("version", raw("--format", "'{{."+which+".APIVersion}}'")), cancel)
176+ if err != nil {
177+ return "", err
178+ }
179+ if len(l) == 0 {
180+ return "", ghErrorf("Value cannot be null. (Parameter 'Docker.%s.Version')", which)
181+ }
182+ return l[0], nil
183+ }
184+ server, err := ver("Server")
185+ if err != nil {
186+ return err
187+ }
188+ j.out(num, "Docker daemon API version: "+server)
189+ client, err := ver("Client")
190+ if err != nil {
191+ return err
192+ }
193+ j.out(num, "Docker client API version: "+client)
194+ j.out(num, "##[endgroup]")
195+ v, ok := parseAPIVersion(server)
196+ if !ok {
197+ return ghErrorf("Value cannot be null. (Parameter 'ServerVersion')")
198+ }
199+ if v[0] < minDockerAPI[0] || (v[0] == minDockerAPI[0] && v[1] < minDockerAPI[1]) {
200+ return ghErrorf("Min required docker engine API server version is '%d.%d', your docker ('%s') server version is '%d.%d'",
201+ minDockerAPI[0], minDockerAPI[1], j.dockerPath(), v[0], v[1])
202+ }
203+ return nil
204+}
205+
206+func parseAPIVersion(s string) ([2]int, bool) {
207+ m := apiVersionRe.FindString(s)
208+ parts := strings.Split(m, ".")
209+ if len(parts) < 2 {
210+ return [2]int{}, false
211+ }
212+ a, e1 := strconv.Atoi(parts[0])
213+ b, e2 := strconv.Atoi(parts[1])
214+ return [2]int{a, b}, e1 == nil && e2 == nil
215+}
216+
217+// containerBackoff is BackoffTimerHelper.GetRandomBackoff(1 s, 10 s).
218+func (j *job) containerBackoff() time.Duration {
219+ if f := j.cfg.Actions.Backoff; f != nil {
220+ return f()
221+ }
222+ return time.Duration(1000+rand.IntN(9000)) * time.Millisecond
223+}
224+
225+// seconds is the way the runner prints a backoff, TimeSpan.TotalSeconds.
226+func seconds(d time.Duration) string { return strconv.FormatFloat(d.Seconds(), 'f', -1, 64) }
227+
228+// pullArgs builds `docker [--config dir] pull image`.
229+func (j *job) pullCmd(image, config string) dockerCmd {
230+ if config == "" {
231+ return j.newDockerCmd("pull", raw(image))
232+ }
233+ p := j.dockerPath()
234+ return dockerCmd{line: p + " --config " + config + " pull " + image, argv: []string{p, "--config", config, "pull", image}}
235+}
236+
237+// removeConfigDir is ContainerRegistryLogout (:457-470): the credentials
238+// directory goes, and a failure is the runner's error. A directory that
239+// cannot be removed is kept for the cleanup of the job, which tries again.
240+func (j *job) removeConfigDir(dir string) error {
241+ if dir == "" {
242+ return nil
243+ }
244+ if err := os.RemoveAll(dir); err != nil {
245+ j.cs.addDir(dir)
246+ return ghErrorf("Failed to remove directory containing Docker client credentials: %v", err)
247+ }
248+ j.cs.delDir(dir) // unregistered only once it is gone
249+ return nil
250+}
251+
252+// registryLogin is ContainerRegistryLogin (:394-455): the credentials go to a
253+// private docker config directory, the password through stdin. It returns the
254+// directory ("" without credentials); the caller removes it with
255+// removeConfigDir, whatever happens.
256+func (j *job) registryLogin(s *stepRun, c *container, cancel <-chan struct{}) (string, error) {
257+ if c.credUser == "" || c.credPass == "" {
258+ return "", nil
259+ }
260+ // The password is a secret for the logs of this job from here on.
261+ j.addMask(c.credPass)
262+ dir := filepath.Join(j.state.Layout().Temp, ".docker_"+strings.ReplaceAll(newImageTag(""), ":", ""))
263+ if err := os.MkdirAll(dir, 0o700); err != nil {
264+ return "", ghErrorf("Failed to create directory to store registry client credentials: %v", err)
265+ }
266+ // Registered with the cleanup of the job before anything can fail or panic:
267+ // from here on the fallback removes it whatever happens.
268+ j.cs.addDir(dir)
269+ j.own(dir)
270+ args := []string{j.dockerPath(), "--config", dir, "login"}
271+ line := "--config " + dir + " login"
272+ if c.registryServer != "" {
273+ args, line = append(args, c.registryServer), line+" "+c.registryServer
274+ }
275+ args, line = append(args, "-u", c.credUser, "--password-stdin"), line+" -u "+c.credUser+" --password-stdin"
276+ for try := 1; ; try++ {
277+ j.out(s.num, "##[command]"+j.dockerPath()+" "+line)
278+ bc, timedOut, stop := bound(cancel, j.timeouts().Command)
279+ res, err := runProcess(procSpec{
280+ Argv: args, Dir: dirOrCwd(j.githubValue("workspace")), Env: j.processEnv(newOrdered()), Credential: j.cfg.Credential,
281+ OnLine: func(string) {}, Stdin: c.credPass + "\n",
282+ Cancel: bc, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
283+ })
284+ stop()
285+ if err != nil {
286+ return dir, err
287+ }
288+ if res.Cancelled {
289+ if timedOut() {
290+ return dir, &cmdTimeoutError{cmd: "login", d: j.timeouts().Command}
291+ }
292+ return dir, errCancelled
293+ }
294+ if res.ExitCode == 0 {
295+ return dir, nil
296+ }
297+ if try == 3 {
298+ return dir, ghErrorf("Docker login for '%s' failed with exit code %d", c.registryServer, res.ExitCode)
299+ }
300+ d := j.containerBackoff()
301+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker login for '%s' failed with exit code %d, back off %s seconds before retry.", c.registryServer, res.ExitCode, seconds(d)))
302+ if !j.sleepOrCancel(d, cancel) {
303+ return dir, errCancelled
304+ }
305+ }
306+}
A internal/executor/containers_init_test.go +103 -0
@@ -0,0 +1,103 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+func TestInitAndStopServiceOnly(t *testing.T) {
11+ f := newFakeCtr(t)
12+ f.set(t, "health", "starting\nhealthy\n")
13+ f.set(t, "ports", "6379/tcp -> 0.0.0.0:16379\n6379/tcp -> [::]:16379\n")
14+ rec, res := ctrRun(t, f, " services:\n redis:\n image: redis:7\n ports:\n - 16379:6379\n options: >-\n --health-cmd \"redis-cli ping\"\n --health-retries 10\n",
15+ " - name: ctx\n run: echo \"p=${{ job.services.redis.ports['6379'] }} n=${{ job.services.redis.network != '' }}\"\n", nil)
16+ if res.Conclusion != ConclusionSuccess {
17+ t.Fatalf("%+v %v", res, rec.names())
18+ }
19+ if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|ctx|Stop containers|Complete job" {
20+ t.Fatalf("steps %s", got)
21+ }
22+ init := logOf(rec.step(t, "Initialize containers"))
23+ for _, w := range []string{
24+ "##[group]Checking docker version\n##[command]", "Docker daemon API version: '1.48'", "Docker client API version: '1.48'",
25+ "##[group]Clean up resources from previous jobs", "##[group]Create local container network",
26+ "##[group]Starting redis service container", " network create --label ",
27+ "--network-alias redis -p 16379:6379 --health-cmd \"redis-cli ping\" --health-retries 10 -e GITHUB_ACTIONS=true -e CI=true redis:7",
28+ "##[group]Waiting for all services to be ready", "redis service is starting, waiting 0 seconds before checking again.", "redis service is healthy.",
29+ } {
30+ if !strings.Contains(init, w) {
31+ t.Errorf("init log lacks %q:\n%s", w, init)
32+ }
33+ }
34+ if strings.Contains(init, "--workdir") || strings.Contains(init, "--entrypoint") {
35+ t.Errorf("a service has neither workdir nor entrypoint:\n%s", init)
36+ }
37+ stop := logOf(rec.step(t, "Stop containers"))
38+ if !strings.Contains(stop, "Print service container logs: ") || !strings.Contains(stop, "Stop and remove container: ") ||
39+ !strings.Contains(stop, "Remove container network: github_network_") || !strings.Contains(stop, "network rm github_network_") {
40+ t.Errorf("stop:\n%s", stop)
41+ }
42+ want := "version version ps network prune network create pull create start ps port inspect inspect logs rm network rm"
43+ if got := strings.Join(f.verbs(t), " "); got != want {
44+ t.Errorf("docker calls:\n%s\nwant\n%s", got, want)
45+ }
46+ run := logOf(rec.step(t, "ctx"))
47+ if !strings.Contains(run, "p=16379 n=true") {
48+ t.Errorf("job.services not in the context:\n%s", run)
49+ }
50+}
51+
52+// GO-004: values of container variables are in the environment of the docker
53+// process; the arguments carry "-e NAME". The echoed line is the runner's
54+// (recorded: -e "NAME=value"), masked like any log.
55+func TestContainerEnvValuesStayOffTheArguments(t *testing.T) {
56+ f := newFakeCtr(t)
57+ rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n CVAR: s3cret-value\n EMPTYV: \"\"\n DOCKER_HOST: tcp://x:1\n",
58+ " - name: p\n run: echo\n - name: q\n env:\n STEPVAR: s3cret-step\n run: echo\n", func(c *Config) { f.set(t, "add_path", c.Layout.WorkRoot) })
59+ if res.Conclusion != ConclusionSuccess {
60+ t.Fatalf("%+v", res)
61+ }
62+ var create []string
63+ for _, c := range f.calls(t) {
64+ if c[0] == "create" {
65+ create = c
66+ }
67+ }
68+ joined := strings.Join(create, "\n")
69+ for _, w := range []string{"\n-e\nCVAR\n", "\n-e\nEMPTYV\n", "\n-e\nDOCKER_HOST=tcp://x:1\n", "ENV:CVAR=s3cret-value:EMPTYV=:"} {
70+ if !strings.Contains(joined, w) {
71+ t.Errorf("create lacks %q:\n%s", w, joined)
72+ }
73+ }
74+ if strings.Contains(joined, "CVAR=s3cret") && !strings.Contains(joined, "ENV:CVAR=s3cret") {
75+ t.Errorf("value on the arguments:\n%s", joined)
76+ }
77+ for _, a := range create {
78+ if strings.HasPrefix(a, "CVAR=") || strings.HasPrefix(a, "EMPTYV=") {
79+ t.Errorf("argument %q carries a value", a)
80+ }
81+ }
82+ // What the runner echoes (recorded in ctr-options).
83+ init := logOf(rec.step(t, "Initialize containers"))
84+ if !strings.Contains(init, ` -e "CVAR=s3cret-value" -e "EMPTYV" `) {
85+ t.Errorf("echo:\n%s", init)
86+ }
87+ // exec: no value of a step variable and no PATH on the arguments.
88+ for _, c := range f.execCalls(t) {
89+ for _, a := range c {
90+ if strings.HasPrefix(a, "ENV:") {
91+ continue // what the fake saw in its environment
92+ }
93+ if strings.Contains(a, "s3cret-step") || strings.HasPrefix(a, "PATH=") || strings.Contains(a, "/added") {
94+ t.Errorf("exec argument %q", a)
95+ }
96+ }
97+ }
98+ ex := f.execCalls(t)
99+ last := strings.Join(ex[len(ex)-1], "\n")
100+ if !strings.Contains(last, "\n-e\nPATH\n") || !strings.Contains(last, "ENV:") || !strings.Contains(last, "PATH=/added:") {
101+ t.Errorf("second exec:\n%s", last)
102+ }
103+}
@@ -0,0 +1,103 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+func TestInitAndStopServiceOnly(t *testing.T) {
11+ f := newFakeCtr(t)
12+ f.set(t, "health", "starting\nhealthy\n")
13+ f.set(t, "ports", "6379/tcp -> 0.0.0.0:16379\n6379/tcp -> [::]:16379\n")
14+ rec, res := ctrRun(t, f, " services:\n redis:\n image: redis:7\n ports:\n - 16379:6379\n options: >-\n --health-cmd \"redis-cli ping\"\n --health-retries 10\n",
15+ " - name: ctx\n run: echo \"p=${{ job.services.redis.ports['6379'] }} n=${{ job.services.redis.network != '' }}\"\n", nil)
16+ if res.Conclusion != ConclusionSuccess {
17+ t.Fatalf("%+v %v", res, rec.names())
18+ }
19+ if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|ctx|Stop containers|Complete job" {
20+ t.Fatalf("steps %s", got)
21+ }
22+ init := logOf(rec.step(t, "Initialize containers"))
23+ for _, w := range []string{
24+ "##[group]Checking docker version\n##[command]", "Docker daemon API version: '1.48'", "Docker client API version: '1.48'",
25+ "##[group]Clean up resources from previous jobs", "##[group]Create local container network",
26+ "##[group]Starting redis service container", " network create --label ",
27+ "--network-alias redis -p 16379:6379 --health-cmd \"redis-cli ping\" --health-retries 10 -e GITHUB_ACTIONS=true -e CI=true redis:7",
28+ "##[group]Waiting for all services to be ready", "redis service is starting, waiting 0 seconds before checking again.", "redis service is healthy.",
29+ } {
30+ if !strings.Contains(init, w) {
31+ t.Errorf("init log lacks %q:\n%s", w, init)
32+ }
33+ }
34+ if strings.Contains(init, "--workdir") || strings.Contains(init, "--entrypoint") {
35+ t.Errorf("a service has neither workdir nor entrypoint:\n%s", init)
36+ }
37+ stop := logOf(rec.step(t, "Stop containers"))
38+ if !strings.Contains(stop, "Print service container logs: ") || !strings.Contains(stop, "Stop and remove container: ") ||
39+ !strings.Contains(stop, "Remove container network: github_network_") || !strings.Contains(stop, "network rm github_network_") {
40+ t.Errorf("stop:\n%s", stop)
41+ }
42+ want := "version version ps network prune network create pull create start ps port inspect inspect logs rm network rm"
43+ if got := strings.Join(f.verbs(t), " "); got != want {
44+ t.Errorf("docker calls:\n%s\nwant\n%s", got, want)
45+ }
46+ run := logOf(rec.step(t, "ctx"))
47+ if !strings.Contains(run, "p=16379 n=true") {
48+ t.Errorf("job.services not in the context:\n%s", run)
49+ }
50+}
51+
52+// GO-004: values of container variables are in the environment of the docker
53+// process; the arguments carry "-e NAME". The echoed line is the runner's
54+// (recorded: -e "NAME=value"), masked like any log.
55+func TestContainerEnvValuesStayOffTheArguments(t *testing.T) {
56+ f := newFakeCtr(t)
57+ rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n CVAR: s3cret-value\n EMPTYV: \"\"\n DOCKER_HOST: tcp://x:1\n",
58+ " - name: p\n run: echo\n - name: q\n env:\n STEPVAR: s3cret-step\n run: echo\n", func(c *Config) { f.set(t, "add_path", c.Layout.WorkRoot) })
59+ if res.Conclusion != ConclusionSuccess {
60+ t.Fatalf("%+v", res)
61+ }
62+ var create []string
63+ for _, c := range f.calls(t) {
64+ if c[0] == "create" {
65+ create = c
66+ }
67+ }
68+ joined := strings.Join(create, "\n")
69+ for _, w := range []string{"\n-e\nCVAR\n", "\n-e\nEMPTYV\n", "\n-e\nDOCKER_HOST=tcp://x:1\n", "ENV:CVAR=s3cret-value:EMPTYV=:"} {
70+ if !strings.Contains(joined, w) {
71+ t.Errorf("create lacks %q:\n%s", w, joined)
72+ }
73+ }
74+ if strings.Contains(joined, "CVAR=s3cret") && !strings.Contains(joined, "ENV:CVAR=s3cret") {
75+ t.Errorf("value on the arguments:\n%s", joined)
76+ }
77+ for _, a := range create {
78+ if strings.HasPrefix(a, "CVAR=") || strings.HasPrefix(a, "EMPTYV=") {
79+ t.Errorf("argument %q carries a value", a)
80+ }
81+ }
82+ // What the runner echoes (recorded in ctr-options).
83+ init := logOf(rec.step(t, "Initialize containers"))
84+ if !strings.Contains(init, ` -e "CVAR=s3cret-value" -e "EMPTYV" `) {
85+ t.Errorf("echo:\n%s", init)
86+ }
87+ // exec: no value of a step variable and no PATH on the arguments.
88+ for _, c := range f.execCalls(t) {
89+ for _, a := range c {
90+ if strings.HasPrefix(a, "ENV:") {
91+ continue // what the fake saw in its environment
92+ }
93+ if strings.Contains(a, "s3cret-step") || strings.HasPrefix(a, "PATH=") || strings.Contains(a, "/added") {
94+ t.Errorf("exec argument %q", a)
95+ }
96+ }
97+ }
98+ ex := f.execCalls(t)
99+ last := strings.Join(ex[len(ex)-1], "\n")
100+ if !strings.Contains(last, "\n-e\nPATH\n") || !strings.Contains(last, "ENV:") || !strings.Contains(last, "PATH=/added:") {
101+ t.Errorf("second exec:\n%s", last)
102+ }
103+}
A internal/executor/containers_life_test.go +193 -0
@@ -0,0 +1,193 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+const ctrJob = " container: ubuntu:24.04\n"
14+
15+func TestPullRetriesThenFailsAndStillCleansUp(t *testing.T) {
16+ f := newFakeCtr(t)
17+ f.set(t, "pull_fail", "9")
18+ rec, res := ctrRun(t, f, " container: no-such-image:nope\n", " - name: never\n run: echo never\n", nil)
19+ if res.Conclusion != ConclusionFailure {
20+ t.Fatalf("%+v", res)
21+ }
22+ if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|never|Stop containers|Complete job" {
23+ t.Fatalf("steps %s", got)
24+ }
25+ if c := rec.concl("never"); c != ConclusionSkipped {
26+ t.Errorf("never: %s", c)
27+ }
28+ log := logOf(rec.step(t, "Initialize containers"))
29+ if n := strings.Count(log, "##[command]"+f.bin+" pull no-such-image:nope"); n != 3 {
30+ t.Errorf("%d pulls:\n%s", n, log)
31+ }
32+ for _, w := range []string{"##[warning]Docker pull failed with exit code 1, back off 0.001 seconds before retry.", "##[error]Docker pull failed with exit code 1"} {
33+ if !strings.Contains(log, w) {
34+ t.Errorf("lacks %q:\n%s", w, log)
35+ }
36+ }
37+ // Nothing was created: the stop step has only the network to remove.
38+ stop := logOf(rec.step(t, "Stop containers"))
39+ if strings.Contains(stop, "Stop and remove container") || !strings.Contains(stop, "Remove container network: github_network_") {
40+ t.Errorf("stop:\n%s", stop)
41+ }
42+}
43+
44+func TestCreateFailureMessage(t *testing.T) {
45+ f := newFakeCtr(t)
46+ f.set(t, "create_fail", "1")
47+ rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n options: --not-a-docker-flag\n", " - run: echo never\n", nil)
48+ if res.Conclusion != ConclusionFailure {
49+ t.Fatalf("%+v", res)
50+ }
51+ log := logOf(rec.step(t, "Initialize containers"))
52+ if !strings.Contains(log, "##[error]Exit code 125 returned from process: file name '"+f.bin+"', arguments 'create --name ") ||
53+ !strings.Contains(log, " --not-a-docker-flag -e \"HOME=/github/home\"") {
54+ t.Errorf("log:\n%s", log)
55+ }
56+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network") {
57+ t.Errorf("stop:\n%s", got)
58+ }
59+}
60+
61+func TestUnhealthyServiceFailsInit(t *testing.T) {
62+ f := newFakeCtr(t)
63+ f.set(t, "health", "starting\nstarting\nunhealthy\n")
64+ rec, res := ctrRun(t, f, " services:\n broken:\n image: redis:7\n", " - run: echo never\n", nil)
65+ if res.Conclusion != ConclusionFailure {
66+ t.Fatalf("%+v", res)
67+ }
68+ log := logOf(rec.step(t, "Initialize containers"))
69+ for _, w := range []string{
70+ "##[group]Service container broken failed.\n##[command]" + f.bin + " logs --details ",
71+ "##[error]Failed to initialize container redis:7\n##[endgroup]\n##[error]One or more containers failed to start.",
72+ } {
73+ if !strings.Contains(log, w) {
74+ t.Errorf("lacks %q:\n%s", w, log)
75+ }
76+ }
77+ // A service that failed to initialise has its logs printed once, not twice.
78+ stop := logOf(rec.step(t, "Stop containers"))
79+ if strings.Contains(stop, "Print service container logs") || !strings.Contains(stop, "Stop and remove container: ") {
80+ t.Errorf("stop:\n%s", stop)
81+ }
82+}
83+
84+func TestRegistryCredentialsAreMaskedAndRemoved(t *testing.T) {
85+ f := newFakeCtr(t)
86+ var temp string
87+ rec, res := ctrRun(t, f, " container:\n image: ghcr.io/o/img:1\n credentials:\n username: bob\n password: hunter2-secret\n", " - run: echo ok\n", func(c *Config) { temp = c.Layout.Temp })
88+ if res.Conclusion != ConclusionSuccess {
89+ t.Fatalf("%+v", res)
90+ }
91+ all := ""
92+ for _, s := range rec.steps {
93+ all += logOf(s)
94+ }
95+ if strings.Contains(all, "hunter2-secret") {
96+ t.Errorf("password in the log")
97+ }
98+ if b, _ := os.ReadFile(filepath.Join(f.dir, "login_stdin")); string(b) != "hunter2-secret\n" {
99+ t.Errorf("password not passed through stdin: %q", b)
100+ }
101+ log := logOf(rec.step(t, "Initialize containers"))
102+ if !strings.Contains(log, " login ghcr.io -u bob --password-stdin") || !strings.Contains(log, " pull ghcr.io/o/img:1") || !strings.Contains(log, " --config ") {
103+ t.Errorf("log:\n%s", log)
104+ }
105+ if m, _ := filepath.Glob(filepath.Join(temp, ".docker_*")); len(m) != 0 {
106+ t.Errorf("config left behind: %v", m)
107+ }
108+}
109+
110+func TestCancelDuringStepStillStopsContainers(t *testing.T) {
111+ f := newFakeCtr(t)
112+ f.set(t, "hang_exec", "1")
113+ cancel := NewCanceller()
114+ go func() { time.Sleep(600 * time.Millisecond); cancel.Cancel(CancelUser) }()
115+ rec, res := ctrRunC(t, f, cancel, ctrJob, " - name: long\n run: sleep 60\n", nil)
116+ if res.Conclusion != ConclusionCancelled {
117+ t.Fatalf("%+v %v", res, rec.names())
118+ }
119+ stop := rec.step(t, "Stop containers")
120+ if got := logOf(stop); !strings.Contains(got, "Stop and remove container: ") || !strings.Contains(got, "Remove container network: ") {
121+ t.Errorf("stop:\n%s", got)
122+ }
123+ if c := rec.concl("Stop containers"); c != ConclusionSuccess {
124+ t.Errorf("Stop containers: %s", c)
125+ }
126+ if v := f.verbs(t); v[len(v)-1] != "network rm" || v[len(v)-2] != "rm" {
127+ t.Errorf("last calls %v", v)
128+ }
129+}
130+
131+func TestStepTimeoutStopsContainersAndIsAFailure(t *testing.T) {
132+ f := newFakeCtr(t)
133+ f.set(t, "hang_exec", "1")
134+ rec, res := ctrRun(t, f, ctrJob, " - name: long\n timeout-minutes: 1\n run: sleep 60\n", func(c *Config) {
135+ c.StepTimeoutSkew = -59*time.Second - 700*time.Millisecond
136+ })
137+ if res.Conclusion != ConclusionFailure {
138+ t.Fatalf("%+v %v", res, rec.names())
139+ }
140+ if got := logOf(rec.step(t, "long")); !strings.Contains(got, "##[error]The action 'long' has timed out after 1 minutes.") {
141+ t.Errorf("long:\n%s", got)
142+ }
143+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network: ") {
144+ t.Errorf("stop:\n%s", got)
145+ }
146+}
147+
148+func TestJobTimeoutStopsContainers(t *testing.T) {
149+ f := newFakeCtr(t)
150+ f.set(t, "hang_exec", "1")
151+ rec, res := ctrRun(t, f, ctrJob, " - name: long\n run: sleep 60\n", func(c *Config) { c.Deadline = 700 * time.Millisecond })
152+ if res.Conclusion != ConclusionTimedOut {
153+ t.Fatalf("%+v %v", res, rec.names())
154+ }
155+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Stop and remove container: ") || !strings.Contains(got, "Remove container network: ") {
156+ t.Errorf("stop:\n%s", got)
157+ }
158+}
159+
160+func TestStopContainersRunsAfterAFailedStepAndPostStepsRunInTheContainer(t *testing.T) {
161+ f := newFakeCtr(t)
162+ f.set(t, "exec_exit", "1") // the first exec fails
163+ rec, res := ctrRun(t, f, ctrJob, " - name: bad\n run: exit 1\n - name: after\n if: always()\n run: echo after\n", nil)
164+ if res.Conclusion != ConclusionFailure {
165+ t.Fatalf("%+v", res)
166+ }
167+ if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|bad|after|Stop containers|Complete job" {
168+ t.Errorf("steps %s", got)
169+ }
170+ if len(f.execCalls(t)) != 2 {
171+ t.Errorf("both steps run through docker exec: %v", f.execCalls(t))
172+ }
173+}
174+
175+// After a failed "Initialize containers" an always() step has no container:
176+// the runner fails it before its header with a null ContainerId (recorded:
177+// the ermite-dump step of ctr-errors).
178+func TestStepAfterFailedInitFailsWithNullContainerId(t *testing.T) {
179+ f := newFakeCtr(t)
180+ f.set(t, "create_fail", "1")
181+ tree := files{".github/actions/x/action.yml": "name: x\nruns:\n using: node20\n main: i.js\n", ".github/actions/x/i.js": "x\n"}
182+ rec, res := ctrRun(t, f, ctrJob, " - name: run\n if: always()\n run: echo no\n - name: act\n if: always()\n uses: ./.github/actions/x\n", func(c *Config) {
183+ writeFiles(t, c.Layout.Workspace, tree)
184+ })
185+ if res.Conclusion != ConclusionFailure {
186+ t.Fatalf("%+v", res)
187+ }
188+ for _, n := range []string{"run", "act"} {
189+ if got := logOf(rec.step(t, n)); got != "##[error]Value cannot be null. (Parameter 'ContainerId')" || rec.concl(n) != ConclusionFailure {
190+ t.Errorf("%s: %q %s", n, got, rec.concl(n))
191+ }
192+ }
193+}
@@ -0,0 +1,193 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+const ctrJob = " container: ubuntu:24.04\n"
14+
15+func TestPullRetriesThenFailsAndStillCleansUp(t *testing.T) {
16+ f := newFakeCtr(t)
17+ f.set(t, "pull_fail", "9")
18+ rec, res := ctrRun(t, f, " container: no-such-image:nope\n", " - name: never\n run: echo never\n", nil)
19+ if res.Conclusion != ConclusionFailure {
20+ t.Fatalf("%+v", res)
21+ }
22+ if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|never|Stop containers|Complete job" {
23+ t.Fatalf("steps %s", got)
24+ }
25+ if c := rec.concl("never"); c != ConclusionSkipped {
26+ t.Errorf("never: %s", c)
27+ }
28+ log := logOf(rec.step(t, "Initialize containers"))
29+ if n := strings.Count(log, "##[command]"+f.bin+" pull no-such-image:nope"); n != 3 {
30+ t.Errorf("%d pulls:\n%s", n, log)
31+ }
32+ for _, w := range []string{"##[warning]Docker pull failed with exit code 1, back off 0.001 seconds before retry.", "##[error]Docker pull failed with exit code 1"} {
33+ if !strings.Contains(log, w) {
34+ t.Errorf("lacks %q:\n%s", w, log)
35+ }
36+ }
37+ // Nothing was created: the stop step has only the network to remove.
38+ stop := logOf(rec.step(t, "Stop containers"))
39+ if strings.Contains(stop, "Stop and remove container") || !strings.Contains(stop, "Remove container network: github_network_") {
40+ t.Errorf("stop:\n%s", stop)
41+ }
42+}
43+
44+func TestCreateFailureMessage(t *testing.T) {
45+ f := newFakeCtr(t)
46+ f.set(t, "create_fail", "1")
47+ rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n options: --not-a-docker-flag\n", " - run: echo never\n", nil)
48+ if res.Conclusion != ConclusionFailure {
49+ t.Fatalf("%+v", res)
50+ }
51+ log := logOf(rec.step(t, "Initialize containers"))
52+ if !strings.Contains(log, "##[error]Exit code 125 returned from process: file name '"+f.bin+"', arguments 'create --name ") ||
53+ !strings.Contains(log, " --not-a-docker-flag -e \"HOME=/github/home\"") {
54+ t.Errorf("log:\n%s", log)
55+ }
56+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network") {
57+ t.Errorf("stop:\n%s", got)
58+ }
59+}
60+
61+func TestUnhealthyServiceFailsInit(t *testing.T) {
62+ f := newFakeCtr(t)
63+ f.set(t, "health", "starting\nstarting\nunhealthy\n")
64+ rec, res := ctrRun(t, f, " services:\n broken:\n image: redis:7\n", " - run: echo never\n", nil)
65+ if res.Conclusion != ConclusionFailure {
66+ t.Fatalf("%+v", res)
67+ }
68+ log := logOf(rec.step(t, "Initialize containers"))
69+ for _, w := range []string{
70+ "##[group]Service container broken failed.\n##[command]" + f.bin + " logs --details ",
71+ "##[error]Failed to initialize container redis:7\n##[endgroup]\n##[error]One or more containers failed to start.",
72+ } {
73+ if !strings.Contains(log, w) {
74+ t.Errorf("lacks %q:\n%s", w, log)
75+ }
76+ }
77+ // A service that failed to initialise has its logs printed once, not twice.
78+ stop := logOf(rec.step(t, "Stop containers"))
79+ if strings.Contains(stop, "Print service container logs") || !strings.Contains(stop, "Stop and remove container: ") {
80+ t.Errorf("stop:\n%s", stop)
81+ }
82+}
83+
84+func TestRegistryCredentialsAreMaskedAndRemoved(t *testing.T) {
85+ f := newFakeCtr(t)
86+ var temp string
87+ rec, res := ctrRun(t, f, " container:\n image: ghcr.io/o/img:1\n credentials:\n username: bob\n password: hunter2-secret\n", " - run: echo ok\n", func(c *Config) { temp = c.Layout.Temp })
88+ if res.Conclusion != ConclusionSuccess {
89+ t.Fatalf("%+v", res)
90+ }
91+ all := ""
92+ for _, s := range rec.steps {
93+ all += logOf(s)
94+ }
95+ if strings.Contains(all, "hunter2-secret") {
96+ t.Errorf("password in the log")
97+ }
98+ if b, _ := os.ReadFile(filepath.Join(f.dir, "login_stdin")); string(b) != "hunter2-secret\n" {
99+ t.Errorf("password not passed through stdin: %q", b)
100+ }
101+ log := logOf(rec.step(t, "Initialize containers"))
102+ if !strings.Contains(log, " login ghcr.io -u bob --password-stdin") || !strings.Contains(log, " pull ghcr.io/o/img:1") || !strings.Contains(log, " --config ") {
103+ t.Errorf("log:\n%s", log)
104+ }
105+ if m, _ := filepath.Glob(filepath.Join(temp, ".docker_*")); len(m) != 0 {
106+ t.Errorf("config left behind: %v", m)
107+ }
108+}
109+
110+func TestCancelDuringStepStillStopsContainers(t *testing.T) {
111+ f := newFakeCtr(t)
112+ f.set(t, "hang_exec", "1")
113+ cancel := NewCanceller()
114+ go func() { time.Sleep(600 * time.Millisecond); cancel.Cancel(CancelUser) }()
115+ rec, res := ctrRunC(t, f, cancel, ctrJob, " - name: long\n run: sleep 60\n", nil)
116+ if res.Conclusion != ConclusionCancelled {
117+ t.Fatalf("%+v %v", res, rec.names())
118+ }
119+ stop := rec.step(t, "Stop containers")
120+ if got := logOf(stop); !strings.Contains(got, "Stop and remove container: ") || !strings.Contains(got, "Remove container network: ") {
121+ t.Errorf("stop:\n%s", got)
122+ }
123+ if c := rec.concl("Stop containers"); c != ConclusionSuccess {
124+ t.Errorf("Stop containers: %s", c)
125+ }
126+ if v := f.verbs(t); v[len(v)-1] != "network rm" || v[len(v)-2] != "rm" {
127+ t.Errorf("last calls %v", v)
128+ }
129+}
130+
131+func TestStepTimeoutStopsContainersAndIsAFailure(t *testing.T) {
132+ f := newFakeCtr(t)
133+ f.set(t, "hang_exec", "1")
134+ rec, res := ctrRun(t, f, ctrJob, " - name: long\n timeout-minutes: 1\n run: sleep 60\n", func(c *Config) {
135+ c.StepTimeoutSkew = -59*time.Second - 700*time.Millisecond
136+ })
137+ if res.Conclusion != ConclusionFailure {
138+ t.Fatalf("%+v %v", res, rec.names())
139+ }
140+ if got := logOf(rec.step(t, "long")); !strings.Contains(got, "##[error]The action 'long' has timed out after 1 minutes.") {
141+ t.Errorf("long:\n%s", got)
142+ }
143+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network: ") {
144+ t.Errorf("stop:\n%s", got)
145+ }
146+}
147+
148+func TestJobTimeoutStopsContainers(t *testing.T) {
149+ f := newFakeCtr(t)
150+ f.set(t, "hang_exec", "1")
151+ rec, res := ctrRun(t, f, ctrJob, " - name: long\n run: sleep 60\n", func(c *Config) { c.Deadline = 700 * time.Millisecond })
152+ if res.Conclusion != ConclusionTimedOut {
153+ t.Fatalf("%+v %v", res, rec.names())
154+ }
155+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Stop and remove container: ") || !strings.Contains(got, "Remove container network: ") {
156+ t.Errorf("stop:\n%s", got)
157+ }
158+}
159+
160+func TestStopContainersRunsAfterAFailedStepAndPostStepsRunInTheContainer(t *testing.T) {
161+ f := newFakeCtr(t)
162+ f.set(t, "exec_exit", "1") // the first exec fails
163+ rec, res := ctrRun(t, f, ctrJob, " - name: bad\n run: exit 1\n - name: after\n if: always()\n run: echo after\n", nil)
164+ if res.Conclusion != ConclusionFailure {
165+ t.Fatalf("%+v", res)
166+ }
167+ if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|bad|after|Stop containers|Complete job" {
168+ t.Errorf("steps %s", got)
169+ }
170+ if len(f.execCalls(t)) != 2 {
171+ t.Errorf("both steps run through docker exec: %v", f.execCalls(t))
172+ }
173+}
174+
175+// After a failed "Initialize containers" an always() step has no container:
176+// the runner fails it before its header with a null ContainerId (recorded:
177+// the ermite-dump step of ctr-errors).
178+func TestStepAfterFailedInitFailsWithNullContainerId(t *testing.T) {
179+ f := newFakeCtr(t)
180+ f.set(t, "create_fail", "1")
181+ tree := files{".github/actions/x/action.yml": "name: x\nruns:\n using: node20\n main: i.js\n", ".github/actions/x/i.js": "x\n"}
182+ rec, res := ctrRun(t, f, ctrJob, " - name: run\n if: always()\n run: echo no\n - name: act\n if: always()\n uses: ./.github/actions/x\n", func(c *Config) {
183+ writeFiles(t, c.Layout.Workspace, tree)
184+ })
185+ if res.Conclusion != ConclusionFailure {
186+ t.Fatalf("%+v", res)
187+ }
188+ for _, n := range []string{"run", "act"} {
189+ if got := logOf(rec.step(t, n)); got != "##[error]Value cannot be null. (Parameter 'ContainerId')" || rec.concl(n) != ConclusionFailure {
190+ t.Errorf("%s: %q %s", n, got, rec.concl(n))
191+ }
192+ }
193+}
A internal/executor/containers_node.go +96 -0
@@ -0,0 +1,96 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "path/filepath"
8+ "runtime"
9+ "strings"
10+)
11+
12+// alpineCheck is the command of ContainerStepHost.DetermineNodeRuntimeVersion
13+// (StepHost.cs:176): the distribution is read from the release files.
14+const alpineCheck = `cat /etc/*release | grep ^ID`
15+
16+// nodeInContainer is ContainerStepHost.DetermineNodeRuntimeVersion
17+// (StepHost.cs:154-196, container hooks off): "<version>_alpine" when the
18+// image is Alpine (musl, where the glibc builds do not run), else the version
19+// itself. The output of the probe is not logged, only the command is.
20+func (j *job) nodeInContainer(s *stepRun, c *container, version string, cancel <-chan struct{}) (string, error) {
21+ cmd := j.newDockerCmd("exec", dockerPart{line: ""}, raw(c.id),
22+ dockerPart{line: `sh -c "` + alpineCheck + `"`, args: []string{"sh", "-c", alpineCheck}})
23+ j.out(s.num, "##[command]"+cmd.line)
24+ var out []string
25+ res, err := runProcess(procSpec{
26+ Argv: cmd.argv, Dir: dirOrCwd(j.githubValue("workspace")), Env: j.processEnv(newOrdered()), Credential: j.cfg.Credential,
27+ OnLine: func(l string) {
28+ if l != "" {
29+ out = append(out, l)
30+ }
31+ },
32+ OnErrLine: func(l string) {
33+ if l != "" {
34+ out = append(out, l)
35+ }
36+ },
37+ Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
38+ })
39+ if err != nil {
40+ return "", err
41+ }
42+ if res.Cancelled {
43+ return "", errCancelled
44+ }
45+ if res.ExitCode == 0 {
46+ for _, l := range out {
47+ j.debug(s.num, l)
48+ if strings.Contains(strings.ToLower(l), "alpine") {
49+ return j.alpineNode(s, version)
50+ }
51+ }
52+ }
53+ j.debug(s.num, "Running JavaScript Action with default external tool: "+version)
54+ return version, nil
55+}
56+
57+// alpineNode is CheckPlatformForAlpineContainer (StepHost.cs:248-279): the
58+// alpine builds exist for x64 only.
59+func (j *job) alpineNode(s *stepRun, version string) (string, error) {
60+ if runtime.GOARCH != "amd64" {
61+ arch := map[string]string{"arm64": "Arm64", "arm": "Arm", "386": "X86"}[runtime.GOARCH]
62+ if arch == "" {
63+ arch = runtime.GOARCH
64+ }
65+ return "", fmt.Errorf("JavaScript Actions in Alpine containers are only supported on x64 Linux runners. Detected Linux %s", arch)
66+ }
67+ v := version + "_alpine"
68+ j.debug(s.num, "Container distribution is alpine. Running JavaScript Action with external tool: "+v)
69+ return v, nil
70+}
71+
72+// runNodeInContainer is NodeScriptActionHandler.RunAsync (:127-190) with a
73+// ContainerStepHost: the node of the externals mounted at /__e runs the script
74+// at its container path, in the container's workspace.
75+func (j *job) runNodeInContainer(s *stepRun, c *container, script string, env *ordered, cancel <-chan struct{}) stepResult {
76+ num := s.num
77+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
78+ if err := j.containerReady(); err != nil {
79+ return fail(err)
80+ }
81+ version, err := j.nodeInContainer(s, c, s.nodeVersion, cancel)
82+ if err != nil {
83+ return j.containerResult(s, err)
84+ }
85+ file := c.resolvePath(filepath.Join(j.externalsDir(), version, "bin", "node"))
86+ j.mu.Lock()
87+ wd := j.state.GitHub("workspace")
88+ j.mu.Unlock()
89+ henv := j.handlerEnv(env)
90+ if err := checkEnvNames(henv.Keys()); err != nil {
91+ return fail(err)
92+ }
93+ argv, penv := j.execArgv(c, c.resolvePath(wd), henv, file, []string{c.resolvePath(`"` + script + `"`)})
94+ s.rawEnvList = penv
95+ return j.runProc(s, file, j.state.Layout().WorkRoot, argv, "Node Action run completed with exit code %d", cancel)
96+}
@@ -0,0 +1,96 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "path/filepath"
8+ "runtime"
9+ "strings"
10+)
11+
12+// alpineCheck is the command of ContainerStepHost.DetermineNodeRuntimeVersion
13+// (StepHost.cs:176): the distribution is read from the release files.
14+const alpineCheck = `cat /etc/*release | grep ^ID`
15+
16+// nodeInContainer is ContainerStepHost.DetermineNodeRuntimeVersion
17+// (StepHost.cs:154-196, container hooks off): "<version>_alpine" when the
18+// image is Alpine (musl, where the glibc builds do not run), else the version
19+// itself. The output of the probe is not logged, only the command is.
20+func (j *job) nodeInContainer(s *stepRun, c *container, version string, cancel <-chan struct{}) (string, error) {
21+ cmd := j.newDockerCmd("exec", dockerPart{line: ""}, raw(c.id),
22+ dockerPart{line: `sh -c "` + alpineCheck + `"`, args: []string{"sh", "-c", alpineCheck}})
23+ j.out(s.num, "##[command]"+cmd.line)
24+ var out []string
25+ res, err := runProcess(procSpec{
26+ Argv: cmd.argv, Dir: dirOrCwd(j.githubValue("workspace")), Env: j.processEnv(newOrdered()), Credential: j.cfg.Credential,
27+ OnLine: func(l string) {
28+ if l != "" {
29+ out = append(out, l)
30+ }
31+ },
32+ OnErrLine: func(l string) {
33+ if l != "" {
34+ out = append(out, l)
35+ }
36+ },
37+ Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
38+ })
39+ if err != nil {
40+ return "", err
41+ }
42+ if res.Cancelled {
43+ return "", errCancelled
44+ }
45+ if res.ExitCode == 0 {
46+ for _, l := range out {
47+ j.debug(s.num, l)
48+ if strings.Contains(strings.ToLower(l), "alpine") {
49+ return j.alpineNode(s, version)
50+ }
51+ }
52+ }
53+ j.debug(s.num, "Running JavaScript Action with default external tool: "+version)
54+ return version, nil
55+}
56+
57+// alpineNode is CheckPlatformForAlpineContainer (StepHost.cs:248-279): the
58+// alpine builds exist for x64 only.
59+func (j *job) alpineNode(s *stepRun, version string) (string, error) {
60+ if runtime.GOARCH != "amd64" {
61+ arch := map[string]string{"arm64": "Arm64", "arm": "Arm", "386": "X86"}[runtime.GOARCH]
62+ if arch == "" {
63+ arch = runtime.GOARCH
64+ }
65+ return "", fmt.Errorf("JavaScript Actions in Alpine containers are only supported on x64 Linux runners. Detected Linux %s", arch)
66+ }
67+ v := version + "_alpine"
68+ j.debug(s.num, "Container distribution is alpine. Running JavaScript Action with external tool: "+v)
69+ return v, nil
70+}
71+
72+// runNodeInContainer is NodeScriptActionHandler.RunAsync (:127-190) with a
73+// ContainerStepHost: the node of the externals mounted at /__e runs the script
74+// at its container path, in the container's workspace.
75+func (j *job) runNodeInContainer(s *stepRun, c *container, script string, env *ordered, cancel <-chan struct{}) stepResult {
76+ num := s.num
77+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
78+ if err := j.containerReady(); err != nil {
79+ return fail(err)
80+ }
81+ version, err := j.nodeInContainer(s, c, s.nodeVersion, cancel)
82+ if err != nil {
83+ return j.containerResult(s, err)
84+ }
85+ file := c.resolvePath(filepath.Join(j.externalsDir(), version, "bin", "node"))
86+ j.mu.Lock()
87+ wd := j.state.GitHub("workspace")
88+ j.mu.Unlock()
89+ henv := j.handlerEnv(env)
90+ if err := checkEnvNames(henv.Keys()); err != nil {
91+ return fail(err)
92+ }
93+ argv, penv := j.execArgv(c, c.resolvePath(wd), henv, file, []string{c.resolvePath(`"` + script + `"`)})
94+ s.rawEnvList = penv
95+ return j.runProc(s, file, j.state.Layout().WorkRoot, argv, "Node Action run completed with exit code %d", cancel)
96+}
A internal/executor/containers_real_test.go +89 -0
@@ -0,0 +1,89 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "os/exec"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+// Real docker: set ERMITE_RUNNER_IT_DOCKER=1 on a host with a Docker daemon
14+// and the images alpine:3.20 and busybox:latest (no pulls are needed; the
15+// tests do pull, so the network must work otherwise).
16+func realDocker(t *testing.T) string {
17+ t.Helper()
18+ if os.Getenv("ERMITE_RUNNER_IT_DOCKER") != "1" {
19+ t.Skip("set ERMITE_RUNNER_IT_DOCKER=1 (needs a Docker daemon)")
20+ }
21+ p, err := exec.LookPath("docker")
22+ if err != nil {
23+ t.Skip("no docker")
24+ }
25+ return p
26+}
27+
28+func TestRealDockerJobContainerAndService(t *testing.T) {
29+ docker := realDocker(t)
30+ needTools(t, "bash")
31+ yaml := ` container: alpine:3.20
32+ services:
33+ web:
34+ image: busybox:latest
35+ options: >-
36+ --health-cmd "true" --health-interval 1s --health-retries 3
37+ --entrypoint sh
38+ command: -c "sleep 300"
39+ steps:
40+ - name: where
41+ run: |
42+ echo "pwd=$PWD"
43+ echo "id=$(id -u)"
44+ echo "home=$HOME"
45+ echo "temp=$RUNNER_TEMP"
46+ ls -d /__w /__t /__e /github/home /github/workflow
47+ echo "hi from alpine" >> "$GITHUB_STEP_SUMMARY"
48+ echo "k=v" >> "$GITHUB_OUTPUT"
49+ - name: svc
50+ run: ping -c1 web >/dev/null && echo "web resolves"
51+`
52+ rec, res := runLocal(t, wfHead+yaml, "j", func(c *Config) {
53+ c.Actions.DockerPath = docker
54+ c.BaseEnv = append(c.BaseEnv, "ACTIONS_SERVICE_CONTAINER_COMMAND=true", "DOCKER_HOST="+os.Getenv("DOCKER_HOST"), "XDG_RUNTIME_DIR="+os.Getenv("XDG_RUNTIME_DIR"))
55+ c.Actions.ExternalsDir = fakeNode(t)
56+ })
57+ log := logOf(rec.step(t, "where")) + logOf(rec.step(t, "svc"))
58+ if res.Conclusion != ConclusionSuccess {
59+ t.Fatalf("%+v\n%s\n%s", res, logOf(rec.step(t, "Initialize containers")), log)
60+ }
61+ for _, w := range []string{"pwd=/__w/hello/hello", "home=/github/home", "temp=/__w/_temp", "/github/workflow", "web resolves"} {
62+ if !strings.Contains(log, w) {
63+ t.Errorf("lacks %q:\n%s", w, log)
64+ }
65+ }
66+ if out, _ := exec.Command(docker, "ps", "-a", "--format", "{{.Names}}", "--filter", "name=busybox").Output(); strings.TrimSpace(string(out)) != "" {
67+ t.Errorf("containers left: %s", out)
68+ }
69+ if out, _ := exec.Command(docker, "network", "ls", "--format", "{{.Name}}", "--filter", "name=github_network_").Output(); strings.TrimSpace(string(out)) != "" {
70+ t.Errorf("networks left: %s", out)
71+ }
72+}
73+
74+func TestRealDockerCancelRemovesEverything(t *testing.T) {
75+ docker := realDocker(t)
76+ needTools(t, "bash")
77+ cancel := NewCanceller()
78+ go func() { time.Sleep(6 * time.Second); cancel.Cancel(CancelUser) }()
79+ rec, res := runLocalC(t, cancel, wfHead+" container: alpine:3.20\n steps:\n - name: long\n run: sleep 120\n", "j", func(c *Config) {
80+ c.Actions.DockerPath = docker
81+ c.BaseEnv = append(c.BaseEnv, "XDG_RUNTIME_DIR="+os.Getenv("XDG_RUNTIME_DIR"))
82+ })
83+ if res.Conclusion != ConclusionCancelled {
84+ t.Fatalf("%+v %v", res, rec.names())
85+ }
86+ if out, _ := exec.Command(docker, "network", "ls", "--format", "{{.Name}}", "--filter", "name=github_network_").Output(); strings.TrimSpace(string(out)) != "" {
87+ t.Errorf("networks left: %s", out)
88+ }
89+}
@@ -0,0 +1,89 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "os/exec"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+// Real docker: set ERMITE_RUNNER_IT_DOCKER=1 on a host with a Docker daemon
14+// and the images alpine:3.20 and busybox:latest (no pulls are needed; the
15+// tests do pull, so the network must work otherwise).
16+func realDocker(t *testing.T) string {
17+ t.Helper()
18+ if os.Getenv("ERMITE_RUNNER_IT_DOCKER") != "1" {
19+ t.Skip("set ERMITE_RUNNER_IT_DOCKER=1 (needs a Docker daemon)")
20+ }
21+ p, err := exec.LookPath("docker")
22+ if err != nil {
23+ t.Skip("no docker")
24+ }
25+ return p
26+}
27+
28+func TestRealDockerJobContainerAndService(t *testing.T) {
29+ docker := realDocker(t)
30+ needTools(t, "bash")
31+ yaml := ` container: alpine:3.20
32+ services:
33+ web:
34+ image: busybox:latest
35+ options: >-
36+ --health-cmd "true" --health-interval 1s --health-retries 3
37+ --entrypoint sh
38+ command: -c "sleep 300"
39+ steps:
40+ - name: where
41+ run: |
42+ echo "pwd=$PWD"
43+ echo "id=$(id -u)"
44+ echo "home=$HOME"
45+ echo "temp=$RUNNER_TEMP"
46+ ls -d /__w /__t /__e /github/home /github/workflow
47+ echo "hi from alpine" >> "$GITHUB_STEP_SUMMARY"
48+ echo "k=v" >> "$GITHUB_OUTPUT"
49+ - name: svc
50+ run: ping -c1 web >/dev/null && echo "web resolves"
51+`
52+ rec, res := runLocal(t, wfHead+yaml, "j", func(c *Config) {
53+ c.Actions.DockerPath = docker
54+ c.BaseEnv = append(c.BaseEnv, "ACTIONS_SERVICE_CONTAINER_COMMAND=true", "DOCKER_HOST="+os.Getenv("DOCKER_HOST"), "XDG_RUNTIME_DIR="+os.Getenv("XDG_RUNTIME_DIR"))
55+ c.Actions.ExternalsDir = fakeNode(t)
56+ })
57+ log := logOf(rec.step(t, "where")) + logOf(rec.step(t, "svc"))
58+ if res.Conclusion != ConclusionSuccess {
59+ t.Fatalf("%+v\n%s\n%s", res, logOf(rec.step(t, "Initialize containers")), log)
60+ }
61+ for _, w := range []string{"pwd=/__w/hello/hello", "home=/github/home", "temp=/__w/_temp", "/github/workflow", "web resolves"} {
62+ if !strings.Contains(log, w) {
63+ t.Errorf("lacks %q:\n%s", w, log)
64+ }
65+ }
66+ if out, _ := exec.Command(docker, "ps", "-a", "--format", "{{.Names}}", "--filter", "name=busybox").Output(); strings.TrimSpace(string(out)) != "" {
67+ t.Errorf("containers left: %s", out)
68+ }
69+ if out, _ := exec.Command(docker, "network", "ls", "--format", "{{.Name}}", "--filter", "name=github_network_").Output(); strings.TrimSpace(string(out)) != "" {
70+ t.Errorf("networks left: %s", out)
71+ }
72+}
73+
74+func TestRealDockerCancelRemovesEverything(t *testing.T) {
75+ docker := realDocker(t)
76+ needTools(t, "bash")
77+ cancel := NewCanceller()
78+ go func() { time.Sleep(6 * time.Second); cancel.Cancel(CancelUser) }()
79+ rec, res := runLocalC(t, cancel, wfHead+" container: alpine:3.20\n steps:\n - name: long\n run: sleep 120\n", "j", func(c *Config) {
80+ c.Actions.DockerPath = docker
81+ c.BaseEnv = append(c.BaseEnv, "XDG_RUNTIME_DIR="+os.Getenv("XDG_RUNTIME_DIR"))
82+ })
83+ if res.Conclusion != ConclusionCancelled {
84+ t.Fatalf("%+v %v", res, rec.names())
85+ }
86+ if out, _ := exec.Command(docker, "network", "ls", "--format", "{{.Name}}", "--filter", "name=github_network_").Output(); strings.TrimSpace(string(out)) != "" {
87+ t.Errorf("networks left: %s", out)
88+ }
89+}
A internal/executor/containers_spec.go +457 -0
@@ -0,0 +1,457 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "fmt"
8+ "path"
9+ "regexp"
10+ "strings"
11+
12+ "bnema.dev/ermite-actions/expr"
13+ "bnema.dev/ermite-actions/template"
14+ "github.com/google/uuid"
15+)
16+
17+// This file is the model of the containers of a job (the job container and
18+// the service containers): ContainerInfo.cs and the evaluation of
19+// jobs.<id>.container and jobs.<id>.services
20+// (PipelineTemplateConverter.ConvertToJobContainer, :248-352).
21+
22+// mount is MountVolume (ContainerInfo.cs:246-318).
23+type mount struct {
24+ source, target string
25+ readOnly bool
26+ // user is the text the workflow gave (UserProvidedValue), empty for the
27+ // mounts of the runner.
28+ user string
29+}
30+
31+// parseVolume is MountVolume.ParseVolumeString (ContainerInfo.cs:283-318):
32+// "source:target[:ro]", "target:ro" or "target". The runner does not look at
33+// the text further; docker rejects what is not valid.
34+func parseVolume(v string) mount {
35+ parts := strings.Split(v, ":")
36+ switch len(parts) {
37+ case 3:
38+ return mount{source: parts[0], target: parts[1], readOnly: strings.EqualFold(parts[2], "ro")}
39+ case 2:
40+ if strings.EqualFold(parts[1], "ro") {
41+ return mount{target: parts[0], readOnly: true}
42+ }
43+ return mount{source: parts[0], target: parts[1]}
44+ }
45+ return mount{target: v}
46+}
47+
48+// portMapping is PortMapping: a published port as `docker port` shows it.
49+type portMapping struct{ host, container, proto string }
50+
51+// container is ContainerInfo: what the workflow asked for and what is known
52+// of the container once it runs.
53+type container struct {
54+ isJob bool
55+ // alias is the network alias of a service (its key in services:).
56+ alias string
57+ // name is the container name (ContainerDisplayName).
58+ name string
59+ image, options, entrypoint, entryArgs string
60+ env *ordered
61+ ports []string // UserPortMappings, in order, without duplicates
62+ mounts []mount
63+ credUser, credPass string
64+ registryServer string
65+ workDir string
66+ id, network, runtimePath string
67+ failedInit bool
68+ createTried, removed bool
69+ maps []pathMap // path translation, first match wins
70+ portMappings []portMapping
71+}
72+
73+// userMounts are the mounts the workflow listed.
74+func (c *container) userMounts() []mount {
75+ var out []mount
76+ for _, m := range c.mounts {
77+ if m.user != "" {
78+ out = append(out, m)
79+ }
80+ }
81+ return out
82+}
83+
84+// translateToContainer is TranslateToContainerPath (ContainerInfo.cs:190-228,
85+// Linux): a path equal to a mapped host directory, or below it.
86+func (c *container) translateToContainer(p string) string {
87+ if p == "" {
88+ return p
89+ }
90+ for _, m := range c.maps {
91+ if p == m.host {
92+ return m.container
93+ }
94+ if strings.HasPrefix(p, m.host+"/") {
95+ return m.container + p[len(m.host):]
96+ }
97+ }
98+ return p
99+}
100+
101+// translateToHost is TranslateToHostPath (ContainerInfo.cs:230-268) for a path
102+// the container printed. An absolute path is normalised first, so that
103+// "/__w/../outside/x" is "/outside/x" and is not found below /__w: the runner
104+// joins the text as it is, which would read <work>/../outside/x on the host.
105+// What comes out of a mapping is the host directory and a cleaned relative
106+// part, so it is always inside the host directory of that mapping. Paths that
107+// match no mapping are returned as given (relative ones included).
108+func (c *container) translateToHost(p string) string {
109+ if p == "" {
110+ return p
111+ }
112+ if strings.HasPrefix(p, "/") {
113+ p = path.Clean(p)
114+ }
115+ for _, m := range c.maps {
116+ if p == m.container {
117+ return m.host
118+ }
119+ if strings.HasPrefix(p, m.container+"/") {
120+ return m.host + p[len(m.container):]
121+ }
122+ }
123+ return p
124+}
125+
126+// insideMappings reports whether a host path (cleaned) is a mapped host
127+// directory or below one: the only host files a path printed by the container
128+// can stand for. A path of the container that no mount covers (/etc/passwd of
129+// the image) is not a file of the host.
130+func (c *container) insideMappings(host string) bool {
131+ host = path.Clean(host)
132+ for _, m := range c.maps {
133+ if host == m.host || strings.HasPrefix(host, m.host+"/") {
134+ return true
135+ }
136+ }
137+ return false
138+}
139+
140+// resolvePathForStepHost is ContainerStepHost.ResolvePathForStepHost
141+// (StepHost.cs:127-152): the path is translated when it starts with the host
142+// path of one of the mounts (a plain string prefix, as in the runner).
143+func (c *container) resolvePath(p string) string {
144+ p = strings.Trim(p, `"`)
145+ for _, m := range c.mounts {
146+ if m.source != "" && strings.HasPrefix(p, m.source) {
147+ return c.translateToContainer(p)
148+ }
149+ }
150+ return p
151+}
152+
153+// parseRegistryHost is DockerUtil.ParseRegistryHostnameFromImageName
154+// (DockerUtil.cs:55-68).
155+func parseRegistryHost(image string) string {
156+ parts := strings.Split(image, "/")
157+ if len(parts) == 2 && strings.ContainsAny(parts[0], ":.") {
158+ return parts[0]
159+ }
160+ if len(parts) > 2 {
161+ return parts[0]
162+ }
163+ return ""
164+}
165+
166+// containerName is the display name `<alias>_<sanitized image>_<6 hex>`
167+// (ContainerInfo.cs:44) with a fresh 32-hex alias.
168+func containerName(image string) string {
169+ return strings.ReplaceAll(uuid.NewString(), "-", "") + "_" + sanitizeName(image) + "_" +
170+ strings.ReplaceAll(uuid.NewString(), "-", "")[:6]
171+}
172+
173+// newContainer is the constructor of ContainerInfo (ContainerInfo.cs:35-96).
174+func (j *job) newContainer(spec *containerSpec, isJob bool, alias string) *container {
175+ c := &container{
176+ isJob: isJob, alias: alias, image: spec.image, options: spec.options,
177+ entrypoint: spec.entrypoint, entryArgs: spec.command, env: newOrdered(),
178+ credUser: spec.credUser, credPass: spec.credPass, registryServer: parseRegistryHost(spec.image),
179+ }
180+ c.name = containerName(spec.image)
181+ for _, p := range spec.env {
182+ c.env.Set(p.Key, p.Value)
183+ }
184+ lay := j.state.Layout()
185+ // The order of the mappings is the runner's (ContainerInfo.cs:55-58).
186+ c.maps = []pathMap{{lay.WorkRoot, "/__w"}, {lay.ToolCache, "/__t"}, {j.externalsDir(), "/__e"}}
187+ if isJob {
188+ c.mounts = append(c.mounts, mount{source: "/var/run/docker.sock", target: "/var/run/docker.sock"})
189+ }
190+ for _, p := range spec.ports {
191+ if !containsString(c.ports, p) { // UserPortMappings[port] = port
192+ c.ports = append(c.ports, p)
193+ }
194+ }
195+ for _, v := range spec.volumes {
196+ m := parseVolume(v)
197+ m.user = v
198+ c.mounts = append(c.mounts, m)
199+ }
200+ c.proxyEnv(j.baseEnv)
201+ return c
202+}
203+
204+func containsString(l []string, s string) bool {
205+ for _, x := range l {
206+ if x == s {
207+ return true
208+ }
209+ }
210+ return false
211+}
212+
213+// proxyEnv is UpdateWebProxyEnv (ContainerInfo.cs:353-374): the proxy
214+// settings of the runner, unless the container sets them itself.
215+func (c *container) proxyEnv(base []string) {
216+ pick := func(names ...string) string {
217+ for _, n := range names {
218+ if v := strings.TrimSpace(envValue(base, n)); v != "" {
219+ return v
220+ }
221+ }
222+ return ""
223+ }
224+ add := func(v string, keys ...string) {
225+ if v == "" {
226+ return
227+ }
228+ for _, k := range keys {
229+ if _, ok := c.env.Get(k); !ok {
230+ c.env.Set(k, v)
231+ }
232+ }
233+ }
234+ add(pick("http_proxy", "HTTP_PROXY"), "HTTP_PROXY", "http_proxy")
235+ add(pick("https_proxy", "HTTPS_PROXY"), "HTTPS_PROXY", "https_proxy")
236+ add(pick("no_proxy", "NO_PROXY"), "NO_PROXY", "no_proxy")
237+}
238+
239+// containerSpec is JobContainer: the evaluated container: or service.
240+type containerSpec struct {
241+ image, options, entrypoint, command string
242+ env []Pair
243+ ports, volumes []string
244+ credUser, credPass string
245+}
246+
247+// asContainerString is AssertString of the converter: scalars are strings.
248+func asContainerString(v expr.Value, what string) (string, error) {
249+ switch v.Kind() {
250+ case expr.KindArray, expr.KindDictionary:
251+ return "", ghErrorf("Unexpected type '%s' encountered while reading '%s'. The type 'StringToken' was expected.", v.Kind(), what)
252+ }
253+ return expr.CoerceString(v), nil
254+}
255+
256+func containerStrings(v expr.Value, what string) ([]string, error) {
257+ a, ok := v.(*expr.Array)
258+ if !ok {
259+ return nil, ghErrorf("Unexpected type '%s' encountered while reading '%s'. The type 'SequenceToken' was expected.", v.Kind(), what)
260+ }
261+ var out []string
262+ for _, it := range a.Items() {
263+ s, err := asContainerString(it, what+" "+expr.CoerceString(it))
264+ if err != nil {
265+ return nil, err
266+ }
267+ out = append(out, s)
268+ }
269+ return out, nil
270+}
271+
272+// imageRefRe is the grammar of a docker image reference
273+// (github.com/distribution/reference): [domain[:port]/]path[:tag][@digest].
274+// Path components may be upper case here: docker words that error itself, as
275+// it does for the image of the recorded ctr-errors scenario. What matters is
276+// that the text is one argument that cannot be taken for an option (the first
277+// character is a letter or a digit) and holds no space or control character.
278+var imageRefRe = regexp.MustCompile(
279+ `^(?:(?:` + domainName + `|` + ipv6Address + `)(?::[0-9]+)?/)?` +
280+ `[A-Za-z0-9]+(?:(?:[._]|__|-+)[A-Za-z0-9]+)*(?:/[A-Za-z0-9]+(?:(?:[._]|__|-+)[A-Za-z0-9]+)*)*` +
281+ `(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?` +
282+ `(?:@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9A-Fa-f]{32,})?$`)
283+
284+// domainName and ipv6Address are the domain grammar of distribution/reference
285+// (reference/regexp.go: domainNameComponent, domainName, ipv6address),
286+// copied here instead of importing the module.
287+const (
288+ domainNameComponent = `(?:[A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])`
289+ domainName = domainNameComponent + `(?:\.` + domainNameComponent + `)*`
290+ ipv6Address = `\[(?:[A-Fa-f0-9:]+)\]`
291+)
292+
293+// validImageRef reports whether image can be passed to docker pull and create.
294+func validImageRef(image string) bool { return len(image) <= 1024 && imageRefRe.MatchString(image) }
295+
296+// aliasRe is the name of a service: it becomes a network alias and is never
297+// taken for an option.
298+var aliasRe = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,62}$`)
299+
300+// containerIDRe is the id docker create prints.
301+var containerIDRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
302+
303+// serviceCommandAllowed is the feature flag of entrypoint: and command: of a
304+// service (ExecutionContext.cs:1545: actions_service_container_command or
305+// ACTIONS_SERVICE_CONTAINER_COMMAND; ermite has no feature flags).
306+func (j *job) serviceCommandAllowed() bool {
307+ return convertToBool(envValue(j.baseEnv, "ACTIONS_SERVICE_CONTAINER_COMMAND"))
308+}
309+
310+// convertContainer is ConvertToJobContainer (PipelineTemplateConverter.cs:
311+// 248-352) on an evaluated value. nil means no container (an empty image).
312+func (j *job) convertContainer(v expr.Value, service bool) (*containerSpec, error) {
313+ spec := &containerSpec{}
314+ if d, ok := v.(*expr.Dictionary); ok {
315+ for _, p := range d.Pairs() {
316+ var err error
317+ switch p.Key {
318+ case "image":
319+ spec.image, err = asContainerString(p.Value, "container image")
320+ case "env":
321+ spec.env, err = containerEnv(p.Value)
322+ case "options":
323+ spec.options, err = asContainerString(p.Value, "container options")
324+ case "entrypoint", "command":
325+ if !service || !j.serviceCommandAllowed() {
326+ return nil, ghErrorf("The key '%s' is not allowed", p.Key)
327+ }
328+ s, e := asContainerString(p.Value, "container "+p.Key)
329+ err = e
330+ if p.Key == "entrypoint" {
331+ spec.entrypoint = s
332+ } else {
333+ spec.command = s
334+ }
335+ case "ports":
336+ spec.ports, err = containerStrings(p.Value, "container ports")
337+ case "volumes":
338+ spec.volumes, err = containerStrings(p.Value, "container volumes")
339+ case "credentials":
340+ err = containerCredentials(p.Value, spec)
341+ default:
342+ err = ghErrorf("Error while reading 'container key'. Unexpected value '%s'", p.Key)
343+ }
344+ if err != nil {
345+ return nil, err
346+ }
347+ }
348+ } else {
349+ s, err := asContainerString(v, "container")
350+ if err != nil {
351+ return nil, err
352+ }
353+ spec.image = s
354+ }
355+ spec.image = strings.TrimPrefix(spec.image, "docker://")
356+ if spec.image == "" {
357+ return nil, nil
358+ }
359+ if !validImageRef(spec.image) {
360+ return nil, ghErrorf("Invalid container image '%s'", spec.image)
361+ }
362+ return spec, nil
363+}
364+
365+func containerEnv(v expr.Value) ([]Pair, error) {
366+ d, ok := v.(*expr.Dictionary)
367+ if !ok {
368+ return nil, ghErrorf("Unexpected type '%s' encountered while reading 'container env'. The type 'MappingToken' was expected.", v.Kind())
369+ }
370+ var out []Pair
371+ for _, p := range d.Pairs() {
372+ if err := checkEnvNames([]string{p.Key}); err != nil {
373+ return nil, err
374+ }
375+ s, err := asContainerString(p.Value, "container env "+p.Key)
376+ if err != nil {
377+ return nil, err
378+ }
379+ out = append(out, Pair{p.Key, s})
380+ }
381+ return out, nil
382+}
383+
384+func containerCredentials(v expr.Value, spec *containerSpec) error {
385+ d, ok := v.(*expr.Dictionary)
386+ if !ok {
387+ return ghErrorf("Unexpected type '%s' encountered while reading 'credentials'. The type 'MappingToken' was expected.", v.Kind())
388+ }
389+ for _, p := range d.Pairs() {
390+ s, err := asContainerString(p.Value, p.Key)
391+ if err != nil {
392+ return err
393+ }
394+ switch p.Key {
395+ case "username":
396+ spec.credUser = s
397+ case "password":
398+ spec.credPass = s
399+ default:
400+ return ghErrorf("Error while reading 'credentials key %s'. Unexpected value '%s'", p.Key, p.Key)
401+ }
402+ }
403+ return nil
404+}
405+
406+// evalContainers evaluates container: and services: of the job
407+// (JobExtension.cs:250-275) with the contexts the job has by then.
408+func (j *job) evalContainers() (job *container, services []*container, err error) {
409+ ctx := context.Background()
410+ j.mu.Lock()
411+ env := j.state.JobEnv()
412+ j.mu.Unlock()
413+ te := j.evalEnv(env)
414+ if t := j.jb.Container; t != nil {
415+ v, e := template.Evaluate(ctx, t, te)
416+ if e != nil {
417+ return nil, nil, e
418+ }
419+ if _, null := v.(expr.Null); !null {
420+ spec, e := j.convertContainer(v, false)
421+ if e != nil {
422+ return nil, nil, e
423+ }
424+ if spec != nil {
425+ job = j.newContainer(spec, true, "")
426+ }
427+ }
428+ }
429+ if t := j.jb.Services; t != nil {
430+ v, e := template.Evaluate(ctx, t, te)
431+ if e != nil {
432+ return nil, nil, e
433+ }
434+ d, ok := v.(*expr.Dictionary)
435+ if !ok {
436+ if _, null := v.(expr.Null); null {
437+ return job, nil, nil
438+ }
439+ return nil, nil, ghErrorf("Unexpected type '%s' encountered while reading 'services'. The type 'MappingToken' was expected.", v.Kind())
440+ }
441+ for _, p := range d.Pairs() {
442+ if !aliasRe.MatchString(p.Key) {
443+ return nil, nil, ghErrorf("Invalid service name '%s': use letters, digits, '_', '.' and '-', starting with a letter, a digit or '_'", p.Key)
444+ }
445+ spec, e := j.convertContainer(p.Value, true)
446+ if e != nil {
447+ return nil, nil, e
448+ }
449+ if spec == nil {
450+ j.out(1, fmt.Sprintf("The service '%s' will not be started because the container definition has an empty image.", p.Key))
451+ continue
452+ }
453+ services = append(services, j.newContainer(spec, false, p.Key))
454+ }
455+ }
456+ return job, services, nil
457+}
@@ -0,0 +1,457 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "fmt"
8+ "path"
9+ "regexp"
10+ "strings"
11+
12+ "bnema.dev/ermite-actions/expr"
13+ "bnema.dev/ermite-actions/template"
14+ "github.com/google/uuid"
15+)
16+
17+// This file is the model of the containers of a job (the job container and
18+// the service containers): ContainerInfo.cs and the evaluation of
19+// jobs.<id>.container and jobs.<id>.services
20+// (PipelineTemplateConverter.ConvertToJobContainer, :248-352).
21+
22+// mount is MountVolume (ContainerInfo.cs:246-318).
23+type mount struct {
24+ source, target string
25+ readOnly bool
26+ // user is the text the workflow gave (UserProvidedValue), empty for the
27+ // mounts of the runner.
28+ user string
29+}
30+
31+// parseVolume is MountVolume.ParseVolumeString (ContainerInfo.cs:283-318):
32+// "source:target[:ro]", "target:ro" or "target". The runner does not look at
33+// the text further; docker rejects what is not valid.
34+func parseVolume(v string) mount {
35+ parts := strings.Split(v, ":")
36+ switch len(parts) {
37+ case 3:
38+ return mount{source: parts[0], target: parts[1], readOnly: strings.EqualFold(parts[2], "ro")}
39+ case 2:
40+ if strings.EqualFold(parts[1], "ro") {
41+ return mount{target: parts[0], readOnly: true}
42+ }
43+ return mount{source: parts[0], target: parts[1]}
44+ }
45+ return mount{target: v}
46+}
47+
48+// portMapping is PortMapping: a published port as `docker port` shows it.
49+type portMapping struct{ host, container, proto string }
50+
51+// container is ContainerInfo: what the workflow asked for and what is known
52+// of the container once it runs.
53+type container struct {
54+ isJob bool
55+ // alias is the network alias of a service (its key in services:).
56+ alias string
57+ // name is the container name (ContainerDisplayName).
58+ name string
59+ image, options, entrypoint, entryArgs string
60+ env *ordered
61+ ports []string // UserPortMappings, in order, without duplicates
62+ mounts []mount
63+ credUser, credPass string
64+ registryServer string
65+ workDir string
66+ id, network, runtimePath string
67+ failedInit bool
68+ createTried, removed bool
69+ maps []pathMap // path translation, first match wins
70+ portMappings []portMapping
71+}
72+
73+// userMounts are the mounts the workflow listed.
74+func (c *container) userMounts() []mount {
75+ var out []mount
76+ for _, m := range c.mounts {
77+ if m.user != "" {
78+ out = append(out, m)
79+ }
80+ }
81+ return out
82+}
83+
84+// translateToContainer is TranslateToContainerPath (ContainerInfo.cs:190-228,
85+// Linux): a path equal to a mapped host directory, or below it.
86+func (c *container) translateToContainer(p string) string {
87+ if p == "" {
88+ return p
89+ }
90+ for _, m := range c.maps {
91+ if p == m.host {
92+ return m.container
93+ }
94+ if strings.HasPrefix(p, m.host+"/") {
95+ return m.container + p[len(m.host):]
96+ }
97+ }
98+ return p
99+}
100+
101+// translateToHost is TranslateToHostPath (ContainerInfo.cs:230-268) for a path
102+// the container printed. An absolute path is normalised first, so that
103+// "/__w/../outside/x" is "/outside/x" and is not found below /__w: the runner
104+// joins the text as it is, which would read <work>/../outside/x on the host.
105+// What comes out of a mapping is the host directory and a cleaned relative
106+// part, so it is always inside the host directory of that mapping. Paths that
107+// match no mapping are returned as given (relative ones included).
108+func (c *container) translateToHost(p string) string {
109+ if p == "" {
110+ return p
111+ }
112+ if strings.HasPrefix(p, "/") {
113+ p = path.Clean(p)
114+ }
115+ for _, m := range c.maps {
116+ if p == m.container {
117+ return m.host
118+ }
119+ if strings.HasPrefix(p, m.container+"/") {
120+ return m.host + p[len(m.container):]
121+ }
122+ }
123+ return p
124+}
125+
126+// insideMappings reports whether a host path (cleaned) is a mapped host
127+// directory or below one: the only host files a path printed by the container
128+// can stand for. A path of the container that no mount covers (/etc/passwd of
129+// the image) is not a file of the host.
130+func (c *container) insideMappings(host string) bool {
131+ host = path.Clean(host)
132+ for _, m := range c.maps {
133+ if host == m.host || strings.HasPrefix(host, m.host+"/") {
134+ return true
135+ }
136+ }
137+ return false
138+}
139+
140+// resolvePathForStepHost is ContainerStepHost.ResolvePathForStepHost
141+// (StepHost.cs:127-152): the path is translated when it starts with the host
142+// path of one of the mounts (a plain string prefix, as in the runner).
143+func (c *container) resolvePath(p string) string {
144+ p = strings.Trim(p, `"`)
145+ for _, m := range c.mounts {
146+ if m.source != "" && strings.HasPrefix(p, m.source) {
147+ return c.translateToContainer(p)
148+ }
149+ }
150+ return p
151+}
152+
153+// parseRegistryHost is DockerUtil.ParseRegistryHostnameFromImageName
154+// (DockerUtil.cs:55-68).
155+func parseRegistryHost(image string) string {
156+ parts := strings.Split(image, "/")
157+ if len(parts) == 2 && strings.ContainsAny(parts[0], ":.") {
158+ return parts[0]
159+ }
160+ if len(parts) > 2 {
161+ return parts[0]
162+ }
163+ return ""
164+}
165+
166+// containerName is the display name `<alias>_<sanitized image>_<6 hex>`
167+// (ContainerInfo.cs:44) with a fresh 32-hex alias.
168+func containerName(image string) string {
169+ return strings.ReplaceAll(uuid.NewString(), "-", "") + "_" + sanitizeName(image) + "_" +
170+ strings.ReplaceAll(uuid.NewString(), "-", "")[:6]
171+}
172+
173+// newContainer is the constructor of ContainerInfo (ContainerInfo.cs:35-96).
174+func (j *job) newContainer(spec *containerSpec, isJob bool, alias string) *container {
175+ c := &container{
176+ isJob: isJob, alias: alias, image: spec.image, options: spec.options,
177+ entrypoint: spec.entrypoint, entryArgs: spec.command, env: newOrdered(),
178+ credUser: spec.credUser, credPass: spec.credPass, registryServer: parseRegistryHost(spec.image),
179+ }
180+ c.name = containerName(spec.image)
181+ for _, p := range spec.env {
182+ c.env.Set(p.Key, p.Value)
183+ }
184+ lay := j.state.Layout()
185+ // The order of the mappings is the runner's (ContainerInfo.cs:55-58).
186+ c.maps = []pathMap{{lay.WorkRoot, "/__w"}, {lay.ToolCache, "/__t"}, {j.externalsDir(), "/__e"}}
187+ if isJob {
188+ c.mounts = append(c.mounts, mount{source: "/var/run/docker.sock", target: "/var/run/docker.sock"})
189+ }
190+ for _, p := range spec.ports {
191+ if !containsString(c.ports, p) { // UserPortMappings[port] = port
192+ c.ports = append(c.ports, p)
193+ }
194+ }
195+ for _, v := range spec.volumes {
196+ m := parseVolume(v)
197+ m.user = v
198+ c.mounts = append(c.mounts, m)
199+ }
200+ c.proxyEnv(j.baseEnv)
201+ return c
202+}
203+
204+func containsString(l []string, s string) bool {
205+ for _, x := range l {
206+ if x == s {
207+ return true
208+ }
209+ }
210+ return false
211+}
212+
213+// proxyEnv is UpdateWebProxyEnv (ContainerInfo.cs:353-374): the proxy
214+// settings of the runner, unless the container sets them itself.
215+func (c *container) proxyEnv(base []string) {
216+ pick := func(names ...string) string {
217+ for _, n := range names {
218+ if v := strings.TrimSpace(envValue(base, n)); v != "" {
219+ return v
220+ }
221+ }
222+ return ""
223+ }
224+ add := func(v string, keys ...string) {
225+ if v == "" {
226+ return
227+ }
228+ for _, k := range keys {
229+ if _, ok := c.env.Get(k); !ok {
230+ c.env.Set(k, v)
231+ }
232+ }
233+ }
234+ add(pick("http_proxy", "HTTP_PROXY"), "HTTP_PROXY", "http_proxy")
235+ add(pick("https_proxy", "HTTPS_PROXY"), "HTTPS_PROXY", "https_proxy")
236+ add(pick("no_proxy", "NO_PROXY"), "NO_PROXY", "no_proxy")
237+}
238+
239+// containerSpec is JobContainer: the evaluated container: or service.
240+type containerSpec struct {
241+ image, options, entrypoint, command string
242+ env []Pair
243+ ports, volumes []string
244+ credUser, credPass string
245+}
246+
247+// asContainerString is AssertString of the converter: scalars are strings.
248+func asContainerString(v expr.Value, what string) (string, error) {
249+ switch v.Kind() {
250+ case expr.KindArray, expr.KindDictionary:
251+ return "", ghErrorf("Unexpected type '%s' encountered while reading '%s'. The type 'StringToken' was expected.", v.Kind(), what)
252+ }
253+ return expr.CoerceString(v), nil
254+}
255+
256+func containerStrings(v expr.Value, what string) ([]string, error) {
257+ a, ok := v.(*expr.Array)
258+ if !ok {
259+ return nil, ghErrorf("Unexpected type '%s' encountered while reading '%s'. The type 'SequenceToken' was expected.", v.Kind(), what)
260+ }
261+ var out []string
262+ for _, it := range a.Items() {
263+ s, err := asContainerString(it, what+" "+expr.CoerceString(it))
264+ if err != nil {
265+ return nil, err
266+ }
267+ out = append(out, s)
268+ }
269+ return out, nil
270+}
271+
272+// imageRefRe is the grammar of a docker image reference
273+// (github.com/distribution/reference): [domain[:port]/]path[:tag][@digest].
274+// Path components may be upper case here: docker words that error itself, as
275+// it does for the image of the recorded ctr-errors scenario. What matters is
276+// that the text is one argument that cannot be taken for an option (the first
277+// character is a letter or a digit) and holds no space or control character.
278+var imageRefRe = regexp.MustCompile(
279+ `^(?:(?:` + domainName + `|` + ipv6Address + `)(?::[0-9]+)?/)?` +
280+ `[A-Za-z0-9]+(?:(?:[._]|__|-+)[A-Za-z0-9]+)*(?:/[A-Za-z0-9]+(?:(?:[._]|__|-+)[A-Za-z0-9]+)*)*` +
281+ `(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?` +
282+ `(?:@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9A-Fa-f]{32,})?$`)
283+
284+// domainName and ipv6Address are the domain grammar of distribution/reference
285+// (reference/regexp.go: domainNameComponent, domainName, ipv6address),
286+// copied here instead of importing the module.
287+const (
288+ domainNameComponent = `(?:[A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])`
289+ domainName = domainNameComponent + `(?:\.` + domainNameComponent + `)*`
290+ ipv6Address = `\[(?:[A-Fa-f0-9:]+)\]`
291+)
292+
293+// validImageRef reports whether image can be passed to docker pull and create.
294+func validImageRef(image string) bool { return len(image) <= 1024 && imageRefRe.MatchString(image) }
295+
296+// aliasRe is the name of a service: it becomes a network alias and is never
297+// taken for an option.
298+var aliasRe = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,62}$`)
299+
300+// containerIDRe is the id docker create prints.
301+var containerIDRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
302+
303+// serviceCommandAllowed is the feature flag of entrypoint: and command: of a
304+// service (ExecutionContext.cs:1545: actions_service_container_command or
305+// ACTIONS_SERVICE_CONTAINER_COMMAND; ermite has no feature flags).
306+func (j *job) serviceCommandAllowed() bool {
307+ return convertToBool(envValue(j.baseEnv, "ACTIONS_SERVICE_CONTAINER_COMMAND"))
308+}
309+
310+// convertContainer is ConvertToJobContainer (PipelineTemplateConverter.cs:
311+// 248-352) on an evaluated value. nil means no container (an empty image).
312+func (j *job) convertContainer(v expr.Value, service bool) (*containerSpec, error) {
313+ spec := &containerSpec{}
314+ if d, ok := v.(*expr.Dictionary); ok {
315+ for _, p := range d.Pairs() {
316+ var err error
317+ switch p.Key {
318+ case "image":
319+ spec.image, err = asContainerString(p.Value, "container image")
320+ case "env":
321+ spec.env, err = containerEnv(p.Value)
322+ case "options":
323+ spec.options, err = asContainerString(p.Value, "container options")
324+ case "entrypoint", "command":
325+ if !service || !j.serviceCommandAllowed() {
326+ return nil, ghErrorf("The key '%s' is not allowed", p.Key)
327+ }
328+ s, e := asContainerString(p.Value, "container "+p.Key)
329+ err = e
330+ if p.Key == "entrypoint" {
331+ spec.entrypoint = s
332+ } else {
333+ spec.command = s
334+ }
335+ case "ports":
336+ spec.ports, err = containerStrings(p.Value, "container ports")
337+ case "volumes":
338+ spec.volumes, err = containerStrings(p.Value, "container volumes")
339+ case "credentials":
340+ err = containerCredentials(p.Value, spec)
341+ default:
342+ err = ghErrorf("Error while reading 'container key'. Unexpected value '%s'", p.Key)
343+ }
344+ if err != nil {
345+ return nil, err
346+ }
347+ }
348+ } else {
349+ s, err := asContainerString(v, "container")
350+ if err != nil {
351+ return nil, err
352+ }
353+ spec.image = s
354+ }
355+ spec.image = strings.TrimPrefix(spec.image, "docker://")
356+ if spec.image == "" {
357+ return nil, nil
358+ }
359+ if !validImageRef(spec.image) {
360+ return nil, ghErrorf("Invalid container image '%s'", spec.image)
361+ }
362+ return spec, nil
363+}
364+
365+func containerEnv(v expr.Value) ([]Pair, error) {
366+ d, ok := v.(*expr.Dictionary)
367+ if !ok {
368+ return nil, ghErrorf("Unexpected type '%s' encountered while reading 'container env'. The type 'MappingToken' was expected.", v.Kind())
369+ }
370+ var out []Pair
371+ for _, p := range d.Pairs() {
372+ if err := checkEnvNames([]string{p.Key}); err != nil {
373+ return nil, err
374+ }
375+ s, err := asContainerString(p.Value, "container env "+p.Key)
376+ if err != nil {
377+ return nil, err
378+ }
379+ out = append(out, Pair{p.Key, s})
380+ }
381+ return out, nil
382+}
383+
384+func containerCredentials(v expr.Value, spec *containerSpec) error {
385+ d, ok := v.(*expr.Dictionary)
386+ if !ok {
387+ return ghErrorf("Unexpected type '%s' encountered while reading 'credentials'. The type 'MappingToken' was expected.", v.Kind())
388+ }
389+ for _, p := range d.Pairs() {
390+ s, err := asContainerString(p.Value, p.Key)
391+ if err != nil {
392+ return err
393+ }
394+ switch p.Key {
395+ case "username":
396+ spec.credUser = s
397+ case "password":
398+ spec.credPass = s
399+ default:
400+ return ghErrorf("Error while reading 'credentials key %s'. Unexpected value '%s'", p.Key, p.Key)
401+ }
402+ }
403+ return nil
404+}
405+
406+// evalContainers evaluates container: and services: of the job
407+// (JobExtension.cs:250-275) with the contexts the job has by then.
408+func (j *job) evalContainers() (job *container, services []*container, err error) {
409+ ctx := context.Background()
410+ j.mu.Lock()
411+ env := j.state.JobEnv()
412+ j.mu.Unlock()
413+ te := j.evalEnv(env)
414+ if t := j.jb.Container; t != nil {
415+ v, e := template.Evaluate(ctx, t, te)
416+ if e != nil {
417+ return nil, nil, e
418+ }
419+ if _, null := v.(expr.Null); !null {
420+ spec, e := j.convertContainer(v, false)
421+ if e != nil {
422+ return nil, nil, e
423+ }
424+ if spec != nil {
425+ job = j.newContainer(spec, true, "")
426+ }
427+ }
428+ }
429+ if t := j.jb.Services; t != nil {
430+ v, e := template.Evaluate(ctx, t, te)
431+ if e != nil {
432+ return nil, nil, e
433+ }
434+ d, ok := v.(*expr.Dictionary)
435+ if !ok {
436+ if _, null := v.(expr.Null); null {
437+ return job, nil, nil
438+ }
439+ return nil, nil, ghErrorf("Unexpected type '%s' encountered while reading 'services'. The type 'MappingToken' was expected.", v.Kind())
440+ }
441+ for _, p := range d.Pairs() {
442+ if !aliasRe.MatchString(p.Key) {
443+ return nil, nil, ghErrorf("Invalid service name '%s': use letters, digits, '_', '.' and '-', starting with a letter, a digit or '_'", p.Key)
444+ }
445+ spec, e := j.convertContainer(p.Value, true)
446+ if e != nil {
447+ return nil, nil, e
448+ }
449+ if spec == nil {
450+ j.out(1, fmt.Sprintf("The service '%s' will not be started because the container definition has an empty image.", p.Key))
451+ continue
452+ }
453+ services = append(services, j.newContainer(spec, false, p.Key))
454+ }
455+ }
456+ return job, services, nil
457+}
A internal/executor/containers_spec_test.go +188 -0
@@ -0,0 +1,188 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "path"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "bnema.dev/ermite-actions/expr"
12+)
13+
14+func TestParseVolume(t *testing.T) {
15+ for in, want := range map[string]mount{
16+ "/data": {target: "/data"},
17+ "vol:/data": {source: "vol", target: "/data"},
18+ "/data:ro": {target: "/data", readOnly: true},
19+ "/h:/c:ro": {source: "/h", target: "/c", readOnly: true},
20+ "/h:/c:rw": {source: "/h", target: "/c"},
21+ "/h:/c:ro:extra": {target: "/h:/c:ro:extra"},
22+ "/data:RO": {target: "/data", readOnly: true},
23+ } {
24+ if got := parseVolume(in); got != want {
25+ t.Errorf("%q: %+v, want %+v", in, got, want)
26+ }
27+ }
28+}
29+
30+func TestContainerPathTranslation(t *testing.T) {
31+ c := &container{maps: []pathMap{{"/h/work", "/__w"}, {"/opt/tc", "/__t"}}}
32+ c.mounts = []mount{{source: "/h/work", target: "/__w"}}
33+ for in, want := range map[string]string{
34+ "/h/work": "/__w", "/h/work/_temp/x": "/__w/_temp/x", "/h/workx": "/h/workx", "": "", "/etc": "/etc",
35+ } {
36+ if got := c.translateToContainer(in); got != want {
37+ t.Errorf("%q -> %q, want %q", in, got, want)
38+ }
39+ }
40+ if got := c.translateToHost("/__w/a/b"); got != "/h/work/a/b" {
41+ t.Error(got)
42+ }
43+ // ResolvePathForStepHost translates quoted paths under a mount only.
44+ if got := c.resolvePath(`"/h/work/a"`); got != "/__w/a" {
45+ t.Error(got)
46+ }
47+ if got := c.resolvePath("/opt/tc/x"); got != "/opt/tc/x" {
48+ t.Errorf("no mount source covers it: %s", got)
49+ }
50+}
51+
52+func TestRegistryHost(t *testing.T) {
53+ for in, want := range map[string]string{
54+ "ubuntu:24.04": "", "owner/img": "", "ghcr.io/owner/img:1": "ghcr.io",
55+ "localhost:5000/img": "localhost:5000", "a/b/c": "a",
56+ } {
57+ if got := parseRegistryHost(in); got != want {
58+ t.Errorf("%q: %q want %q", in, got, want)
59+ }
60+ }
61+}
62+
63+func mustValue(t *testing.T, js string) expr.Value {
64+ t.Helper()
65+ v, err := expr.FromJSON([]byte(js))
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ return v
70+}
71+
72+func TestConvertContainerForms(t *testing.T) {
73+ j := &job{}
74+ v := mustValue(t, `{"image":"docker://ubuntu:24.04","env":{"A":"1","B":2},"ports":[8080,"9000:9001"],"volumes":["v:/d"],"options":"--cpus 1","credentials":{"username":"u","password":"p"}}`)
75+ spec, err := j.convertContainer(v, false)
76+ if err != nil || spec.image != "ubuntu:24.04" || len(spec.env) != 2 || spec.env[1].Value != "2" ||
77+ strings.Join(spec.ports, ",") != "8080,9000:9001" || spec.credPass != "p" || spec.options != "--cpus 1" {
78+ t.Fatalf("%+v %v", spec, err)
79+ }
80+ if s, err := j.convertContainer(mustValue(t, `""`), false); s != nil || err != nil {
81+ t.Errorf("empty string: %v %v", s, err)
82+ }
83+ if s, err := j.convertContainer(mustValue(t, `{"image":""}`), true); s != nil || err != nil {
84+ t.Errorf("empty image: %v %v", s, err)
85+ }
86+ for _, bad := range []string{`{"entrypoint":"x","image":"i"}`, `{"nope":1,"image":"i"}`, `{"image":["a"]}`} {
87+ if _, err := j.convertContainer(mustValue(t, bad), false); err == nil {
88+ t.Errorf("%s accepted", bad)
89+ }
90+ }
91+ // A service may only set entrypoint and command when the server allows it.
92+ if _, err := j.convertContainer(mustValue(t, `{"image":"i","command":"x"}`), true); err == nil {
93+ t.Error("command without the feature")
94+ }
95+ j.baseEnv = []string{"ACTIONS_SERVICE_CONTAINER_COMMAND=true"}
96+ if s, err := j.convertContainer(mustValue(t, `{"image":"i","command":"x","entrypoint":"e"}`), true); err != nil || s.command != "x" || s.entrypoint != "e" {
97+ t.Errorf("%+v %v", s, err)
98+ }
99+}
100+
101+func TestImageReferenceGrammar(t *testing.T) {
102+ good := []string{"ubuntu", "ubuntu:24.04", "ghcr.io/o/img:1", "localhost:5000/a/b:tag", "a/b_c.d-e", "redis@sha256:" + strings.Repeat("a", 64),
103+ "r.io:443/x/y:1@sha256:" + strings.Repeat("0", 64), "Ubuntu:24.04",
104+ "[::1]:5000/x", "[::1]/x/y:tag", "[2001:db8::1]:443/a/b@sha256:" + strings.Repeat("a", 64)}
105+ bad := []string{"", "--help", "-x", "-", " a", "a b", "a\nb", "a;b", "a$(x)", "a:", "a:-x", "a@sha256:zz", "/a", "a//b", "a:b:c", "[::1/x", "[]/x", "[zz]/x", "[::1]x", "[::1]:/x", strings.Repeat("a", 1100)}
106+ for _, g := range good {
107+ if !validImageRef(g) {
108+ t.Errorf("rejected %q", g)
109+ }
110+ }
111+ for _, b := range bad {
112+ if validImageRef(b) {
113+ t.Errorf("accepted %q", b)
114+ }
115+ }
116+ j := &job{}
117+ if _, err := j.convertContainer(mustValue(t, `"--help"`), false); err == nil || !strings.Contains(err.Error(), "Invalid container image '--help'") {
118+ t.Errorf("container: --help: %v", err)
119+ }
120+}
121+
122+func TestServiceNamesAndContainerIDs(t *testing.T) {
123+ f := newFakeCtr(t)
124+ for _, name := range []string{"-x", ".y", "a b", "a/b"} {
125+ rec, res := ctrRun(t, f, " services:\n \""+name+"\":\n image: redis:7\n", " - run: echo never\n", nil)
126+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "Set up job")), "Invalid service name") {
127+ t.Errorf("%q: %+v", name, res)
128+ }
129+ }
130+ for id, ok := range map[string]bool{strings.Repeat("a", 64): true, strings.Repeat("0", 64): true, "Usage: docker create": false, strings.Repeat("a", 63): false, strings.Repeat("A", 64): false, "--help": false} {
131+ if containerIDRe.MatchString(id) != ok {
132+ t.Errorf("id %q", id)
133+ }
134+ }
135+ // A help text printed by create is not taken for an id: nothing is started.
136+ f.set(t, "create_out", "Usage: docker create [OPTIONS] IMAGE\n")
137+ rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - run: echo never\n", nil)
138+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "Initialize containers")), "##[error]Docker create did not return a container id: 'Usage: docker create [OPTIONS] IMAGE'") {
139+ t.Errorf("%+v\n%s", res, logOf(rec.step(t, "Initialize containers")))
140+ }
141+ for _, c := range f.calls(t) {
142+ if c[0] == "start" {
143+ t.Errorf("started %v", c)
144+ }
145+ }
146+}
147+
148+func TestTranslateToHostDoesNotEscapeTheMapping(t *testing.T) {
149+ c := &container{maps: []pathMap{{"/h/work", "/__w"}, {"/opt/tc", "/__t"}}}
150+ for in, want := range map[string]string{
151+ "/__w/a/b": "/h/work/a/b",
152+ "/__w/a/../b": "/h/work/b",
153+ "/__w/../outside/x": "/outside/x",
154+ "/__w/a/../../../etc": "/etc",
155+ "/__w//a///b/": "/h/work/a/b",
156+ "/__w": "/h/work",
157+ "/__t/../__w/x": "/h/work/x",
158+ "/__wx/y": "/__wx/y",
159+ "rel/../x": "rel/../x",
160+ } {
161+ got := c.translateToHost(in)
162+ if got != want {
163+ t.Errorf("%q -> %q, want %q", in, got, want)
164+ }
165+ if strings.HasPrefix(in, "/__w") && !strings.HasPrefix(got, "/h/work") && got != path.Clean(in) {
166+ t.Errorf("%q escaped to %q", in, got)
167+ }
168+ }
169+}
170+
171+// A matcher file named by a command of the container is read only when it is
172+// in a directory shared with the host: ".." cannot reach another host file.
173+func TestMatcherPathFromTheContainerStaysInside(t *testing.T) {
174+ f := newFakeCtr(t)
175+ var outside string
176+ rec, res := ctrRun(t, f, ctrJob, " - name: s\n run: echo\n", func(c *Config) {
177+ outside = filepath.Join(filepath.Dir(c.Layout.WorkRoot), "outside")
178+ writeFiles(t, filepath.Dir(c.Layout.WorkRoot), files{"outside/m.json": `{"problemMatcher":[{"owner":"leak","pattern":[{"regexp":"x"}]}]}`})
179+ f.set(t, "exec_out", "::add-matcher::/__w/../outside/m.json\n::add-matcher::"+outside+"/m.json\n::add-matcher::/etc/hostname\n")
180+ })
181+ if res.Conclusion != ConclusionFailure {
182+ t.Fatalf("%+v", res)
183+ }
184+ log := logOf(rec.step(t, "s"))
185+ if strings.Contains(log, "leak") || strings.Count(log, "##[error]") < 2 || !strings.Contains(log, "Could not find file") && !strings.Contains(log, "ould not find") {
186+ t.Errorf("log:\n%s", log)
187+ }
188+}
@@ -0,0 +1,188 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "path"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "bnema.dev/ermite-actions/expr"
12+)
13+
14+func TestParseVolume(t *testing.T) {
15+ for in, want := range map[string]mount{
16+ "/data": {target: "/data"},
17+ "vol:/data": {source: "vol", target: "/data"},
18+ "/data:ro": {target: "/data", readOnly: true},
19+ "/h:/c:ro": {source: "/h", target: "/c", readOnly: true},
20+ "/h:/c:rw": {source: "/h", target: "/c"},
21+ "/h:/c:ro:extra": {target: "/h:/c:ro:extra"},
22+ "/data:RO": {target: "/data", readOnly: true},
23+ } {
24+ if got := parseVolume(in); got != want {
25+ t.Errorf("%q: %+v, want %+v", in, got, want)
26+ }
27+ }
28+}
29+
30+func TestContainerPathTranslation(t *testing.T) {
31+ c := &container{maps: []pathMap{{"/h/work", "/__w"}, {"/opt/tc", "/__t"}}}
32+ c.mounts = []mount{{source: "/h/work", target: "/__w"}}
33+ for in, want := range map[string]string{
34+ "/h/work": "/__w", "/h/work/_temp/x": "/__w/_temp/x", "/h/workx": "/h/workx", "": "", "/etc": "/etc",
35+ } {
36+ if got := c.translateToContainer(in); got != want {
37+ t.Errorf("%q -> %q, want %q", in, got, want)
38+ }
39+ }
40+ if got := c.translateToHost("/__w/a/b"); got != "/h/work/a/b" {
41+ t.Error(got)
42+ }
43+ // ResolvePathForStepHost translates quoted paths under a mount only.
44+ if got := c.resolvePath(`"/h/work/a"`); got != "/__w/a" {
45+ t.Error(got)
46+ }
47+ if got := c.resolvePath("/opt/tc/x"); got != "/opt/tc/x" {
48+ t.Errorf("no mount source covers it: %s", got)
49+ }
50+}
51+
52+func TestRegistryHost(t *testing.T) {
53+ for in, want := range map[string]string{
54+ "ubuntu:24.04": "", "owner/img": "", "ghcr.io/owner/img:1": "ghcr.io",
55+ "localhost:5000/img": "localhost:5000", "a/b/c": "a",
56+ } {
57+ if got := parseRegistryHost(in); got != want {
58+ t.Errorf("%q: %q want %q", in, got, want)
59+ }
60+ }
61+}
62+
63+func mustValue(t *testing.T, js string) expr.Value {
64+ t.Helper()
65+ v, err := expr.FromJSON([]byte(js))
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ return v
70+}
71+
72+func TestConvertContainerForms(t *testing.T) {
73+ j := &job{}
74+ v := mustValue(t, `{"image":"docker://ubuntu:24.04","env":{"A":"1","B":2},"ports":[8080,"9000:9001"],"volumes":["v:/d"],"options":"--cpus 1","credentials":{"username":"u","password":"p"}}`)
75+ spec, err := j.convertContainer(v, false)
76+ if err != nil || spec.image != "ubuntu:24.04" || len(spec.env) != 2 || spec.env[1].Value != "2" ||
77+ strings.Join(spec.ports, ",") != "8080,9000:9001" || spec.credPass != "p" || spec.options != "--cpus 1" {
78+ t.Fatalf("%+v %v", spec, err)
79+ }
80+ if s, err := j.convertContainer(mustValue(t, `""`), false); s != nil || err != nil {
81+ t.Errorf("empty string: %v %v", s, err)
82+ }
83+ if s, err := j.convertContainer(mustValue(t, `{"image":""}`), true); s != nil || err != nil {
84+ t.Errorf("empty image: %v %v", s, err)
85+ }
86+ for _, bad := range []string{`{"entrypoint":"x","image":"i"}`, `{"nope":1,"image":"i"}`, `{"image":["a"]}`} {
87+ if _, err := j.convertContainer(mustValue(t, bad), false); err == nil {
88+ t.Errorf("%s accepted", bad)
89+ }
90+ }
91+ // A service may only set entrypoint and command when the server allows it.
92+ if _, err := j.convertContainer(mustValue(t, `{"image":"i","command":"x"}`), true); err == nil {
93+ t.Error("command without the feature")
94+ }
95+ j.baseEnv = []string{"ACTIONS_SERVICE_CONTAINER_COMMAND=true"}
96+ if s, err := j.convertContainer(mustValue(t, `{"image":"i","command":"x","entrypoint":"e"}`), true); err != nil || s.command != "x" || s.entrypoint != "e" {
97+ t.Errorf("%+v %v", s, err)
98+ }
99+}
100+
101+func TestImageReferenceGrammar(t *testing.T) {
102+ good := []string{"ubuntu", "ubuntu:24.04", "ghcr.io/o/img:1", "localhost:5000/a/b:tag", "a/b_c.d-e", "redis@sha256:" + strings.Repeat("a", 64),
103+ "r.io:443/x/y:1@sha256:" + strings.Repeat("0", 64), "Ubuntu:24.04",
104+ "[::1]:5000/x", "[::1]/x/y:tag", "[2001:db8::1]:443/a/b@sha256:" + strings.Repeat("a", 64)}
105+ bad := []string{"", "--help", "-x", "-", " a", "a b", "a\nb", "a;b", "a$(x)", "a:", "a:-x", "a@sha256:zz", "/a", "a//b", "a:b:c", "[::1/x", "[]/x", "[zz]/x", "[::1]x", "[::1]:/x", strings.Repeat("a", 1100)}
106+ for _, g := range good {
107+ if !validImageRef(g) {
108+ t.Errorf("rejected %q", g)
109+ }
110+ }
111+ for _, b := range bad {
112+ if validImageRef(b) {
113+ t.Errorf("accepted %q", b)
114+ }
115+ }
116+ j := &job{}
117+ if _, err := j.convertContainer(mustValue(t, `"--help"`), false); err == nil || !strings.Contains(err.Error(), "Invalid container image '--help'") {
118+ t.Errorf("container: --help: %v", err)
119+ }
120+}
121+
122+func TestServiceNamesAndContainerIDs(t *testing.T) {
123+ f := newFakeCtr(t)
124+ for _, name := range []string{"-x", ".y", "a b", "a/b"} {
125+ rec, res := ctrRun(t, f, " services:\n \""+name+"\":\n image: redis:7\n", " - run: echo never\n", nil)
126+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "Set up job")), "Invalid service name") {
127+ t.Errorf("%q: %+v", name, res)
128+ }
129+ }
130+ for id, ok := range map[string]bool{strings.Repeat("a", 64): true, strings.Repeat("0", 64): true, "Usage: docker create": false, strings.Repeat("a", 63): false, strings.Repeat("A", 64): false, "--help": false} {
131+ if containerIDRe.MatchString(id) != ok {
132+ t.Errorf("id %q", id)
133+ }
134+ }
135+ // A help text printed by create is not taken for an id: nothing is started.
136+ f.set(t, "create_out", "Usage: docker create [OPTIONS] IMAGE\n")
137+ rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - run: echo never\n", nil)
138+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "Initialize containers")), "##[error]Docker create did not return a container id: 'Usage: docker create [OPTIONS] IMAGE'") {
139+ t.Errorf("%+v\n%s", res, logOf(rec.step(t, "Initialize containers")))
140+ }
141+ for _, c := range f.calls(t) {
142+ if c[0] == "start" {
143+ t.Errorf("started %v", c)
144+ }
145+ }
146+}
147+
148+func TestTranslateToHostDoesNotEscapeTheMapping(t *testing.T) {
149+ c := &container{maps: []pathMap{{"/h/work", "/__w"}, {"/opt/tc", "/__t"}}}
150+ for in, want := range map[string]string{
151+ "/__w/a/b": "/h/work/a/b",
152+ "/__w/a/../b": "/h/work/b",
153+ "/__w/../outside/x": "/outside/x",
154+ "/__w/a/../../../etc": "/etc",
155+ "/__w//a///b/": "/h/work/a/b",
156+ "/__w": "/h/work",
157+ "/__t/../__w/x": "/h/work/x",
158+ "/__wx/y": "/__wx/y",
159+ "rel/../x": "rel/../x",
160+ } {
161+ got := c.translateToHost(in)
162+ if got != want {
163+ t.Errorf("%q -> %q, want %q", in, got, want)
164+ }
165+ if strings.HasPrefix(in, "/__w") && !strings.HasPrefix(got, "/h/work") && got != path.Clean(in) {
166+ t.Errorf("%q escaped to %q", in, got)
167+ }
168+ }
169+}
170+
171+// A matcher file named by a command of the container is read only when it is
172+// in a directory shared with the host: ".." cannot reach another host file.
173+func TestMatcherPathFromTheContainerStaysInside(t *testing.T) {
174+ f := newFakeCtr(t)
175+ var outside string
176+ rec, res := ctrRun(t, f, ctrJob, " - name: s\n run: echo\n", func(c *Config) {
177+ outside = filepath.Join(filepath.Dir(c.Layout.WorkRoot), "outside")
178+ writeFiles(t, filepath.Dir(c.Layout.WorkRoot), files{"outside/m.json": `{"problemMatcher":[{"owner":"leak","pattern":[{"regexp":"x"}]}]}`})
179+ f.set(t, "exec_out", "::add-matcher::/__w/../outside/m.json\n::add-matcher::"+outside+"/m.json\n::add-matcher::/etc/hostname\n")
180+ })
181+ if res.Conclusion != ConclusionFailure {
182+ t.Fatalf("%+v", res)
183+ }
184+ log := logOf(rec.step(t, "s"))
185+ if strings.Contains(log, "leak") || strings.Count(log, "##[error]") < 2 || !strings.Contains(log, "Could not find file") && !strings.Contains(log, "ould not find") {
186+ t.Errorf("log:\n%s", log)
187+ }
188+}
A internal/executor/containers_start.go +241 -0
@@ -0,0 +1,241 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+)
12+
13+// createCmd is DockerCommandManager.DockerCreate (DockerCommandManager.cs:
14+// 119-215), in the order of the runner's options.
15+func (j *job) createCmd(c *container) dockerCmd {
16+ parts := []dockerPart{raw("--name", c.name), raw("--label", j.dockerLabel())}
17+ if c.workDir != "" {
18+ parts = append(parts, raw("--workdir", c.workDir))
19+ }
20+ if c.network != "" {
21+ parts = append(parts, raw("--network", c.network))
22+ }
23+ if c.alias != "" {
24+ parts = append(parts, raw("--network-alias", c.alias))
25+ }
26+ for _, p := range c.ports {
27+ parts = append(parts, raw("-p", p))
28+ }
29+ parts = append(parts, optionsParts(c.options))
30+ for _, k := range c.env.Keys() {
31+ v, _ := c.env.Get(k)
32+ parts = append(parts, j.envKV(k, v))
33+ }
34+ parts = append(parts, raw("-e", "GITHUB_ACTIONS=true"))
35+ if _, ok := c.env.Get("CI"); !ok {
36+ parts = append(parts, raw("-e", "CI=true"))
37+ }
38+ for _, m := range c.mounts {
39+ parts = append(parts, mountOpt(m))
40+ }
41+ if c.entrypoint != "" {
42+ parts = append(parts, entrypointOpt(c.entrypoint))
43+ }
44+ parts = append(parts, raw(c.image), dockerPart{line: c.entryArgs, args: ParseArguments(c.entryArgs)})
45+ return j.newDockerCmd("create", parts...)
46+}
47+
48+// mountWellKnownDirectories is ContainerOperationProvider.MountWellKnownDirectories
49+// (:242-283): the job container sees the work directory, the externals, the
50+// temp, actions and tool cache directories, and a home and a workflow
51+// directory of its own.
52+func (j *job) mountWellKnownDirectories(c *container) error {
53+ lay := j.state.Layout()
54+ workspace := j.githubValue("workspace")
55+ tr := c.translateToContainer
56+ c.mounts = append(c.mounts,
57+ mount{source: lay.WorkRoot, target: tr(lay.WorkRoot)},
58+ mount{source: j.externalsDir(), target: tr(j.externalsDir()), readOnly: true},
59+ mount{source: lay.Temp, target: tr(lay.Temp)},
60+ mount{source: lay.Actions, target: tr(lay.Actions)},
61+ mount{source: lay.ToolCache, target: tr(lay.ToolCache)})
62+ for _, d := range []struct{ dir, target string }{
63+ {"_github_home", "/github/home"}, {"_github_workflow", "/github/workflow"},
64+ } {
65+ host := filepath.Join(lay.Temp, d.dir)
66+ if err := os.MkdirAll(host, 0o755); err != nil {
67+ return err
68+ }
69+ j.own(host)
70+ c.mounts = append(c.mounts, mount{source: host, target: d.target})
71+ c.maps = append([]pathMap{{host, d.target}}, c.maps...) // AddPathTranslateMapping inserts first
72+ if d.dir == "_github_home" {
73+ c.env.Set("HOME", tr(host))
74+ }
75+ }
76+ c.workDir = tr(workspace)
77+ c.entrypoint = "tail"
78+ c.entryArgs = `"-f" "/dev/null"`
79+ return nil
80+}
81+
82+// startContainer is StartContainerAsync (ContainerOperationProvider.cs:
83+// 123-240).
84+func (j *job) startContainer(s *stepRun, c *container, cancel <-chan struct{}) error {
85+ num := s.num
86+ group := "Starting job container"
87+ if !c.isJob {
88+ group = "Starting " + c.alias + " service container"
89+ }
90+ j.out(num, "##[group]"+group)
91+ for _, m := range c.userMounts() {
92+ if m.source == "/" {
93+ j.tagged(num, "##[warning]", fmt.Sprintf("Volume mount %s is going to mount '/' into the container which may cause file ownership change in the entire file system and cause Actions Runner to lose permission to access the disk.", m.user))
94+ }
95+ }
96+ cfg, err := j.registryLogin(s, c, cancel)
97+ if err != nil {
98+ if rerr := j.removeConfigDir(cfg); rerr != nil {
99+ j.errorf(num, "%s", rerr.Error())
100+ }
101+ return err
102+ }
103+ defer func() { _ = j.removeConfigDir(cfg) }() // also on a panic; idempotent
104+ code, err := j.pullWithRetry(s, c.image, cfg, cancel)
105+ // Credentials are removed after the pull, whatever it did (:171-173).
106+ if rerr := j.removeConfigDir(cfg); rerr != nil {
107+ if err == nil {
108+ err = rerr
109+ } else {
110+ j.errorf(num, "%s", rerr.Error())
111+ }
112+ }
113+ if err != nil {
114+ return err
115+ }
116+ if code != 0 {
117+ return ghErrorf("Docker pull failed with exit code %d", code)
118+ }
119+ if c.isJob {
120+ if err := j.mountWellKnownDirectories(c); err != nil {
121+ return err
122+ }
123+ }
124+ if err := checkEnvNames(c.env.Keys()); err != nil {
125+ return err
126+ }
127+ c.createTried = true // the container may exist from here on, named c.name
128+ out, err := j.dockerList(s, j.createCmd(c), cancel)
129+ if err != nil {
130+ var ee *exitError
131+ if errors.As(err, &ee) {
132+ c.createTried = false // docker said no: there is nothing to remove
133+ }
134+ return err
135+ }
136+ if len(out) == 0 || out[0] == "" {
137+ return ghErrorf("Value cannot be null. (Parameter 'container.ContainerId')")
138+ }
139+ // docker create prints the id and nothing else; anything else (the help
140+ // text of an option in options:, say) is not an id to use or to remove.
141+ if !containerIDRe.MatchString(out[0]) {
142+ return ghErrorf("Docker create did not return a container id: '%s'", out[0])
143+ }
144+ c.id = out[0]
145+ code, err = j.dockerCode(s, j.newDockerCmd("start", raw(c.id)), cancel)
146+ if err != nil {
147+ return err
148+ }
149+ if code != 0 {
150+ return ghErrorf("Docker start fail with exit code %d", code)
151+ }
152+ j.checkRunning(s, c, cancel)
153+ if !c.isJob {
154+ ports, err := j.dockerList(s, j.newDockerCmd("port", raw(c.id)), cancel)
155+ if err != nil {
156+ return err
157+ }
158+ c.portMappings = parseDockerPort(ports)
159+ j.mu.Lock()
160+ j.state.SetJobService(c.alias, c.id, c.network, c.portMappings)
161+ j.mu.Unlock()
162+ } else {
163+ env, err := j.dockerList(s, j.newDockerCmd("inspect", quotedOpt("--format", "{{range .Config.Env}}{{println .}}{{end}}"), raw(c.id)), cancel)
164+ if err != nil {
165+ return err
166+ }
167+ c.runtimePath = parsePathFromConfigEnv(env)
168+ j.mu.Lock()
169+ j.state.SetJobContainer("id", c.id)
170+ j.mu.Unlock()
171+ }
172+ j.out(num, "##[endgroup]")
173+ return nil
174+}
175+
176+// pullWithRetry is the pull loop of StartContainerAsync (:158-178): three
177+// tries, a random back off of 1 to 10 s between them.
178+func (j *job) pullWithRetry(s *stepRun, image, cfg string, cancel <-chan struct{}) (int, error) {
179+ code := 0
180+ for try := 1; try <= 3; try++ {
181+ var err error
182+ _, code, err = j.dockerDo(j.pullCmd(image, cfg), dockerOpts{s: s, cancel: cancel, all: true, timeout: j.timeouts().Pull})
183+ if err != nil {
184+ return code, err
185+ }
186+ if code == 0 {
187+ return 0, nil
188+ }
189+ if try < 3 {
190+ d := j.containerBackoff()
191+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker pull failed with exit code %d, back off %s seconds before retry.", code, seconds(d)))
192+ if !j.sleepOrCancel(d, cancel) {
193+ return code, errCancelled
194+ }
195+ }
196+ }
197+ return code, nil
198+}
199+
200+// checkRunning is the best-effort check of :193-213.
201+func (j *job) checkRunning(s *stepRun, c *container, cancel <-chan struct{}) {
202+ fmtOpt := quotedOpt("--format", "{{.ID}} {{.Status}}")
203+ out, err := j.dockerList(s, j.newDockerCmd("ps", raw("--all"), raw("--filter", "id="+c.id), raw("--filter", "status=running"), raw("--no-trunc"), fmtOpt), cancel)
204+ if err != nil {
205+ return
206+ }
207+ for _, l := range out {
208+ if l != "" {
209+ if strings.HasPrefix(l, c.id) {
210+ return
211+ }
212+ break
213+ }
214+ }
215+ _, _ = j.dockerList(s, j.newDockerCmd("ps", raw("--all"), raw("--filter", "id="+c.id), raw("--no-trunc"), fmtOpt), cancel)
216+ if code, err := j.dockerCode(s, j.newDockerCmd("logs", raw("--details", c.id)), cancel); err == nil && code != 0 {
217+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker logs fail with exit code %d", code))
218+ }
219+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker container %s is not in running state.", c.id))
220+}
221+
222+// parseDockerPort is DockerUtil.ParseDockerPort (DockerUtil.cs:14-37).
223+func parseDockerPort(lines []string) []portMapping {
224+ var out []portMapping
225+ for _, l := range lines {
226+ if m := portLineRe.FindStringSubmatch(l); m != nil {
227+ out = append(out, portMapping{host: m[4], container: m[1], proto: m[2]})
228+ }
229+ }
230+ return out
231+}
232+
233+// parsePathFromConfigEnv is DockerUtil.ParsePathFromConfigEnv (:39-52).
234+func parsePathFromConfigEnv(lines []string) string {
235+ for _, l := range lines {
236+ if k, v, ok := strings.Cut(l, "="); ok && k == "PATH" {
237+ return v
238+ }
239+ }
240+ return ""
241+}
@@ -0,0 +1,241 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+)
12+
13+// createCmd is DockerCommandManager.DockerCreate (DockerCommandManager.cs:
14+// 119-215), in the order of the runner's options.
15+func (j *job) createCmd(c *container) dockerCmd {
16+ parts := []dockerPart{raw("--name", c.name), raw("--label", j.dockerLabel())}
17+ if c.workDir != "" {
18+ parts = append(parts, raw("--workdir", c.workDir))
19+ }
20+ if c.network != "" {
21+ parts = append(parts, raw("--network", c.network))
22+ }
23+ if c.alias != "" {
24+ parts = append(parts, raw("--network-alias", c.alias))
25+ }
26+ for _, p := range c.ports {
27+ parts = append(parts, raw("-p", p))
28+ }
29+ parts = append(parts, optionsParts(c.options))
30+ for _, k := range c.env.Keys() {
31+ v, _ := c.env.Get(k)
32+ parts = append(parts, j.envKV(k, v))
33+ }
34+ parts = append(parts, raw("-e", "GITHUB_ACTIONS=true"))
35+ if _, ok := c.env.Get("CI"); !ok {
36+ parts = append(parts, raw("-e", "CI=true"))
37+ }
38+ for _, m := range c.mounts {
39+ parts = append(parts, mountOpt(m))
40+ }
41+ if c.entrypoint != "" {
42+ parts = append(parts, entrypointOpt(c.entrypoint))
43+ }
44+ parts = append(parts, raw(c.image), dockerPart{line: c.entryArgs, args: ParseArguments(c.entryArgs)})
45+ return j.newDockerCmd("create", parts...)
46+}
47+
48+// mountWellKnownDirectories is ContainerOperationProvider.MountWellKnownDirectories
49+// (:242-283): the job container sees the work directory, the externals, the
50+// temp, actions and tool cache directories, and a home and a workflow
51+// directory of its own.
52+func (j *job) mountWellKnownDirectories(c *container) error {
53+ lay := j.state.Layout()
54+ workspace := j.githubValue("workspace")
55+ tr := c.translateToContainer
56+ c.mounts = append(c.mounts,
57+ mount{source: lay.WorkRoot, target: tr(lay.WorkRoot)},
58+ mount{source: j.externalsDir(), target: tr(j.externalsDir()), readOnly: true},
59+ mount{source: lay.Temp, target: tr(lay.Temp)},
60+ mount{source: lay.Actions, target: tr(lay.Actions)},
61+ mount{source: lay.ToolCache, target: tr(lay.ToolCache)})
62+ for _, d := range []struct{ dir, target string }{
63+ {"_github_home", "/github/home"}, {"_github_workflow", "/github/workflow"},
64+ } {
65+ host := filepath.Join(lay.Temp, d.dir)
66+ if err := os.MkdirAll(host, 0o755); err != nil {
67+ return err
68+ }
69+ j.own(host)
70+ c.mounts = append(c.mounts, mount{source: host, target: d.target})
71+ c.maps = append([]pathMap{{host, d.target}}, c.maps...) // AddPathTranslateMapping inserts first
72+ if d.dir == "_github_home" {
73+ c.env.Set("HOME", tr(host))
74+ }
75+ }
76+ c.workDir = tr(workspace)
77+ c.entrypoint = "tail"
78+ c.entryArgs = `"-f" "/dev/null"`
79+ return nil
80+}
81+
82+// startContainer is StartContainerAsync (ContainerOperationProvider.cs:
83+// 123-240).
84+func (j *job) startContainer(s *stepRun, c *container, cancel <-chan struct{}) error {
85+ num := s.num
86+ group := "Starting job container"
87+ if !c.isJob {
88+ group = "Starting " + c.alias + " service container"
89+ }
90+ j.out(num, "##[group]"+group)
91+ for _, m := range c.userMounts() {
92+ if m.source == "/" {
93+ j.tagged(num, "##[warning]", fmt.Sprintf("Volume mount %s is going to mount '/' into the container which may cause file ownership change in the entire file system and cause Actions Runner to lose permission to access the disk.", m.user))
94+ }
95+ }
96+ cfg, err := j.registryLogin(s, c, cancel)
97+ if err != nil {
98+ if rerr := j.removeConfigDir(cfg); rerr != nil {
99+ j.errorf(num, "%s", rerr.Error())
100+ }
101+ return err
102+ }
103+ defer func() { _ = j.removeConfigDir(cfg) }() // also on a panic; idempotent
104+ code, err := j.pullWithRetry(s, c.image, cfg, cancel)
105+ // Credentials are removed after the pull, whatever it did (:171-173).
106+ if rerr := j.removeConfigDir(cfg); rerr != nil {
107+ if err == nil {
108+ err = rerr
109+ } else {
110+ j.errorf(num, "%s", rerr.Error())
111+ }
112+ }
113+ if err != nil {
114+ return err
115+ }
116+ if code != 0 {
117+ return ghErrorf("Docker pull failed with exit code %d", code)
118+ }
119+ if c.isJob {
120+ if err := j.mountWellKnownDirectories(c); err != nil {
121+ return err
122+ }
123+ }
124+ if err := checkEnvNames(c.env.Keys()); err != nil {
125+ return err
126+ }
127+ c.createTried = true // the container may exist from here on, named c.name
128+ out, err := j.dockerList(s, j.createCmd(c), cancel)
129+ if err != nil {
130+ var ee *exitError
131+ if errors.As(err, &ee) {
132+ c.createTried = false // docker said no: there is nothing to remove
133+ }
134+ return err
135+ }
136+ if len(out) == 0 || out[0] == "" {
137+ return ghErrorf("Value cannot be null. (Parameter 'container.ContainerId')")
138+ }
139+ // docker create prints the id and nothing else; anything else (the help
140+ // text of an option in options:, say) is not an id to use or to remove.
141+ if !containerIDRe.MatchString(out[0]) {
142+ return ghErrorf("Docker create did not return a container id: '%s'", out[0])
143+ }
144+ c.id = out[0]
145+ code, err = j.dockerCode(s, j.newDockerCmd("start", raw(c.id)), cancel)
146+ if err != nil {
147+ return err
148+ }
149+ if code != 0 {
150+ return ghErrorf("Docker start fail with exit code %d", code)
151+ }
152+ j.checkRunning(s, c, cancel)
153+ if !c.isJob {
154+ ports, err := j.dockerList(s, j.newDockerCmd("port", raw(c.id)), cancel)
155+ if err != nil {
156+ return err
157+ }
158+ c.portMappings = parseDockerPort(ports)
159+ j.mu.Lock()
160+ j.state.SetJobService(c.alias, c.id, c.network, c.portMappings)
161+ j.mu.Unlock()
162+ } else {
163+ env, err := j.dockerList(s, j.newDockerCmd("inspect", quotedOpt("--format", "{{range .Config.Env}}{{println .}}{{end}}"), raw(c.id)), cancel)
164+ if err != nil {
165+ return err
166+ }
167+ c.runtimePath = parsePathFromConfigEnv(env)
168+ j.mu.Lock()
169+ j.state.SetJobContainer("id", c.id)
170+ j.mu.Unlock()
171+ }
172+ j.out(num, "##[endgroup]")
173+ return nil
174+}
175+
176+// pullWithRetry is the pull loop of StartContainerAsync (:158-178): three
177+// tries, a random back off of 1 to 10 s between them.
178+func (j *job) pullWithRetry(s *stepRun, image, cfg string, cancel <-chan struct{}) (int, error) {
179+ code := 0
180+ for try := 1; try <= 3; try++ {
181+ var err error
182+ _, code, err = j.dockerDo(j.pullCmd(image, cfg), dockerOpts{s: s, cancel: cancel, all: true, timeout: j.timeouts().Pull})
183+ if err != nil {
184+ return code, err
185+ }
186+ if code == 0 {
187+ return 0, nil
188+ }
189+ if try < 3 {
190+ d := j.containerBackoff()
191+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker pull failed with exit code %d, back off %s seconds before retry.", code, seconds(d)))
192+ if !j.sleepOrCancel(d, cancel) {
193+ return code, errCancelled
194+ }
195+ }
196+ }
197+ return code, nil
198+}
199+
200+// checkRunning is the best-effort check of :193-213.
201+func (j *job) checkRunning(s *stepRun, c *container, cancel <-chan struct{}) {
202+ fmtOpt := quotedOpt("--format", "{{.ID}} {{.Status}}")
203+ out, err := j.dockerList(s, j.newDockerCmd("ps", raw("--all"), raw("--filter", "id="+c.id), raw("--filter", "status=running"), raw("--no-trunc"), fmtOpt), cancel)
204+ if err != nil {
205+ return
206+ }
207+ for _, l := range out {
208+ if l != "" {
209+ if strings.HasPrefix(l, c.id) {
210+ return
211+ }
212+ break
213+ }
214+ }
215+ _, _ = j.dockerList(s, j.newDockerCmd("ps", raw("--all"), raw("--filter", "id="+c.id), raw("--no-trunc"), fmtOpt), cancel)
216+ if code, err := j.dockerCode(s, j.newDockerCmd("logs", raw("--details", c.id)), cancel); err == nil && code != 0 {
217+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker logs fail with exit code %d", code))
218+ }
219+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker container %s is not in running state.", c.id))
220+}
221+
222+// parseDockerPort is DockerUtil.ParseDockerPort (DockerUtil.cs:14-37).
223+func parseDockerPort(lines []string) []portMapping {
224+ var out []portMapping
225+ for _, l := range lines {
226+ if m := portLineRe.FindStringSubmatch(l); m != nil {
227+ out = append(out, portMapping{host: m[4], container: m[1], proto: m[2]})
228+ }
229+ }
230+ return out
231+}
232+
233+// parsePathFromConfigEnv is DockerUtil.ParsePathFromConfigEnv (:39-52).
234+func parsePathFromConfigEnv(lines []string) string {
235+ for _, l := range lines {
236+ if k, v, ok := strings.Cut(l, "="); ok && k == "PATH" {
237+ return v
238+ }
239+ }
240+ return ""
241+}
A internal/executor/containers_stop.go +324 -0
@@ -0,0 +1,324 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "math"
8+ "math/rand/v2"
9+ "os"
10+ "strings"
11+ "time"
12+)
13+
14+// healthBackoff is BackoffTimerHelper.GetExponentialBackoff(retry, 2 s, 32 s,
15+// 2 s) (BackoffTimerHelper.cs:27-36).
16+func healthBackoff(attempt int) time.Duration {
17+ random := 1600 + rand.Float64()*800 // Random.Next(1600, 2400) ms
18+ add := (math.Pow(2, float64(attempt)) - 1) * random
19+ ms := math.Min(2000+add, 32000)
20+ return time.Duration(ms * float64(time.Millisecond))
21+}
22+
23+// containerHealth is ContainerHealthcheck (ContainerOperationProvider.cs:
24+// 372-392): a container without a HEALTHCHECK is healthy; while docker says
25+// "starting" the check waits with an exponential back off.
26+func (j *job) containerHealth(s *stepRun, c *container, cancel <-chan struct{}) (bool, error) {
27+ inspect := func() (string, error) {
28+ cmd := j.newDockerCmd("inspect",
29+ eqOpt("--format", "{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}"), raw(c.id))
30+ l, err := j.dockerList(s, cmd, cancel)
31+ if err != nil || len(l) == 0 {
32+ return "", err
33+ }
34+ return l[0], nil
35+ }
36+ h, err := inspect()
37+ if err != nil {
38+ return false, err
39+ }
40+ if h == "" {
41+ return true, nil
42+ }
43+ for retry := 0; strings.EqualFold(h, "starting"); retry++ {
44+ d := healthBackoff(retry)
45+ if f := j.cfg.Actions.Backoff; f != nil {
46+ d = f()
47+ }
48+ // TimeSpan.Seconds is the seconds component, not the total.
49+ j.out(s.num, fmt.Sprintf("%s service is starting, waiting %d seconds before checking again.", c.alias, int(d.Seconds())%60))
50+ if !j.sleepOrCancel(d, cancel) {
51+ return false, errCancelled
52+ }
53+ if h, err = inspect(); err != nil {
54+ return false, err
55+ }
56+ }
57+ return strings.EqualFold(h, "healthy"), nil
58+}
59+
60+// healthchecks is RunContainersHealthcheck (:105-139).
61+func (j *job) healthchecks(s *stepRun, all []*container, cancel <-chan struct{}) error {
62+ num := s.num
63+ j.out(num, "##[group]Waiting for all services to be ready")
64+ var bad []*container
65+ for _, c := range all {
66+ if c.isJob {
67+ continue
68+ }
69+ ok, err := j.containerHealth(s, c, cancel)
70+ if err != nil {
71+ return err
72+ }
73+ if !ok {
74+ bad = append(bad, c)
75+ continue
76+ }
77+ j.out(num, c.alias+" service is healthy.")
78+ }
79+ j.out(num, "##[endgroup]")
80+ if len(bad) == 0 {
81+ return nil
82+ }
83+ for _, c := range bad {
84+ j.out(num, "##[group]Service container "+c.alias+" failed.")
85+ if _, err := j.dockerCode(s, j.newDockerCmd("logs", raw("--details", c.id)), cancel); err != nil {
86+ return err
87+ }
88+ j.errorf(num, "Failed to initialize container %s", c.image)
89+ c.failedInit = true
90+ j.out(num, "##[endgroup]")
91+ }
92+ return ghErrorf("One or more containers failed to start.")
93+}
94+
95+// stopContainers is "Stop containers" (StopContainersAsync, :105-124 and
96+// StopContainerAsync, :328-351). It is registered with the condition always(),
97+// so it runs after a failure, a cancellation and a timeout alike; its commands
98+// run on a budget of their own (they do not end with the job's cancellation,
99+// and a stalled daemon does not hold them forever).
100+func (j *job) stopContainers(s *stepRun, _ <-chan struct{}) stepResult {
101+ if j.cs == nil {
102+ return resSucceeded
103+ }
104+ errs := j.cleanupContainers(s)
105+ for _, e := range errs {
106+ j.errorf(s.num, "%s", e.Error())
107+ }
108+ if len(errs) > 0 {
109+ return resFailed
110+ }
111+ return resSucceeded
112+}
113+
114+// fallbackCleanup is the cleanup of the job when "Stop containers" did not (or
115+// not completely) run: a panic, a runner shutdown that skips the queued post
116+// steps, a budget that ran out. It does only what is left; with nothing left
117+// it runs no docker command. Its messages go to the diagnostics of the runner,
118+// the job log being closed.
119+func (j *job) fallbackCleanup() {
120+ if j.cs == nil || !j.cs.pending() {
121+ return
122+ }
123+ for _, e := range j.cleanupContainers(nil) {
124+ j.cfg.Log.Warn().Err(e).Msg("fallback cleanup of the job containers")
125+ }
126+}
127+
128+// pending reports whether something created by the job is still to be removed.
129+func (cs *containerSet) pending() bool {
130+ cs.mu.Lock()
131+ defer cs.mu.Unlock()
132+ if cs.networkTry != "" && !cs.netRemoved || len(cs.dirs) > 0 {
133+ return true
134+ }
135+ for _, c := range cs.all() {
136+ if c.createTried && !c.removed {
137+ return true
138+ }
139+ }
140+ return false
141+}
142+
143+// cleanupContainers removes what the job created: every container, then the
144+// network, then the credentials directories left behind. It attempts all of
145+// them whatever fails, within CleanupBudget, each command within
146+// CleanupCommand, and returns the errors. Logs of a service are best effort and
147+// never stand in the way of its removal. s, when set, gets the runner's lines.
148+// It is idempotent: what is gone is not tried again.
149+func (j *job) cleanupContainers(s *stepRun) (errs []error) {
150+ cs := j.cs
151+ cs.cleanupMu.Lock()
152+ defer cs.cleanupMu.Unlock()
153+ t := j.timeouts()
154+ cs.mu.Lock()
155+ if cs.deadline.IsZero() {
156+ cs.deadline = time.Now().Add(t.CleanupBudget)
157+ }
158+ remaining := time.Until(cs.deadline)
159+ cs.mu.Unlock()
160+ budget, budgetOut, stop := bound(nil, max(remaining, time.Nanosecond))
161+ defer stop()
162+ defer func() { errs = dedupeErrors(errs) }()
163+ out := func(line string) {
164+ if s != nil {
165+ j.out(s.num, line)
166+ }
167+ }
168+ run := func(c dockerCmd) (int, error) {
169+ _, code, err := j.dockerDoCleanup(c, s, budget, budgetOut, t, t.CleanupCommand)
170+ return code, err
171+ }
172+ // rm runs `docker rm --force target`. It reports whether the target is
173+ // gone: removed, or docker says it does not exist. Any other failure leaves
174+ // it pending, for the fallback of the job to try again.
175+ rm := func(kind string, c dockerCmd) (gone bool) {
176+ lines, code, err := j.dockerDoCleanup(c, s, budget, budgetOut, t, t.CleanupCommand)
177+ switch {
178+ case err != nil:
179+ errs = append(errs, err)
180+ return false
181+ case code == 0:
182+ return true
183+ case absent(lines):
184+ return true
185+ }
186+ j.warnTo(s, fmt.Sprintf("Docker %s fail with exit code %d", kind, code))
187+ return false
188+ }
189+ // A container whose create did not finish is not known by its id, and its
190+ // name may not be ours (options: can set another --name): it is found by
191+ // the label every container of the job carries (DockerCreate adds it).
192+ var unresolved []*container
193+ for _, c := range cs.all() {
194+ if c.createTried && !c.removed && c.id == "" {
195+ unresolved = append(unresolved, c)
196+ }
197+ }
198+ if len(unresolved) > 0 {
199+ ok := true
200+ ids, code, err := j.dockerDoCleanup(j.newDockerCmd("ps", raw("--all", "--quiet", "--no-trunc"), quotedOpt("--filter", "label="+j.dockerLabel())), nil, budget, budgetOut, t, t.CleanupCommand)
201+ switch {
202+ case err != nil:
203+ errs, ok = append(errs, err), false
204+ case code != 0:
205+ j.warnTo(s, fmt.Sprintf("Docker ps fail with exit code %d", code))
206+ ok = false
207+ }
208+ known := map[string]bool{}
209+ for _, c := range cs.all() {
210+ if c.id != "" {
211+ known[c.id] = true // those are removed below, after their logs
212+ }
213+ }
214+ for _, id := range ids {
215+ if containerIDRe.MatchString(id) && !known[id] && !rm("rm", j.newDockerCmd("rm", raw("--force", id))) {
216+ ok = false
217+ }
218+ }
219+ if ok { // listed, and everything listed is gone: nothing of the job is left
220+ for _, c := range unresolved {
221+ c.removed = true
222+ }
223+ }
224+ }
225+ for _, c := range cs.all() {
226+ if !c.createTried || c.removed || c.id == "" {
227+ continue
228+ }
229+ if !c.isJob && !c.failedInit {
230+ out("Print service container logs: " + c.name)
231+ code, err := run(j.newDockerCmd("logs", raw("--details", c.id)))
232+ switch {
233+ case err != nil:
234+ // Best effort, as the runner's warning for a failed logs: it
235+ // neither fails the step nor stops the removal.
236+ j.warnTo(s, "Docker logs failed: "+err.Error())
237+ case code != 0:
238+ j.warnTo(s, fmt.Sprintf("Docker logs fail with exit code %d", code))
239+ }
240+ }
241+ out("Stop and remove container: " + c.name)
242+ if rm("rm", j.newDockerCmd("rm", raw("--force", c.id))) {
243+ c.removed = true
244+ }
245+ }
246+ cs.mu.Lock()
247+ network, gone := cs.networkTry, cs.netRemoved
248+ cs.mu.Unlock()
249+ if network != "" && !gone {
250+ out("Remove container network: " + network)
251+ if rm("network rm", j.newDockerCmd("network", raw("rm", network))) {
252+ cs.mu.Lock()
253+ cs.netRemoved = true
254+ cs.mu.Unlock()
255+ }
256+ }
257+ cs.mu.Lock()
258+ dirs := cs.dirs
259+ cs.dirs = nil
260+ cs.mu.Unlock()
261+ for _, d := range dirs {
262+ if err := os.RemoveAll(d); err != nil {
263+ cs.addDir(d)
264+ errs = append(errs, ghErrorf("Failed to remove directory containing Docker client credentials: %v", err))
265+ }
266+ }
267+ return errs
268+}
269+
270+// warnTo logs a warning to the step, or to the diagnostics without one.
271+func (j *job) warnTo(s *stepRun, msg string) {
272+ if s != nil {
273+ j.tagged(s.num, "##[warning]", msg)
274+ return
275+ }
276+ j.cfg.Log.Warn().Msg(msg)
277+}
278+
279+// absent reports whether docker's answer to a removal says the object does not
280+// exist: that is the state the cleanup wants.
281+func absent(lines []string) bool {
282+ for _, l := range lines {
283+ l = strings.ToLower(l)
284+ if strings.Contains(l, "no such container") || strings.Contains(l, "no such network") ||
285+ strings.Contains(l, "no such object") || strings.Contains(l, "network") && strings.Contains(l, "not found") {
286+ return true
287+ }
288+ }
289+ return false
290+}
291+
292+// dockerDoCleanup runs a command of the cleanup: bounded by its own limit and
293+// by the budget of the whole cleanup, its output (stdout and stderr) returned,
294+// logged to s if any. The budget is checked here, before every command: once
295+// it is spent nothing is started (a command started with a closed cancel would
296+// still cost a signal escalation) and the object stays pending.
297+func (j *job) dockerDoCleanup(c dockerCmd, s *stepRun, budget <-chan struct{}, budgetOut func() bool, t ContainerTimeouts, limit time.Duration) ([]string, int, error) {
298+ spent := func() error {
299+ return ghErrorf("The cleanup of the containers ran out of time (%s): not run: docker %s", t.CleanupBudget, j.dockerArgString(c))
300+ }
301+ select {
302+ case <-budget:
303+ return nil, 0, spent()
304+ default:
305+ }
306+ lines, code, err := j.dockerDo(c, dockerOpts{s: s, cancel: budget, all: true, timeout: limit, errToOut: true})
307+ if err == errCancelled && budgetOut() {
308+ return lines, code, ghErrorf("The cleanup of the containers ran out of time (%s): stopped: docker %s", t.CleanupBudget, j.dockerArgString(c))
309+ }
310+ return lines, code, err
311+}
312+
313+// dedupeErrors keeps the first of the errors that read the same.
314+func dedupeErrors(errs []error) []error {
315+ seen := map[string]bool{}
316+ var out []error
317+ for _, e := range errs {
318+ if !seen[e.Error()] {
319+ seen[e.Error()] = true
320+ out = append(out, e)
321+ }
322+ }
323+ return out
324+}
@@ -0,0 +1,324 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "math"
8+ "math/rand/v2"
9+ "os"
10+ "strings"
11+ "time"
12+)
13+
14+// healthBackoff is BackoffTimerHelper.GetExponentialBackoff(retry, 2 s, 32 s,
15+// 2 s) (BackoffTimerHelper.cs:27-36).
16+func healthBackoff(attempt int) time.Duration {
17+ random := 1600 + rand.Float64()*800 // Random.Next(1600, 2400) ms
18+ add := (math.Pow(2, float64(attempt)) - 1) * random
19+ ms := math.Min(2000+add, 32000)
20+ return time.Duration(ms * float64(time.Millisecond))
21+}
22+
23+// containerHealth is ContainerHealthcheck (ContainerOperationProvider.cs:
24+// 372-392): a container without a HEALTHCHECK is healthy; while docker says
25+// "starting" the check waits with an exponential back off.
26+func (j *job) containerHealth(s *stepRun, c *container, cancel <-chan struct{}) (bool, error) {
27+ inspect := func() (string, error) {
28+ cmd := j.newDockerCmd("inspect",
29+ eqOpt("--format", "{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}"), raw(c.id))
30+ l, err := j.dockerList(s, cmd, cancel)
31+ if err != nil || len(l) == 0 {
32+ return "", err
33+ }
34+ return l[0], nil
35+ }
36+ h, err := inspect()
37+ if err != nil {
38+ return false, err
39+ }
40+ if h == "" {
41+ return true, nil
42+ }
43+ for retry := 0; strings.EqualFold(h, "starting"); retry++ {
44+ d := healthBackoff(retry)
45+ if f := j.cfg.Actions.Backoff; f != nil {
46+ d = f()
47+ }
48+ // TimeSpan.Seconds is the seconds component, not the total.
49+ j.out(s.num, fmt.Sprintf("%s service is starting, waiting %d seconds before checking again.", c.alias, int(d.Seconds())%60))
50+ if !j.sleepOrCancel(d, cancel) {
51+ return false, errCancelled
52+ }
53+ if h, err = inspect(); err != nil {
54+ return false, err
55+ }
56+ }
57+ return strings.EqualFold(h, "healthy"), nil
58+}
59+
60+// healthchecks is RunContainersHealthcheck (:105-139).
61+func (j *job) healthchecks(s *stepRun, all []*container, cancel <-chan struct{}) error {
62+ num := s.num
63+ j.out(num, "##[group]Waiting for all services to be ready")
64+ var bad []*container
65+ for _, c := range all {
66+ if c.isJob {
67+ continue
68+ }
69+ ok, err := j.containerHealth(s, c, cancel)
70+ if err != nil {
71+ return err
72+ }
73+ if !ok {
74+ bad = append(bad, c)
75+ continue
76+ }
77+ j.out(num, c.alias+" service is healthy.")
78+ }
79+ j.out(num, "##[endgroup]")
80+ if len(bad) == 0 {
81+ return nil
82+ }
83+ for _, c := range bad {
84+ j.out(num, "##[group]Service container "+c.alias+" failed.")
85+ if _, err := j.dockerCode(s, j.newDockerCmd("logs", raw("--details", c.id)), cancel); err != nil {
86+ return err
87+ }
88+ j.errorf(num, "Failed to initialize container %s", c.image)
89+ c.failedInit = true
90+ j.out(num, "##[endgroup]")
91+ }
92+ return ghErrorf("One or more containers failed to start.")
93+}
94+
95+// stopContainers is "Stop containers" (StopContainersAsync, :105-124 and
96+// StopContainerAsync, :328-351). It is registered with the condition always(),
97+// so it runs after a failure, a cancellation and a timeout alike; its commands
98+// run on a budget of their own (they do not end with the job's cancellation,
99+// and a stalled daemon does not hold them forever).
100+func (j *job) stopContainers(s *stepRun, _ <-chan struct{}) stepResult {
101+ if j.cs == nil {
102+ return resSucceeded
103+ }
104+ errs := j.cleanupContainers(s)
105+ for _, e := range errs {
106+ j.errorf(s.num, "%s", e.Error())
107+ }
108+ if len(errs) > 0 {
109+ return resFailed
110+ }
111+ return resSucceeded
112+}
113+
114+// fallbackCleanup is the cleanup of the job when "Stop containers" did not (or
115+// not completely) run: a panic, a runner shutdown that skips the queued post
116+// steps, a budget that ran out. It does only what is left; with nothing left
117+// it runs no docker command. Its messages go to the diagnostics of the runner,
118+// the job log being closed.
119+func (j *job) fallbackCleanup() {
120+ if j.cs == nil || !j.cs.pending() {
121+ return
122+ }
123+ for _, e := range j.cleanupContainers(nil) {
124+ j.cfg.Log.Warn().Err(e).Msg("fallback cleanup of the job containers")
125+ }
126+}
127+
128+// pending reports whether something created by the job is still to be removed.
129+func (cs *containerSet) pending() bool {
130+ cs.mu.Lock()
131+ defer cs.mu.Unlock()
132+ if cs.networkTry != "" && !cs.netRemoved || len(cs.dirs) > 0 {
133+ return true
134+ }
135+ for _, c := range cs.all() {
136+ if c.createTried && !c.removed {
137+ return true
138+ }
139+ }
140+ return false
141+}
142+
143+// cleanupContainers removes what the job created: every container, then the
144+// network, then the credentials directories left behind. It attempts all of
145+// them whatever fails, within CleanupBudget, each command within
146+// CleanupCommand, and returns the errors. Logs of a service are best effort and
147+// never stand in the way of its removal. s, when set, gets the runner's lines.
148+// It is idempotent: what is gone is not tried again.
149+func (j *job) cleanupContainers(s *stepRun) (errs []error) {
150+ cs := j.cs
151+ cs.cleanupMu.Lock()
152+ defer cs.cleanupMu.Unlock()
153+ t := j.timeouts()
154+ cs.mu.Lock()
155+ if cs.deadline.IsZero() {
156+ cs.deadline = time.Now().Add(t.CleanupBudget)
157+ }
158+ remaining := time.Until(cs.deadline)
159+ cs.mu.Unlock()
160+ budget, budgetOut, stop := bound(nil, max(remaining, time.Nanosecond))
161+ defer stop()
162+ defer func() { errs = dedupeErrors(errs) }()
163+ out := func(line string) {
164+ if s != nil {
165+ j.out(s.num, line)
166+ }
167+ }
168+ run := func(c dockerCmd) (int, error) {
169+ _, code, err := j.dockerDoCleanup(c, s, budget, budgetOut, t, t.CleanupCommand)
170+ return code, err
171+ }
172+ // rm runs `docker rm --force target`. It reports whether the target is
173+ // gone: removed, or docker says it does not exist. Any other failure leaves
174+ // it pending, for the fallback of the job to try again.
175+ rm := func(kind string, c dockerCmd) (gone bool) {
176+ lines, code, err := j.dockerDoCleanup(c, s, budget, budgetOut, t, t.CleanupCommand)
177+ switch {
178+ case err != nil:
179+ errs = append(errs, err)
180+ return false
181+ case code == 0:
182+ return true
183+ case absent(lines):
184+ return true
185+ }
186+ j.warnTo(s, fmt.Sprintf("Docker %s fail with exit code %d", kind, code))
187+ return false
188+ }
189+ // A container whose create did not finish is not known by its id, and its
190+ // name may not be ours (options: can set another --name): it is found by
191+ // the label every container of the job carries (DockerCreate adds it).
192+ var unresolved []*container
193+ for _, c := range cs.all() {
194+ if c.createTried && !c.removed && c.id == "" {
195+ unresolved = append(unresolved, c)
196+ }
197+ }
198+ if len(unresolved) > 0 {
199+ ok := true
200+ ids, code, err := j.dockerDoCleanup(j.newDockerCmd("ps", raw("--all", "--quiet", "--no-trunc"), quotedOpt("--filter", "label="+j.dockerLabel())), nil, budget, budgetOut, t, t.CleanupCommand)
201+ switch {
202+ case err != nil:
203+ errs, ok = append(errs, err), false
204+ case code != 0:
205+ j.warnTo(s, fmt.Sprintf("Docker ps fail with exit code %d", code))
206+ ok = false
207+ }
208+ known := map[string]bool{}
209+ for _, c := range cs.all() {
210+ if c.id != "" {
211+ known[c.id] = true // those are removed below, after their logs
212+ }
213+ }
214+ for _, id := range ids {
215+ if containerIDRe.MatchString(id) && !known[id] && !rm("rm", j.newDockerCmd("rm", raw("--force", id))) {
216+ ok = false
217+ }
218+ }
219+ if ok { // listed, and everything listed is gone: nothing of the job is left
220+ for _, c := range unresolved {
221+ c.removed = true
222+ }
223+ }
224+ }
225+ for _, c := range cs.all() {
226+ if !c.createTried || c.removed || c.id == "" {
227+ continue
228+ }
229+ if !c.isJob && !c.failedInit {
230+ out("Print service container logs: " + c.name)
231+ code, err := run(j.newDockerCmd("logs", raw("--details", c.id)))
232+ switch {
233+ case err != nil:
234+ // Best effort, as the runner's warning for a failed logs: it
235+ // neither fails the step nor stops the removal.
236+ j.warnTo(s, "Docker logs failed: "+err.Error())
237+ case code != 0:
238+ j.warnTo(s, fmt.Sprintf("Docker logs fail with exit code %d", code))
239+ }
240+ }
241+ out("Stop and remove container: " + c.name)
242+ if rm("rm", j.newDockerCmd("rm", raw("--force", c.id))) {
243+ c.removed = true
244+ }
245+ }
246+ cs.mu.Lock()
247+ network, gone := cs.networkTry, cs.netRemoved
248+ cs.mu.Unlock()
249+ if network != "" && !gone {
250+ out("Remove container network: " + network)
251+ if rm("network rm", j.newDockerCmd("network", raw("rm", network))) {
252+ cs.mu.Lock()
253+ cs.netRemoved = true
254+ cs.mu.Unlock()
255+ }
256+ }
257+ cs.mu.Lock()
258+ dirs := cs.dirs
259+ cs.dirs = nil
260+ cs.mu.Unlock()
261+ for _, d := range dirs {
262+ if err := os.RemoveAll(d); err != nil {
263+ cs.addDir(d)
264+ errs = append(errs, ghErrorf("Failed to remove directory containing Docker client credentials: %v", err))
265+ }
266+ }
267+ return errs
268+}
269+
270+// warnTo logs a warning to the step, or to the diagnostics without one.
271+func (j *job) warnTo(s *stepRun, msg string) {
272+ if s != nil {
273+ j.tagged(s.num, "##[warning]", msg)
274+ return
275+ }
276+ j.cfg.Log.Warn().Msg(msg)
277+}
278+
279+// absent reports whether docker's answer to a removal says the object does not
280+// exist: that is the state the cleanup wants.
281+func absent(lines []string) bool {
282+ for _, l := range lines {
283+ l = strings.ToLower(l)
284+ if strings.Contains(l, "no such container") || strings.Contains(l, "no such network") ||
285+ strings.Contains(l, "no such object") || strings.Contains(l, "network") && strings.Contains(l, "not found") {
286+ return true
287+ }
288+ }
289+ return false
290+}
291+
292+// dockerDoCleanup runs a command of the cleanup: bounded by its own limit and
293+// by the budget of the whole cleanup, its output (stdout and stderr) returned,
294+// logged to s if any. The budget is checked here, before every command: once
295+// it is spent nothing is started (a command started with a closed cancel would
296+// still cost a signal escalation) and the object stays pending.
297+func (j *job) dockerDoCleanup(c dockerCmd, s *stepRun, budget <-chan struct{}, budgetOut func() bool, t ContainerTimeouts, limit time.Duration) ([]string, int, error) {
298+ spent := func() error {
299+ return ghErrorf("The cleanup of the containers ran out of time (%s): not run: docker %s", t.CleanupBudget, j.dockerArgString(c))
300+ }
301+ select {
302+ case <-budget:
303+ return nil, 0, spent()
304+ default:
305+ }
306+ lines, code, err := j.dockerDo(c, dockerOpts{s: s, cancel: budget, all: true, timeout: limit, errToOut: true})
307+ if err == errCancelled && budgetOut() {
308+ return lines, code, ghErrorf("The cleanup of the containers ran out of time (%s): stopped: docker %s", t.CleanupBudget, j.dockerArgString(c))
309+ }
310+ return lines, code, err
311+}
312+
313+// dedupeErrors keeps the first of the errors that read the same.
314+func dedupeErrors(errs []error) []error {
315+ seen := map[string]bool{}
316+ var out []error
317+ for _, e := range errs {
318+ if !seen[e.Error()] {
319+ seen[e.Error()] = true
320+ out = append(out, e)
321+ }
322+ }
323+ return out
324+}
A internal/executor/contexts_containers.go +63 -0
@@ -0,0 +1,63 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import "bnema.dev/ermite-actions/expr"
6+
7+// The job.container and job.services contexts (JobContext.cs:20-47,
8+// ContainerOperationProvider.cs:96, :215-232, :237-240). The runner's
9+// JobContext.Container and .Services create their dictionary when they are
10+// read, which is why a recorded job that ran a docker action has
11+// "container": {} (act-docker-image).
12+
13+// jobSub returns the dictionary at job.<key>, creating it when missing.
14+func (s *State) jobSub(key string) *expr.Dictionary {
15+ if v, ok := s.job.Get(key); ok {
16+ if d, ok := v.(*expr.Dictionary); ok {
17+ return d
18+ }
19+ }
20+ d := expr.NewDictionary()
21+ s.job = setKey(s.job, key, d)
22+ return d
23+}
24+
25+// TouchJobContainer is the read of JobContext.Container: job.container exists
26+// from then on.
27+func (s *State) TouchJobContainer() { s.jobSub("container") }
28+
29+// SetJobContainer sets job.container.<key> ("network", "id").
30+func (s *State) SetJobContainer(key, value string) {
31+ d := s.jobSub("container")
32+ if _, ok := d.Get(key); ok {
33+ s.job = setKey(s.job, "container", replaceKey(d, key, expr.String(value)))
34+ return
35+ }
36+ d.Add(key, expr.String(value))
37+}
38+
39+// JobContainerNetwork is job.container.network, "" when it is not set.
40+func (s *State) JobContainerNetwork() string {
41+ if v, ok := s.jobSub("container").Get("network"); ok {
42+ return expr.CoerceString(v)
43+ }
44+ return ""
45+}
46+
47+// SetJobService sets job.services.<alias> ({id, ports, network}).
48+func (s *State) SetJobService(alias, id, network string, ports []portMapping) {
49+ svc := expr.NewDictionary()
50+ svc.Add("id", expr.String(id))
51+ pd := expr.NewDictionary()
52+ for _, p := range ports {
53+ pd.Add(p.container, expr.String(p.host))
54+ }
55+ svc.Add("ports", pd)
56+ svc.Add("network", expr.String(network))
57+ d := s.jobSub("services")
58+ s.job = setKey(s.job, "services", replaceKey(d, alias, svc))
59+}
60+
61+func replaceKey(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary {
62+ return setKey(d, key, v)
63+}
@@ -0,0 +1,63 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import "bnema.dev/ermite-actions/expr"
6+
7+// The job.container and job.services contexts (JobContext.cs:20-47,
8+// ContainerOperationProvider.cs:96, :215-232, :237-240). The runner's
9+// JobContext.Container and .Services create their dictionary when they are
10+// read, which is why a recorded job that ran a docker action has
11+// "container": {} (act-docker-image).
12+
13+// jobSub returns the dictionary at job.<key>, creating it when missing.
14+func (s *State) jobSub(key string) *expr.Dictionary {
15+ if v, ok := s.job.Get(key); ok {
16+ if d, ok := v.(*expr.Dictionary); ok {
17+ return d
18+ }
19+ }
20+ d := expr.NewDictionary()
21+ s.job = setKey(s.job, key, d)
22+ return d
23+}
24+
25+// TouchJobContainer is the read of JobContext.Container: job.container exists
26+// from then on.
27+func (s *State) TouchJobContainer() { s.jobSub("container") }
28+
29+// SetJobContainer sets job.container.<key> ("network", "id").
30+func (s *State) SetJobContainer(key, value string) {
31+ d := s.jobSub("container")
32+ if _, ok := d.Get(key); ok {
33+ s.job = setKey(s.job, "container", replaceKey(d, key, expr.String(value)))
34+ return
35+ }
36+ d.Add(key, expr.String(value))
37+}
38+
39+// JobContainerNetwork is job.container.network, "" when it is not set.
40+func (s *State) JobContainerNetwork() string {
41+ if v, ok := s.jobSub("container").Get("network"); ok {
42+ return expr.CoerceString(v)
43+ }
44+ return ""
45+}
46+
47+// SetJobService sets job.services.<alias> ({id, ports, network}).
48+func (s *State) SetJobService(alias, id, network string, ports []portMapping) {
49+ svc := expr.NewDictionary()
50+ svc.Add("id", expr.String(id))
51+ pd := expr.NewDictionary()
52+ for _, p := range ports {
53+ pd.Add(p.container, expr.String(p.host))
54+ }
55+ svc.Add("ports", pd)
56+ svc.Add("network", expr.String(network))
57+ d := s.jobSub("services")
58+ s.job = setKey(s.job, "services", replaceKey(d, alias, svc))
59+}
60+
61+func replaceKey(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary {
62+ return setKey(d, key, v)
63+}
M internal/executor/corpus_test.go +7 -1
@@ -155,6 +155,12 @@ func testGitHubContext(job string) string {
155155 // runLocal runs one job of a workflow file on this machine under a temporary
156156 // home. The environment is minimal and fixed.
157157 func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) {
158+ t.Helper()
159+ return runLocalC(t, nil, yaml, jobKey, tweak)
160+}
161+
162+// runLocalC is runLocal with a canceller.
163+func runLocalC(t *testing.T, cancel *Canceller, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) {
158164 t.Helper()
159165 home := t.TempDir()
160166 layout := NewLayout(home, "hello", filepath.Join(home, "toolcache"))
@@ -169,7 +175,7 @@ func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder
169175 if tweak != nil {
170176 tweak(&cfg)
171177 }
172- res := Run(nil, cfg)
178+ res := Run(cancel, cfg)
173179 return rec, res
174180 }
175181
@@ -155,6 +155,12 @@ func testGitHubContext(job string) string {
155 // runLocal runs one job of a workflow file on this machine under a temporary155 // runLocal runs one job of a workflow file on this machine under a temporary
156 // home. The environment is minimal and fixed.156 // home. The environment is minimal and fixed.
157 func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) {157 func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) {
158+ t.Helper()
159+ return runLocalC(t, nil, yaml, jobKey, tweak)
160+}
161+
162+// runLocalC is runLocal with a canceller.
163+func runLocalC(t *testing.T, cancel *Canceller, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) {
158 t.Helper()164 t.Helper()
159 home := t.TempDir()165 home := t.TempDir()
160 layout := NewLayout(home, "hello", filepath.Join(home, "toolcache"))166 layout := NewLayout(home, "hello", filepath.Join(home, "toolcache"))
@@ -169,7 +175,7 @@ func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder
169 if tweak != nil {175 if tweak != nil {
170 tweak(&cfg)176 tweak(&cfg)
171 }177 }
172- res := Run(nil, cfg)178+ res := Run(cancel, cfg)
173 return rec, res179 return rec, res
174 }180 }
175 181
M internal/executor/exec_step.go +17 -1
@@ -15,6 +15,9 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
1515 return j.runAction(s, stepCancel)
1616 }
1717 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
18+ if err := j.containerReady(); err != nil {
19+ return fail(err)
20+ }
1821 failTemplate := func(err error) stepResult {
1922 j.errorf(num, "%s", templateNotValid(err, true))
2023 return resFailed
@@ -34,6 +37,13 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
3437 if err != nil {
3538 return fail(err) // the group stays open, like the original
3639 }
40+ if c := j.stepContainer(); c != nil {
41+ argv, err := j.containerScript(c, plan, in, s)
42+ if err != nil {
43+ return fail(err)
44+ }
45+ return j.runProc(s, argv[0], j.state.Layout().WorkRoot, argv, "", stepCancel)
46+ }
3747 dir, argv, err := j.scriptCommand(plan, in)
3848 if err != nil {
3949 return fail(err)
@@ -120,7 +130,13 @@ func (j *job) printHeader(s *stepRun, in scriptIn) (ShellPlan, error) {
120130 j.mu.Lock()
121131 lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()}
122132 j.mu.Unlock()
123- plan, err := PlanShell(in.shell, lookup)
133+ var plan ShellPlan
134+ var err error
135+ if j.stepContainer() != nil {
136+ plan, err = PlanShellInContainer(in.shell)
137+ } else {
138+ plan, err = PlanShell(in.shell, lookup)
139+ }
124140 if err != nil {
125141 return plan, err
126142 }
@@ -15,6 +15,9 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
15 return j.runAction(s, stepCancel)15 return j.runAction(s, stepCancel)
16 }16 }
17 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }17 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
18+ if err := j.containerReady(); err != nil {
19+ return fail(err)
20+ }
18 failTemplate := func(err error) stepResult {21 failTemplate := func(err error) stepResult {
19 j.errorf(num, "%s", templateNotValid(err, true))22 j.errorf(num, "%s", templateNotValid(err, true))
20 return resFailed23 return resFailed
@@ -34,6 +37,13 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
34 if err != nil {37 if err != nil {
35 return fail(err) // the group stays open, like the original38 return fail(err) // the group stays open, like the original
36 }39 }
40+ if c := j.stepContainer(); c != nil {
41+ argv, err := j.containerScript(c, plan, in, s)
42+ if err != nil {
43+ return fail(err)
44+ }
45+ return j.runProc(s, argv[0], j.state.Layout().WorkRoot, argv, "", stepCancel)
46+ }
37 dir, argv, err := j.scriptCommand(plan, in)47 dir, argv, err := j.scriptCommand(plan, in)
38 if err != nil {48 if err != nil {
39 return fail(err)49 return fail(err)
@@ -120,7 +130,13 @@ func (j *job) printHeader(s *stepRun, in scriptIn) (ShellPlan, error) {
120 j.mu.Lock()130 j.mu.Lock()
121 lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()}131 lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()}
122 j.mu.Unlock()132 j.mu.Unlock()
123- plan, err := PlanShell(in.shell, lookup)133+ var plan ShellPlan
134+ var err error
135+ if j.stepContainer() != nil {
136+ plan, err = PlanShellInContainer(in.shell)
137+ } else {
138+ plan, err = PlanShell(in.shell, lookup)
139+ }
124 if err != nil {140 if err != nil {
125 return plan, err141 return plan, err
126 }142 }
M internal/executor/issues.go +5 -0
@@ -290,6 +290,11 @@ func (m *commandManager) commandIssue(level AnnotationLevel, name string, c *act
290290 }
291291 validateLinesAndColumns(props, name, h.Debug)
292292 if file := props["file"]; file != "" {
293+ // ActionCommandManager.cs:675-681: the path of a container is the host's.
294+ if hp := h.HostPath(file); hp != file {
295+ file = hp
296+ props["file"] = hp
297+ }
293298 if rel := makeRelative(file, h.Workspace()); rel != file && rel != "" {
294299 props["file"] = rel
295300 }
@@ -290,6 +290,11 @@ func (m *commandManager) commandIssue(level AnnotationLevel, name string, c *act
290 }290 }
291 validateLinesAndColumns(props, name, h.Debug)291 validateLinesAndColumns(props, name, h.Debug)
292 if file := props["file"]; file != "" {292 if file := props["file"]; file != "" {
293+ // ActionCommandManager.cs:675-681: the path of a container is the host's.
294+ if hp := h.HostPath(file); hp != file {
295+ file = hp
296+ props["file"] = hp
297+ }
293 if rel := makeRelative(file, h.Workspace()); rel != file && rel != "" {298 if rel := makeRelative(file, h.Workspace()); rel != file && rel != "" {
294 props["file"] = rel299 props["file"] = rel
295 }300 }
M internal/executor/job.go +17 -4
@@ -169,6 +169,8 @@ type job struct {
169169 // upgradedToNode24 are the actions that asked for node20 and got node24.
170170 upgradedToNode24 []string
171171 jobCtx context.Context
172+ // cs is the containers of the job (nil container and no services: none).
173+ cs *containerSet
172174
173175 // Results of the commands (see issues.go, matchers.go, results.go).
174176 matchers *matcherRoot
@@ -211,6 +213,9 @@ func Run(cancel *Canceller, cfg Config) Result {
211213 }
212214
213215 func (j *job) run() Result {
216+ // Whatever happens below (a panic, a shutdown that skips the post steps),
217+ // the containers, the network and the credentials directories are removed.
218+ defer j.fallbackCleanup()
214219 limit := j.cfg.Deadline
215220 if limit <= 0 {
216221 limit = time.Duration(j.timeoutMinutes()) * time.Minute
@@ -435,10 +440,6 @@ func (j *job) setUp() ([]qstep, error) {
435440 switch {
436441 case jb.IsReusable():
437442 return nil, errors.New("not supported yet: reusable workflows (jobs.<id>.uses)")
438- case jb.Container != nil:
439- return nil, errors.New("not supported yet: jobs.<id>.container")
440- case jb.Services != nil:
441- return nil, errors.New("not supported yet: jobs.<id>.services")
442443 }
443444
444445 j.printTokenInfo()
@@ -508,6 +509,18 @@ func (j *job) setUp() ([]qstep, error) {
508509 j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd
509510 }
510511
512+ // JobExtension.cs:250-275: the job container and the services are
513+ // evaluated after the job environment, before the actions are prepared.
514+ j.debug(1, "Evaluating job container")
515+ j.debug(1, "Evaluating job service containers")
516+ jc, svcs, err := j.evalContainers()
517+ if err != nil {
518+ return nil, err
519+ }
520+ if jc != nil || len(svcs) > 0 {
521+ j.cs = &containerSet{job: jc, services: svcs}
522+ }
523+
511524 j.out(1, "Prepare all required actions")
512525 queue, err := j.prepareJob()
513526 if err != nil {
@@ -169,6 +169,8 @@ type job struct {
169 // upgradedToNode24 are the actions that asked for node20 and got node24.169 // upgradedToNode24 are the actions that asked for node20 and got node24.
170 upgradedToNode24 []string170 upgradedToNode24 []string
171 jobCtx context.Context171 jobCtx context.Context
172+ // cs is the containers of the job (nil container and no services: none).
173+ cs *containerSet
172 174
173 // Results of the commands (see issues.go, matchers.go, results.go).175 // Results of the commands (see issues.go, matchers.go, results.go).
174 matchers *matcherRoot176 matchers *matcherRoot
@@ -211,6 +213,9 @@ func Run(cancel *Canceller, cfg Config) Result {
211 }213 }
212 214
213 func (j *job) run() Result {215 func (j *job) run() Result {
216+ // Whatever happens below (a panic, a shutdown that skips the post steps),
217+ // the containers, the network and the credentials directories are removed.
218+ defer j.fallbackCleanup()
214 limit := j.cfg.Deadline219 limit := j.cfg.Deadline
215 if limit <= 0 {220 if limit <= 0 {
216 limit = time.Duration(j.timeoutMinutes()) * time.Minute221 limit = time.Duration(j.timeoutMinutes()) * time.Minute
@@ -435,10 +440,6 @@ func (j *job) setUp() ([]qstep, error) {
435 switch {440 switch {
436 case jb.IsReusable():441 case jb.IsReusable():
437 return nil, errors.New("not supported yet: reusable workflows (jobs.<id>.uses)")442 return nil, errors.New("not supported yet: reusable workflows (jobs.<id>.uses)")
438- case jb.Container != nil:
439- return nil, errors.New("not supported yet: jobs.<id>.container")
440- case jb.Services != nil:
441- return nil, errors.New("not supported yet: jobs.<id>.services")
442 }443 }
443 444
444 j.printTokenInfo()445 j.printTokenInfo()
@@ -508,6 +509,18 @@ func (j *job) setUp() ([]qstep, error) {
508 j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd509 j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd
509 }510 }
510 511
512+ // JobExtension.cs:250-275: the job container and the services are
513+ // evaluated after the job environment, before the actions are prepared.
514+ j.debug(1, "Evaluating job container")
515+ j.debug(1, "Evaluating job service containers")
516+ jc, svcs, err := j.evalContainers()
517+ if err != nil {
518+ return nil, err
519+ }
520+ if jc != nil || len(svcs) > 0 {
521+ j.cs = &containerSet{job: jc, services: svcs}
522+ }
523+
511 j.out(1, "Prepare all required actions")524 j.out(1, "Prepare all required actions")
512 queue, err := j.prepareJob()525 queue, err := j.prepareJob()
513 if err != nil {526 if err != nil {
M internal/executor/job_test.go +5 -6
@@ -192,13 +192,12 @@ jobs:
192192 _ = rec
193193 }
194194
195-func TestNotSupportedYet(t *testing.T) {
195+// Reusable workflows stay refused; container: and services: are supported.
196+func TestReusableWorkflowNotSupportedYet(t *testing.T) {
196197 needTools(t, "bash")
197- for _, f := range []string{"container: alpine", "services:\n s:\n image: x"} {
198- rec2, res2 := runLocal(t, wfHead+" "+f+"\n steps:\n - run: echo hi\n", "j", nil)
199- if res2.Conclusion != ConclusionFailure || res2.Error == "" || !strings.Contains(logOf(rec2.steps[0]), "not supported yet") {
200- t.Errorf("%q: %+v %v", f, res2, rec2.names())
201- }
198+ rec, res := runLocal(t, "on: push\njobs:\n j:\n uses: ./.github/workflows/x.yml\n", "j", nil)
199+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[0]), "not supported yet") {
200+ t.Errorf("%+v %v", res, rec.names())
202201 }
203202 }
204203
@@ -192,13 +192,12 @@ jobs:
192 _ = rec192 _ = rec
193 }193 }
194 194
195-func TestNotSupportedYet(t *testing.T) {195+// Reusable workflows stay refused; container: and services: are supported.
196+func TestReusableWorkflowNotSupportedYet(t *testing.T) {
196 needTools(t, "bash")197 needTools(t, "bash")
197- for _, f := range []string{"container: alpine", "services:\n s:\n image: x"} {198+ rec, res := runLocal(t, "on: push\njobs:\n j:\n uses: ./.github/workflows/x.yml\n", "j", nil)
198- rec2, res2 := runLocal(t, wfHead+" "+f+"\n steps:\n - run: echo hi\n", "j", nil)199+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[0]), "not supported yet") {
199- if res2.Conclusion != ConclusionFailure || res2.Error == "" || !strings.Contains(logOf(rec2.steps[0]), "not supported yet") {200+ t.Errorf("%+v %v", res, rec.names())
200- t.Errorf("%q: %+v %v", f, res2, rec2.names())
201- }
202 }201 }
203 }202 }
204 203
M internal/executor/outputmgr.go +5 -1
@@ -343,9 +343,13 @@ func (o *outputManager) convertToIssue(m *issueMatch) (issue, bool) {
343343 file = filepath.Join(dir, file)
344344 }
345345 }
346- // Root using workspace.
346+ // Root using workspace; an absolute path is the container's and is
347+ // translated to the host's (OutputManager.cs:281-311).
347348 if !filepath.IsAbs(file) {
348349 file = filepath.Join(s.Workspace(), file)
350+ } else {
351+ file = filepath.Clean(file)
352+ file = filepath.Clean(s.HostPath(file))
349353 }
350354 file = filepath.Clean(file)
351355 if st, err := os.Stat(file); err == nil && !st.IsDir() {
@@ -343,9 +343,13 @@ func (o *outputManager) convertToIssue(m *issueMatch) (issue, bool) {
343 file = filepath.Join(dir, file)343 file = filepath.Join(dir, file)
344 }344 }
345 }345 }
346- // Root using workspace.346+ // Root using workspace; an absolute path is the container's and is
347+ // translated to the host's (OutputManager.cs:281-311).
347 if !filepath.IsAbs(file) {348 if !filepath.IsAbs(file) {
348 file = filepath.Join(s.Workspace(), file)349 file = filepath.Join(s.Workspace(), file)
350+ } else {
351+ file = filepath.Clean(file)
352+ file = filepath.Clean(s.HostPath(file))
349 }353 }
350 file = filepath.Clean(file)354 file = filepath.Clean(file)
351 if st, err := os.Stat(file); err == nil && !st.IsDir() {355 if st, err := os.Stat(file); err == nil && !st.IsDir() {
M internal/executor/proc.go +14 -0
@@ -125,6 +125,7 @@ const dispatchBatchDelay = 2 * time.Millisecond
125125 // which is visible with `bash -x` (the corpus recording of shell-custom).
126126 type dispatcher struct {
127127 fn func(string)
128+ errFn func(string) // when set, stderr lines go here instead of fn
128129 mu sync.Mutex
129130 outQ []string
130131 errQ []string
@@ -159,6 +160,10 @@ func (d *dispatcher) drain() {
159160 d.errQ, d.outQ = nil, nil
160161 d.mu.Unlock()
161162 for _, l := range errs {
163+ if d.errFn != nil {
164+ d.errFn(l)
165+ continue
166+ }
162167 d.fn(l)
163168 }
164169 for _, l := range outs {
@@ -199,6 +204,11 @@ type procSpec struct {
199204 // OnLine receives the lines of stdout and stderr, one call at a time, in
200205 // the order of dispatcher.
201206 OnLine func(line string)
207+ // OnErrLine, when set, receives the lines of stderr instead of OnLine.
208+ OnErrLine func(line string)
209+ // Stdin, when set, is written to the standard input of the process (the
210+ // password of docker login); otherwise stdin is empty.
211+ Stdin string
202212 // Cancel starts the SIGINT, SIGTERM, SIGKILL escalation when closed.
203213 Cancel <-chan struct{}
204214
@@ -248,6 +258,10 @@ func runProcess(spec procSpec) (procResult, error) {
248258 cmd.Env = spec.Env
249259 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Credential: spec.Credential}
250260 disp := newDispatcher(spec.OnLine)
261+ disp.errFn = spec.OnErrLine
262+ if spec.Stdin != "" {
263+ cmd.Stdin = strings.NewReader(spec.Stdin)
264+ }
251265 cmd.Stdout, cmd.Stderr = disp.out, disp.err
252266 cmd.WaitDelay = spec.WaitDelay
253267 if err := cmd.Start(); err != nil {
@@ -125,6 +125,7 @@ const dispatchBatchDelay = 2 * time.Millisecond
125 // which is visible with `bash -x` (the corpus recording of shell-custom).125 // which is visible with `bash -x` (the corpus recording of shell-custom).
126 type dispatcher struct {126 type dispatcher struct {
127 fn func(string)127 fn func(string)
128+ errFn func(string) // when set, stderr lines go here instead of fn
128 mu sync.Mutex129 mu sync.Mutex
129 outQ []string130 outQ []string
130 errQ []string131 errQ []string
@@ -159,6 +160,10 @@ func (d *dispatcher) drain() {
159 d.errQ, d.outQ = nil, nil160 d.errQ, d.outQ = nil, nil
160 d.mu.Unlock()161 d.mu.Unlock()
161 for _, l := range errs {162 for _, l := range errs {
163+ if d.errFn != nil {
164+ d.errFn(l)
165+ continue
166+ }
162 d.fn(l)167 d.fn(l)
163 }168 }
164 for _, l := range outs {169 for _, l := range outs {
@@ -199,6 +204,11 @@ type procSpec struct {
199 // OnLine receives the lines of stdout and stderr, one call at a time, in204 // OnLine receives the lines of stdout and stderr, one call at a time, in
200 // the order of dispatcher.205 // the order of dispatcher.
201 OnLine func(line string)206 OnLine func(line string)
207+ // OnErrLine, when set, receives the lines of stderr instead of OnLine.
208+ OnErrLine func(line string)
209+ // Stdin, when set, is written to the standard input of the process (the
210+ // password of docker login); otherwise stdin is empty.
211+ Stdin string
202 // Cancel starts the SIGINT, SIGTERM, SIGKILL escalation when closed.212 // Cancel starts the SIGINT, SIGTERM, SIGKILL escalation when closed.
203 Cancel <-chan struct{}213 Cancel <-chan struct{}
204 214
@@ -248,6 +258,10 @@ func runProcess(spec procSpec) (procResult, error) {
248 cmd.Env = spec.Env258 cmd.Env = spec.Env
249 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Credential: spec.Credential}259 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Credential: spec.Credential}
250 disp := newDispatcher(spec.OnLine)260 disp := newDispatcher(spec.OnLine)
261+ disp.errFn = spec.OnErrLine
262+ if spec.Stdin != "" {
263+ cmd.Stdin = strings.NewReader(spec.Stdin)
264+ }
251 cmd.Stdout, cmd.Stderr = disp.out, disp.err265 cmd.Stdout, cmd.Stderr = disp.out, disp.err
252 cmd.WaitDelay = spec.WaitDelay266 cmd.WaitDelay = spec.WaitDelay
253 if err := cmd.Start(); err != nil {267 if err := cmd.Start(); err != nil {
M internal/executor/results.go +15 -0
@@ -6,6 +6,7 @@ import (
66 "context"
77 "fmt"
88 "io"
9+ "io/fs"
910 "os"
1011 "strings"
1112 "sync/atomic"
@@ -42,6 +43,14 @@ func (j *job) addIssueFor(s *stepRun, is issue) {
4243 // Workspace is github.workspace.
4344 func (s *stepRun) Workspace() string { return s.j.githubValue("workspace") }
4445
46+// HostPath is TranslateToHostPath of the job container.
47+func (s *stepRun) HostPath(p string) string {
48+ if c := s.j.stepContainer(); c != nil {
49+ return c.translateToHost(p)
50+ }
51+ return p
52+}
53+
4554 func (j *job) githubValue(k string) string {
4655 j.mu.Lock()
4756 defer j.mu.Unlock()
@@ -71,6 +80,12 @@ const maxMatcherFile = 1 << 20
7180
7281 // ReadMatcherFile reads a problem matcher file.
7382 func (s *stepRun) ReadMatcherFile(path string) ([]byte, error) {
83+ // With a job container the path came from a command of the container: it
84+ // is read only when it is a file of a directory the container shares with
85+ // the host, never the host's own file of the same name.
86+ if c := s.j.stepContainer(); c != nil && !c.insideMappings(path) {
87+ return nil, &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist}
88+ }
7489 f, err := os.Open(path)
7590 if err != nil {
7691 return nil, err
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "fmt"7 "fmt"
8 "io"8 "io"
9+ "io/fs"
9 "os"10 "os"
10 "strings"11 "strings"
11 "sync/atomic"12 "sync/atomic"
@@ -42,6 +43,14 @@ func (j *job) addIssueFor(s *stepRun, is issue) {
42 // Workspace is github.workspace.43 // Workspace is github.workspace.
43 func (s *stepRun) Workspace() string { return s.j.githubValue("workspace") }44 func (s *stepRun) Workspace() string { return s.j.githubValue("workspace") }
44 45
46+// HostPath is TranslateToHostPath of the job container.
47+func (s *stepRun) HostPath(p string) string {
48+ if c := s.j.stepContainer(); c != nil {
49+ return c.translateToHost(p)
50+ }
51+ return p
52+}
53+
45 func (j *job) githubValue(k string) string {54 func (j *job) githubValue(k string) string {
46 j.mu.Lock()55 j.mu.Lock()
47 defer j.mu.Unlock()56 defer j.mu.Unlock()
@@ -71,6 +80,12 @@ const maxMatcherFile = 1 << 20
71 80
72 // ReadMatcherFile reads a problem matcher file.81 // ReadMatcherFile reads a problem matcher file.
73 func (s *stepRun) ReadMatcherFile(path string) ([]byte, error) {82 func (s *stepRun) ReadMatcherFile(path string) ([]byte, error) {
83+ // With a job container the path came from a command of the container: it
84+ // is read only when it is a file of a directory the container shares with
85+ // the host, never the host's own file of the same name.
86+ if c := s.j.stepContainer(); c != nil && !c.insideMappings(path) {
87+ return nil, &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist}
88+ }
74 f, err := os.Open(path)89 f, err := os.Open(path)
75 if err != nil {90 if err != nil {
76 return nil, err91 return nil, err
M internal/executor/shell.go +29 -1
@@ -291,9 +291,21 @@ type Script struct {
291291 // so the file is created through root, exclusively and without following links
292292 // (see createExcl).
293293 func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) {
294+ return WriteScriptFor(root, p, contents, nil)
295+}
296+
297+// WriteScriptFor is WriteScript for a shell that sees the file under another
298+// path: tr translates the host path for the arguments (a job container sees
299+// RUNNER_TEMP as /__w/_temp, ScriptHandler.cs:252). Script.Path stays the
300+// host's.
301+func WriteScriptFor(root *os.Root, p ShellPlan, contents string, tr func(string) string) (Script, error) {
294302 name := uuid.NewString() + p.Extension()
295303 path := filepath.Join(root.Name(), name)
296- args, err := FormatArguments(p.ArgFormat, path)
304+ shown := path
305+ if tr != nil {
306+ shown = tr(path)
307+ }
308+ args, err := FormatArguments(p.ArgFormat, shown)
297309 if err != nil {
298310 return Script{}, err
299311 }
@@ -315,3 +327,19 @@ func ResolveWorkingDirectory(workspace, dir string) string {
315327 }
316328 return strings.TrimRight(workspace, "/") + "/" + dir
317329 }
330+
331+// PlanShellInContainer is the shell of a step that runs in the job container
332+// (ScriptHandler.cs:73-117, :182-232 with isContainerStepHost): nothing is
333+// looked up, the shell is found by the container itself, so the default is
334+// always "sh -e {0}" even when the image has bash.
335+func PlanShellInContainer(shell string) (ShellPlan, error) {
336+ if shell == "" {
337+ return checkFormat(ShellPlan{Command: "sh", CommandPath: "sh", ArgFormat: ScriptArgumentsFormat("sh")})
338+ }
339+ cmd, args := ParseShellOption(shell)
340+ format := strings.TrimLeft(args, " \t\n\v\f\r")
341+ if format == "" {
342+ format = ScriptArgumentsFormat(cmd)
343+ }
344+ return checkFormat(ShellPlan{Command: cmd, CommandPath: cmd, ArgFormat: format})
345+}
@@ -291,9 +291,21 @@ type Script struct {
291 // so the file is created through root, exclusively and without following links291 // so the file is created through root, exclusively and without following links
292 // (see createExcl).292 // (see createExcl).
293 func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) {293 func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) {
294+ return WriteScriptFor(root, p, contents, nil)
295+}
296+
297+// WriteScriptFor is WriteScript for a shell that sees the file under another
298+// path: tr translates the host path for the arguments (a job container sees
299+// RUNNER_TEMP as /__w/_temp, ScriptHandler.cs:252). Script.Path stays the
300+// host's.
301+func WriteScriptFor(root *os.Root, p ShellPlan, contents string, tr func(string) string) (Script, error) {
294 name := uuid.NewString() + p.Extension()302 name := uuid.NewString() + p.Extension()
295 path := filepath.Join(root.Name(), name)303 path := filepath.Join(root.Name(), name)
296- args, err := FormatArguments(p.ArgFormat, path)304+ shown := path
305+ if tr != nil {
306+ shown = tr(path)
307+ }
308+ args, err := FormatArguments(p.ArgFormat, shown)
297 if err != nil {309 if err != nil {
298 return Script{}, err310 return Script{}, err
299 }311 }
@@ -315,3 +327,19 @@ func ResolveWorkingDirectory(workspace, dir string) string {
315 }327 }
316 return strings.TrimRight(workspace, "/") + "/" + dir328 return strings.TrimRight(workspace, "/") + "/" + dir
317 }329 }
330+
331+// PlanShellInContainer is the shell of a step that runs in the job container
332+// (ScriptHandler.cs:73-117, :182-232 with isContainerStepHost): nothing is
333+// looked up, the shell is found by the container itself, so the default is
334+// always "sh -e {0}" even when the image has bash.
335+func PlanShellInContainer(shell string) (ShellPlan, error) {
336+ if shell == "" {
337+ return checkFormat(ShellPlan{Command: "sh", CommandPath: "sh", ArgFormat: ScriptArgumentsFormat("sh")})
338+ }
339+ cmd, args := ParseShellOption(shell)
340+ format := strings.TrimLeft(args, " \t\n\v\f\r")
341+ if format == "" {
342+ format = ScriptArgumentsFormat(cmd)
343+ }
344+ return checkFormat(ShellPlan{Command: cmd, CommandPath: cmd, ArgFormat: format})
345+}
M internal/executor/step_run.go +2 -2
@@ -249,10 +249,10 @@ func (j *job) runGuarded(s *stepRun, q qstep, ok bool) stepResult {
249249 j.mu.Lock()
250250 defer j.mu.Unlock()
251251 switch {
252- case q.setup != nil:
253- return j.evalCondString("success()", s.env, j.statusFuncs())
254252 case q.cond != "":
255253 return j.evalCondString(q.cond, s.env, s.statusFuncs())
254+ case q.setup != nil:
255+ return j.evalCondString("success()", s.env, j.statusFuncs())
256256 }
257257 return st.EvaluateIf(context.Background(), j.evalEnv(s.env), s.statusFuncs())
258258 }
@@ -249,10 +249,10 @@ func (j *job) runGuarded(s *stepRun, q qstep, ok bool) stepResult {
249 j.mu.Lock()249 j.mu.Lock()
250 defer j.mu.Unlock()250 defer j.mu.Unlock()
251 switch {251 switch {
252- case q.setup != nil:
253- return j.evalCondString("success()", s.env, j.statusFuncs())
254 case q.cond != "":252 case q.cond != "":
255 return j.evalCondString(q.cond, s.env, s.statusFuncs())253 return j.evalCondString(q.cond, s.env, s.statusFuncs())
254+ case q.setup != nil:
255+ return j.evalCondString("success()", s.env, j.statusFuncs())
256 }256 }
257 return st.EvaluateIf(context.Background(), j.evalEnv(s.env), s.statusFuncs())257 return st.EvaluateIf(context.Background(), j.evalEnv(s.env), s.statusFuncs())
258 }258 }

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