detached 596a6c3

feat(executor): Initialize containers and Stop containers steps with service health checks

commit
596a6c3dd1fc6294465f3e0eb4bb40d4873057c0
author
brice · · 2026-10-14 05:46 UTC
parent
f72c62d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +898 -6
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()})
A internal/executor/containers_docker.go +102 -0
@@ -0,0 +1,102 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+)
8+
9+// Helpers for the docker commands of the container steps. The runner builds a
10+// command line as a string, echoes it (ExecutionContext.Command, "##[command]")
11+// and lets .NET split it into arguments. Here the echoed line and the
12+// arguments are built together (dockerPart), so that nothing a workflow wrote
13+// can end up as an argument of its own, except through `options:` which the
14+// runner splits too (ParseArguments).
15+
16+// quotedOpt is `flag "value"` in the echoed line and two arguments.
17+func quotedOpt(flag, value string) dockerPart {
18+ return dockerPart{line: flag + ` "` + value + `"`, args: []string{flag, value}}
19+}
20+
21+// eqOpt is `flag="value"` (docker inspect --format="...").
22+func eqOpt(flag, value string) dockerPart {
23+ return dockerPart{line: flag + `="` + value + `"`, args: []string{flag + "=" + value}}
24+}
25+
26+// envKV is DockerUtil.CreateEscapedOption("-e", key, value), or ("-e", key)
27+// for an empty value (docker then reads it from its own environment).
28+func envKV(key, value string) dockerPart {
29+ if value == "" {
30+ return envOpt(key)
31+ }
32+ kv := key + "=" + value
33+ return dockerPart{line: "-e " + escapeDockerArg(kv), args: []string{"-e", kv}}
34+}
35+
36+// mountOpt is the -v option of DockerCommandManager.DockerCreate (:164-185).
37+func mountOpt(m mount) dockerPart {
38+ esc := func(s string) string { return strings.ReplaceAll(s, `"`, `\"`) }
39+ var p dockerPart
40+ if m.source == "" {
41+ p = dockerPart{line: `-v "` + esc(m.target) + `"`, args: []string{"-v", m.target}}
42+ } else {
43+ p = dockerPart{line: `-v "` + esc(m.source) + `":"` + esc(m.target) + `"`, args: []string{"-v", m.source + ":" + m.target}}
44+ }
45+ if m.readOnly {
46+ p.line += ":ro"
47+ p.args[1] += ":ro"
48+ }
49+ return p
50+}
51+
52+// optionsParts is the user's `options:` text: echoed as written, split into
53+// arguments like .NET splits a command line. An empty text still leaves its
54+// place in the line (the runner joins an empty string: two spaces).
55+func optionsParts(options string) dockerPart {
56+ return dockerPart{line: options, args: ParseArguments(options)}
57+}
58+
59+// dockerArgString is the arguments of the command as ProcessExitCodeException
60+// words them: the echoed line without the path of docker.
61+func (j *job) dockerArgString(c dockerCmd) string {
62+ return strings.TrimPrefix(c.line, j.dockerPath()+" ")
63+}
64+
65+// dockerList is the overload of ExecuteDockerCommandAsync that returns the
66+// output (DockerCommandManager.cs:462-492): non-empty lines of stdout are kept
67+// and logged, non-empty lines of stderr are logged, and a non-zero exit is an
68+// error.
69+func (j *job) dockerList(s *stepRun, c dockerCmd, cancel <-chan struct{}) ([]string, error) {
70+ j.out(s.num, "##[command]"+c.line)
71+ var lines []string
72+ res, err := runProcess(procSpec{
73+ Argv: c.argv, Dir: dirOrCwd(j.githubValue("workspace")), Env: j.processEnv(newOrdered()), Credential: j.cfg.Credential,
74+ OnLine: func(l string) {
75+ if l != "" {
76+ lines = append(lines, l)
77+ j.write(s.num, l)
78+ }
79+ },
80+ OnErrLine: func(l string) {
81+ if l != "" {
82+ j.write(s.num, l)
83+ }
84+ },
85+ Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
86+ })
87+ if err != nil {
88+ return nil, err
89+ }
90+ if res.Cancelled {
91+ return lines, errCancelled
92+ }
93+ if res.ExitCode != 0 {
94+ return lines, ghErrorf("Exit code %d returned from process: file name '%s', arguments '%s'.", res.ExitCode, j.dockerPath(), j.dockerArgString(c))
95+ }
96+ return lines, nil
97+}
98+
99+// dockerCode is the overload that logs every line and returns the exit code.
100+func (j *job) dockerCode(s *stepRun, c dockerCmd, cancel <-chan struct{}) (int, error) {
101+ return j.runDockerCmd(s, c, dirOrCwd(j.githubValue("workspace")), nil, cancel)
102+}
@@ -0,0 +1,102 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+)
8+
9+// Helpers for the docker commands of the container steps. The runner builds a
10+// command line as a string, echoes it (ExecutionContext.Command, "##[command]")
11+// and lets .NET split it into arguments. Here the echoed line and the
12+// arguments are built together (dockerPart), so that nothing a workflow wrote
13+// can end up as an argument of its own, except through `options:` which the
14+// runner splits too (ParseArguments).
15+
16+// quotedOpt is `flag "value"` in the echoed line and two arguments.
17+func quotedOpt(flag, value string) dockerPart {
18+ return dockerPart{line: flag + ` "` + value + `"`, args: []string{flag, value}}
19+}
20+
21+// eqOpt is `flag="value"` (docker inspect --format="...").
22+func eqOpt(flag, value string) dockerPart {
23+ return dockerPart{line: flag + `="` + value + `"`, args: []string{flag + "=" + value}}
24+}
25+
26+// envKV is DockerUtil.CreateEscapedOption("-e", key, value), or ("-e", key)
27+// for an empty value (docker then reads it from its own environment).
28+func envKV(key, value string) dockerPart {
29+ if value == "" {
30+ return envOpt(key)
31+ }
32+ kv := key + "=" + value
33+ return dockerPart{line: "-e " + escapeDockerArg(kv), args: []string{"-e", kv}}
34+}
35+
36+// mountOpt is the -v option of DockerCommandManager.DockerCreate (:164-185).
37+func mountOpt(m mount) dockerPart {
38+ esc := func(s string) string { return strings.ReplaceAll(s, `"`, `\"`) }
39+ var p dockerPart
40+ if m.source == "" {
41+ p = dockerPart{line: `-v "` + esc(m.target) + `"`, args: []string{"-v", m.target}}
42+ } else {
43+ p = dockerPart{line: `-v "` + esc(m.source) + `":"` + esc(m.target) + `"`, args: []string{"-v", m.source + ":" + m.target}}
44+ }
45+ if m.readOnly {
46+ p.line += ":ro"
47+ p.args[1] += ":ro"
48+ }
49+ return p
50+}
51+
52+// optionsParts is the user's `options:` text: echoed as written, split into
53+// arguments like .NET splits a command line. An empty text still leaves its
54+// place in the line (the runner joins an empty string: two spaces).
55+func optionsParts(options string) dockerPart {
56+ return dockerPart{line: options, args: ParseArguments(options)}
57+}
58+
59+// dockerArgString is the arguments of the command as ProcessExitCodeException
60+// words them: the echoed line without the path of docker.
61+func (j *job) dockerArgString(c dockerCmd) string {
62+ return strings.TrimPrefix(c.line, j.dockerPath()+" ")
63+}
64+
65+// dockerList is the overload of ExecuteDockerCommandAsync that returns the
66+// output (DockerCommandManager.cs:462-492): non-empty lines of stdout are kept
67+// and logged, non-empty lines of stderr are logged, and a non-zero exit is an
68+// error.
69+func (j *job) dockerList(s *stepRun, c dockerCmd, cancel <-chan struct{}) ([]string, error) {
70+ j.out(s.num, "##[command]"+c.line)
71+ var lines []string
72+ res, err := runProcess(procSpec{
73+ Argv: c.argv, Dir: dirOrCwd(j.githubValue("workspace")), Env: j.processEnv(newOrdered()), Credential: j.cfg.Credential,
74+ OnLine: func(l string) {
75+ if l != "" {
76+ lines = append(lines, l)
77+ j.write(s.num, l)
78+ }
79+ },
80+ OnErrLine: func(l string) {
81+ if l != "" {
82+ j.write(s.num, l)
83+ }
84+ },
85+ Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
86+ })
87+ if err != nil {
88+ return nil, err
89+ }
90+ if res.Cancelled {
91+ return lines, errCancelled
92+ }
93+ if res.ExitCode != 0 {
94+ return lines, ghErrorf("Exit code %d returned from process: file name '%s', arguments '%s'.", res.ExitCode, j.dockerPath(), j.dockerArgString(c))
95+ }
96+ return lines, nil
97+}
98+
99+// dockerCode is the overload that logs every line and returns the exit code.
100+func (j *job) dockerCode(s *stepRun, c dockerCmd, cancel <-chan struct{}) (int, error) {
101+ return j.runDockerCmd(s, c, dirOrCwd(j.githubValue("workspace")), nil, cancel)
102+}
A internal/executor/containers_fake_test.go +106 -0
@@ -0,0 +1,106 @@
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+// fakeCtrDocker is a docker CLI for the container steps: it records every
14+// call (one argument per line between "--" markers, like fakeDocker) and
15+// answers like the real one. Behaviour is chosen by files in its directory:
16+//
17+// pull_fail N: the first N pulls fail
18+// health words, one per inspect: starting, healthy, unhealthy
19+// create_fail docker create exits 125
20+// exec_out what docker exec prints
21+// hang_exec docker exec waits until it gets a signal
22+type fakeCtr struct {
23+ dir, bin, log string
24+}
25+
26+func newFakeCtr(t *testing.T) *fakeCtr {
27+ t.Helper()
28+ d := t.TempDir()
29+ f := &fakeCtr{dir: d, bin: filepath.Join(d, "docker"), log: filepath.Join(d, "calls")}
30+ script := `#!/bin/sh
31+D=` + d + `
32+{ echo "--"; for a in "$@"; do printf "%s\n" "$a"; done; } >> $D/calls
33+[ "$1" = "--config" ] && { cat > $D/login_stdin_$$ 2>/dev/null </dev/null; shift 2; }
34+case "$1" in
35+version) echo "'1.48'" ;;
36+ps) case "$*" in *--quiet*) ;; *status=running*) id=$(echo "$*" | sed 's/.*id=\([^ ]*\).*/\1/'); echo "$id Up Less than a second" ;; esac ;;
37+network) ;;
38+login) cat > $D/login_stdin ;;
39+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" ;;
40+create) [ -f $D/create_fail ] && { echo "unknown flag"; exit 125; }
41+ n=$(cat $D/seq 2>/dev/null || echo 0); n=$((n+1)); echo $n > $D/seq; printf "%064d\n" $n ;;
42+start) echo "$2" ;;
43+inspect) case "$*" in
44+ *Healthcheck*) if [ -s $D/health ]; then w=$(head -1 $D/health); sed -i 1d $D/health; echo "$w"; fi ;;
45+ *Config.Env*) printf 'HOME=/github/home\nGITHUB_ACTIONS=true\nPATH=/usr/bin:/bin\n' ;;
46+ esac ;;
47+port) [ -f $D/ports ] && cat $D/ports ;;
48+logs) echo "service log of $3" ;;
49+rm) echo "$3" ;;
50+exec) [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; }
51+ [ -f $D/exec_out ] && cat $D/exec_out ;;
52+esac
53+exit 0
54+`
55+ if err := os.WriteFile(f.bin, []byte(script), 0o755); err != nil {
56+ t.Fatal(err)
57+ }
58+ return f
59+}
60+
61+func (f *fakeCtr) set(t *testing.T, name, content string) {
62+ t.Helper()
63+ if err := os.WriteFile(filepath.Join(f.dir, name), []byte(content), 0o644); err != nil {
64+ t.Fatal(err)
65+ }
66+}
67+
68+func (f *fakeCtr) calls(t *testing.T) [][]string { return calls(t, f.log) }
69+
70+// verbs are the docker subcommands called, in order.
71+func (f *fakeCtr) verbs(t *testing.T) []string {
72+ var out []string
73+ for _, c := range f.calls(t) {
74+ if c[0] == "--config" && len(c) > 2 {
75+ c = c[2:]
76+ }
77+ v := c[0]
78+ if v == "network" && len(c) > 1 {
79+ v += " " + c[1]
80+ }
81+ out = append(out, v)
82+ }
83+ return out
84+}
85+
86+// ctrRun runs a workflow whose job has the given container/services YAML.
87+func ctrRun(t *testing.T, f *fakeCtr, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) {
88+ t.Helper()
89+ needTools(t, "bash")
90+ return runLocal(t, wfHead+jobYAML+" steps:\n"+steps, "j", func(c *Config) {
91+ c.Actions.DockerPath = f.bin
92+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
93+ c.Actions.ExternalsDir = fakeNode(t)
94+ if tweak != nil {
95+ tweak(c)
96+ }
97+ })
98+}
99+
100+func joinCalls(cs [][]string) string {
101+ var b strings.Builder
102+ for _, c := range cs {
103+ b.WriteString(strings.Join(c, " ") + "\n")
104+ }
105+ return b.String()
106+}
@@ -0,0 +1,106 @@
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+// fakeCtrDocker is a docker CLI for the container steps: it records every
14+// call (one argument per line between "--" markers, like fakeDocker) and
15+// answers like the real one. Behaviour is chosen by files in its directory:
16+//
17+// pull_fail N: the first N pulls fail
18+// health words, one per inspect: starting, healthy, unhealthy
19+// create_fail docker create exits 125
20+// exec_out what docker exec prints
21+// hang_exec docker exec waits until it gets a signal
22+type fakeCtr struct {
23+ dir, bin, log string
24+}
25+
26+func newFakeCtr(t *testing.T) *fakeCtr {
27+ t.Helper()
28+ d := t.TempDir()
29+ f := &fakeCtr{dir: d, bin: filepath.Join(d, "docker"), log: filepath.Join(d, "calls")}
30+ script := `#!/bin/sh
31+D=` + d + `
32+{ echo "--"; for a in "$@"; do printf "%s\n" "$a"; done; } >> $D/calls
33+[ "$1" = "--config" ] && { cat > $D/login_stdin_$$ 2>/dev/null </dev/null; shift 2; }
34+case "$1" in
35+version) echo "'1.48'" ;;
36+ps) case "$*" in *--quiet*) ;; *status=running*) id=$(echo "$*" | sed 's/.*id=\([^ ]*\).*/\1/'); echo "$id Up Less than a second" ;; esac ;;
37+network) ;;
38+login) cat > $D/login_stdin ;;
39+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" ;;
40+create) [ -f $D/create_fail ] && { echo "unknown flag"; exit 125; }
41+ n=$(cat $D/seq 2>/dev/null || echo 0); n=$((n+1)); echo $n > $D/seq; printf "%064d\n" $n ;;
42+start) echo "$2" ;;
43+inspect) case "$*" in
44+ *Healthcheck*) if [ -s $D/health ]; then w=$(head -1 $D/health); sed -i 1d $D/health; echo "$w"; fi ;;
45+ *Config.Env*) printf 'HOME=/github/home\nGITHUB_ACTIONS=true\nPATH=/usr/bin:/bin\n' ;;
46+ esac ;;
47+port) [ -f $D/ports ] && cat $D/ports ;;
48+logs) echo "service log of $3" ;;
49+rm) echo "$3" ;;
50+exec) [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; }
51+ [ -f $D/exec_out ] && cat $D/exec_out ;;
52+esac
53+exit 0
54+`
55+ if err := os.WriteFile(f.bin, []byte(script), 0o755); err != nil {
56+ t.Fatal(err)
57+ }
58+ return f
59+}
60+
61+func (f *fakeCtr) set(t *testing.T, name, content string) {
62+ t.Helper()
63+ if err := os.WriteFile(filepath.Join(f.dir, name), []byte(content), 0o644); err != nil {
64+ t.Fatal(err)
65+ }
66+}
67+
68+func (f *fakeCtr) calls(t *testing.T) [][]string { return calls(t, f.log) }
69+
70+// verbs are the docker subcommands called, in order.
71+func (f *fakeCtr) verbs(t *testing.T) []string {
72+ var out []string
73+ for _, c := range f.calls(t) {
74+ if c[0] == "--config" && len(c) > 2 {
75+ c = c[2:]
76+ }
77+ v := c[0]
78+ if v == "network" && len(c) > 1 {
79+ v += " " + c[1]
80+ }
81+ out = append(out, v)
82+ }
83+ return out
84+}
85+
86+// ctrRun runs a workflow whose job has the given container/services YAML.
87+func ctrRun(t *testing.T, f *fakeCtr, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) {
88+ t.Helper()
89+ needTools(t, "bash")
90+ return runLocal(t, wfHead+jobYAML+" steps:\n"+steps, "j", func(c *Config) {
91+ c.Actions.DockerPath = f.bin
92+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
93+ c.Actions.ExternalsDir = fakeNode(t)
94+ if tweak != nil {
95+ tweak(c)
96+ }
97+ })
98+}
99+
100+func joinCalls(cs [][]string) string {
101+ var b strings.Builder
102+ for _, c := range cs {
103+ b.WriteString(strings.Join(c, " ") + "\n")
104+ }
105+ return b.String()
106+}
A internal/executor/containers_init.go +247 -0
@@ -0,0 +1,247 @@
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+ "strconv"
12+ "strings"
13+ "time"
14+)
15+
16+// containerSet is the containers of a job: the job container, if any, then the
17+// services (JobExtension.cs:322-326: the order of the list the provider gets).
18+type containerSet struct {
19+ job *container
20+ services []*container
21+ network string
22+}
23+
24+func (cs *containerSet) all() []*container {
25+ var l []*container
26+ if cs.job != nil {
27+ l = append(l, cs.job)
28+ }
29+ return append(l, cs.services...)
30+}
31+
32+// minDockerAPI is requiredDockerEngineAPIVersion (ContainerOperationProvider.cs
33+// :591): Docker-CE 17.12.
34+var minDockerAPI = [2]int{1, 35}
35+
36+var (
37+ apiVersionRe = regexp.MustCompile(`(?i)\d+\.\d+(\.\d+)?`)
38+ portLineRe = regexp.MustCompile(`^(\d+)/(\w+) -> (.+):(\d+)$`)
39+)
40+
41+// initContainersStep is the "Initialize containers" step
42+// (JobExtension.cs:328-331), run if success().
43+func (j *job) initContainersStep() qstep {
44+ return qstep{name: "Initialize containers", setup: func(s *stepRun, cancel <-chan struct{}) stepResult {
45+ return j.initContainers(s, cancel)
46+ }}
47+}
48+
49+// result turns the error of a step body into the result StepsRunner gives it
50+// (StepsRunner.cs:314-340): a cancellation is logged and cancels the step,
51+// anything else is logged with its message and fails it.
52+func (j *job) containerResult(s *stepRun, err error) stepResult {
53+ switch {
54+ case err == nil:
55+ return resSucceeded
56+ case err == errCancelled:
57+ return j.cancelledStep(s)
58+ }
59+ j.errorf(s.num, "%s", err.Error())
60+ return resFailed
61+}
62+
63+// initContainers is ContainerOperationProvider.StartContainersAsync
64+// (:40-103). The step that stops the containers is registered first, so that
65+// it runs even when this one fails (:56-60).
66+func (j *job) initContainers(s *stepRun, cancel <-chan struct{}) stepResult {
67+ j.debug(s.num, "Register post job cleanup for stopping/deleting containers.")
68+ j.mu.Lock()
69+ j.postStack = append(j.postStack, qstep{name: "Stop containers", cond: "always()", setup: j.stopContainers})
70+ j.mu.Unlock()
71+ return j.containerResult(s, j.startContainers(s, cancel))
72+}
73+
74+func (j *job) startContainers(s *stepRun, cancel <-chan struct{}) error {
75+ num, cs := s.num, j.cs
76+ // AssertCompatibleOS (:559-571) refuses to run in a container because the
77+ // paths it mounts would be those of the container's host. The docker
78+ // daemon of a job runs next to the executor, in the same sandbox, so the
79+ // paths are right and the check is left out.
80+ if err := j.checkDockerVersion(s, cancel); err != nil {
81+ return err
82+ }
83+ j.out(num, "##[group]Clean up resources from previous jobs")
84+ label := j.dockerLabel()
85+ ps := j.newDockerCmd("ps", raw("--all", "--quiet", "--no-trunc"), quotedOpt("--filter", "label="+label))
86+ stale, err := j.dockerList(s, ps, nil)
87+ if err != nil {
88+ return err
89+ }
90+ for _, id := range stale {
91+ code, err := j.dockerCode(s, j.newDockerCmd("rm", raw("--force", id)), cancel)
92+ if err != nil {
93+ return err
94+ }
95+ if code != 0 {
96+ j.tagged(num, "##[warning]", fmt.Sprintf("Delete stale containers failed, docker rm fail with exit code %d for container %s", code, id))
97+ }
98+ }
99+ prune := j.newDockerCmd("network", raw("prune", "--force"), quotedOpt("--filter", "label="+label))
100+ code, err := j.dockerCode(s, prune, cancel)
101+ if err != nil {
102+ return err
103+ }
104+ if code != 0 {
105+ j.tagged(num, "##[warning]", fmt.Sprintf("Delete stale container networks failed, docker network prune fail with exit code %d", code))
106+ }
107+ j.out(num, "##[endgroup]")
108+
109+ j.out(num, "##[group]Create local container network")
110+ network := "github_network_" + strings.ReplaceAll(newImageTag(""), ":", "")
111+ code, err = j.dockerCode(s, j.newDockerCmd("network", raw("create", "--label", label, network)), cancel)
112+ if err != nil {
113+ return err
114+ }
115+ if code != 0 {
116+ return ghErrorf("Docker network create failed with exit code %d", code)
117+ }
118+ j.mu.Lock()
119+ j.state.SetJobContainer("network", network)
120+ j.mu.Unlock()
121+ cs.network = network
122+ j.out(num, "##[endgroup]")
123+
124+ for _, c := range cs.all() {
125+ c.network = network
126+ if err := j.startContainer(s, c, cancel); err != nil {
127+ return err
128+ }
129+ }
130+ return j.healthchecks(s, cs.all(), cancel)
131+}
132+
133+// checkDockerVersion is AssertCompatibleOS' version check (:573-598) with
134+// DockerCommandManager.DockerVersion (:47-90), including its use of the
135+// server's text for the client version (:71, the runner's own slip).
136+func (j *job) checkDockerVersion(s *stepRun, cancel <-chan struct{}) error {
137+ num := s.num
138+ j.out(num, "##[group]Checking docker version")
139+ ver := func(which string) (string, error) {
140+ l, err := j.dockerList(s, j.newDockerCmd("version", raw("--format", "'{{."+which+".APIVersion}}'")), nil)
141+ if err != nil {
142+ return "", err
143+ }
144+ if len(l) == 0 {
145+ return "", ghErrorf("Value cannot be null. (Parameter 'Docker.%s.Version')", which)
146+ }
147+ return l[0], nil
148+ }
149+ server, err := ver("Server")
150+ if err != nil {
151+ return err
152+ }
153+ j.out(num, "Docker daemon API version: "+server)
154+ client, err := ver("Client")
155+ if err != nil {
156+ return err
157+ }
158+ j.out(num, "Docker client API version: "+client)
159+ j.out(num, "##[endgroup]")
160+ v, ok := parseAPIVersion(server)
161+ if !ok {
162+ return ghErrorf("Value cannot be null. (Parameter 'ServerVersion')")
163+ }
164+ if v[0] < minDockerAPI[0] || (v[0] == minDockerAPI[0] && v[1] < minDockerAPI[1]) {
165+ return ghErrorf("Min required docker engine API server version is '%d.%d', your docker ('%s') server version is '%d.%d'",
166+ minDockerAPI[0], minDockerAPI[1], j.dockerPath(), v[0], v[1])
167+ }
168+ return nil
169+}
170+
171+func parseAPIVersion(s string) ([2]int, bool) {
172+ m := apiVersionRe.FindString(s)
173+ parts := strings.Split(m, ".")
174+ if len(parts) < 2 {
175+ return [2]int{}, false
176+ }
177+ a, e1 := strconv.Atoi(parts[0])
178+ b, e2 := strconv.Atoi(parts[1])
179+ return [2]int{a, b}, e1 == nil && e2 == nil
180+}
181+
182+// containerBackoff is BackoffTimerHelper.GetRandomBackoff(1 s, 10 s).
183+func (j *job) containerBackoff() time.Duration {
184+ if f := j.cfg.Actions.Backoff; f != nil {
185+ return f()
186+ }
187+ return time.Duration(1000+rand.IntN(9000)) * time.Millisecond
188+}
189+
190+// seconds is the way the runner prints a backoff, TimeSpan.TotalSeconds.
191+func seconds(d time.Duration) string { return strconv.FormatFloat(d.Seconds(), 'f', -1, 64) }
192+
193+// pullArgs builds `docker [--config dir] pull image`.
194+func (j *job) pullCmd(image, config string) dockerCmd {
195+ if config == "" {
196+ return j.newDockerCmd("pull", raw(image))
197+ }
198+ p := j.dockerPath()
199+ return dockerCmd{line: p + " --config " + config + " pull " + image, argv: []string{p, "--config", config, "pull", image}}
200+}
201+
202+// registryLogin is ContainerRegistryLogin (:394-455): the credentials go to a
203+// private docker config directory, the password through stdin. It returns the
204+// directory ("" without credentials); the caller removes it.
205+func (j *job) registryLogin(s *stepRun, c *container, cancel <-chan struct{}) (string, error) {
206+ if c.credUser == "" || c.credPass == "" {
207+ return "", nil
208+ }
209+ // The password is a secret for the logs of this job from here on.
210+ j.addMask(c.credPass)
211+ dir := filepath.Join(j.state.Layout().Temp, ".docker_"+strings.ReplaceAll(newImageTag(""), ":", ""))
212+ if err := os.MkdirAll(dir, 0o700); err != nil {
213+ return "", fmt.Errorf("Failed to create directory to store registry client credentials: %v", err)
214+ }
215+ j.own(dir)
216+ args := []string{j.dockerPath(), "--config", dir, "login"}
217+ line := "--config " + dir + " login"
218+ if c.registryServer != "" {
219+ args, line = append(args, c.registryServer), line+" "+c.registryServer
220+ }
221+ args, line = append(args, "-u", c.credUser, "--password-stdin"), line+" -u "+c.credUser+" --password-stdin"
222+ for try := 1; ; try++ {
223+ j.out(s.num, "##[command]"+j.dockerPath()+" "+line)
224+ res, err := runProcess(procSpec{
225+ Argv: args, Dir: dirOrCwd(j.githubValue("workspace")), Env: j.processEnv(newOrdered()), Credential: j.cfg.Credential,
226+ OnLine: func(string) {}, Stdin: c.credPass + "\n",
227+ Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
228+ })
229+ if err != nil {
230+ return dir, err
231+ }
232+ if res.Cancelled {
233+ return dir, errCancelled
234+ }
235+ if res.ExitCode == 0 {
236+ return dir, nil
237+ }
238+ if try == 3 {
239+ return dir, ghErrorf("Docker login for '%s' failed with exit code %d", c.registryServer, res.ExitCode)
240+ }
241+ d := j.containerBackoff()
242+ 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)))
243+ if !j.sleepOrCancel(d, cancel) {
244+ return dir, errCancelled
245+ }
246+ }
247+}
@@ -0,0 +1,247 @@
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+ "strconv"
12+ "strings"
13+ "time"
14+)
15+
16+// containerSet is the containers of a job: the job container, if any, then the
17+// services (JobExtension.cs:322-326: the order of the list the provider gets).
18+type containerSet struct {
19+ job *container
20+ services []*container
21+ network string
22+}
23+
24+func (cs *containerSet) all() []*container {
25+ var l []*container
26+ if cs.job != nil {
27+ l = append(l, cs.job)
28+ }
29+ return append(l, cs.services...)
30+}
31+
32+// minDockerAPI is requiredDockerEngineAPIVersion (ContainerOperationProvider.cs
33+// :591): Docker-CE 17.12.
34+var minDockerAPI = [2]int{1, 35}
35+
36+var (
37+ apiVersionRe = regexp.MustCompile(`(?i)\d+\.\d+(\.\d+)?`)
38+ portLineRe = regexp.MustCompile(`^(\d+)/(\w+) -> (.+):(\d+)$`)
39+)
40+
41+// initContainersStep is the "Initialize containers" step
42+// (JobExtension.cs:328-331), run if success().
43+func (j *job) initContainersStep() qstep {
44+ return qstep{name: "Initialize containers", setup: func(s *stepRun, cancel <-chan struct{}) stepResult {
45+ return j.initContainers(s, cancel)
46+ }}
47+}
48+
49+// result turns the error of a step body into the result StepsRunner gives it
50+// (StepsRunner.cs:314-340): a cancellation is logged and cancels the step,
51+// anything else is logged with its message and fails it.
52+func (j *job) containerResult(s *stepRun, err error) stepResult {
53+ switch {
54+ case err == nil:
55+ return resSucceeded
56+ case err == errCancelled:
57+ return j.cancelledStep(s)
58+ }
59+ j.errorf(s.num, "%s", err.Error())
60+ return resFailed
61+}
62+
63+// initContainers is ContainerOperationProvider.StartContainersAsync
64+// (:40-103). The step that stops the containers is registered first, so that
65+// it runs even when this one fails (:56-60).
66+func (j *job) initContainers(s *stepRun, cancel <-chan struct{}) stepResult {
67+ j.debug(s.num, "Register post job cleanup for stopping/deleting containers.")
68+ j.mu.Lock()
69+ j.postStack = append(j.postStack, qstep{name: "Stop containers", cond: "always()", setup: j.stopContainers})
70+ j.mu.Unlock()
71+ return j.containerResult(s, j.startContainers(s, cancel))
72+}
73+
74+func (j *job) startContainers(s *stepRun, cancel <-chan struct{}) error {
75+ num, cs := s.num, j.cs
76+ // AssertCompatibleOS (:559-571) refuses to run in a container because the
77+ // paths it mounts would be those of the container's host. The docker
78+ // daemon of a job runs next to the executor, in the same sandbox, so the
79+ // paths are right and the check is left out.
80+ if err := j.checkDockerVersion(s, cancel); err != nil {
81+ return err
82+ }
83+ j.out(num, "##[group]Clean up resources from previous jobs")
84+ label := j.dockerLabel()
85+ ps := j.newDockerCmd("ps", raw("--all", "--quiet", "--no-trunc"), quotedOpt("--filter", "label="+label))
86+ stale, err := j.dockerList(s, ps, nil)
87+ if err != nil {
88+ return err
89+ }
90+ for _, id := range stale {
91+ code, err := j.dockerCode(s, j.newDockerCmd("rm", raw("--force", id)), cancel)
92+ if err != nil {
93+ return err
94+ }
95+ if code != 0 {
96+ j.tagged(num, "##[warning]", fmt.Sprintf("Delete stale containers failed, docker rm fail with exit code %d for container %s", code, id))
97+ }
98+ }
99+ prune := j.newDockerCmd("network", raw("prune", "--force"), quotedOpt("--filter", "label="+label))
100+ code, err := j.dockerCode(s, prune, cancel)
101+ if err != nil {
102+ return err
103+ }
104+ if code != 0 {
105+ j.tagged(num, "##[warning]", fmt.Sprintf("Delete stale container networks failed, docker network prune fail with exit code %d", code))
106+ }
107+ j.out(num, "##[endgroup]")
108+
109+ j.out(num, "##[group]Create local container network")
110+ network := "github_network_" + strings.ReplaceAll(newImageTag(""), ":", "")
111+ code, err = j.dockerCode(s, j.newDockerCmd("network", raw("create", "--label", label, network)), cancel)
112+ if err != nil {
113+ return err
114+ }
115+ if code != 0 {
116+ return ghErrorf("Docker network create failed with exit code %d", code)
117+ }
118+ j.mu.Lock()
119+ j.state.SetJobContainer("network", network)
120+ j.mu.Unlock()
121+ cs.network = network
122+ j.out(num, "##[endgroup]")
123+
124+ for _, c := range cs.all() {
125+ c.network = network
126+ if err := j.startContainer(s, c, cancel); err != nil {
127+ return err
128+ }
129+ }
130+ return j.healthchecks(s, cs.all(), cancel)
131+}
132+
133+// checkDockerVersion is AssertCompatibleOS' version check (:573-598) with
134+// DockerCommandManager.DockerVersion (:47-90), including its use of the
135+// server's text for the client version (:71, the runner's own slip).
136+func (j *job) checkDockerVersion(s *stepRun, cancel <-chan struct{}) error {
137+ num := s.num
138+ j.out(num, "##[group]Checking docker version")
139+ ver := func(which string) (string, error) {
140+ l, err := j.dockerList(s, j.newDockerCmd("version", raw("--format", "'{{."+which+".APIVersion}}'")), nil)
141+ if err != nil {
142+ return "", err
143+ }
144+ if len(l) == 0 {
145+ return "", ghErrorf("Value cannot be null. (Parameter 'Docker.%s.Version')", which)
146+ }
147+ return l[0], nil
148+ }
149+ server, err := ver("Server")
150+ if err != nil {
151+ return err
152+ }
153+ j.out(num, "Docker daemon API version: "+server)
154+ client, err := ver("Client")
155+ if err != nil {
156+ return err
157+ }
158+ j.out(num, "Docker client API version: "+client)
159+ j.out(num, "##[endgroup]")
160+ v, ok := parseAPIVersion(server)
161+ if !ok {
162+ return ghErrorf("Value cannot be null. (Parameter 'ServerVersion')")
163+ }
164+ if v[0] < minDockerAPI[0] || (v[0] == minDockerAPI[0] && v[1] < minDockerAPI[1]) {
165+ return ghErrorf("Min required docker engine API server version is '%d.%d', your docker ('%s') server version is '%d.%d'",
166+ minDockerAPI[0], minDockerAPI[1], j.dockerPath(), v[0], v[1])
167+ }
168+ return nil
169+}
170+
171+func parseAPIVersion(s string) ([2]int, bool) {
172+ m := apiVersionRe.FindString(s)
173+ parts := strings.Split(m, ".")
174+ if len(parts) < 2 {
175+ return [2]int{}, false
176+ }
177+ a, e1 := strconv.Atoi(parts[0])
178+ b, e2 := strconv.Atoi(parts[1])
179+ return [2]int{a, b}, e1 == nil && e2 == nil
180+}
181+
182+// containerBackoff is BackoffTimerHelper.GetRandomBackoff(1 s, 10 s).
183+func (j *job) containerBackoff() time.Duration {
184+ if f := j.cfg.Actions.Backoff; f != nil {
185+ return f()
186+ }
187+ return time.Duration(1000+rand.IntN(9000)) * time.Millisecond
188+}
189+
190+// seconds is the way the runner prints a backoff, TimeSpan.TotalSeconds.
191+func seconds(d time.Duration) string { return strconv.FormatFloat(d.Seconds(), 'f', -1, 64) }
192+
193+// pullArgs builds `docker [--config dir] pull image`.
194+func (j *job) pullCmd(image, config string) dockerCmd {
195+ if config == "" {
196+ return j.newDockerCmd("pull", raw(image))
197+ }
198+ p := j.dockerPath()
199+ return dockerCmd{line: p + " --config " + config + " pull " + image, argv: []string{p, "--config", config, "pull", image}}
200+}
201+
202+// registryLogin is ContainerRegistryLogin (:394-455): the credentials go to a
203+// private docker config directory, the password through stdin. It returns the
204+// directory ("" without credentials); the caller removes it.
205+func (j *job) registryLogin(s *stepRun, c *container, cancel <-chan struct{}) (string, error) {
206+ if c.credUser == "" || c.credPass == "" {
207+ return "", nil
208+ }
209+ // The password is a secret for the logs of this job from here on.
210+ j.addMask(c.credPass)
211+ dir := filepath.Join(j.state.Layout().Temp, ".docker_"+strings.ReplaceAll(newImageTag(""), ":", ""))
212+ if err := os.MkdirAll(dir, 0o700); err != nil {
213+ return "", fmt.Errorf("Failed to create directory to store registry client credentials: %v", err)
214+ }
215+ j.own(dir)
216+ args := []string{j.dockerPath(), "--config", dir, "login"}
217+ line := "--config " + dir + " login"
218+ if c.registryServer != "" {
219+ args, line = append(args, c.registryServer), line+" "+c.registryServer
220+ }
221+ args, line = append(args, "-u", c.credUser, "--password-stdin"), line+" -u "+c.credUser+" --password-stdin"
222+ for try := 1; ; try++ {
223+ j.out(s.num, "##[command]"+j.dockerPath()+" "+line)
224+ res, err := runProcess(procSpec{
225+ Argv: args, Dir: dirOrCwd(j.githubValue("workspace")), Env: j.processEnv(newOrdered()), Credential: j.cfg.Credential,
226+ OnLine: func(string) {}, Stdin: c.credPass + "\n",
227+ Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
228+ })
229+ if err != nil {
230+ return dir, err
231+ }
232+ if res.Cancelled {
233+ return dir, errCancelled
234+ }
235+ if res.ExitCode == 0 {
236+ return dir, nil
237+ }
238+ if try == 3 {
239+ return dir, ghErrorf("Docker login for '%s' failed with exit code %d", c.registryServer, res.ExitCode)
240+ }
241+ d := j.containerBackoff()
242+ 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)))
243+ if !j.sleepOrCancel(d, cancel) {
244+ return dir, errCancelled
245+ }
246+ }
247+}
A internal/executor/containers_init_test.go +50 -0
@@ -0,0 +1,50 @@
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+}
@@ -0,0 +1,50 @@
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+}
A internal/executor/containers_start.go +221 -0
@@ -0,0 +1,221 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+)
11+
12+// createCmd is DockerCommandManager.DockerCreate (DockerCommandManager.cs:
13+// 119-215), in the order of the runner's options.
14+func (j *job) createCmd(c *container) dockerCmd {
15+ parts := []dockerPart{raw("--name", c.name), raw("--label", j.dockerLabel())}
16+ if c.workDir != "" {
17+ parts = append(parts, raw("--workdir", c.workDir))
18+ }
19+ if c.network != "" {
20+ parts = append(parts, raw("--network", c.network))
21+ }
22+ if c.alias != "" {
23+ parts = append(parts, raw("--network-alias", c.alias))
24+ }
25+ for _, p := range c.ports {
26+ parts = append(parts, raw("-p", p))
27+ }
28+ parts = append(parts, optionsParts(c.options))
29+ for _, k := range c.env.Keys() {
30+ v, _ := c.env.Get(k)
31+ parts = append(parts, envKV(k, v))
32+ }
33+ parts = append(parts, raw("-e", "GITHUB_ACTIONS=true"))
34+ if _, ok := c.env.Get("CI"); !ok {
35+ parts = append(parts, raw("-e", "CI=true"))
36+ }
37+ for _, m := range c.mounts {
38+ parts = append(parts, mountOpt(m))
39+ }
40+ if c.entrypoint != "" {
41+ parts = append(parts, entrypointOpt(c.entrypoint))
42+ }
43+ parts = append(parts, raw(c.image), dockerPart{line: c.entryArgs, args: ParseArguments(c.entryArgs)})
44+ return j.newDockerCmd("create", parts...)
45+}
46+
47+// mountWellKnownDirectories is ContainerOperationProvider.MountWellKnownDirectories
48+// (:242-283): the job container sees the work directory, the externals, the
49+// temp, actions and tool cache directories, and a home and a workflow
50+// directory of its own.
51+func (j *job) mountWellKnownDirectories(c *container) error {
52+ lay := j.state.Layout()
53+ workspace := j.githubValue("workspace")
54+ tr := c.translateToContainer
55+ c.mounts = append(c.mounts,
56+ mount{source: lay.WorkRoot, target: tr(lay.WorkRoot)},
57+ mount{source: j.externalsDir(), target: tr(j.externalsDir()), readOnly: true},
58+ mount{source: lay.Temp, target: tr(lay.Temp)},
59+ mount{source: lay.Actions, target: tr(lay.Actions)},
60+ mount{source: lay.ToolCache, target: tr(lay.ToolCache)})
61+ for _, d := range []struct{ dir, target string }{
62+ {"_github_home", "/github/home"}, {"_github_workflow", "/github/workflow"},
63+ } {
64+ host := filepath.Join(lay.Temp, d.dir)
65+ if err := os.MkdirAll(host, 0o755); err != nil {
66+ return err
67+ }
68+ j.own(host)
69+ c.mounts = append(c.mounts, mount{source: host, target: d.target})
70+ c.maps = append([]pathMap{{host, d.target}}, c.maps...) // AddPathTranslateMapping inserts first
71+ if d.dir == "_github_home" {
72+ c.env.Set("HOME", tr(host))
73+ }
74+ }
75+ c.workDir = tr(workspace)
76+ c.entrypoint = "tail"
77+ c.entryArgs = `"-f" "/dev/null"`
78+ return nil
79+}
80+
81+// startContainer is StartContainerAsync (ContainerOperationProvider.cs:
82+// 123-240).
83+func (j *job) startContainer(s *stepRun, c *container, cancel <-chan struct{}) error {
84+ num := s.num
85+ group := "Starting job container"
86+ if !c.isJob {
87+ group = "Starting " + c.alias + " service container"
88+ }
89+ j.out(num, "##[group]"+group)
90+ for _, m := range c.userMounts() {
91+ if m.source == "/" {
92+ 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))
93+ }
94+ }
95+ cfg, err := j.registryLogin(s, c, cancel)
96+ if err != nil {
97+ if cfg != "" {
98+ _ = os.RemoveAll(cfg)
99+ }
100+ return err
101+ }
102+ code, err := j.pullWithRetry(s, c.image, cfg, cancel)
103+ if cfg != "" {
104+ _ = os.RemoveAll(cfg)
105+ }
106+ if err != nil {
107+ return err
108+ }
109+ if code != 0 {
110+ return ghErrorf("Docker pull failed with exit code %d", code)
111+ }
112+ if c.isJob {
113+ if err := j.mountWellKnownDirectories(c); err != nil {
114+ return err
115+ }
116+ }
117+ out, err := j.dockerList(s, j.createCmd(c), nil)
118+ if err != nil {
119+ return err
120+ }
121+ if len(out) == 0 || out[0] == "" {
122+ return ghErrorf("Value cannot be null. (Parameter 'container.ContainerId')")
123+ }
124+ c.id = out[0]
125+ code, err = j.dockerCode(s, j.newDockerCmd("start", raw(c.id)), cancel)
126+ if err != nil {
127+ return err
128+ }
129+ if code != 0 {
130+ return ghErrorf("Docker start fail with exit code %d", code)
131+ }
132+ j.checkRunning(s, c)
133+ if !c.isJob {
134+ ports, err := j.dockerList(s, j.newDockerCmd("port", raw(c.id)), nil)
135+ if err != nil {
136+ return err
137+ }
138+ c.portMappings = parseDockerPort(ports)
139+ j.mu.Lock()
140+ j.state.SetJobService(c.alias, c.id, c.network, c.portMappings)
141+ j.mu.Unlock()
142+ } else {
143+ env, err := j.dockerList(s, j.newDockerCmd("inspect", quotedOpt("--format", "{{range .Config.Env}}{{println .}}{{end}}"), raw(c.id)), nil)
144+ if err != nil {
145+ return err
146+ }
147+ c.runtimePath = parsePathFromConfigEnv(env)
148+ j.mu.Lock()
149+ j.state.SetJobContainer("id", c.id)
150+ j.mu.Unlock()
151+ }
152+ j.out(num, "##[endgroup]")
153+ return nil
154+}
155+
156+// pullWithRetry is the pull loop of StartContainerAsync (:158-178): three
157+// tries, a random back off of 1 to 10 s between them.
158+func (j *job) pullWithRetry(s *stepRun, image, cfg string, cancel <-chan struct{}) (int, error) {
159+ code := 0
160+ for try := 1; try <= 3; try++ {
161+ var err error
162+ code, err = j.dockerCode(s, j.pullCmd(image, cfg), cancel)
163+ if err != nil {
164+ return code, err
165+ }
166+ if code == 0 {
167+ return 0, nil
168+ }
169+ if try < 3 {
170+ d := j.containerBackoff()
171+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker pull failed with exit code %d, back off %s seconds before retry.", code, seconds(d)))
172+ if !j.sleepOrCancel(d, cancel) {
173+ return code, errCancelled
174+ }
175+ }
176+ }
177+ return code, nil
178+}
179+
180+// checkRunning is the best-effort check of :193-213.
181+func (j *job) checkRunning(s *stepRun, c *container) {
182+ fmtOpt := quotedOpt("--format", "{{.ID}} {{.Status}}")
183+ out, err := j.dockerList(s, j.newDockerCmd("ps", raw("--all"), raw("--filter", "id="+c.id), raw("--filter", "status=running"), raw("--no-trunc"), fmtOpt), nil)
184+ if err != nil {
185+ return
186+ }
187+ for _, l := range out {
188+ if l != "" {
189+ if strings.HasPrefix(l, c.id) {
190+ return
191+ }
192+ break
193+ }
194+ }
195+ _, _ = j.dockerList(s, j.newDockerCmd("ps", raw("--all"), raw("--filter", "id="+c.id), raw("--no-trunc"), fmtOpt), nil)
196+ if code, err := j.dockerCode(s, j.newDockerCmd("logs", raw("--details", c.id)), nil); err == nil && code != 0 {
197+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker logs fail with exit code %d", code))
198+ }
199+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker container %s is not in running state.", c.id))
200+}
201+
202+// parseDockerPort is DockerUtil.ParseDockerPort (DockerUtil.cs:14-37).
203+func parseDockerPort(lines []string) []portMapping {
204+ var out []portMapping
205+ for _, l := range lines {
206+ if m := portLineRe.FindStringSubmatch(l); m != nil {
207+ out = append(out, portMapping{host: m[4], container: m[1], proto: m[2]})
208+ }
209+ }
210+ return out
211+}
212+
213+// parsePathFromConfigEnv is DockerUtil.ParsePathFromConfigEnv (:39-52).
214+func parsePathFromConfigEnv(lines []string) string {
215+ for _, l := range lines {
216+ if k, v, ok := strings.Cut(l, "="); ok && k == "PATH" {
217+ return v
218+ }
219+ }
220+ return ""
221+}
@@ -0,0 +1,221 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+)
11+
12+// createCmd is DockerCommandManager.DockerCreate (DockerCommandManager.cs:
13+// 119-215), in the order of the runner's options.
14+func (j *job) createCmd(c *container) dockerCmd {
15+ parts := []dockerPart{raw("--name", c.name), raw("--label", j.dockerLabel())}
16+ if c.workDir != "" {
17+ parts = append(parts, raw("--workdir", c.workDir))
18+ }
19+ if c.network != "" {
20+ parts = append(parts, raw("--network", c.network))
21+ }
22+ if c.alias != "" {
23+ parts = append(parts, raw("--network-alias", c.alias))
24+ }
25+ for _, p := range c.ports {
26+ parts = append(parts, raw("-p", p))
27+ }
28+ parts = append(parts, optionsParts(c.options))
29+ for _, k := range c.env.Keys() {
30+ v, _ := c.env.Get(k)
31+ parts = append(parts, envKV(k, v))
32+ }
33+ parts = append(parts, raw("-e", "GITHUB_ACTIONS=true"))
34+ if _, ok := c.env.Get("CI"); !ok {
35+ parts = append(parts, raw("-e", "CI=true"))
36+ }
37+ for _, m := range c.mounts {
38+ parts = append(parts, mountOpt(m))
39+ }
40+ if c.entrypoint != "" {
41+ parts = append(parts, entrypointOpt(c.entrypoint))
42+ }
43+ parts = append(parts, raw(c.image), dockerPart{line: c.entryArgs, args: ParseArguments(c.entryArgs)})
44+ return j.newDockerCmd("create", parts...)
45+}
46+
47+// mountWellKnownDirectories is ContainerOperationProvider.MountWellKnownDirectories
48+// (:242-283): the job container sees the work directory, the externals, the
49+// temp, actions and tool cache directories, and a home and a workflow
50+// directory of its own.
51+func (j *job) mountWellKnownDirectories(c *container) error {
52+ lay := j.state.Layout()
53+ workspace := j.githubValue("workspace")
54+ tr := c.translateToContainer
55+ c.mounts = append(c.mounts,
56+ mount{source: lay.WorkRoot, target: tr(lay.WorkRoot)},
57+ mount{source: j.externalsDir(), target: tr(j.externalsDir()), readOnly: true},
58+ mount{source: lay.Temp, target: tr(lay.Temp)},
59+ mount{source: lay.Actions, target: tr(lay.Actions)},
60+ mount{source: lay.ToolCache, target: tr(lay.ToolCache)})
61+ for _, d := range []struct{ dir, target string }{
62+ {"_github_home", "/github/home"}, {"_github_workflow", "/github/workflow"},
63+ } {
64+ host := filepath.Join(lay.Temp, d.dir)
65+ if err := os.MkdirAll(host, 0o755); err != nil {
66+ return err
67+ }
68+ j.own(host)
69+ c.mounts = append(c.mounts, mount{source: host, target: d.target})
70+ c.maps = append([]pathMap{{host, d.target}}, c.maps...) // AddPathTranslateMapping inserts first
71+ if d.dir == "_github_home" {
72+ c.env.Set("HOME", tr(host))
73+ }
74+ }
75+ c.workDir = tr(workspace)
76+ c.entrypoint = "tail"
77+ c.entryArgs = `"-f" "/dev/null"`
78+ return nil
79+}
80+
81+// startContainer is StartContainerAsync (ContainerOperationProvider.cs:
82+// 123-240).
83+func (j *job) startContainer(s *stepRun, c *container, cancel <-chan struct{}) error {
84+ num := s.num
85+ group := "Starting job container"
86+ if !c.isJob {
87+ group = "Starting " + c.alias + " service container"
88+ }
89+ j.out(num, "##[group]"+group)
90+ for _, m := range c.userMounts() {
91+ if m.source == "/" {
92+ 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))
93+ }
94+ }
95+ cfg, err := j.registryLogin(s, c, cancel)
96+ if err != nil {
97+ if cfg != "" {
98+ _ = os.RemoveAll(cfg)
99+ }
100+ return err
101+ }
102+ code, err := j.pullWithRetry(s, c.image, cfg, cancel)
103+ if cfg != "" {
104+ _ = os.RemoveAll(cfg)
105+ }
106+ if err != nil {
107+ return err
108+ }
109+ if code != 0 {
110+ return ghErrorf("Docker pull failed with exit code %d", code)
111+ }
112+ if c.isJob {
113+ if err := j.mountWellKnownDirectories(c); err != nil {
114+ return err
115+ }
116+ }
117+ out, err := j.dockerList(s, j.createCmd(c), nil)
118+ if err != nil {
119+ return err
120+ }
121+ if len(out) == 0 || out[0] == "" {
122+ return ghErrorf("Value cannot be null. (Parameter 'container.ContainerId')")
123+ }
124+ c.id = out[0]
125+ code, err = j.dockerCode(s, j.newDockerCmd("start", raw(c.id)), cancel)
126+ if err != nil {
127+ return err
128+ }
129+ if code != 0 {
130+ return ghErrorf("Docker start fail with exit code %d", code)
131+ }
132+ j.checkRunning(s, c)
133+ if !c.isJob {
134+ ports, err := j.dockerList(s, j.newDockerCmd("port", raw(c.id)), nil)
135+ if err != nil {
136+ return err
137+ }
138+ c.portMappings = parseDockerPort(ports)
139+ j.mu.Lock()
140+ j.state.SetJobService(c.alias, c.id, c.network, c.portMappings)
141+ j.mu.Unlock()
142+ } else {
143+ env, err := j.dockerList(s, j.newDockerCmd("inspect", quotedOpt("--format", "{{range .Config.Env}}{{println .}}{{end}}"), raw(c.id)), nil)
144+ if err != nil {
145+ return err
146+ }
147+ c.runtimePath = parsePathFromConfigEnv(env)
148+ j.mu.Lock()
149+ j.state.SetJobContainer("id", c.id)
150+ j.mu.Unlock()
151+ }
152+ j.out(num, "##[endgroup]")
153+ return nil
154+}
155+
156+// pullWithRetry is the pull loop of StartContainerAsync (:158-178): three
157+// tries, a random back off of 1 to 10 s between them.
158+func (j *job) pullWithRetry(s *stepRun, image, cfg string, cancel <-chan struct{}) (int, error) {
159+ code := 0
160+ for try := 1; try <= 3; try++ {
161+ var err error
162+ code, err = j.dockerCode(s, j.pullCmd(image, cfg), cancel)
163+ if err != nil {
164+ return code, err
165+ }
166+ if code == 0 {
167+ return 0, nil
168+ }
169+ if try < 3 {
170+ d := j.containerBackoff()
171+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker pull failed with exit code %d, back off %s seconds before retry.", code, seconds(d)))
172+ if !j.sleepOrCancel(d, cancel) {
173+ return code, errCancelled
174+ }
175+ }
176+ }
177+ return code, nil
178+}
179+
180+// checkRunning is the best-effort check of :193-213.
181+func (j *job) checkRunning(s *stepRun, c *container) {
182+ fmtOpt := quotedOpt("--format", "{{.ID}} {{.Status}}")
183+ out, err := j.dockerList(s, j.newDockerCmd("ps", raw("--all"), raw("--filter", "id="+c.id), raw("--filter", "status=running"), raw("--no-trunc"), fmtOpt), nil)
184+ if err != nil {
185+ return
186+ }
187+ for _, l := range out {
188+ if l != "" {
189+ if strings.HasPrefix(l, c.id) {
190+ return
191+ }
192+ break
193+ }
194+ }
195+ _, _ = j.dockerList(s, j.newDockerCmd("ps", raw("--all"), raw("--filter", "id="+c.id), raw("--no-trunc"), fmtOpt), nil)
196+ if code, err := j.dockerCode(s, j.newDockerCmd("logs", raw("--details", c.id)), nil); err == nil && code != 0 {
197+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker logs fail with exit code %d", code))
198+ }
199+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker container %s is not in running state.", c.id))
200+}
201+
202+// parseDockerPort is DockerUtil.ParseDockerPort (DockerUtil.cs:14-37).
203+func parseDockerPort(lines []string) []portMapping {
204+ var out []portMapping
205+ for _, l := range lines {
206+ if m := portLineRe.FindStringSubmatch(l); m != nil {
207+ out = append(out, portMapping{host: m[4], container: m[1], proto: m[2]})
208+ }
209+ }
210+ return out
211+}
212+
213+// parsePathFromConfigEnv is DockerUtil.ParsePathFromConfigEnv (:39-52).
214+func parsePathFromConfigEnv(lines []string) string {
215+ for _, l := range lines {
216+ if k, v, ok := strings.Cut(l, "="); ok && k == "PATH" {
217+ return v
218+ }
219+ }
220+ return ""
221+}
A internal/executor/containers_stop.go +138 -0
@@ -0,0 +1,138 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "math"
8+ "math/rand/v2"
9+ "strings"
10+ "time"
11+)
12+
13+// healthBackoff is BackoffTimerHelper.GetExponentialBackoff(retry, 2 s, 32 s,
14+// 2 s) (BackoffTimerHelper.cs:27-36).
15+func healthBackoff(attempt int) time.Duration {
16+ random := 1600 + rand.Float64()*800 // Random.Next(1600, 2400) ms
17+ add := (math.Pow(2, float64(attempt)) - 1) * random
18+ ms := math.Min(2000+add, 32000)
19+ return time.Duration(ms * float64(time.Millisecond))
20+}
21+
22+// containerHealth is ContainerHealthcheck (ContainerOperationProvider.cs:
23+// 372-392): a container without a HEALTHCHECK is healthy; while docker says
24+// "starting" the check waits with an exponential back off.
25+func (j *job) containerHealth(s *stepRun, c *container, cancel <-chan struct{}) (bool, error) {
26+ inspect := func() (string, error) {
27+ cmd := j.newDockerCmd("inspect",
28+ eqOpt("--format", "{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}"), raw(c.id))
29+ l, err := j.dockerList(s, cmd, nil)
30+ if err != nil || len(l) == 0 {
31+ return "", err
32+ }
33+ return l[0], nil
34+ }
35+ h, err := inspect()
36+ if err != nil {
37+ return false, err
38+ }
39+ if h == "" {
40+ return true, nil
41+ }
42+ for retry := 0; strings.EqualFold(h, "starting"); retry++ {
43+ d := healthBackoff(retry)
44+ if f := j.cfg.Actions.Backoff; f != nil {
45+ d = f()
46+ }
47+ // TimeSpan.Seconds is the seconds component, not the total.
48+ j.out(s.num, fmt.Sprintf("%s service is starting, waiting %d seconds before checking again.", c.alias, int(d.Seconds())%60))
49+ if !j.sleepOrCancel(d, cancel) {
50+ return false, errCancelled
51+ }
52+ if h, err = inspect(); err != nil {
53+ return false, err
54+ }
55+ }
56+ return strings.EqualFold(h, "healthy"), nil
57+}
58+
59+// healthchecks is RunContainersHealthcheck (:105-139).
60+func (j *job) healthchecks(s *stepRun, all []*container, cancel <-chan struct{}) error {
61+ num := s.num
62+ j.out(num, "##[group]Waiting for all services to be ready")
63+ var bad []*container
64+ for _, c := range all {
65+ if c.isJob {
66+ continue
67+ }
68+ ok, err := j.containerHealth(s, c, cancel)
69+ if err != nil {
70+ return err
71+ }
72+ if !ok {
73+ bad = append(bad, c)
74+ continue
75+ }
76+ j.out(num, c.alias+" service is healthy.")
77+ }
78+ j.out(num, "##[endgroup]")
79+ if len(bad) == 0 {
80+ return nil
81+ }
82+ for _, c := range bad {
83+ j.out(num, "##[group]Service container "+c.alias+" failed.")
84+ if _, err := j.dockerCode(s, j.newDockerCmd("logs", raw("--details", c.id)), cancel); err != nil {
85+ return err
86+ }
87+ j.errorf(num, "Failed to initialize container %s", c.image)
88+ c.failedInit = true
89+ j.out(num, "##[endgroup]")
90+ }
91+ return ghErrorf("One or more containers failed to start.")
92+}
93+
94+// stopContainers is "Stop containers" (StopContainersAsync, :105-124 and
95+// StopContainerAsync, :328-351). It is registered with the condition always(),
96+// so it runs after a failure, a cancellation and a timeout alike.
97+func (j *job) stopContainers(s *stepRun, cancel <-chan struct{}) stepResult {
98+ num := s.num
99+ cs := j.cs
100+ for _, c := range cs.all() {
101+ if c.id == "" {
102+ continue
103+ }
104+ if !c.isJob && !c.failedInit {
105+ j.out(num, "Print service container logs: "+c.name)
106+ code, err := j.dockerCode(s, j.newDockerCmd("logs", raw("--details", c.id)), cancel)
107+ if err != nil {
108+ return j.containerResult(s, err)
109+ }
110+ if code != 0 {
111+ j.tagged(num, "##[warning]", fmt.Sprintf("Docker logs fail with exit code %d", code))
112+ }
113+ }
114+ j.out(num, "Stop and remove container: "+c.name)
115+ code, err := j.dockerCode(s, j.newDockerCmd("rm", raw("--force", c.id)), cancel)
116+ if err != nil {
117+ return j.containerResult(s, err)
118+ }
119+ if code != 0 {
120+ j.tagged(num, "##[warning]", fmt.Sprintf("Docker rm fail with exit code %d", code))
121+ }
122+ }
123+ // The network of the first container; without one the runner throws
124+ // (ArgUtil.NotNull, ContainerOperationProvider.cs:272).
125+ if len(cs.all()) > 0 && cs.all()[0].network == "" {
126+ return j.containerResult(s, ghErrorf("Value cannot be null. (Parameter 'network')"))
127+ }
128+ network := cs.all()[0].network
129+ j.out(num, "Remove container network: "+network)
130+ code, err := j.dockerCode(s, j.newDockerCmd("network", raw("rm", network)), cancel)
131+ if err != nil {
132+ return j.containerResult(s, err)
133+ }
134+ if code != 0 {
135+ j.tagged(num, "##[warning]", fmt.Sprintf("Docker network rm failed with exit code %d", code))
136+ }
137+ return resSucceeded
138+}
@@ -0,0 +1,138 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "math"
8+ "math/rand/v2"
9+ "strings"
10+ "time"
11+)
12+
13+// healthBackoff is BackoffTimerHelper.GetExponentialBackoff(retry, 2 s, 32 s,
14+// 2 s) (BackoffTimerHelper.cs:27-36).
15+func healthBackoff(attempt int) time.Duration {
16+ random := 1600 + rand.Float64()*800 // Random.Next(1600, 2400) ms
17+ add := (math.Pow(2, float64(attempt)) - 1) * random
18+ ms := math.Min(2000+add, 32000)
19+ return time.Duration(ms * float64(time.Millisecond))
20+}
21+
22+// containerHealth is ContainerHealthcheck (ContainerOperationProvider.cs:
23+// 372-392): a container without a HEALTHCHECK is healthy; while docker says
24+// "starting" the check waits with an exponential back off.
25+func (j *job) containerHealth(s *stepRun, c *container, cancel <-chan struct{}) (bool, error) {
26+ inspect := func() (string, error) {
27+ cmd := j.newDockerCmd("inspect",
28+ eqOpt("--format", "{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}"), raw(c.id))
29+ l, err := j.dockerList(s, cmd, nil)
30+ if err != nil || len(l) == 0 {
31+ return "", err
32+ }
33+ return l[0], nil
34+ }
35+ h, err := inspect()
36+ if err != nil {
37+ return false, err
38+ }
39+ if h == "" {
40+ return true, nil
41+ }
42+ for retry := 0; strings.EqualFold(h, "starting"); retry++ {
43+ d := healthBackoff(retry)
44+ if f := j.cfg.Actions.Backoff; f != nil {
45+ d = f()
46+ }
47+ // TimeSpan.Seconds is the seconds component, not the total.
48+ j.out(s.num, fmt.Sprintf("%s service is starting, waiting %d seconds before checking again.", c.alias, int(d.Seconds())%60))
49+ if !j.sleepOrCancel(d, cancel) {
50+ return false, errCancelled
51+ }
52+ if h, err = inspect(); err != nil {
53+ return false, err
54+ }
55+ }
56+ return strings.EqualFold(h, "healthy"), nil
57+}
58+
59+// healthchecks is RunContainersHealthcheck (:105-139).
60+func (j *job) healthchecks(s *stepRun, all []*container, cancel <-chan struct{}) error {
61+ num := s.num
62+ j.out(num, "##[group]Waiting for all services to be ready")
63+ var bad []*container
64+ for _, c := range all {
65+ if c.isJob {
66+ continue
67+ }
68+ ok, err := j.containerHealth(s, c, cancel)
69+ if err != nil {
70+ return err
71+ }
72+ if !ok {
73+ bad = append(bad, c)
74+ continue
75+ }
76+ j.out(num, c.alias+" service is healthy.")
77+ }
78+ j.out(num, "##[endgroup]")
79+ if len(bad) == 0 {
80+ return nil
81+ }
82+ for _, c := range bad {
83+ j.out(num, "##[group]Service container "+c.alias+" failed.")
84+ if _, err := j.dockerCode(s, j.newDockerCmd("logs", raw("--details", c.id)), cancel); err != nil {
85+ return err
86+ }
87+ j.errorf(num, "Failed to initialize container %s", c.image)
88+ c.failedInit = true
89+ j.out(num, "##[endgroup]")
90+ }
91+ return ghErrorf("One or more containers failed to start.")
92+}
93+
94+// stopContainers is "Stop containers" (StopContainersAsync, :105-124 and
95+// StopContainerAsync, :328-351). It is registered with the condition always(),
96+// so it runs after a failure, a cancellation and a timeout alike.
97+func (j *job) stopContainers(s *stepRun, cancel <-chan struct{}) stepResult {
98+ num := s.num
99+ cs := j.cs
100+ for _, c := range cs.all() {
101+ if c.id == "" {
102+ continue
103+ }
104+ if !c.isJob && !c.failedInit {
105+ j.out(num, "Print service container logs: "+c.name)
106+ code, err := j.dockerCode(s, j.newDockerCmd("logs", raw("--details", c.id)), cancel)
107+ if err != nil {
108+ return j.containerResult(s, err)
109+ }
110+ if code != 0 {
111+ j.tagged(num, "##[warning]", fmt.Sprintf("Docker logs fail with exit code %d", code))
112+ }
113+ }
114+ j.out(num, "Stop and remove container: "+c.name)
115+ code, err := j.dockerCode(s, j.newDockerCmd("rm", raw("--force", c.id)), cancel)
116+ if err != nil {
117+ return j.containerResult(s, err)
118+ }
119+ if code != 0 {
120+ j.tagged(num, "##[warning]", fmt.Sprintf("Docker rm fail with exit code %d", code))
121+ }
122+ }
123+ // The network of the first container; without one the runner throws
124+ // (ArgUtil.NotNull, ContainerOperationProvider.cs:272).
125+ if len(cs.all()) > 0 && cs.all()[0].network == "" {
126+ return j.containerResult(s, ghErrorf("Value cannot be null. (Parameter 'network')"))
127+ }
128+ network := cs.all()[0].network
129+ j.out(num, "Remove container network: "+network)
130+ code, err := j.dockerCode(s, j.newDockerCmd("network", raw("rm", network)), cancel)
131+ if err != nil {
132+ return j.containerResult(s, err)
133+ }
134+ if code != 0 {
135+ j.tagged(num, "##[warning]", fmt.Sprintf("Docker network rm failed with exit code %d", code))
136+ }
137+ return resSucceeded
138+}
M internal/executor/job.go +13 -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
@@ -435,10 +437,6 @@ func (j *job) setUp() ([]qstep, error) {
435437 switch {
436438 case jb.IsReusable():
437439 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")
442440 }
443441
444442 j.printTokenInfo()
@@ -508,6 +506,17 @@ func (j *job) setUp() ([]qstep, error) {
508506 j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd
509507 }
510508
509+ // JobExtension.cs:250-275: the job container and the services are
510+ // evaluated after the job environment, before the actions are prepared.
511+ j.debug(1, "Evaluating job container")
512+ jc, svcs, err := j.evalContainers()
513+ if err != nil {
514+ return nil, err
515+ }
516+ if jc != nil || len(svcs) > 0 {
517+ j.cs = &containerSet{job: jc, services: svcs}
518+ }
519+
511520 j.out(1, "Prepare all required actions")
512521 queue, err := j.prepareJob()
513522 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
@@ -435,10 +437,6 @@ func (j *job) setUp() ([]qstep, error) {
435 switch {437 switch {
436 case jb.IsReusable():438 case jb.IsReusable():
437 return nil, errors.New("not supported yet: reusable workflows (jobs.<id>.uses)")439 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 }440 }
443 441
444 j.printTokenInfo()442 j.printTokenInfo()
@@ -508,6 +506,17 @@ func (j *job) setUp() ([]qstep, error) {
508 j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd506 j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd
509 }507 }
510 508
509+ // JobExtension.cs:250-275: the job container and the services are
510+ // evaluated after the job environment, before the actions are prepared.
511+ j.debug(1, "Evaluating job container")
512+ jc, svcs, err := j.evalContainers()
513+ if err != nil {
514+ return nil, err
515+ }
516+ if jc != nil || len(svcs) > 0 {
517+ j.cs = &containerSet{job: jc, services: svcs}
518+ }
519+
511 j.out(1, "Prepare all required actions")520 j.out(1, "Prepare all required actions")
512 queue, err := j.prepareJob()521 queue, err := j.prepareJob()
513 if err != nil {522 if err != nil {
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/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