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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 25 | 25 | return nil, j.setupError(err) | |
| 26 | 26 | } | |
| 27 | 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 | 33 | for _, n := range nodes { | |
| 29 | 34 | if j.actions.pre[n] { | |
| 30 | 35 | 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 { | |||
| 169 | 169 | // upgradedToNode24 are the actions that asked for node20 and got node24. | |
| 170 | 170 | upgradedToNode24 []string | |
| 171 | 171 | jobCtx context.Context | |
| 172 | + | // cs is the containers of the job (nil container and no services: none). | |
| 173 | + | cs *containerSet | |
| 172 | 174 | ||
| 173 | 175 | // Results of the commands (see issues.go, matchers.go, results.go). | |
| 174 | 176 | matchers *matcherRoot | |
| @@ -435,10 +437,6 @@ func (j *job) setUp() ([]qstep, error) { | |||
| 435 | 437 | switch { | |
| 436 | 438 | case jb.IsReusable(): | |
| 437 | 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 | 442 | j.printTokenInfo() | |
| @@ -508,6 +506,17 @@ func (j *job) setUp() ([]qstep, error) { | |||
| 508 | 506 | 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 | 520 | j.out(1, "Prepare all required actions") | |
| 512 | 521 | queue, err := j.prepareJob() | |
| 513 | 522 | 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 []string | 170 | upgradedToNode24 []string | ||
| 171 | jobCtx context.Context | 171 | 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 *matcherRoot | 176 | 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, wd | 506 | 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 | |||
| 125 | 125 | // which is visible with `bash -x` (the corpus recording of shell-custom). | |
| 126 | 126 | type dispatcher struct { | |
| 127 | 127 | fn func(string) | |
| 128 | + | errFn func(string) // when set, stderr lines go here instead of fn | |
| 128 | 129 | mu sync.Mutex | |
| 129 | 130 | outQ []string | |
| 130 | 131 | errQ []string | |
| @@ -159,6 +160,10 @@ func (d *dispatcher) drain() { | |||
| 159 | 160 | d.errQ, d.outQ = nil, nil | |
| 160 | 161 | d.mu.Unlock() | |
| 161 | 162 | for _, l := range errs { | |
| 163 | + | if d.errFn != nil { | |
| 164 | + | d.errFn(l) | |
| 165 | + | continue | |
| 166 | + | } | |
| 162 | 167 | d.fn(l) | |
| 163 | 168 | } | |
| 164 | 169 | for _, l := range outs { | |
| @@ -199,6 +204,11 @@ type procSpec struct { | |||
| 199 | 204 | // OnLine receives the lines of stdout and stderr, one call at a time, in | |
| 200 | 205 | // the order of dispatcher. | |
| 201 | 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 | 212 | // Cancel starts the SIGINT, SIGTERM, SIGKILL escalation when closed. | |
| 203 | 213 | Cancel <-chan struct{} | |
| 204 | 214 | ||
| @@ -248,6 +258,10 @@ func runProcess(spec procSpec) (procResult, error) { | |||
| 248 | 258 | cmd.Env = spec.Env | |
| 249 | 259 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Credential: spec.Credential} | |
| 250 | 260 | disp := newDispatcher(spec.OnLine) | |
| 261 | + | disp.errFn = spec.OnErrLine | |
| 262 | + | if spec.Stdin != "" { | |
| 263 | + | cmd.Stdin = strings.NewReader(spec.Stdin) | |
| 264 | + | } | |
| 251 | 265 | cmd.Stdout, cmd.Stderr = disp.out, disp.err | |
| 252 | 266 | cmd.WaitDelay = spec.WaitDelay | |
| 253 | 267 | 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.Mutex | 129 | mu sync.Mutex | ||
| 129 | outQ []string | 130 | outQ []string | ||
| 130 | errQ []string | 131 | errQ []string | ||
| @@ -159,6 +160,10 @@ func (d *dispatcher) drain() { | |||||
| 159 | d.errQ, d.outQ = nil, nil | 160 | 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, in | 204 | // 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.Env | 258 | 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.err | 265 | cmd.Stdout, cmd.Stderr = disp.out, disp.err | ||
| 252 | cmd.WaitDelay = spec.WaitDelay | 266 | 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 { | |||
| 249 | 249 | j.mu.Lock() | |
| 250 | 250 | defer j.mu.Unlock() | |
| 251 | 251 | switch { | |
| 252 | - | case q.setup != nil: | |
| 253 | - | return j.evalCondString("success()", s.env, j.statusFuncs()) | |
| 254 | 252 | case q.cond != "": | |
| 255 | 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 | 257 | return st.EvaluateIf(context.Background(), j.evalEnv(s.env), s.statusFuncs()) | |
| 258 | 258 | } | |
| @@ -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 | } | ||