Merge pull request 'feat(executor): job containers and service containers (CI-2 phase H)' (#13) from feat/ci2-containers into main
33 files · +3324 -17
M README.md +42 -0
| @@ -256,6 +256,48 @@ image: | |||
| 256 | 256 | tzdata, zip/unzip. Full parity with the hosted images (`actions/runner-images`) | |
| 257 | 257 | is a later phase. | |
| 258 | 258 | ||
| 259 | + | ## Job containers and service containers | |
| 260 | + | ||
| 261 | + | `container:` and `services:` work as on GitHub-hosted Linux runners | |
| 262 | + | (`internal/executor/containers_*.go`, a port of `ContainerOperationProvider`, | |
| 263 | + | `ContainerInfo`, `DockerCommandManager` and `ContainerStepHost` of | |
| 264 | + | actions/runner, with their line numbers in the comments). The containers are | |
| 265 | + | created in the **sandbox's own `dockerd`**, so nothing reaches the host: in `vm` | |
| 266 | + | mode (the mode of hosts without Docker Engine) the daemon runs inside the | |
| 267 | + | micro-VM, in `container` mode it is the nested daemon of the job. | |
| 268 | + | ||
| 269 | + | - **Initialize containers** (a step after "Set up job"): docker version check | |
| 270 | + | (API ≥ 1.35), cleanup of labelled leftovers, network `github_network_<32 hex>`, | |
| 271 | + | for each container `docker pull` (3 tries, random 1–10 s back off), | |
| 272 | + | `docker create`/`start`, `docker port` and the health checks of the services | |
| 273 | + | (exponential back off 2–32 s, "`<alias>` service is healthy."). Registry | |
| 274 | + | `credentials:` go to a private `docker --config` directory under RUNNER_TEMP | |
| 275 | + | (password through stdin, masked in every log, directory removed after the pull). | |
| 276 | + | - **Stop containers** (post step, `always()`): service logs, `docker rm --force`, | |
| 277 | + | `docker network rm`. It runs after a failure, a cancellation, a step timeout | |
| 278 | + | and the job timeout. | |
| 279 | + | - **Steps in the job container**: `run:` steps and node actions go through | |
| 280 | + | `docker exec -i --workdir … -e NAME… <id> …` (values are passed in the | |
| 281 | + | environment of the docker process, translated to `/__w`, `/__t`, `/__e`, | |
| 282 | + | `/github/home`, `/github/workflow`, never on the command line). The shell is | |
| 283 | + | looked up by the container: `sh -e {0}` unless the step says otherwise. Node | |
| 284 | + | actions use the externals mounted read-only at `/__e`, `node<N>_alpine` when | |
| 285 | + | `/etc/*release` says alpine. Docker actions run as siblings on the job network. | |
| 286 | + | - Contexts: `job.container.{id,network}`, `job.services.<alias>.{id,network,ports}` | |
| 287 | + | (host ports from `docker port`). | |
| 288 | + | - **Untrusted input.** `options:` is split into arguments like .NET splits a | |
| 289 | + | command line and passed to `docker create` as an argument vector, never through | |
| 290 | + | a shell, so a workflow cannot inject a second command; it can still pass any | |
| 291 | + | `docker create` flag (`--privileged`, `--pid host`, `--device`, …) and bind | |
| 292 | + | volumes (`volumes: /:/host`), exactly as on a self-hosted GitHub runner. That | |
| 293 | + | is contained by the sandbox (its own daemon, its own disk), not by the runner: | |
| 294 | + | **host paths in `volumes:` and `options:` are paths of the sandbox**. A job | |
| 295 | + | that mounts `/` exposes the sandbox's filesystem to its own container only. | |
| 296 | + | The runner warns, as GitHub does, about a volume that mounts `/`. | |
| 297 | + | - Not ported: container hooks (`ACTIONS_RUNNER_CONTAINER_HOOKS`), the | |
| 298 | + | "already running in a container" refusal (the sandbox's daemon is local to the | |
| 299 | + | executor). | |
| 300 | + | ||
| 259 | 301 | ## Job limits | |
| 260 | 302 | ||
| 261 | 303 | Each job gets its own resources, set for the runner as a whole: | |
| @@ -256,6 +256,48 @@ image: | |||||
| 256 | tzdata, zip/unzip. Full parity with the hosted images (`actions/runner-images`) | 256 | tzdata, zip/unzip. Full parity with the hosted images (`actions/runner-images`) | ||
| 257 | is a later phase. | 257 | is a later phase. | ||
| 258 | 258 | ||||
| 259 | + | ## Job containers and service containers | |||
| 260 | + | ||||
| 261 | + | `container:` and `services:` work as on GitHub-hosted Linux runners | |||
| 262 | + | (`internal/executor/containers_*.go`, a port of `ContainerOperationProvider`, | |||
| 263 | + | `ContainerInfo`, `DockerCommandManager` and `ContainerStepHost` of | |||
| 264 | + | actions/runner, with their line numbers in the comments). The containers are | |||
| 265 | + | created in the **sandbox's own `dockerd`**, so nothing reaches the host: in `vm` | |||
| 266 | + | mode (the mode of hosts without Docker Engine) the daemon runs inside the | |||
| 267 | + | micro-VM, in `container` mode it is the nested daemon of the job. | |||
| 268 | + | ||||
| 269 | + | - **Initialize containers** (a step after "Set up job"): docker version check | |||
| 270 | + | (API ≥ 1.35), cleanup of labelled leftovers, network `github_network_<32 hex>`, | |||
| 271 | + | for each container `docker pull` (3 tries, random 1–10 s back off), | |||
| 272 | + | `docker create`/`start`, `docker port` and the health checks of the services | |||
| 273 | + | (exponential back off 2–32 s, "`<alias>` service is healthy."). Registry | |||
| 274 | + | `credentials:` go to a private `docker --config` directory under RUNNER_TEMP | |||
| 275 | + | (password through stdin, masked in every log, directory removed after the pull). | |||
| 276 | + | - **Stop containers** (post step, `always()`): service logs, `docker rm --force`, | |||
| 277 | + | `docker network rm`. It runs after a failure, a cancellation, a step timeout | |||
| 278 | + | and the job timeout. | |||
| 279 | + | - **Steps in the job container**: `run:` steps and node actions go through | |||
| 280 | + | `docker exec -i --workdir … -e NAME… <id> …` (values are passed in the | |||
| 281 | + | environment of the docker process, translated to `/__w`, `/__t`, `/__e`, | |||
| 282 | + | `/github/home`, `/github/workflow`, never on the command line). The shell is | |||
| 283 | + | looked up by the container: `sh -e {0}` unless the step says otherwise. Node | |||
| 284 | + | actions use the externals mounted read-only at `/__e`, `node<N>_alpine` when | |||
| 285 | + | `/etc/*release` says alpine. Docker actions run as siblings on the job network. | |||
| 286 | + | - Contexts: `job.container.{id,network}`, `job.services.<alias>.{id,network,ports}` | |||
| 287 | + | (host ports from `docker port`). | |||
| 288 | + | - **Untrusted input.** `options:` is split into arguments like .NET splits a | |||
| 289 | + | command line and passed to `docker create` as an argument vector, never through | |||
| 290 | + | a shell, so a workflow cannot inject a second command; it can still pass any | |||
| 291 | + | `docker create` flag (`--privileged`, `--pid host`, `--device`, …) and bind | |||
| 292 | + | volumes (`volumes: /:/host`), exactly as on a self-hosted GitHub runner. That | |||
| 293 | + | is contained by the sandbox (its own daemon, its own disk), not by the runner: | |||
| 294 | + | **host paths in `volumes:` and `options:` are paths of the sandbox**. A job | |||
| 295 | + | that mounts `/` exposes the sandbox's filesystem to its own container only. | |||
| 296 | + | The runner warns, as GitHub does, about a volume that mounts `/`. | |||
| 297 | + | - Not ported: container hooks (`ACTIONS_RUNNER_CONTAINER_HOOKS`), the | |||
| 298 | + | "already running in a container" refusal (the sandbox's daemon is local to the | |||
| 299 | + | executor). | |||
| 300 | + | ||||
| 259 | ## Job limits | 301 | ## Job limits | ||
| 260 | 302 | ||||
| 261 | Each job gets its own resources, set for the runner as a whole: | 303 | Each job gets its own resources, set for the runner as a whole: | ||
M internal/executor/actions_docker.go +14 -0
| @@ -203,6 +203,9 @@ func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered, | |||
| 203 | 203 | v, _ := runtime.Get(k) | |
| 204 | 204 | env.Set(k, v) | |
| 205 | 205 | } | |
| 206 | + | if err := checkEnvNames(env.Keys()); err != nil { | |
| 207 | + | return fail(err) | |
| 208 | + | } | |
| 206 | 209 | cenv := newOrdered() | |
| 207 | 210 | for _, k := range env.Keys() { | |
| 208 | 211 | v, _ := env.Get(k) | |
| @@ -224,6 +227,17 @@ func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered, | |||
| 224 | 227 | if entry != "" { | |
| 225 | 228 | parts = append(parts, entrypointOpt(entry)) | |
| 226 | 229 | } | |
| 230 | + | // A docker action joins the network of the job's containers, so that it | |
| 231 | + | // reaches the services by their aliases (ContainerActionHandler.cs:172-175 | |
| 232 | + | // reads job.container.network; the read creates job.container, which is why | |
| 233 | + | // the context of any job that ran a docker action has "container": {}). | |
| 234 | + | j.mu.Lock() | |
| 235 | + | j.state.TouchJobContainer() | |
| 236 | + | network := j.state.JobContainerNetwork() | |
| 237 | + | j.mu.Unlock() | |
| 238 | + | if network != "" { | |
| 239 | + | parts = append(parts, raw("--network", network)) | |
| 240 | + | } | |
| 227 | 241 | parts = append(parts, | |
| 228 | 242 | volumeOpt("/var/run/docker.sock", "/var/run/docker.sock"), | |
| 229 | 243 | volumeOpt(temp, "/github/runner_temp"), | |
| @@ -203,6 +203,9 @@ func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered, | |||||
| 203 | v, _ := runtime.Get(k) | 203 | v, _ := runtime.Get(k) | ||
| 204 | env.Set(k, v) | 204 | env.Set(k, v) | ||
| 205 | } | 205 | } | ||
| 206 | + | if err := checkEnvNames(env.Keys()); err != nil { | |||
| 207 | + | return fail(err) | |||
| 208 | + | } | |||
| 206 | cenv := newOrdered() | 209 | cenv := newOrdered() | ||
| 207 | for _, k := range env.Keys() { | 210 | for _, k := range env.Keys() { | ||
| 208 | v, _ := env.Get(k) | 211 | v, _ := env.Get(k) | ||
| @@ -224,6 +227,17 @@ func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered, | |||||
| 224 | if entry != "" { | 227 | if entry != "" { | ||
| 225 | parts = append(parts, entrypointOpt(entry)) | 228 | parts = append(parts, entrypointOpt(entry)) | ||
| 226 | } | 229 | } | ||
| 230 | + | // A docker action joins the network of the job's containers, so that it | |||
| 231 | + | // reaches the services by their aliases (ContainerActionHandler.cs:172-175 | |||
| 232 | + | // reads job.container.network; the read creates job.container, which is why | |||
| 233 | + | // the context of any job that ran a docker action has "container": {}). | |||
| 234 | + | j.mu.Lock() | |||
| 235 | + | j.state.TouchJobContainer() | |||
| 236 | + | network := j.state.JobContainerNetwork() | |||
| 237 | + | j.mu.Unlock() | |||
| 238 | + | if network != "" { | |||
| 239 | + | parts = append(parts, raw("--network", network)) | |||
| 240 | + | } | |||
| 227 | parts = append(parts, | 241 | parts = append(parts, | ||
| 228 | volumeOpt("/var/run/docker.sock", "/var/run/docker.sock"), | 242 | volumeOpt("/var/run/docker.sock", "/var/run/docker.sock"), | ||
| 229 | volumeOpt(temp, "/github/runner_temp"), | 243 | volumeOpt(temp, "/github/runner_temp"), | ||
M internal/executor/actions_docker_cmd.go +10 -1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "os" | |
| 9 | 9 | "os/exec" | |
| 10 | 10 | "regexp" | |
| 11 | + | "slices" | |
| 11 | 12 | "strings" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/google/uuid" | |
| @@ -54,6 +55,10 @@ func escapeDockerArg(v string) string { | |||
| 54 | 55 | type dockerCmd struct { | |
| 55 | 56 | line string | |
| 56 | 57 | argv []string | |
| 58 | + | // env is added to the environment of the docker process, after its own: | |
| 59 | + | // "-e NAME" options read their values there, so that they are neither on | |
| 60 | + | // the command line nor in the echoed text. | |
| 61 | + | env []string | |
| 57 | 62 | } | |
| 58 | 63 | ||
| 59 | 64 | // newDockerCmd builds "docker <command> <options>" the way the runner does | |
| @@ -62,12 +67,14 @@ type dockerCmd struct { | |||
| 62 | 67 | func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd { | |
| 63 | 68 | lines := []string{} | |
| 64 | 69 | argv := []string{j.dockerPath(), command} | |
| 70 | + | var env []string | |
| 65 | 71 | for _, p := range parts { | |
| 66 | 72 | lines = append(lines, p.line) | |
| 67 | 73 | argv = append(argv, p.args...) | |
| 74 | + | env = append(env, p.env...) | |
| 68 | 75 | } | |
| 69 | 76 | line := strings.TrimSpace(command + " " + strings.Join(lines, " ")) | |
| 70 | - | return dockerCmd{line: j.dockerPath() + " " + line, argv: argv} | |
| 77 | + | return dockerCmd{line: j.dockerPath() + " " + line, argv: argv, env: env} | |
| 71 | 78 | } | |
| 72 | 79 | ||
| 73 | 80 | // dockerPart is one piece of a docker command: its text in the echoed line and | |
| @@ -75,6 +82,7 @@ func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd { | |||
| 75 | 82 | type dockerPart struct { | |
| 76 | 83 | line string | |
| 77 | 84 | args []string | |
| 85 | + | env []string // NAME=value for the process of docker, see dockerCmd.env | |
| 78 | 86 | } | |
| 79 | 87 | ||
| 80 | 88 | func raw(args ...string) dockerPart { return dockerPart{line: strings.Join(args, " "), args: args} } | |
| @@ -172,6 +180,7 @@ func (j *job) runDockerCmd(s *stepRun, c dockerCmd, dir string, env []string, ca | |||
| 172 | 180 | if env == nil { | |
| 173 | 181 | env = j.processEnv(newOrdered()) | |
| 174 | 182 | } | |
| 183 | + | env = append(slices.Clone(env), c.env...) | |
| 175 | 184 | res, err := runProcess(procSpec{ | |
| 176 | 185 | Argv: c.argv, Dir: dir, Env: env, Credential: j.cfg.Credential, | |
| 177 | 186 | OnLine: func(line string) { j.write(s.num, line) }, | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "os" | 8 | "os" | ||
| 9 | "os/exec" | 9 | "os/exec" | ||
| 10 | "regexp" | 10 | "regexp" | ||
| 11 | + | "slices" | |||
| 11 | "strings" | 12 | "strings" | ||
| 12 | 13 | ||||
| 13 | "github.com/google/uuid" | 14 | "github.com/google/uuid" | ||
| @@ -54,6 +55,10 @@ func escapeDockerArg(v string) string { | |||||
| 54 | type dockerCmd struct { | 55 | type dockerCmd struct { | ||
| 55 | line string | 56 | line string | ||
| 56 | argv []string | 57 | argv []string | ||
| 58 | + | // env is added to the environment of the docker process, after its own: | |||
| 59 | + | // "-e NAME" options read their values there, so that they are neither on | |||
| 60 | + | // the command line nor in the echoed text. | |||
| 61 | + | env []string | |||
| 57 | } | 62 | } | ||
| 58 | 63 | ||||
| 59 | // newDockerCmd builds "docker <command> <options>" the way the runner does | 64 | // newDockerCmd builds "docker <command> <options>" the way the runner does | ||
| @@ -62,12 +67,14 @@ type dockerCmd struct { | |||||
| 62 | func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd { | 67 | func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd { | ||
| 63 | lines := []string{} | 68 | lines := []string{} | ||
| 64 | argv := []string{j.dockerPath(), command} | 69 | argv := []string{j.dockerPath(), command} | ||
| 70 | + | var env []string | |||
| 65 | for _, p := range parts { | 71 | for _, p := range parts { | ||
| 66 | lines = append(lines, p.line) | 72 | lines = append(lines, p.line) | ||
| 67 | argv = append(argv, p.args...) | 73 | argv = append(argv, p.args...) | ||
| 74 | + | env = append(env, p.env...) | |||
| 68 | } | 75 | } | ||
| 69 | line := strings.TrimSpace(command + " " + strings.Join(lines, " ")) | 76 | line := strings.TrimSpace(command + " " + strings.Join(lines, " ")) | ||
| 70 | - | return dockerCmd{line: j.dockerPath() + " " + line, argv: argv} | 77 | + | return dockerCmd{line: j.dockerPath() + " " + line, argv: argv, env: env} |
| 71 | } | 78 | } | ||
| 72 | 79 | ||||
| 73 | // dockerPart is one piece of a docker command: its text in the echoed line and | 80 | // dockerPart is one piece of a docker command: its text in the echoed line and | ||
| @@ -75,6 +82,7 @@ func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd { | |||||
| 75 | type dockerPart struct { | 82 | type dockerPart struct { | ||
| 76 | line string | 83 | line string | ||
| 77 | args []string | 84 | args []string | ||
| 85 | + | env []string // NAME=value for the process of docker, see dockerCmd.env | |||
| 78 | } | 86 | } | ||
| 79 | 87 | ||||
| 80 | func raw(args ...string) dockerPart { return dockerPart{line: strings.Join(args, " "), args: args} } | 88 | func raw(args ...string) dockerPart { return dockerPart{line: strings.Join(args, " "), args: args} } | ||
| @@ -172,6 +180,7 @@ func (j *job) runDockerCmd(s *stepRun, c dockerCmd, dir string, env []string, ca | |||||
| 172 | if env == nil { | 180 | if env == nil { | ||
| 173 | env = j.processEnv(newOrdered()) | 181 | env = j.processEnv(newOrdered()) | ||
| 174 | } | 182 | } | ||
| 183 | + | env = append(slices.Clone(env), c.env...) | |||
| 175 | res, err := runProcess(procSpec{ | 184 | res, err := runProcess(procSpec{ | ||
| 176 | Argv: c.argv, Dir: dir, Env: env, Credential: j.cfg.Credential, | 185 | Argv: c.argv, Dir: dir, Env: env, Credential: j.cfg.Credential, | ||
| 177 | OnLine: func(line string) { j.write(s.num, line) }, | 186 | OnLine: func(line string) { j.write(s.num, line) }, | ||
M internal/executor/actions_job.go +5 -0
| @@ -25,6 +25,11 @@ func (j *job) prepareJob() ([]qstep, error) { | |||
| 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()}) | ||
M internal/executor/actions_node.go +3 -0
| @@ -175,6 +175,9 @@ func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, ca | |||
| 175 | 175 | if st, err := os.Stat(script); err != nil || st.IsDir() { | |
| 176 | 176 | return fail(ghErrorf("File not found: '%s'", script)) | |
| 177 | 177 | } | |
| 178 | + | if c := j.stepContainer(); c != nil { | |
| 179 | + | return j.runNodeInContainer(s, c, script, env, cancel) | |
| 180 | + | } | |
| 178 | 181 | j.mu.Lock() | |
| 179 | 182 | wd := j.state.GitHub("workspace") | |
| 180 | 183 | j.mu.Unlock() | |
| @@ -175,6 +175,9 @@ func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, ca | |||||
| 175 | if st, err := os.Stat(script); err != nil || st.IsDir() { | 175 | if st, err := os.Stat(script); err != nil || st.IsDir() { | ||
| 176 | return fail(ghErrorf("File not found: '%s'", script)) | 176 | return fail(ghErrorf("File not found: '%s'", script)) | ||
| 177 | } | 177 | } | ||
| 178 | + | if c := j.stepContainer(); c != nil { | |||
| 179 | + | return j.runNodeInContainer(s, c, script, env, cancel) | |||
| 180 | + | } | |||
| 178 | j.mu.Lock() | 181 | j.mu.Lock() | ||
| 179 | wd := j.state.GitHub("workspace") | 182 | wd := j.state.GitHub("workspace") | ||
| 180 | j.mu.Unlock() | 183 | j.mu.Unlock() | ||
M internal/executor/actions_run.go +4 -0
| @@ -175,6 +175,10 @@ func (j *job) runAction(s *stepRun, cancel <-chan struct{}) stepResult { | |||
| 175 | 175 | j.registerPost(s, def) | |
| 176 | 176 | } | |
| 177 | 177 | j.setActionRepository(n) | |
| 178 | + | // ActionRunner.cs:157-164: with a job container, the container must exist. | |
| 179 | + | if err := j.containerReady(); err != nil { | |
| 180 | + | return fail(err) | |
| 181 | + | } | |
| 178 | 182 | if err := j.prepareFileCommands(s); err != nil { | |
| 179 | 183 | return fail(err) | |
| 180 | 184 | } | |
| @@ -175,6 +175,10 @@ func (j *job) runAction(s *stepRun, cancel <-chan struct{}) stepResult { | |||||
| 175 | j.registerPost(s, def) | 175 | j.registerPost(s, def) | ||
| 176 | } | 176 | } | ||
| 177 | j.setActionRepository(n) | 177 | j.setActionRepository(n) | ||
| 178 | + | // ActionRunner.cs:157-164: with a job container, the container must exist. | |||
| 179 | + | if err := j.containerReady(); err != nil { | |||
| 180 | + | return fail(err) | |||
| 181 | + | } | |||
| 178 | if err := j.prepareFileCommands(s); err != nil { | 182 | if err := j.prepareFileCommands(s); err != nil { | ||
| 179 | return fail(err) | 183 | return fail(err) | ||
| 180 | } | 184 | } | ||
M internal/executor/actions_types.go +3 -0
| @@ -46,6 +46,9 @@ type ActionsConfig struct { | |||
| 46 | 46 | // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no | |
| 47 | 47 | // feature flags, so the default is pinned here. | |
| 48 | 48 | Node24ByDefault bool | |
| 49 | + | // Containers are the time limits of the docker commands of job containers | |
| 50 | + | // and services; the zero value means the defaults. | |
| 51 | + | Containers ContainerTimeouts | |
| 49 | 52 | // Backoff, when set, replaces the random 10 to 30 s wait between download | |
| 50 | 53 | // attempts (tests). | |
| 51 | 54 | Backoff func() time.Duration | |
| @@ -46,6 +46,9 @@ type ActionsConfig struct { | |||||
| 46 | // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no | 46 | // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no | ||
| 47 | // feature flags, so the default is pinned here. | 47 | // feature flags, so the default is pinned here. | ||
| 48 | Node24ByDefault bool | 48 | Node24ByDefault bool | ||
| 49 | + | // Containers are the time limits of the docker commands of job containers | |||
| 50 | + | // and services; the zero value means the defaults. | |||
| 51 | + | Containers ContainerTimeouts | |||
| 49 | // Backoff, when set, replaces the random 10 to 30 s wait between download | 52 | // Backoff, when set, replaces the random 10 to 30 s wait between download | ||
| 50 | // attempts (tests). | 53 | // attempts (tests). | ||
| 51 | Backoff func() time.Duration | 54 | Backoff func() time.Duration | ||
M internal/executor/commands.go +5 -0
| @@ -144,6 +144,10 @@ type commandHost interface { | |||
| 144 | 144 | AddIssue(issue) | |
| 145 | 145 | // Workspace is github.workspace. | |
| 146 | 146 | Workspace() string | |
| 147 | + | // HostPath translates a path printed by a process in the job container | |
| 148 | + | // back to the host's (ContainerInfo.TranslateToHostPath); without a | |
| 149 | + | // container it is the identity. | |
| 150 | + | HostPath(p string) string | |
| 147 | 151 | // AddMask is SecretMasker.AddValue. | |
| 148 | 152 | AddMask(value string) | |
| 149 | 153 | // Matchers is the root of the problem matchers of the job. | |
| @@ -339,6 +343,7 @@ func (m *commandManager) addMask(c *actionCommand) { | |||
| 339 | 343 | ||
| 340 | 344 | // matcherPath roots a path of add-matcher and remove-matcher in the workspace. | |
| 341 | 345 | func (m *commandManager) matcherPath(file string) string { | |
| 346 | + | file = m.host.HostPath(file) // ActionCommandManager.cs:513-517, :575-580 | |
| 342 | 347 | if !filepath.IsAbs(file) { | |
| 343 | 348 | return filepath.Join(m.host.Workspace(), file) | |
| 344 | 349 | } | |
| @@ -144,6 +144,10 @@ type commandHost interface { | |||||
| 144 | AddIssue(issue) | 144 | AddIssue(issue) | ||
| 145 | // Workspace is github.workspace. | 145 | // Workspace is github.workspace. | ||
| 146 | Workspace() string | 146 | Workspace() string | ||
| 147 | + | // HostPath translates a path printed by a process in the job container | |||
| 148 | + | // back to the host's (ContainerInfo.TranslateToHostPath); without a | |||
| 149 | + | // container it is the identity. | |||
| 150 | + | HostPath(p string) string | |||
| 147 | // AddMask is SecretMasker.AddValue. | 151 | // AddMask is SecretMasker.AddValue. | ||
| 148 | AddMask(value string) | 152 | AddMask(value string) | ||
| 149 | // Matchers is the root of the problem matchers of the job. | 153 | // Matchers is the root of the problem matchers of the job. | ||
| @@ -339,6 +343,7 @@ func (m *commandManager) addMask(c *actionCommand) { | |||||
| 339 | 343 | ||||
| 340 | // matcherPath roots a path of add-matcher and remove-matcher in the workspace. | 344 | // matcherPath roots a path of add-matcher and remove-matcher in the workspace. | ||
| 341 | func (m *commandManager) matcherPath(file string) string { | 345 | func (m *commandManager) matcherPath(file string) string { | ||
| 346 | + | file = m.host.HostPath(file) // ActionCommandManager.cs:513-517, :575-580 | |||
| 342 | if !filepath.IsAbs(file) { | 347 | if !filepath.IsAbs(file) { | ||
| 343 | return filepath.Join(m.host.Workspace(), file) | 348 | return filepath.Join(m.host.Workspace(), file) | ||
| 344 | } | 349 | } | ||
A internal/executor/containers_cleanup_test.go +380 -0
| @@ -0,0 +1,380 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func fastTimeouts(c *Config) { | |
| 14 | + | c.Actions.Containers = ContainerTimeouts{Command: 400 * time.Millisecond, Pull: 400 * time.Millisecond, CleanupCommand: 400 * time.Millisecond, CleanupBudget: 3 * time.Second} | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func names(l [][]string) string { | |
| 18 | + | var v []string | |
| 19 | + | for _, c := range l { | |
| 20 | + | v = append(v, c[0]) | |
| 21 | + | } | |
| 22 | + | return strings.Join(v, " ") | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // GO-001: a docker command that stalls is stopped by its own limit, the job | |
| 26 | + | // does not wait for its timeout, and the cleanup still finds the container | |
| 27 | + | // that the daemon did create, by the label of the job. | |
| 28 | + | func TestStalledCreateIsBoundedAndCleanedByLabel(t *testing.T) { | |
| 29 | + | f := newFakeCtr(t) | |
| 30 | + | f.set(t, "hang_create_after", "1") // created in the daemon, the CLI never returns | |
| 31 | + | start := time.Now() | |
| 32 | + | rec, res := ctrRun(t, f, ctrJob, " - run: echo never\n", fastTimeouts) | |
| 33 | + | if res.Conclusion != ConclusionFailure || time.Since(start) > 20*time.Second { | |
| 34 | + | t.Fatalf("%+v after %s", res, time.Since(start)) | |
| 35 | + | } | |
| 36 | + | log := logOf(rec.step(t, "Initialize containers")) | |
| 37 | + | if !strings.Contains(log, "##[error]The docker command timed out after 400ms: create --name ") { | |
| 38 | + | t.Errorf("init:\n%s", log) | |
| 39 | + | } | |
| 40 | + | if b, _ := os.ReadFile(filepath.Join(f.dir, "created")); strings.TrimSpace(string(b)) != "" { | |
| 41 | + | t.Errorf("container left in the daemon: %q", b) | |
| 42 | + | } | |
| 43 | + | if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network: github_network_") { | |
| 44 | + | t.Errorf("stop:\n%s", got) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // GO-003-R: options: may carry another --name (the runner passes it on). The | |
| 49 | + | // container is then not named as recorded; it is found by the label. | |
| 50 | + | func TestOverridingNameAndTimedOutCreateAreCleanedByLabel(t *testing.T) { | |
| 51 | + | f := newFakeCtr(t) | |
| 52 | + | f.set(t, "hang_create_after", "1") | |
| 53 | + | rec, _ := ctrRun(t, f, " container:\n image: ubuntu:24.04\n options: --name my-own-name\n", " - run: echo never\n", fastTimeouts) | |
| 54 | + | var ps, rm []string | |
| 55 | + | for _, c := range f.calls(t) { | |
| 56 | + | if c[0] == "ps" && strings.Contains(strings.Join(c, " "), "--quiet") { | |
| 57 | + | ps = c | |
| 58 | + | } | |
| 59 | + | if c[0] == "rm" { | |
| 60 | + | rm = c | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | if ps == nil || !strings.Contains(strings.Join(ps, "\n"), "label=") { | |
| 64 | + | t.Errorf("no listing by label: %v", ps) | |
| 65 | + | } | |
| 66 | + | if len(rm) < 3 || !containerIDRe.MatchString(rm[2]) { | |
| 67 | + | t.Errorf("rm by id expected: %v", rm) | |
| 68 | + | } | |
| 69 | + | if b, _ := os.ReadFile(filepath.Join(f.dir, "created")); strings.TrimSpace(string(b)) != "" { | |
| 70 | + | t.Errorf("container left: %q", b) | |
| 71 | + | } | |
| 72 | + | _ = rec | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // GO-001: the cancellation of the job reaches every docker call of the init. | |
| 76 | + | func TestCancelDuringInitStopsTheCommandAndCleans(t *testing.T) { | |
| 77 | + | for _, verb := range []string{"version", "inspect", "port", "start", "ps"} { | |
| 78 | + | f := newFakeCtr(t) | |
| 79 | + | f.set(t, "hang_"+verb, "1") | |
| 80 | + | cancel := NewCanceller() | |
| 81 | + | go func() { time.Sleep(500 * time.Millisecond); cancel.Cancel(CancelUser) }() | |
| 82 | + | start := time.Now() | |
| 83 | + | rec, res := ctrRunC(t, f, cancel, " container: ubuntu:24.04\n services:\n s:\n image: redis:7\n", " - run: echo never\n", func(c *Config) { | |
| 84 | + | c.Actions.Containers.Command = time.Minute | |
| 85 | + | }) | |
| 86 | + | if res.Conclusion != ConclusionCancelled || time.Since(start) > 15*time.Second { | |
| 87 | + | t.Fatalf("%s: %+v after %s %v", verb, res, time.Since(start), rec.names()) | |
| 88 | + | } | |
| 89 | + | if got := rec.concl("Stop containers"); got != ConclusionSuccess { | |
| 90 | + | t.Errorf("%s: Stop containers %s", verb, got) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // GO-002: one removal that stalls does not keep the others and the network from | |
| 96 | + | // being removed; the logs of a service never prevent its removal; the | |
| 97 | + | // failures are reported by the step. | |
| 98 | + | func TestCleanupAttemptsEverythingWithItsOwnBudget(t *testing.T) { | |
| 99 | + | f := newFakeCtr(t) | |
| 100 | + | f.set(t, "hang_logs", "1") | |
| 101 | + | f.set(t, "hang_for", "rm --force "+strings.Repeat("0", 63)+"1") // the rm of the job container (first id) | |
| 102 | + | cancel := NewCanceller() | |
| 103 | + | rec, res := ctrRunC(t, f, cancel, " container: ubuntu:24.04\n services:\n a:\n image: redis:7\n b:\n image: redis:7\n", " - run: echo ok\n", fastTimeouts) | |
| 104 | + | if res.Conclusion == ConclusionSuccess { | |
| 105 | + | t.Fatalf("a failed cleanup must be visible: %+v", res) | |
| 106 | + | } | |
| 107 | + | stop := logOf(rec.step(t, "Stop containers")) | |
| 108 | + | for _, w := range []string{"Stop and remove container: ", "Print service container logs: ", "Remove container network: github_network_", "##[error]The docker command timed out after 400ms: rm --force ", "##[warning]Docker logs failed: The docker command timed out"} { | |
| 109 | + | if !strings.Contains(stop, w) { | |
| 110 | + | t.Errorf("stop lacks %q:\n%s", w, stop) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | rms, nets := 0, 0 | |
| 114 | + | for _, c := range f.calls(t) { | |
| 115 | + | if c[0] == "rm" { | |
| 116 | + | rms++ | |
| 117 | + | } | |
| 118 | + | if c[0] == "network" && len(c) > 1 && c[1] == "rm" { | |
| 119 | + | nets++ | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | // 3 removals, and the fallback of the job tries the one that timed out again. | |
| 123 | + | if rms < 3 || rms > 4 || nets != 1 { | |
| 124 | + | t.Errorf("rm=%d network rm=%d (want 3 or 4 and 1):\n%s", rms, nets, stop) | |
| 125 | + | } | |
| 126 | + | if rec.concl("Stop containers") != ConclusionFailure { | |
| 127 | + | t.Errorf("Stop containers: %s", rec.concl("Stop containers")) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // GO-003: a shutdown skips the queued post steps (StepsRunner); the job-level | |
| 132 | + | // fallback removes the containers and the network all the same, and does not | |
| 133 | + | // run twice what "Stop containers" did. | |
| 134 | + | func TestShutdownStillCleansUpThroughTheFallback(t *testing.T) { | |
| 135 | + | f := newFakeCtr(t) | |
| 136 | + | f.set(t, "hang_exec", "1") | |
| 137 | + | cancel := NewCanceller() | |
| 138 | + | go func() { time.Sleep(600 * time.Millisecond); cancel.Cancel(CancelShutdown) }() | |
| 139 | + | rec, res := ctrRunC(t, f, cancel, ctrJob, " - name: long\n run: sleep 60\n", nil) | |
| 140 | + | _ = res | |
| 141 | + | if rec.concl("Stop containers") == ConclusionSuccess { | |
| 142 | + | t.Skip("the runner ran Stop containers on shutdown") | |
| 143 | + | } | |
| 144 | + | got := names(f.calls(t)) | |
| 145 | + | if !strings.HasSuffix(got, "rm network") { | |
| 146 | + | t.Errorf("fallback did not remove the container and the network: %s", got) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestFallbackDoesNothingWhenStopContainersDidItAll(t *testing.T) { | |
| 151 | + | f := newFakeCtr(t) | |
| 152 | + | _, res := ctrRun(t, f, ctrJob, " - run: echo ok\n", nil) | |
| 153 | + | if res.Conclusion != ConclusionSuccess { | |
| 154 | + | t.Fatalf("%+v", res) | |
| 155 | + | } | |
| 156 | + | n := 0 | |
| 157 | + | for _, c := range f.calls(t) { | |
| 158 | + | if c[0] == "rm" || (c[0] == "network" && len(c) > 1 && c[1] == "rm") { | |
| 159 | + | n++ | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | if n != 2 { | |
| 163 | + | t.Errorf("%d removals, want exactly 2 (container, network): %s", n, names(f.calls(t))) | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // GO-003: a panic of the job still cleans up. | |
| 168 | + | func TestPanicStillCleansUp(t *testing.T) { | |
| 169 | + | f := newFakeCtr(t) | |
| 170 | + | defer func() { | |
| 171 | + | _ = recover() | |
| 172 | + | got := names(f.calls(t)) | |
| 173 | + | if !strings.Contains(got, "rm") || !strings.HasSuffix(got, "rm network") { | |
| 174 | + | t.Errorf("calls after the panic: %s", got) | |
| 175 | + | } | |
| 176 | + | }() | |
| 177 | + | ctrRun(t, f, ctrJob, " - run: echo ok\n", func(c *Config) { | |
| 178 | + | c.Reporter = &panicOnStep{Reporter: c.Reporter, name: "Run echo ok"} | |
| 179 | + | }) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | type panicOnStep struct { | |
| 183 | + | Reporter | |
| 184 | + | name string | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (p *panicOnStep) StepStarted(n int, name string, at time.Time) { | |
| 188 | + | if name == p.name { | |
| 189 | + | panic("boom") | |
| 190 | + | } | |
| 191 | + | p.Reporter.StepStarted(n, name, at) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // GO-007: the credentials directory is removed even when the pull fails; a | |
| 195 | + | // removal that fails is reported and left to the fallback. | |
| 196 | + | func TestRegistryConfigRemovedAfterFailedPullAndRemovalFailureIsReported(t *testing.T) { | |
| 197 | + | f := newFakeCtr(t) | |
| 198 | + | f.set(t, "pull_fail", "9") | |
| 199 | + | var temp string | |
| 200 | + | rec, _ := ctrRun(t, f, " container:\n image: ghcr.io/o/i:1\n credentials:\n username: u\n password: pw-secret\n", " - run: echo\n", func(c *Config) { temp = c.Layout.Temp }) | |
| 201 | + | if m, _ := filepath.Glob(filepath.Join(temp, ".docker_*")); len(m) != 0 { | |
| 202 | + | t.Errorf("config left after a failed pull: %v", m) | |
| 203 | + | } | |
| 204 | + | _ = rec | |
| 205 | + | ||
| 206 | + | if os.Geteuid() == 0 { | |
| 207 | + | t.Skip("root removes anything") | |
| 208 | + | } | |
| 209 | + | f2 := newFakeCtr(t) | |
| 210 | + | f2.set(t, "lock_cfg", "1") | |
| 211 | + | rec2, res2 := ctrRun(t, f2, " container:\n image: ghcr.io/o/i:1\n credentials:\n username: u\n password: pw-secret\n", " - run: echo\n", func(c *Config) { temp = c.Layout.Temp }) | |
| 212 | + | defer func() { | |
| 213 | + | m, _ := filepath.Glob(filepath.Join(temp, ".docker_*", "sub")) | |
| 214 | + | for _, d := range m { | |
| 215 | + | _ = os.Chmod(d, 0o755) | |
| 216 | + | } | |
| 217 | + | }() | |
| 218 | + | if res2.Conclusion != ConclusionFailure { | |
| 219 | + | t.Fatalf("%+v", res2) | |
| 220 | + | } | |
| 221 | + | if got := logOf(rec2.step(t, "Initialize containers")); !strings.Contains(got, "##[error]Failed to remove directory containing Docker client credentials:") { | |
| 222 | + | t.Errorf("init:\n%s", got) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // GO-001-R: the health inspection takes the cancellation too. | |
| 227 | + | func TestCancelDuringHealthInspection(t *testing.T) { | |
| 228 | + | f := newFakeCtr(t) | |
| 229 | + | f.set(t, "hang_inspect", "1") | |
| 230 | + | cancel := NewCanceller() | |
| 231 | + | go func() { time.Sleep(500 * time.Millisecond); cancel.Cancel(CancelUser) }() | |
| 232 | + | start := time.Now() | |
| 233 | + | rec, res := ctrRunC(t, f, cancel, " services:\n s:\n image: redis:7\n", " - run: echo never\n", func(c *Config) { | |
| 234 | + | c.Actions.Containers.Command = time.Minute | |
| 235 | + | }) | |
| 236 | + | if res.Conclusion != ConclusionCancelled || time.Since(start) > 15*time.Second { | |
| 237 | + | t.Fatalf("%+v after %s %v", res, time.Since(start), rec.names()) | |
| 238 | + | } | |
| 239 | + | var health bool | |
| 240 | + | for _, c := range f.calls(t) { | |
| 241 | + | if c[0] == "inspect" && strings.Contains(strings.Join(c, " "), "Healthcheck") { | |
| 242 | + | health = true | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | if !health { | |
| 246 | + | t.Errorf("the health inspection was not reached: %s", names(f.calls(t))) | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // GO-002-R: a failed removal is not a removal. It stays pending and the job's | |
| 251 | + | // fallback tries again; "No such container" is a removal. | |
| 252 | + | func TestFailedRemovalStaysPendingAndAbsenceCounts(t *testing.T) { | |
| 253 | + | count := func(f *fakeCtr, verb string) int { | |
| 254 | + | n := 0 | |
| 255 | + | for _, c := range f.calls(t) { | |
| 256 | + | if c[0] == verb || (verb == "network rm" && c[0] == "network" && len(c) > 1 && c[1] == "rm") { | |
| 257 | + | n++ | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | return n | |
| 261 | + | } | |
| 262 | + | f := newFakeCtr(t) | |
| 263 | + | f.set(t, "rm_fail", "1") | |
| 264 | + | f.set(t, "netrm_fail", "1") | |
| 265 | + | rec, _ := ctrRun(t, f, ctrJob, " - run: echo ok\n", fastTimeouts) | |
| 266 | + | if count(f, "rm") != 2 || count(f, "network rm") != 2 { | |
| 267 | + | t.Errorf("rm=%d network rm=%d, want the fallback to retry both once", count(f, "rm"), count(f, "network rm")) | |
| 268 | + | } | |
| 269 | + | if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Docker rm fail with exit code 1") || !strings.Contains(got, "Docker network rm fail with exit code 1") { | |
| 270 | + | t.Errorf("warnings:\n%s", got) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | f2 := newFakeCtr(t) | |
| 274 | + | f2.set(t, "rm_absent", "1") | |
| 275 | + | f2.set(t, "netrm_absent", "1") | |
| 276 | + | rec2, res2 := ctrRun(t, f2, ctrJob, " - run: echo ok\n", fastTimeouts) | |
| 277 | + | if res2.Conclusion != ConclusionSuccess || count(f2, "rm") != 1 || count(f2, "network rm") != 1 { | |
| 278 | + | t.Errorf("absent: %+v rm=%d network rm=%d\n%s", res2, count(f2, "rm"), count(f2, "network rm"), logOf(rec2.step(t, "Stop containers"))) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // GO-007-R: the credentials directory is registered before the login; a panic | |
| 283 | + | // in the login or in the pull leaves nothing behind. | |
| 284 | + | func TestPanicDuringLoginOrPullLeavesNoCredentials(t *testing.T) { | |
| 285 | + | for _, during := range []string{" login ", " pull "} { | |
| 286 | + | f := newFakeCtr(t) | |
| 287 | + | var temp string | |
| 288 | + | func() { | |
| 289 | + | defer func() { _ = recover() }() | |
| 290 | + | ctrRun(t, f, " container:\n image: ghcr.io/o/i:1\n credentials:\n username: u\n password: pw-secret\n", " - run: echo\n", func(c *Config) { | |
| 291 | + | temp = c.Layout.Temp | |
| 292 | + | c.Reporter = &panicOnLog{Reporter: c.Reporter, contains: during} | |
| 293 | + | }) | |
| 294 | + | }() | |
| 295 | + | if m, _ := filepath.Glob(filepath.Join(temp, ".docker_*")); len(m) != 0 { | |
| 296 | + | t.Errorf("panic during%s: credentials left: %v", during, m) | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | type panicOnLog struct { | |
| 302 | + | Reporter | |
| 303 | + | contains string | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func (p *panicOnLog) Log(n int, at time.Time, line string) { | |
| 307 | + | if strings.HasPrefix(line, "##[command]") && strings.Contains(line, p.contains) { | |
| 308 | + | panic("boom") | |
| 309 | + | } | |
| 310 | + | p.Reporter.Log(n, at, line) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // GO-004-R3: a variable name that holds "=" or NUL, or is empty, is refused | |
| 314 | + | // for create and exec, before docker is called. | |
| 315 | + | func TestInvalidEnvNamesAreRefused(t *testing.T) { | |
| 316 | + | if checkEnvNames([]string{"A", "b_c", "A.B", "A-B"}) != nil { | |
| 317 | + | t.Error("valid names refused") | |
| 318 | + | } | |
| 319 | + | for _, k := range []string{"", "A=B", "HTTP_PROXY=http://x/?y", "A\x00B", "="} { | |
| 320 | + | if err := checkEnvNames([]string{"OK", k}); err == nil || !strings.Contains(err.Error(), "Invalid environment variable name") { | |
| 321 | + | t.Errorf("%q: %v", k, err) | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | f := newFakeCtr(t) | |
| 325 | + | rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n \"HTTP_PROXY=http://x/?y\": v\n", " - run: echo never\n", nil) | |
| 326 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "Set up job")), "##[error]Invalid environment variable name 'HTTP_PROXY=http://x/?y'") { | |
| 327 | + | t.Errorf("container env: %+v", res) | |
| 328 | + | } | |
| 329 | + | rec, res = ctrRun(t, f, ctrJob, " - name: s\n env:\n \"HTTP_PROXY=http://x/?y\": v\n run: echo never\n", nil) | |
| 330 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "s")), "##[error]Invalid environment variable name 'HTTP_PROXY=http://x/?y'") { | |
| 331 | + | t.Errorf("step env: %+v\n%s", res, logOf(rec.step(t, "s"))) | |
| 332 | + | } | |
| 333 | + | if n := len(f.execCalls(t)); n != 0 { | |
| 334 | + | t.Errorf("docker exec called %d times", n) | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // GO-008: the budget of the whole cleanup is checked before every command. With | |
| 339 | + | // a budget spent by the first stalled removal, the others (and the retry of the | |
| 340 | + | // fallback) are not started: they would each cost a signal escalation. | |
| 341 | + | func TestCleanupBudgetIsCheckedBeforeEveryCommand(t *testing.T) { | |
| 342 | + | f := newFakeCtr(t) | |
| 343 | + | f.set(t, "hang_for", "rm --force") | |
| 344 | + | f.set(t, "hang_ignore", "1") // SIGINT is ignored: a started command costs TermAfter | |
| 345 | + | svcs := "" | |
| 346 | + | for _, n := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} { | |
| 347 | + | svcs += " " + n + ":\n image: redis:7\n" | |
| 348 | + | } | |
| 349 | + | start := time.Now() | |
| 350 | + | rec, res := ctrRun(t, f, " container: ubuntu:24.04\n services:\n"+svcs, " - run: echo ok\n", func(c *Config) { | |
| 351 | + | c.Actions.Containers = ContainerTimeouts{Command: time.Minute, Pull: time.Minute, CleanupCommand: time.Minute, CleanupBudget: time.Second} | |
| 352 | + | }) | |
| 353 | + | total := time.Since(start) | |
| 354 | + | if res.Conclusion == ConclusionSuccess { | |
| 355 | + | t.Fatalf("an unfinished cleanup must be visible: %+v", res) | |
| 356 | + | } | |
| 357 | + | stop := rec.step(t, "Stop containers") | |
| 358 | + | took := stop.Completed.Sub(stop.Started) | |
| 359 | + | // The budget (1 s) and one escalation (TermAfter 200 ms in the tests, plus the kill delay). | |
| 360 | + | if took > 1900*time.Millisecond || total > 12*time.Second { | |
| 361 | + | t.Errorf("Stop containers took %s (run %s), want about the budget plus one escalation", took, total) | |
| 362 | + | } | |
| 363 | + | rms := 0 | |
| 364 | + | for _, c := range f.calls(t) { | |
| 365 | + | if c[0] == "rm" { | |
| 366 | + | rms++ | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | if rms != 1 { | |
| 370 | + | t.Errorf("%d removals started, want 1 (the budget was spent by it)", rms) | |
| 371 | + | } | |
| 372 | + | log := logOf(stop) | |
| 373 | + | if !strings.Contains(log, "##[error]The cleanup of the containers ran out of time (1s): stopped: docker rm --force ") || | |
| 374 | + | !strings.Contains(log, "ran out of time (1s): not run: docker ") { | |
| 375 | + | t.Errorf("log:\n%s", log) | |
| 376 | + | } | |
| 377 | + | if strings.Count(log, "not run: docker network rm") != 1 { | |
| 378 | + | t.Errorf("the network removal must be reported as not run once:\n%s", log) | |
| 379 | + | } | |
| 380 | + | } | |
| @@ -0,0 +1,380 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func fastTimeouts(c *Config) { | |||
| 14 | + | c.Actions.Containers = ContainerTimeouts{Command: 400 * time.Millisecond, Pull: 400 * time.Millisecond, CleanupCommand: 400 * time.Millisecond, CleanupBudget: 3 * time.Second} | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func names(l [][]string) string { | |||
| 18 | + | var v []string | |||
| 19 | + | for _, c := range l { | |||
| 20 | + | v = append(v, c[0]) | |||
| 21 | + | } | |||
| 22 | + | return strings.Join(v, " ") | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // GO-001: a docker command that stalls is stopped by its own limit, the job | |||
| 26 | + | // does not wait for its timeout, and the cleanup still finds the container | |||
| 27 | + | // that the daemon did create, by the label of the job. | |||
| 28 | + | func TestStalledCreateIsBoundedAndCleanedByLabel(t *testing.T) { | |||
| 29 | + | f := newFakeCtr(t) | |||
| 30 | + | f.set(t, "hang_create_after", "1") // created in the daemon, the CLI never returns | |||
| 31 | + | start := time.Now() | |||
| 32 | + | rec, res := ctrRun(t, f, ctrJob, " - run: echo never\n", fastTimeouts) | |||
| 33 | + | if res.Conclusion != ConclusionFailure || time.Since(start) > 20*time.Second { | |||
| 34 | + | t.Fatalf("%+v after %s", res, time.Since(start)) | |||
| 35 | + | } | |||
| 36 | + | log := logOf(rec.step(t, "Initialize containers")) | |||
| 37 | + | if !strings.Contains(log, "##[error]The docker command timed out after 400ms: create --name ") { | |||
| 38 | + | t.Errorf("init:\n%s", log) | |||
| 39 | + | } | |||
| 40 | + | if b, _ := os.ReadFile(filepath.Join(f.dir, "created")); strings.TrimSpace(string(b)) != "" { | |||
| 41 | + | t.Errorf("container left in the daemon: %q", b) | |||
| 42 | + | } | |||
| 43 | + | if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network: github_network_") { | |||
| 44 | + | t.Errorf("stop:\n%s", got) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // GO-003-R: options: may carry another --name (the runner passes it on). The | |||
| 49 | + | // container is then not named as recorded; it is found by the label. | |||
| 50 | + | func TestOverridingNameAndTimedOutCreateAreCleanedByLabel(t *testing.T) { | |||
| 51 | + | f := newFakeCtr(t) | |||
| 52 | + | f.set(t, "hang_create_after", "1") | |||
| 53 | + | rec, _ := ctrRun(t, f, " container:\n image: ubuntu:24.04\n options: --name my-own-name\n", " - run: echo never\n", fastTimeouts) | |||
| 54 | + | var ps, rm []string | |||
| 55 | + | for _, c := range f.calls(t) { | |||
| 56 | + | if c[0] == "ps" && strings.Contains(strings.Join(c, " "), "--quiet") { | |||
| 57 | + | ps = c | |||
| 58 | + | } | |||
| 59 | + | if c[0] == "rm" { | |||
| 60 | + | rm = c | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | if ps == nil || !strings.Contains(strings.Join(ps, "\n"), "label=") { | |||
| 64 | + | t.Errorf("no listing by label: %v", ps) | |||
| 65 | + | } | |||
| 66 | + | if len(rm) < 3 || !containerIDRe.MatchString(rm[2]) { | |||
| 67 | + | t.Errorf("rm by id expected: %v", rm) | |||
| 68 | + | } | |||
| 69 | + | if b, _ := os.ReadFile(filepath.Join(f.dir, "created")); strings.TrimSpace(string(b)) != "" { | |||
| 70 | + | t.Errorf("container left: %q", b) | |||
| 71 | + | } | |||
| 72 | + | _ = rec | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // GO-001: the cancellation of the job reaches every docker call of the init. | |||
| 76 | + | func TestCancelDuringInitStopsTheCommandAndCleans(t *testing.T) { | |||
| 77 | + | for _, verb := range []string{"version", "inspect", "port", "start", "ps"} { | |||
| 78 | + | f := newFakeCtr(t) | |||
| 79 | + | f.set(t, "hang_"+verb, "1") | |||
| 80 | + | cancel := NewCanceller() | |||
| 81 | + | go func() { time.Sleep(500 * time.Millisecond); cancel.Cancel(CancelUser) }() | |||
| 82 | + | start := time.Now() | |||
| 83 | + | rec, res := ctrRunC(t, f, cancel, " container: ubuntu:24.04\n services:\n s:\n image: redis:7\n", " - run: echo never\n", func(c *Config) { | |||
| 84 | + | c.Actions.Containers.Command = time.Minute | |||
| 85 | + | }) | |||
| 86 | + | if res.Conclusion != ConclusionCancelled || time.Since(start) > 15*time.Second { | |||
| 87 | + | t.Fatalf("%s: %+v after %s %v", verb, res, time.Since(start), rec.names()) | |||
| 88 | + | } | |||
| 89 | + | if got := rec.concl("Stop containers"); got != ConclusionSuccess { | |||
| 90 | + | t.Errorf("%s: Stop containers %s", verb, got) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // GO-002: one removal that stalls does not keep the others and the network from | |||
| 96 | + | // being removed; the logs of a service never prevent its removal; the | |||
| 97 | + | // failures are reported by the step. | |||
| 98 | + | func TestCleanupAttemptsEverythingWithItsOwnBudget(t *testing.T) { | |||
| 99 | + | f := newFakeCtr(t) | |||
| 100 | + | f.set(t, "hang_logs", "1") | |||
| 101 | + | f.set(t, "hang_for", "rm --force "+strings.Repeat("0", 63)+"1") // the rm of the job container (first id) | |||
| 102 | + | cancel := NewCanceller() | |||
| 103 | + | rec, res := ctrRunC(t, f, cancel, " container: ubuntu:24.04\n services:\n a:\n image: redis:7\n b:\n image: redis:7\n", " - run: echo ok\n", fastTimeouts) | |||
| 104 | + | if res.Conclusion == ConclusionSuccess { | |||
| 105 | + | t.Fatalf("a failed cleanup must be visible: %+v", res) | |||
| 106 | + | } | |||
| 107 | + | stop := logOf(rec.step(t, "Stop containers")) | |||
| 108 | + | for _, w := range []string{"Stop and remove container: ", "Print service container logs: ", "Remove container network: github_network_", "##[error]The docker command timed out after 400ms: rm --force ", "##[warning]Docker logs failed: The docker command timed out"} { | |||
| 109 | + | if !strings.Contains(stop, w) { | |||
| 110 | + | t.Errorf("stop lacks %q:\n%s", w, stop) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | rms, nets := 0, 0 | |||
| 114 | + | for _, c := range f.calls(t) { | |||
| 115 | + | if c[0] == "rm" { | |||
| 116 | + | rms++ | |||
| 117 | + | } | |||
| 118 | + | if c[0] == "network" && len(c) > 1 && c[1] == "rm" { | |||
| 119 | + | nets++ | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | // 3 removals, and the fallback of the job tries the one that timed out again. | |||
| 123 | + | if rms < 3 || rms > 4 || nets != 1 { | |||
| 124 | + | t.Errorf("rm=%d network rm=%d (want 3 or 4 and 1):\n%s", rms, nets, stop) | |||
| 125 | + | } | |||
| 126 | + | if rec.concl("Stop containers") != ConclusionFailure { | |||
| 127 | + | t.Errorf("Stop containers: %s", rec.concl("Stop containers")) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // GO-003: a shutdown skips the queued post steps (StepsRunner); the job-level | |||
| 132 | + | // fallback removes the containers and the network all the same, and does not | |||
| 133 | + | // run twice what "Stop containers" did. | |||
| 134 | + | func TestShutdownStillCleansUpThroughTheFallback(t *testing.T) { | |||
| 135 | + | f := newFakeCtr(t) | |||
| 136 | + | f.set(t, "hang_exec", "1") | |||
| 137 | + | cancel := NewCanceller() | |||
| 138 | + | go func() { time.Sleep(600 * time.Millisecond); cancel.Cancel(CancelShutdown) }() | |||
| 139 | + | rec, res := ctrRunC(t, f, cancel, ctrJob, " - name: long\n run: sleep 60\n", nil) | |||
| 140 | + | _ = res | |||
| 141 | + | if rec.concl("Stop containers") == ConclusionSuccess { | |||
| 142 | + | t.Skip("the runner ran Stop containers on shutdown") | |||
| 143 | + | } | |||
| 144 | + | got := names(f.calls(t)) | |||
| 145 | + | if !strings.HasSuffix(got, "rm network") { | |||
| 146 | + | t.Errorf("fallback did not remove the container and the network: %s", got) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func TestFallbackDoesNothingWhenStopContainersDidItAll(t *testing.T) { | |||
| 151 | + | f := newFakeCtr(t) | |||
| 152 | + | _, res := ctrRun(t, f, ctrJob, " - run: echo ok\n", nil) | |||
| 153 | + | if res.Conclusion != ConclusionSuccess { | |||
| 154 | + | t.Fatalf("%+v", res) | |||
| 155 | + | } | |||
| 156 | + | n := 0 | |||
| 157 | + | for _, c := range f.calls(t) { | |||
| 158 | + | if c[0] == "rm" || (c[0] == "network" && len(c) > 1 && c[1] == "rm") { | |||
| 159 | + | n++ | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | if n != 2 { | |||
| 163 | + | t.Errorf("%d removals, want exactly 2 (container, network): %s", n, names(f.calls(t))) | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // GO-003: a panic of the job still cleans up. | |||
| 168 | + | func TestPanicStillCleansUp(t *testing.T) { | |||
| 169 | + | f := newFakeCtr(t) | |||
| 170 | + | defer func() { | |||
| 171 | + | _ = recover() | |||
| 172 | + | got := names(f.calls(t)) | |||
| 173 | + | if !strings.Contains(got, "rm") || !strings.HasSuffix(got, "rm network") { | |||
| 174 | + | t.Errorf("calls after the panic: %s", got) | |||
| 175 | + | } | |||
| 176 | + | }() | |||
| 177 | + | ctrRun(t, f, ctrJob, " - run: echo ok\n", func(c *Config) { | |||
| 178 | + | c.Reporter = &panicOnStep{Reporter: c.Reporter, name: "Run echo ok"} | |||
| 179 | + | }) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | type panicOnStep struct { | |||
| 183 | + | Reporter | |||
| 184 | + | name string | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func (p *panicOnStep) StepStarted(n int, name string, at time.Time) { | |||
| 188 | + | if name == p.name { | |||
| 189 | + | panic("boom") | |||
| 190 | + | } | |||
| 191 | + | p.Reporter.StepStarted(n, name, at) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // GO-007: the credentials directory is removed even when the pull fails; a | |||
| 195 | + | // removal that fails is reported and left to the fallback. | |||
| 196 | + | func TestRegistryConfigRemovedAfterFailedPullAndRemovalFailureIsReported(t *testing.T) { | |||
| 197 | + | f := newFakeCtr(t) | |||
| 198 | + | f.set(t, "pull_fail", "9") | |||
| 199 | + | var temp string | |||
| 200 | + | rec, _ := ctrRun(t, f, " container:\n image: ghcr.io/o/i:1\n credentials:\n username: u\n password: pw-secret\n", " - run: echo\n", func(c *Config) { temp = c.Layout.Temp }) | |||
| 201 | + | if m, _ := filepath.Glob(filepath.Join(temp, ".docker_*")); len(m) != 0 { | |||
| 202 | + | t.Errorf("config left after a failed pull: %v", m) | |||
| 203 | + | } | |||
| 204 | + | _ = rec | |||
| 205 | + | ||||
| 206 | + | if os.Geteuid() == 0 { | |||
| 207 | + | t.Skip("root removes anything") | |||
| 208 | + | } | |||
| 209 | + | f2 := newFakeCtr(t) | |||
| 210 | + | f2.set(t, "lock_cfg", "1") | |||
| 211 | + | rec2, res2 := ctrRun(t, f2, " container:\n image: ghcr.io/o/i:1\n credentials:\n username: u\n password: pw-secret\n", " - run: echo\n", func(c *Config) { temp = c.Layout.Temp }) | |||
| 212 | + | defer func() { | |||
| 213 | + | m, _ := filepath.Glob(filepath.Join(temp, ".docker_*", "sub")) | |||
| 214 | + | for _, d := range m { | |||
| 215 | + | _ = os.Chmod(d, 0o755) | |||
| 216 | + | } | |||
| 217 | + | }() | |||
| 218 | + | if res2.Conclusion != ConclusionFailure { | |||
| 219 | + | t.Fatalf("%+v", res2) | |||
| 220 | + | } | |||
| 221 | + | if got := logOf(rec2.step(t, "Initialize containers")); !strings.Contains(got, "##[error]Failed to remove directory containing Docker client credentials:") { | |||
| 222 | + | t.Errorf("init:\n%s", got) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // GO-001-R: the health inspection takes the cancellation too. | |||
| 227 | + | func TestCancelDuringHealthInspection(t *testing.T) { | |||
| 228 | + | f := newFakeCtr(t) | |||
| 229 | + | f.set(t, "hang_inspect", "1") | |||
| 230 | + | cancel := NewCanceller() | |||
| 231 | + | go func() { time.Sleep(500 * time.Millisecond); cancel.Cancel(CancelUser) }() | |||
| 232 | + | start := time.Now() | |||
| 233 | + | rec, res := ctrRunC(t, f, cancel, " services:\n s:\n image: redis:7\n", " - run: echo never\n", func(c *Config) { | |||
| 234 | + | c.Actions.Containers.Command = time.Minute | |||
| 235 | + | }) | |||
| 236 | + | if res.Conclusion != ConclusionCancelled || time.Since(start) > 15*time.Second { | |||
| 237 | + | t.Fatalf("%+v after %s %v", res, time.Since(start), rec.names()) | |||
| 238 | + | } | |||
| 239 | + | var health bool | |||
| 240 | + | for _, c := range f.calls(t) { | |||
| 241 | + | if c[0] == "inspect" && strings.Contains(strings.Join(c, " "), "Healthcheck") { | |||
| 242 | + | health = true | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | if !health { | |||
| 246 | + | t.Errorf("the health inspection was not reached: %s", names(f.calls(t))) | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | // GO-002-R: a failed removal is not a removal. It stays pending and the job's | |||
| 251 | + | // fallback tries again; "No such container" is a removal. | |||
| 252 | + | func TestFailedRemovalStaysPendingAndAbsenceCounts(t *testing.T) { | |||
| 253 | + | count := func(f *fakeCtr, verb string) int { | |||
| 254 | + | n := 0 | |||
| 255 | + | for _, c := range f.calls(t) { | |||
| 256 | + | if c[0] == verb || (verb == "network rm" && c[0] == "network" && len(c) > 1 && c[1] == "rm") { | |||
| 257 | + | n++ | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | return n | |||
| 261 | + | } | |||
| 262 | + | f := newFakeCtr(t) | |||
| 263 | + | f.set(t, "rm_fail", "1") | |||
| 264 | + | f.set(t, "netrm_fail", "1") | |||
| 265 | + | rec, _ := ctrRun(t, f, ctrJob, " - run: echo ok\n", fastTimeouts) | |||
| 266 | + | if count(f, "rm") != 2 || count(f, "network rm") != 2 { | |||
| 267 | + | t.Errorf("rm=%d network rm=%d, want the fallback to retry both once", count(f, "rm"), count(f, "network rm")) | |||
| 268 | + | } | |||
| 269 | + | if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Docker rm fail with exit code 1") || !strings.Contains(got, "Docker network rm fail with exit code 1") { | |||
| 270 | + | t.Errorf("warnings:\n%s", got) | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | f2 := newFakeCtr(t) | |||
| 274 | + | f2.set(t, "rm_absent", "1") | |||
| 275 | + | f2.set(t, "netrm_absent", "1") | |||
| 276 | + | rec2, res2 := ctrRun(t, f2, ctrJob, " - run: echo ok\n", fastTimeouts) | |||
| 277 | + | if res2.Conclusion != ConclusionSuccess || count(f2, "rm") != 1 || count(f2, "network rm") != 1 { | |||
| 278 | + | t.Errorf("absent: %+v rm=%d network rm=%d\n%s", res2, count(f2, "rm"), count(f2, "network rm"), logOf(rec2.step(t, "Stop containers"))) | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | // GO-007-R: the credentials directory is registered before the login; a panic | |||
| 283 | + | // in the login or in the pull leaves nothing behind. | |||
| 284 | + | func TestPanicDuringLoginOrPullLeavesNoCredentials(t *testing.T) { | |||
| 285 | + | for _, during := range []string{" login ", " pull "} { | |||
| 286 | + | f := newFakeCtr(t) | |||
| 287 | + | var temp string | |||
| 288 | + | func() { | |||
| 289 | + | defer func() { _ = recover() }() | |||
| 290 | + | ctrRun(t, f, " container:\n image: ghcr.io/o/i:1\n credentials:\n username: u\n password: pw-secret\n", " - run: echo\n", func(c *Config) { | |||
| 291 | + | temp = c.Layout.Temp | |||
| 292 | + | c.Reporter = &panicOnLog{Reporter: c.Reporter, contains: during} | |||
| 293 | + | }) | |||
| 294 | + | }() | |||
| 295 | + | if m, _ := filepath.Glob(filepath.Join(temp, ".docker_*")); len(m) != 0 { | |||
| 296 | + | t.Errorf("panic during%s: credentials left: %v", during, m) | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | type panicOnLog struct { | |||
| 302 | + | Reporter | |||
| 303 | + | contains string | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func (p *panicOnLog) Log(n int, at time.Time, line string) { | |||
| 307 | + | if strings.HasPrefix(line, "##[command]") && strings.Contains(line, p.contains) { | |||
| 308 | + | panic("boom") | |||
| 309 | + | } | |||
| 310 | + | p.Reporter.Log(n, at, line) | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | // GO-004-R3: a variable name that holds "=" or NUL, or is empty, is refused | |||
| 314 | + | // for create and exec, before docker is called. | |||
| 315 | + | func TestInvalidEnvNamesAreRefused(t *testing.T) { | |||
| 316 | + | if checkEnvNames([]string{"A", "b_c", "A.B", "A-B"}) != nil { | |||
| 317 | + | t.Error("valid names refused") | |||
| 318 | + | } | |||
| 319 | + | for _, k := range []string{"", "A=B", "HTTP_PROXY=http://x/?y", "A\x00B", "="} { | |||
| 320 | + | if err := checkEnvNames([]string{"OK", k}); err == nil || !strings.Contains(err.Error(), "Invalid environment variable name") { | |||
| 321 | + | t.Errorf("%q: %v", k, err) | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | f := newFakeCtr(t) | |||
| 325 | + | rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n \"HTTP_PROXY=http://x/?y\": v\n", " - run: echo never\n", nil) | |||
| 326 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "Set up job")), "##[error]Invalid environment variable name 'HTTP_PROXY=http://x/?y'") { | |||
| 327 | + | t.Errorf("container env: %+v", res) | |||
| 328 | + | } | |||
| 329 | + | rec, res = ctrRun(t, f, ctrJob, " - name: s\n env:\n \"HTTP_PROXY=http://x/?y\": v\n run: echo never\n", nil) | |||
| 330 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "s")), "##[error]Invalid environment variable name 'HTTP_PROXY=http://x/?y'") { | |||
| 331 | + | t.Errorf("step env: %+v\n%s", res, logOf(rec.step(t, "s"))) | |||
| 332 | + | } | |||
| 333 | + | if n := len(f.execCalls(t)); n != 0 { | |||
| 334 | + | t.Errorf("docker exec called %d times", n) | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | // GO-008: the budget of the whole cleanup is checked before every command. With | |||
| 339 | + | // a budget spent by the first stalled removal, the others (and the retry of the | |||
| 340 | + | // fallback) are not started: they would each cost a signal escalation. | |||
| 341 | + | func TestCleanupBudgetIsCheckedBeforeEveryCommand(t *testing.T) { | |||
| 342 | + | f := newFakeCtr(t) | |||
| 343 | + | f.set(t, "hang_for", "rm --force") | |||
| 344 | + | f.set(t, "hang_ignore", "1") // SIGINT is ignored: a started command costs TermAfter | |||
| 345 | + | svcs := "" | |||
| 346 | + | for _, n := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} { | |||
| 347 | + | svcs += " " + n + ":\n image: redis:7\n" | |||
| 348 | + | } | |||
| 349 | + | start := time.Now() | |||
| 350 | + | rec, res := ctrRun(t, f, " container: ubuntu:24.04\n services:\n"+svcs, " - run: echo ok\n", func(c *Config) { | |||
| 351 | + | c.Actions.Containers = ContainerTimeouts{Command: time.Minute, Pull: time.Minute, CleanupCommand: time.Minute, CleanupBudget: time.Second} | |||
| 352 | + | }) | |||
| 353 | + | total := time.Since(start) | |||
| 354 | + | if res.Conclusion == ConclusionSuccess { | |||
| 355 | + | t.Fatalf("an unfinished cleanup must be visible: %+v", res) | |||
| 356 | + | } | |||
| 357 | + | stop := rec.step(t, "Stop containers") | |||
| 358 | + | took := stop.Completed.Sub(stop.Started) | |||
| 359 | + | // The budget (1 s) and one escalation (TermAfter 200 ms in the tests, plus the kill delay). | |||
| 360 | + | if took > 1900*time.Millisecond || total > 12*time.Second { | |||
| 361 | + | t.Errorf("Stop containers took %s (run %s), want about the budget plus one escalation", took, total) | |||
| 362 | + | } | |||
| 363 | + | rms := 0 | |||
| 364 | + | for _, c := range f.calls(t) { | |||
| 365 | + | if c[0] == "rm" { | |||
| 366 | + | rms++ | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | if rms != 1 { | |||
| 370 | + | t.Errorf("%d removals started, want 1 (the budget was spent by it)", rms) | |||
| 371 | + | } | |||
| 372 | + | log := logOf(stop) | |||
| 373 | + | if !strings.Contains(log, "##[error]The cleanup of the containers ran out of time (1s): stopped: docker rm --force ") || | |||
| 374 | + | !strings.Contains(log, "ran out of time (1s): not run: docker ") { | |||
| 375 | + | t.Errorf("log:\n%s", log) | |||
| 376 | + | } | |||
| 377 | + | if strings.Count(log, "not run: docker network rm") != 1 { | |||
| 378 | + | t.Errorf("the network removal must be reported as not run once:\n%s", log) | |||
| 379 | + | } | |||
| 380 | + | } | |||
A internal/executor/containers_docker.go +288 -0
| @@ -0,0 +1,288 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | "sync/atomic" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Helpers for the docker commands of the container steps. The runner builds a | |
| 14 | + | // command line as a string, echoes it (ExecutionContext.Command, "##[command]") | |
| 15 | + | // and lets .NET split it into arguments. Here the echoed line and the | |
| 16 | + | // arguments are built together (dockerPart), so that nothing a workflow wrote | |
| 17 | + | // can end up as an argument of its own, except through `options:` which the | |
| 18 | + | // runner splits too (ParseArguments). | |
| 19 | + | ||
| 20 | + | // quotedOpt is `flag "value"` in the echoed line and two arguments. | |
| 21 | + | func quotedOpt(flag, value string) dockerPart { | |
| 22 | + | return dockerPart{line: flag + ` "` + value + `"`, args: []string{flag, value}} | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // eqOpt is `flag="value"` (docker inspect --format="..."). | |
| 26 | + | func eqOpt(flag, value string) dockerPart { | |
| 27 | + | return dockerPart{line: flag + `="` + value + `"`, args: []string{flag + "=" + value}} | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // Accepted risk (review of the pull request, GO-004-R1/R2): a workflow can set | |
| 31 | + | // DOCKER_*, proxy, PATH or SSH_AUTH_SOCK variables for its container, and the | |
| 32 | + | // ones listed here travel as NAME=value on the arguments of the docker command, | |
| 33 | + | // which the docker CLI reads. The CLI runs in the job's own sandbox, which is | |
| 34 | + | // the containment boundary: the job can already run docker with any | |
| 35 | + | // environment and read its own process arguments, and the C# runner has the | |
| 36 | + | // same exposure. Values still go through the masker in the logs. | |
| 37 | + | // | |
| 38 | + | // cliSensitive are the variables the docker CLI itself reads (its daemon, its | |
| 39 | + | // configuration, its proxy and TLS settings, the loader): handing one of them | |
| 40 | + | // to the process of docker as the value of a container variable would change | |
| 41 | + | // what the CLI does, so these are the only values that stay in the arguments. | |
| 42 | + | func cliSensitive(name string) bool { | |
| 43 | + | u := strings.ToUpper(name) | |
| 44 | + | switch u { | |
| 45 | + | case "HOME", "TMPDIR", "USER", "LOGNAME", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", | |
| 46 | + | "GODEBUG", "GOMAXPROCS", "GOGC", "GOMEMLIMIT", "GOTRACEBACK", "KUBECONFIG", ProcessTrackingID: | |
| 47 | + | return true | |
| 48 | + | } | |
| 49 | + | for _, pre := range []string{"DOCKER_", "XDG_", "LD_", "SSL_CERT", "BUILDKIT_", "COMPOSE_", "CONTAINERD_", "REGISTRY_"} { | |
| 50 | + | if strings.HasPrefix(u, pre) { | |
| 51 | + | return true | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | return false | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // envForCLI is how one container variable reaches `docker create`/`exec`: | |
| 58 | + | // "-e NAME" with the value in the environment of the docker process, so that | |
| 59 | + | // it is on no command line (visible to every user of the sandbox in /proc) — | |
| 60 | + | // except for the variables the CLI reads itself, unless their value is the one | |
| 61 | + | // the process has anyway. An empty value is set explicitly, so that the CLI | |
| 62 | + | // cannot inherit one from the executor. echo is the text the runner logs. | |
| 63 | + | func (j *job) envForCLI(key, value, echo string) dockerPart { | |
| 64 | + | if cliSensitive(key) { | |
| 65 | + | if v, ok := envLookup(j.baseEnv, key); ok && v == value { | |
| 66 | + | return dockerPart{line: echo, args: []string{"-e", key}} | |
| 67 | + | } | |
| 68 | + | return dockerPart{line: echo, args: []string{"-e", key + "=" + value}} | |
| 69 | + | } | |
| 70 | + | return dockerPart{line: echo, args: []string{"-e", key}, env: []string{key + "=" + value}} | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func envLookup(env []string, key string) (string, bool) { | |
| 74 | + | for i := len(env) - 1; i >= 0; i-- { | |
| 75 | + | if k, v, ok := strings.Cut(env[i], "="); ok && k == key { | |
| 76 | + | return v, true | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | return "", false | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // checkEnvNames refuses the names `docker -e NAME` would take for NAME=value: | |
| 83 | + | // an empty one, or one holding "=" or NUL. Such a key would let a workflow set | |
| 84 | + | // a variable of the docker CLI itself ("HTTP_PROXY=http://x/?y") past the exact | |
| 85 | + | // name check of cliSensitive. The runner passes them on and docker decides; a | |
| 86 | + | // name like that is never valid in the environment of a process. | |
| 87 | + | func checkEnvNames(keys []string) error { | |
| 88 | + | for _, k := range keys { | |
| 89 | + | if k == "" || strings.ContainsAny(k, "=\x00") { | |
| 90 | + | return ghErrorf("Invalid environment variable name '%s': a name cannot be empty or contain '=' or NUL", strings.ReplaceAll(k, "\x00", `\0`)) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | return nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // envKV is DockerUtil.CreateEscapedOption("-e", key, value), or ("-e", key) | |
| 97 | + | // for an empty value, as the runner echoes it. | |
| 98 | + | func (j *job) envKV(key, value string) dockerPart { | |
| 99 | + | if value == "" { | |
| 100 | + | return j.envForCLI(key, "", "-e "+escapeDockerArg(key)) | |
| 101 | + | } | |
| 102 | + | return j.envForCLI(key, value, "-e "+escapeDockerArg(key+"="+value)) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // mountOpt is the -v option of DockerCommandManager.DockerCreate (:164-185). | |
| 106 | + | func mountOpt(m mount) dockerPart { | |
| 107 | + | esc := func(s string) string { return strings.ReplaceAll(s, `"`, `\"`) } | |
| 108 | + | var p dockerPart | |
| 109 | + | if m.source == "" { | |
| 110 | + | p = dockerPart{line: `-v "` + esc(m.target) + `"`, args: []string{"-v", m.target}} | |
| 111 | + | } else { | |
| 112 | + | p = dockerPart{line: `-v "` + esc(m.source) + `":"` + esc(m.target) + `"`, args: []string{"-v", m.source + ":" + m.target}} | |
| 113 | + | } | |
| 114 | + | if m.readOnly { | |
| 115 | + | p.line += ":ro" | |
| 116 | + | p.args[1] += ":ro" | |
| 117 | + | } | |
| 118 | + | return p | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // optionsParts is the user's `options:` text: echoed as written, split into | |
| 122 | + | // arguments like .NET splits a command line. An empty text still leaves its | |
| 123 | + | // place in the line (the runner joins an empty string: two spaces). | |
| 124 | + | func optionsParts(options string) dockerPart { | |
| 125 | + | return dockerPart{line: options, args: ParseArguments(options)} | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // dockerArgString is the arguments of the command as ProcessExitCodeException | |
| 129 | + | // words them: the echoed line without the path of docker. | |
| 130 | + | func (j *job) dockerArgString(c dockerCmd) string { | |
| 131 | + | return strings.TrimPrefix(c.line, j.dockerPath()+" ") | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // exitError is ProcessExitCodeException: docker ran and said no. | |
| 135 | + | type exitError struct { | |
| 136 | + | code int | |
| 137 | + | msg string | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (e *exitError) Error() string { return e.msg } | |
| 141 | + | ||
| 142 | + | // Time limits of the docker commands of the container steps. The runner has | |
| 143 | + | // none (its commands end with the job); a sandbox whose daemon stalls must not | |
| 144 | + | // hold the job until its timeout, nor the cleanup forever. | |
| 145 | + | type ContainerTimeouts struct { | |
| 146 | + | // Command bounds one docker command (default 2 minutes); Pull bounds a | |
| 147 | + | // docker pull (30 minutes). | |
| 148 | + | Command, Pull time.Duration | |
| 149 | + | // CleanupCommand bounds one command of "Stop containers" (30 seconds) and | |
| 150 | + | // CleanupBudget the whole cleanup (3 minutes), whatever happens to the job. | |
| 151 | + | CleanupCommand, CleanupBudget time.Duration | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (j *job) timeouts() ContainerTimeouts { | |
| 155 | + | t := j.cfg.Actions.Containers | |
| 156 | + | def := func(v *time.Duration, d time.Duration) { | |
| 157 | + | if *v <= 0 { | |
| 158 | + | *v = d | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | def(&t.Command, 2*time.Minute) | |
| 162 | + | def(&t.Pull, 30*time.Minute) | |
| 163 | + | def(&t.CleanupCommand, 30*time.Second) | |
| 164 | + | def(&t.CleanupBudget, 3*time.Minute) | |
| 165 | + | return t | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // cmdTimeoutError is a docker command stopped by its time limit. | |
| 169 | + | type cmdTimeoutError struct { | |
| 170 | + | cmd string | |
| 171 | + | d time.Duration | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func (e *cmdTimeoutError) Error() string { | |
| 175 | + | return fmt.Sprintf("The docker command timed out after %s: %s", e.d, e.cmd) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // bound returns a channel that closes when cancel does (nil never) or after d, | |
| 179 | + | // and a function that tells whether it was the time, with the one that | |
| 180 | + | // releases the timer. | |
| 181 | + | func bound(cancel <-chan struct{}, d time.Duration) (ch <-chan struct{}, timedOut func() bool, stop func()) { | |
| 182 | + | out := make(chan struct{}) | |
| 183 | + | var once sync.Once | |
| 184 | + | var fired atomic.Bool | |
| 185 | + | closeOut := func() { once.Do(func() { close(out) }) } | |
| 186 | + | t := time.AfterFunc(d, func() { fired.Store(true); closeOut() }) | |
| 187 | + | done := make(chan struct{}) | |
| 188 | + | if cancel != nil { | |
| 189 | + | go func() { | |
| 190 | + | select { | |
| 191 | + | case <-cancel: | |
| 192 | + | closeOut() | |
| 193 | + | case <-done: | |
| 194 | + | } | |
| 195 | + | }() | |
| 196 | + | } | |
| 197 | + | return out, fired.Load, func() { t.Stop(); close(done) } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // dockerOpts are the options of dockerDo. | |
| 201 | + | type dockerOpts struct { | |
| 202 | + | // s is the step whose log gets the command and its output; nil runs the | |
| 203 | + | // command silently (the fallback cleanup). | |
| 204 | + | s *stepRun | |
| 205 | + | // cancel stops the command (the escalation of runProcess); always set. | |
| 206 | + | cancel <-chan struct{} | |
| 207 | + | // timeout bounds the command; 0 means the command limit. | |
| 208 | + | timeout time.Duration | |
| 209 | + | // quiet keeps the echoed command and the output out of the log. | |
| 210 | + | quiet bool | |
| 211 | + | // all logs the empty lines too (the overloads that return a code). | |
| 212 | + | all bool | |
| 213 | + | // errToOut also collects the lines of stderr. | |
| 214 | + | errToOut bool | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // do runs the command and returns the non-empty lines of its stdout (and | |
| 218 | + | // stderr with errToOut), its exit code and an error for what is not an exit | |
| 219 | + | // code: a start failure, a cancellation, a time limit. | |
| 220 | + | func (j *job) dockerDo(c dockerCmd, o dockerOpts) (lines []string, code int, err error) { | |
| 221 | + | log := o.s != nil && !o.quiet | |
| 222 | + | if log { | |
| 223 | + | j.out(o.s.num, "##[command]"+c.line) | |
| 224 | + | } | |
| 225 | + | d := o.timeout | |
| 226 | + | if d <= 0 { | |
| 227 | + | d = j.timeouts().Command | |
| 228 | + | } | |
| 229 | + | cancel, timedOut, stop := bound(o.cancel, d) | |
| 230 | + | defer stop() | |
| 231 | + | var mu sync.Mutex | |
| 232 | + | res, perr := runProcess(procSpec{ | |
| 233 | + | Argv: c.argv, Dir: dirOrCwd(j.githubValue("workspace")), Env: append(j.processEnv(newOrdered()), c.env...), Credential: j.cfg.Credential, | |
| 234 | + | OnLine: func(l string) { | |
| 235 | + | mu.Lock() | |
| 236 | + | if l != "" { | |
| 237 | + | lines = append(lines, l) | |
| 238 | + | } | |
| 239 | + | mu.Unlock() | |
| 240 | + | if log && (o.all || l != "") { | |
| 241 | + | j.write(o.s.num, l) | |
| 242 | + | } | |
| 243 | + | }, | |
| 244 | + | OnErrLine: func(l string) { | |
| 245 | + | if o.errToOut && l != "" { | |
| 246 | + | mu.Lock() | |
| 247 | + | lines = append(lines, l) | |
| 248 | + | mu.Unlock() | |
| 249 | + | } | |
| 250 | + | if log && (o.all || l != "") { | |
| 251 | + | j.write(o.s.num, l) | |
| 252 | + | } | |
| 253 | + | }, | |
| 254 | + | Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal, | |
| 255 | + | }) | |
| 256 | + | mu.Lock() | |
| 257 | + | defer mu.Unlock() | |
| 258 | + | switch { | |
| 259 | + | case perr != nil: | |
| 260 | + | return lines, 0, perr | |
| 261 | + | case res.Cancelled && timedOut(): | |
| 262 | + | return lines, res.ExitCode, &cmdTimeoutError{cmd: j.dockerArgString(c), d: d} | |
| 263 | + | case res.Cancelled: | |
| 264 | + | return lines, res.ExitCode, errCancelled | |
| 265 | + | } | |
| 266 | + | return lines, res.ExitCode, nil | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // dockerList is the overload of ExecuteDockerCommandAsync that returns the | |
| 270 | + | // output (DockerCommandManager.cs:462-492): non-empty lines of stdout are kept | |
| 271 | + | // and logged, non-empty lines of stderr are logged, and a non-zero exit is an | |
| 272 | + | // error. | |
| 273 | + | func (j *job) dockerList(s *stepRun, c dockerCmd, cancel <-chan struct{}) ([]string, error) { | |
| 274 | + | lines, code, err := j.dockerDo(c, dockerOpts{s: s, cancel: cancel}) | |
| 275 | + | if err != nil { | |
| 276 | + | return lines, err | |
| 277 | + | } | |
| 278 | + | if code != 0 { | |
| 279 | + | return lines, &exitError{code: code, msg: fmt.Sprintf("Exit code %d returned from process: file name '%s', arguments '%s'.", code, j.dockerPath(), j.dockerArgString(c))} | |
| 280 | + | } | |
| 281 | + | return lines, nil | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // dockerCode is the overload that logs every line and returns the exit code. | |
| 285 | + | func (j *job) dockerCode(s *stepRun, c dockerCmd, cancel <-chan struct{}) (int, error) { | |
| 286 | + | _, code, err := j.dockerDo(c, dockerOpts{s: s, cancel: cancel, all: true}) | |
| 287 | + | return code, err | |
| 288 | + | } | |
| @@ -0,0 +1,288 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // Helpers for the docker commands of the container steps. The runner builds a | |||
| 14 | + | // command line as a string, echoes it (ExecutionContext.Command, "##[command]") | |||
| 15 | + | // and lets .NET split it into arguments. Here the echoed line and the | |||
| 16 | + | // arguments are built together (dockerPart), so that nothing a workflow wrote | |||
| 17 | + | // can end up as an argument of its own, except through `options:` which the | |||
| 18 | + | // runner splits too (ParseArguments). | |||
| 19 | + | ||||
| 20 | + | // quotedOpt is `flag "value"` in the echoed line and two arguments. | |||
| 21 | + | func quotedOpt(flag, value string) dockerPart { | |||
| 22 | + | return dockerPart{line: flag + ` "` + value + `"`, args: []string{flag, value}} | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // eqOpt is `flag="value"` (docker inspect --format="..."). | |||
| 26 | + | func eqOpt(flag, value string) dockerPart { | |||
| 27 | + | return dockerPart{line: flag + `="` + value + `"`, args: []string{flag + "=" + value}} | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // Accepted risk (review of the pull request, GO-004-R1/R2): a workflow can set | |||
| 31 | + | // DOCKER_*, proxy, PATH or SSH_AUTH_SOCK variables for its container, and the | |||
| 32 | + | // ones listed here travel as NAME=value on the arguments of the docker command, | |||
| 33 | + | // which the docker CLI reads. The CLI runs in the job's own sandbox, which is | |||
| 34 | + | // the containment boundary: the job can already run docker with any | |||
| 35 | + | // environment and read its own process arguments, and the C# runner has the | |||
| 36 | + | // same exposure. Values still go through the masker in the logs. | |||
| 37 | + | // | |||
| 38 | + | // cliSensitive are the variables the docker CLI itself reads (its daemon, its | |||
| 39 | + | // configuration, its proxy and TLS settings, the loader): handing one of them | |||
| 40 | + | // to the process of docker as the value of a container variable would change | |||
| 41 | + | // what the CLI does, so these are the only values that stay in the arguments. | |||
| 42 | + | func cliSensitive(name string) bool { | |||
| 43 | + | u := strings.ToUpper(name) | |||
| 44 | + | switch u { | |||
| 45 | + | case "HOME", "TMPDIR", "USER", "LOGNAME", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", | |||
| 46 | + | "GODEBUG", "GOMAXPROCS", "GOGC", "GOMEMLIMIT", "GOTRACEBACK", "KUBECONFIG", ProcessTrackingID: | |||
| 47 | + | return true | |||
| 48 | + | } | |||
| 49 | + | for _, pre := range []string{"DOCKER_", "XDG_", "LD_", "SSL_CERT", "BUILDKIT_", "COMPOSE_", "CONTAINERD_", "REGISTRY_"} { | |||
| 50 | + | if strings.HasPrefix(u, pre) { | |||
| 51 | + | return true | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | return false | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // envForCLI is how one container variable reaches `docker create`/`exec`: | |||
| 58 | + | // "-e NAME" with the value in the environment of the docker process, so that | |||
| 59 | + | // it is on no command line (visible to every user of the sandbox in /proc) — | |||
| 60 | + | // except for the variables the CLI reads itself, unless their value is the one | |||
| 61 | + | // the process has anyway. An empty value is set explicitly, so that the CLI | |||
| 62 | + | // cannot inherit one from the executor. echo is the text the runner logs. | |||
| 63 | + | func (j *job) envForCLI(key, value, echo string) dockerPart { | |||
| 64 | + | if cliSensitive(key) { | |||
| 65 | + | if v, ok := envLookup(j.baseEnv, key); ok && v == value { | |||
| 66 | + | return dockerPart{line: echo, args: []string{"-e", key}} | |||
| 67 | + | } | |||
| 68 | + | return dockerPart{line: echo, args: []string{"-e", key + "=" + value}} | |||
| 69 | + | } | |||
| 70 | + | return dockerPart{line: echo, args: []string{"-e", key}, env: []string{key + "=" + value}} | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func envLookup(env []string, key string) (string, bool) { | |||
| 74 | + | for i := len(env) - 1; i >= 0; i-- { | |||
| 75 | + | if k, v, ok := strings.Cut(env[i], "="); ok && k == key { | |||
| 76 | + | return v, true | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | return "", false | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // checkEnvNames refuses the names `docker -e NAME` would take for NAME=value: | |||
| 83 | + | // an empty one, or one holding "=" or NUL. Such a key would let a workflow set | |||
| 84 | + | // a variable of the docker CLI itself ("HTTP_PROXY=http://x/?y") past the exact | |||
| 85 | + | // name check of cliSensitive. The runner passes them on and docker decides; a | |||
| 86 | + | // name like that is never valid in the environment of a process. | |||
| 87 | + | func checkEnvNames(keys []string) error { | |||
| 88 | + | for _, k := range keys { | |||
| 89 | + | if k == "" || strings.ContainsAny(k, "=\x00") { | |||
| 90 | + | return ghErrorf("Invalid environment variable name '%s': a name cannot be empty or contain '=' or NUL", strings.ReplaceAll(k, "\x00", `\0`)) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | return nil | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // envKV is DockerUtil.CreateEscapedOption("-e", key, value), or ("-e", key) | |||
| 97 | + | // for an empty value, as the runner echoes it. | |||
| 98 | + | func (j *job) envKV(key, value string) dockerPart { | |||
| 99 | + | if value == "" { | |||
| 100 | + | return j.envForCLI(key, "", "-e "+escapeDockerArg(key)) | |||
| 101 | + | } | |||
| 102 | + | return j.envForCLI(key, value, "-e "+escapeDockerArg(key+"="+value)) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // mountOpt is the -v option of DockerCommandManager.DockerCreate (:164-185). | |||
| 106 | + | func mountOpt(m mount) dockerPart { | |||
| 107 | + | esc := func(s string) string { return strings.ReplaceAll(s, `"`, `\"`) } | |||
| 108 | + | var p dockerPart | |||
| 109 | + | if m.source == "" { | |||
| 110 | + | p = dockerPart{line: `-v "` + esc(m.target) + `"`, args: []string{"-v", m.target}} | |||
| 111 | + | } else { | |||
| 112 | + | p = dockerPart{line: `-v "` + esc(m.source) + `":"` + esc(m.target) + `"`, args: []string{"-v", m.source + ":" + m.target}} | |||
| 113 | + | } | |||
| 114 | + | if m.readOnly { | |||
| 115 | + | p.line += ":ro" | |||
| 116 | + | p.args[1] += ":ro" | |||
| 117 | + | } | |||
| 118 | + | return p | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // optionsParts is the user's `options:` text: echoed as written, split into | |||
| 122 | + | // arguments like .NET splits a command line. An empty text still leaves its | |||
| 123 | + | // place in the line (the runner joins an empty string: two spaces). | |||
| 124 | + | func optionsParts(options string) dockerPart { | |||
| 125 | + | return dockerPart{line: options, args: ParseArguments(options)} | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // dockerArgString is the arguments of the command as ProcessExitCodeException | |||
| 129 | + | // words them: the echoed line without the path of docker. | |||
| 130 | + | func (j *job) dockerArgString(c dockerCmd) string { | |||
| 131 | + | return strings.TrimPrefix(c.line, j.dockerPath()+" ") | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // exitError is ProcessExitCodeException: docker ran and said no. | |||
| 135 | + | type exitError struct { | |||
| 136 | + | code int | |||
| 137 | + | msg string | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func (e *exitError) Error() string { return e.msg } | |||
| 141 | + | ||||
| 142 | + | // Time limits of the docker commands of the container steps. The runner has | |||
| 143 | + | // none (its commands end with the job); a sandbox whose daemon stalls must not | |||
| 144 | + | // hold the job until its timeout, nor the cleanup forever. | |||
| 145 | + | type ContainerTimeouts struct { | |||
| 146 | + | // Command bounds one docker command (default 2 minutes); Pull bounds a | |||
| 147 | + | // docker pull (30 minutes). | |||
| 148 | + | Command, Pull time.Duration | |||
| 149 | + | // CleanupCommand bounds one command of "Stop containers" (30 seconds) and | |||
| 150 | + | // CleanupBudget the whole cleanup (3 minutes), whatever happens to the job. | |||
| 151 | + | CleanupCommand, CleanupBudget time.Duration | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func (j *job) timeouts() ContainerTimeouts { | |||
| 155 | + | t := j.cfg.Actions.Containers | |||
| 156 | + | def := func(v *time.Duration, d time.Duration) { | |||
| 157 | + | if *v <= 0 { | |||
| 158 | + | *v = d | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | def(&t.Command, 2*time.Minute) | |||
| 162 | + | def(&t.Pull, 30*time.Minute) | |||
| 163 | + | def(&t.CleanupCommand, 30*time.Second) | |||
| 164 | + | def(&t.CleanupBudget, 3*time.Minute) | |||
| 165 | + | return t | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // cmdTimeoutError is a docker command stopped by its time limit. | |||
| 169 | + | type cmdTimeoutError struct { | |||
| 170 | + | cmd string | |||
| 171 | + | d time.Duration | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func (e *cmdTimeoutError) Error() string { | |||
| 175 | + | return fmt.Sprintf("The docker command timed out after %s: %s", e.d, e.cmd) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // bound returns a channel that closes when cancel does (nil never) or after d, | |||
| 179 | + | // and a function that tells whether it was the time, with the one that | |||
| 180 | + | // releases the timer. | |||
| 181 | + | func bound(cancel <-chan struct{}, d time.Duration) (ch <-chan struct{}, timedOut func() bool, stop func()) { | |||
| 182 | + | out := make(chan struct{}) | |||
| 183 | + | var once sync.Once | |||
| 184 | + | var fired atomic.Bool | |||
| 185 | + | closeOut := func() { once.Do(func() { close(out) }) } | |||
| 186 | + | t := time.AfterFunc(d, func() { fired.Store(true); closeOut() }) | |||
| 187 | + | done := make(chan struct{}) | |||
| 188 | + | if cancel != nil { | |||
| 189 | + | go func() { | |||
| 190 | + | select { | |||
| 191 | + | case <-cancel: | |||
| 192 | + | closeOut() | |||
| 193 | + | case <-done: | |||
| 194 | + | } | |||
| 195 | + | }() | |||
| 196 | + | } | |||
| 197 | + | return out, fired.Load, func() { t.Stop(); close(done) } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // dockerOpts are the options of dockerDo. | |||
| 201 | + | type dockerOpts struct { | |||
| 202 | + | // s is the step whose log gets the command and its output; nil runs the | |||
| 203 | + | // command silently (the fallback cleanup). | |||
| 204 | + | s *stepRun | |||
| 205 | + | // cancel stops the command (the escalation of runProcess); always set. | |||
| 206 | + | cancel <-chan struct{} | |||
| 207 | + | // timeout bounds the command; 0 means the command limit. | |||
| 208 | + | timeout time.Duration | |||
| 209 | + | // quiet keeps the echoed command and the output out of the log. | |||
| 210 | + | quiet bool | |||
| 211 | + | // all logs the empty lines too (the overloads that return a code). | |||
| 212 | + | all bool | |||
| 213 | + | // errToOut also collects the lines of stderr. | |||
| 214 | + | errToOut bool | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // do runs the command and returns the non-empty lines of its stdout (and | |||
| 218 | + | // stderr with errToOut), its exit code and an error for what is not an exit | |||
| 219 | + | // code: a start failure, a cancellation, a time limit. | |||
| 220 | + | func (j *job) dockerDo(c dockerCmd, o dockerOpts) (lines []string, code int, err error) { | |||
| 221 | + | log := o.s != nil && !o.quiet | |||
| 222 | + | if log { | |||
| 223 | + | j.out(o.s.num, "##[command]"+c.line) | |||
| 224 | + | } | |||
| 225 | + | d := o.timeout | |||
| 226 | + | if d <= 0 { | |||
| 227 | + | d = j.timeouts().Command | |||
| 228 | + | } | |||
| 229 | + | cancel, timedOut, stop := bound(o.cancel, d) | |||
| 230 | + | defer stop() | |||
| 231 | + | var mu sync.Mutex | |||
| 232 | + | res, perr := runProcess(procSpec{ | |||
| 233 | + | Argv: c.argv, Dir: dirOrCwd(j.githubValue("workspace")), Env: append(j.processEnv(newOrdered()), c.env...), Credential: j.cfg.Credential, | |||
| 234 | + | OnLine: func(l string) { | |||
| 235 | + | mu.Lock() | |||
| 236 | + | if l != "" { | |||
| 237 | + | lines = append(lines, l) | |||
| 238 | + | } | |||
| 239 | + | mu.Unlock() | |||
| 240 | + | if log && (o.all || l != "") { | |||
| 241 | + | j.write(o.s.num, l) | |||
| 242 | + | } | |||
| 243 | + | }, | |||
| 244 | + | OnErrLine: func(l string) { | |||
| 245 | + | if o.errToOut && l != "" { | |||
| 246 | + | mu.Lock() | |||
| 247 | + | lines = append(lines, l) | |||
| 248 | + | mu.Unlock() | |||
| 249 | + | } | |||
| 250 | + | if log && (o.all || l != "") { | |||
| 251 | + | j.write(o.s.num, l) | |||
| 252 | + | } | |||
| 253 | + | }, | |||
| 254 | + | Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal, | |||
| 255 | + | }) | |||
| 256 | + | mu.Lock() | |||
| 257 | + | defer mu.Unlock() | |||
| 258 | + | switch { | |||
| 259 | + | case perr != nil: | |||
| 260 | + | return lines, 0, perr | |||
| 261 | + | case res.Cancelled && timedOut(): | |||
| 262 | + | return lines, res.ExitCode, &cmdTimeoutError{cmd: j.dockerArgString(c), d: d} | |||
| 263 | + | case res.Cancelled: | |||
| 264 | + | return lines, res.ExitCode, errCancelled | |||
| 265 | + | } | |||
| 266 | + | return lines, res.ExitCode, nil | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | // dockerList is the overload of ExecuteDockerCommandAsync that returns the | |||
| 270 | + | // output (DockerCommandManager.cs:462-492): non-empty lines of stdout are kept | |||
| 271 | + | // and logged, non-empty lines of stderr are logged, and a non-zero exit is an | |||
| 272 | + | // error. | |||
| 273 | + | func (j *job) dockerList(s *stepRun, c dockerCmd, cancel <-chan struct{}) ([]string, error) { | |||
| 274 | + | lines, code, err := j.dockerDo(c, dockerOpts{s: s, cancel: cancel}) | |||
| 275 | + | if err != nil { | |||
| 276 | + | return lines, err | |||
| 277 | + | } | |||
| 278 | + | if code != 0 { | |||
| 279 | + | return lines, &exitError{code: code, msg: fmt.Sprintf("Exit code %d returned from process: file name '%s', arguments '%s'.", code, j.dockerPath(), j.dockerArgString(c))} | |||
| 280 | + | } | |||
| 281 | + | return lines, nil | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // dockerCode is the overload that logs every line and returns the exit code. | |||
| 285 | + | func (j *job) dockerCode(s *stepRun, c dockerCmd, cancel <-chan struct{}) (int, error) { | |||
| 286 | + | _, code, err := j.dockerDo(c, dockerOpts{s: s, cancel: cancel, all: true}) | |||
| 287 | + | return code, err | |||
| 288 | + | } | |||
A internal/executor/containers_exec.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ContainerStepHost (Runner.Worker/Handlers/StepHost.cs:102-296): the steps of | |
| 10 | + | // a job that has a container run through `docker exec` in it. | |
| 11 | + | ||
| 12 | + | // stepContainer is the job container when steps run in it, else nil. | |
| 13 | + | func (j *job) stepContainer() *container { | |
| 14 | + | if j.cs == nil { | |
| 15 | + | return nil | |
| 16 | + | } | |
| 17 | + | return j.cs.job | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // containerReady is the check of ActionRunner.cs:157-164: the container must | |
| 21 | + | // exist. After a failed "Initialize containers" an always() step gets the | |
| 22 | + | // runner's error. | |
| 23 | + | func (j *job) containerReady() error { | |
| 24 | + | if c := j.stepContainer(); c != nil && c.id == "" { | |
| 25 | + | return ghErrorf("Value cannot be null. (Parameter 'ContainerId')") | |
| 26 | + | } | |
| 27 | + | return nil | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // hostEnv is the environment a handler gives its process: the step's | |
| 31 | + | // variables then the runtime variables of the contexts (GITHUB_*, RUNNER_*) | |
| 32 | + | // (ScriptHandler.cs:289-299). It is what `docker exec -e` passes on. | |
| 33 | + | func (j *job) handlerEnv(stepEnv *ordered) *ordered { | |
| 34 | + | env := stepEnv.Clone() | |
| 35 | + | j.mu.Lock() | |
| 36 | + | rt := j.state.RuntimeEnv() | |
| 37 | + | j.mu.Unlock() | |
| 38 | + | for _, k := range rt.Keys() { | |
| 39 | + | v, _ := rt.Get(k) | |
| 40 | + | env.Set(k, v) | |
| 41 | + | } | |
| 42 | + | return env | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // execArgv is ContainerStepHost.ExecuteAsync (StepHost.cs:196-246): | |
| 46 | + | // `docker exec -i --workdir <dir> -e NAME... [-e PATH="<prepend>:<PATH>"] <id> | |
| 47 | + | // <command> <arguments>`. The values of the variables are not on the command | |
| 48 | + | // line: -e NAME takes them from the environment of docker, which gets them | |
| 49 | + | // with the host paths translated to the container's (:248-254). args are the | |
| 50 | + | // arguments of the command, already split. | |
| 51 | + | func (j *job) execArgv(c *container, workDir string, env *ordered, command string, args []string) (argv []string, procEnv []string) { | |
| 52 | + | argv = []string{j.dockerPath(), "exec", "-i", "--workdir", workDir} | |
| 53 | + | cenv := newOrdered() | |
| 54 | + | for _, k := range env.Keys() { | |
| 55 | + | v, _ := env.Get(k) | |
| 56 | + | v = c.translateToContainer(v) | |
| 57 | + | // PATH is set below. The docker process is started by its absolute | |
| 58 | + | // path, so the variables of the container are not read by it, except | |
| 59 | + | // those the CLI itself reads. | |
| 60 | + | p := j.envForCLI(k, v, "") | |
| 61 | + | argv = append(argv, p.args...) | |
| 62 | + | for _, kv := range p.env { | |
| 63 | + | cenv.Set(k, strings.TrimPrefix(kv, k+"=")) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | j.mu.Lock() | |
| 67 | + | prepend := j.state.Prepend() | |
| 68 | + | j.mu.Unlock() | |
| 69 | + | if len(prepend) > 0 { | |
| 70 | + | rev := make([]string, len(prepend)) | |
| 71 | + | for i, p := range prepend { | |
| 72 | + | rev[len(prepend)-1-i] = p | |
| 73 | + | } | |
| 74 | + | full := strings.Join(rev, ":") | |
| 75 | + | if c.runtimePath != "" { | |
| 76 | + | full += ":" + c.runtimePath | |
| 77 | + | } | |
| 78 | + | // The value is in the environment of docker.exec only: the process is | |
| 79 | + | // started by absolute path and `docker exec` runs nothing of its own. | |
| 80 | + | argv = append(argv, "-e", "PATH") | |
| 81 | + | cenv.Set("PATH", full) | |
| 82 | + | } | |
| 83 | + | argv = append(argv, c.id, command) | |
| 84 | + | argv = append(argv, args...) | |
| 85 | + | return argv, j.dockerProcessEnv(cenv) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // containerScript prepares a run step for the container: the script is | |
| 89 | + | // written on the host in RUNNER_TEMP, which the container has at /__w/_temp, | |
| 90 | + | // and the shell gets that path. | |
| 91 | + | func (j *job) containerScript(c *container, plan ShellPlan, in scriptIn, s *stepRun) (argv []string, err error) { | |
| 92 | + | sc, err := WriteScriptFor(j.tempRoot, plan, in.script, c.translateToContainer) | |
| 93 | + | if err != nil { | |
| 94 | + | return nil, err | |
| 95 | + | } | |
| 96 | + | j.mu.Lock() | |
| 97 | + | workspace := j.state.GitHub("workspace") | |
| 98 | + | j.mu.Unlock() | |
| 99 | + | wd := c.resolvePath(ResolveWorkingDirectory(workspace, in.workingDirectory)) | |
| 100 | + | var rest []string | |
| 101 | + | if len(sc.Argv) > 1 { | |
| 102 | + | rest = sc.Argv[1:] | |
| 103 | + | } | |
| 104 | + | henv := j.handlerEnv(s.env) | |
| 105 | + | if err := checkEnvNames(henv.Keys()); err != nil { | |
| 106 | + | return nil, err | |
| 107 | + | } | |
| 108 | + | a, penv := j.execArgv(c, wd, henv, plan.CommandPath, rest) | |
| 109 | + | s.rawEnvList = penv | |
| 110 | + | return a, nil | |
| 111 | + | } | |
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // ContainerStepHost (Runner.Worker/Handlers/StepHost.cs:102-296): the steps of | |||
| 10 | + | // a job that has a container run through `docker exec` in it. | |||
| 11 | + | ||||
| 12 | + | // stepContainer is the job container when steps run in it, else nil. | |||
| 13 | + | func (j *job) stepContainer() *container { | |||
| 14 | + | if j.cs == nil { | |||
| 15 | + | return nil | |||
| 16 | + | } | |||
| 17 | + | return j.cs.job | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // containerReady is the check of ActionRunner.cs:157-164: the container must | |||
| 21 | + | // exist. After a failed "Initialize containers" an always() step gets the | |||
| 22 | + | // runner's error. | |||
| 23 | + | func (j *job) containerReady() error { | |||
| 24 | + | if c := j.stepContainer(); c != nil && c.id == "" { | |||
| 25 | + | return ghErrorf("Value cannot be null. (Parameter 'ContainerId')") | |||
| 26 | + | } | |||
| 27 | + | return nil | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // hostEnv is the environment a handler gives its process: the step's | |||
| 31 | + | // variables then the runtime variables of the contexts (GITHUB_*, RUNNER_*) | |||
| 32 | + | // (ScriptHandler.cs:289-299). It is what `docker exec -e` passes on. | |||
| 33 | + | func (j *job) handlerEnv(stepEnv *ordered) *ordered { | |||
| 34 | + | env := stepEnv.Clone() | |||
| 35 | + | j.mu.Lock() | |||
| 36 | + | rt := j.state.RuntimeEnv() | |||
| 37 | + | j.mu.Unlock() | |||
| 38 | + | for _, k := range rt.Keys() { | |||
| 39 | + | v, _ := rt.Get(k) | |||
| 40 | + | env.Set(k, v) | |||
| 41 | + | } | |||
| 42 | + | return env | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // execArgv is ContainerStepHost.ExecuteAsync (StepHost.cs:196-246): | |||
| 46 | + | // `docker exec -i --workdir <dir> -e NAME... [-e PATH="<prepend>:<PATH>"] <id> | |||
| 47 | + | // <command> <arguments>`. The values of the variables are not on the command | |||
| 48 | + | // line: -e NAME takes them from the environment of docker, which gets them | |||
| 49 | + | // with the host paths translated to the container's (:248-254). args are the | |||
| 50 | + | // arguments of the command, already split. | |||
| 51 | + | func (j *job) execArgv(c *container, workDir string, env *ordered, command string, args []string) (argv []string, procEnv []string) { | |||
| 52 | + | argv = []string{j.dockerPath(), "exec", "-i", "--workdir", workDir} | |||
| 53 | + | cenv := newOrdered() | |||
| 54 | + | for _, k := range env.Keys() { | |||
| 55 | + | v, _ := env.Get(k) | |||
| 56 | + | v = c.translateToContainer(v) | |||
| 57 | + | // PATH is set below. The docker process is started by its absolute | |||
| 58 | + | // path, so the variables of the container are not read by it, except | |||
| 59 | + | // those the CLI itself reads. | |||
| 60 | + | p := j.envForCLI(k, v, "") | |||
| 61 | + | argv = append(argv, p.args...) | |||
| 62 | + | for _, kv := range p.env { | |||
| 63 | + | cenv.Set(k, strings.TrimPrefix(kv, k+"=")) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | j.mu.Lock() | |||
| 67 | + | prepend := j.state.Prepend() | |||
| 68 | + | j.mu.Unlock() | |||
| 69 | + | if len(prepend) > 0 { | |||
| 70 | + | rev := make([]string, len(prepend)) | |||
| 71 | + | for i, p := range prepend { | |||
| 72 | + | rev[len(prepend)-1-i] = p | |||
| 73 | + | } | |||
| 74 | + | full := strings.Join(rev, ":") | |||
| 75 | + | if c.runtimePath != "" { | |||
| 76 | + | full += ":" + c.runtimePath | |||
| 77 | + | } | |||
| 78 | + | // The value is in the environment of docker.exec only: the process is | |||
| 79 | + | // started by absolute path and `docker exec` runs nothing of its own. | |||
| 80 | + | argv = append(argv, "-e", "PATH") | |||
| 81 | + | cenv.Set("PATH", full) | |||
| 82 | + | } | |||
| 83 | + | argv = append(argv, c.id, command) | |||
| 84 | + | argv = append(argv, args...) | |||
| 85 | + | return argv, j.dockerProcessEnv(cenv) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // containerScript prepares a run step for the container: the script is | |||
| 89 | + | // written on the host in RUNNER_TEMP, which the container has at /__w/_temp, | |||
| 90 | + | // and the shell gets that path. | |||
| 91 | + | func (j *job) containerScript(c *container, plan ShellPlan, in scriptIn, s *stepRun) (argv []string, err error) { | |||
| 92 | + | sc, err := WriteScriptFor(j.tempRoot, plan, in.script, c.translateToContainer) | |||
| 93 | + | if err != nil { | |||
| 94 | + | return nil, err | |||
| 95 | + | } | |||
| 96 | + | j.mu.Lock() | |||
| 97 | + | workspace := j.state.GitHub("workspace") | |||
| 98 | + | j.mu.Unlock() | |||
| 99 | + | wd := c.resolvePath(ResolveWorkingDirectory(workspace, in.workingDirectory)) | |||
| 100 | + | var rest []string | |||
| 101 | + | if len(sc.Argv) > 1 { | |||
| 102 | + | rest = sc.Argv[1:] | |||
| 103 | + | } | |||
| 104 | + | henv := j.handlerEnv(s.env) | |||
| 105 | + | if err := checkEnvNames(henv.Keys()); err != nil { | |||
| 106 | + | return nil, err | |||
| 107 | + | } | |||
| 108 | + | a, penv := j.execArgv(c, wd, henv, plan.CommandPath, rest) | |||
| 109 | + | s.rawEnvList = penv | |||
| 110 | + | return a, nil | |||
| 111 | + | } | |||
A internal/executor/containers_exec_test.go +153 -0
| @@ -0,0 +1,153 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // execCalls are the `docker exec` calls recorded by the fake. | |
| 11 | + | func (f *fakeCtr) execCalls(t *testing.T) [][]string { | |
| 12 | + | var out [][]string | |
| 13 | + | for _, c := range f.calls(t) { | |
| 14 | + | if c[0] == "exec" { | |
| 15 | + | out = append(out, c) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | return out | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestRunStepThroughDockerExec(t *testing.T) { | |
| 22 | + | f := newFakeCtr(t) | |
| 23 | + | f.set(t, "exec_out", "hello from the container\n") | |
| 24 | + | var work string | |
| 25 | + | rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n CVAR: c\n", | |
| 26 | + | " - name: s\n working-directory: sub\n env:\n FOO: bar\n run: echo hi\n", func(c *Config) { work = c.Layout.WorkRoot }) | |
| 27 | + | if res.Conclusion != ConclusionSuccess { | |
| 28 | + | t.Fatalf("%+v\n%s", res, logOf(rec.step(t, "Initialize containers"))) | |
| 29 | + | } | |
| 30 | + | if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|s|Stop containers|Complete job" { | |
| 31 | + | t.Fatalf("steps %s", got) | |
| 32 | + | } | |
| 33 | + | ex := f.execCalls(t) | |
| 34 | + | if len(ex) != 1 { | |
| 35 | + | t.Fatalf("exec calls %v", ex) | |
| 36 | + | } | |
| 37 | + | c := strings.Join(ex[0], "\n") | |
| 38 | + | id := strings.Repeat("0", 63) + "1" | |
| 39 | + | for _, w := range []string{ | |
| 40 | + | "exec\n-i\n--workdir\n/__w/hello/hello/sub\n-e\nFOO\n", "\n-e\nGITHUB_WORKSPACE\n", "\n-e\nGITHUB_OUTPUT\n", | |
| 41 | + | "\n" + id + "\nsh\n-e\n/__w/_temp/", | |
| 42 | + | } { | |
| 43 | + | if !strings.Contains(c, w) { | |
| 44 | + | t.Errorf("exec lacks %q:\n%s", w, c) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | if strings.Contains(c, "bar") || strings.Contains(c, work) { | |
| 48 | + | t.Errorf("values and host paths stay off the command line:\n%s", c) | |
| 49 | + | } | |
| 50 | + | log := logOf(rec.step(t, "s")) | |
| 51 | + | if !strings.Contains(log, "shell: sh -e {0}\n") || !strings.Contains(log, "hello from the container") { | |
| 52 | + | t.Errorf("log:\n%s", log) | |
| 53 | + | } | |
| 54 | + | // The create command of the job container. | |
| 55 | + | create := logOf(rec.step(t, "Initialize containers")) | |
| 56 | + | for _, w := range []string{ | |
| 57 | + | "--workdir /__w/hello/hello --network github_network_", " -e \"CVAR=c\" -e \"HOME=/github/home\" -e GITHUB_ACTIONS=true -e CI=true -v \"/var/run/docker.sock\":\"/var/run/docker.sock\" -v \"" + work + "\":\"/__w\"", | |
| 58 | + | "\":\"/__e\":ro -v \"" + work + "/_temp\":\"/__w/_temp\"", "\":\"/__t\" -v \"" + work + "/_temp/_github_home\":\"/github/home\" -v \"" + work + "/_temp/_github_workflow\":\"/github/workflow\" --entrypoint \"tail\" ubuntu:24.04 \"-f\" \"/dev/null\"", | |
| 59 | + | } { | |
| 60 | + | if !strings.Contains(create, w) { | |
| 61 | + | t.Errorf("create lacks %q:\n%s", w, create) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestRunEnvIsTranslatedForTheContainer(t *testing.T) { | |
| 67 | + | f := newFakeCtr(t) | |
| 68 | + | f.set(t, "exec_env", "1") | |
| 69 | + | rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", | |
| 70 | + | " - name: s\n run: echo hi\n", nil) | |
| 71 | + | if res.Conclusion != ConclusionSuccess { | |
| 72 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 73 | + | } | |
| 74 | + | // HOME is the container's own (/github/home): it is not passed with -e. | |
| 75 | + | if c := strings.Join(f.execCalls(t)[0], "\n"); strings.Contains(c, "\nHOME\n") { | |
| 76 | + | t.Errorf("HOME must not be passed:\n%s", c) | |
| 77 | + | } | |
| 78 | + | log := logOf(rec.step(t, "s")) | |
| 79 | + | for _, w := range []string{ | |
| 80 | + | "ENV GITHUB_WORKSPACE=/__w/hello/hello", "RUNNER_TEMP=/__w/_temp", | |
| 81 | + | "GITHUB_OUTPUT=/__w/_temp/_runner_file_commands/set_output_", "GITHUB_EVENT_PATH=/github/workflow/event.json", | |
| 82 | + | } { | |
| 83 | + | if !strings.Contains(log, w) { | |
| 84 | + | t.Errorf("log lacks %q:\n%s", w, log) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestNodeActionInContainerUsesExternalsAndAlpine(t *testing.T) { | |
| 90 | + | for _, tc := range []struct{ release, node string }{{"ID=ubuntu\nVERSION_ID=\"24.04\"\n", "node24"}, {"ID=alpine\nVERSION_ID=3.23.4\n", "node24_alpine"}} { | |
| 91 | + | f := newFakeCtr(t) | |
| 92 | + | f.set(t, "exec_out", tc.release) | |
| 93 | + | tree := files{ | |
| 94 | + | ".github/actions/x/action.yml": "name: x\nruns:\n using: node24\n main: index.js\n", | |
| 95 | + | ".github/actions/x/index.js": "x\n", | |
| 96 | + | } | |
| 97 | + | rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - name: a\n uses: ./.github/actions/x\n", func(c *Config) { | |
| 98 | + | writeFiles(t, c.Layout.Workspace, tree) | |
| 99 | + | }) | |
| 100 | + | if res.Conclusion != ConclusionSuccess { | |
| 101 | + | t.Fatalf("%s: %+v\n%s", tc.node, res, logOf(rec.step(t, "a"))) | |
| 102 | + | } | |
| 103 | + | ex := f.execCalls(t) | |
| 104 | + | if len(ex) != 2 { | |
| 105 | + | t.Fatalf("%s: exec calls %v", tc.node, ex) | |
| 106 | + | } | |
| 107 | + | if got := strings.Join(ex[0][len(ex[0])-4:len(ex[0])-1], " "); got != "sh -c "+alpineCheck { | |
| 108 | + | t.Errorf("probe: %v", ex[0]) | |
| 109 | + | } | |
| 110 | + | run := strings.Join(ex[1], "\n") | |
| 111 | + | if !strings.Contains(run, "\n/__e/"+tc.node+"/bin/node\n") || !strings.Contains(run, "\n/__w/hello/hello/.github/actions/x/index.js") { | |
| 112 | + | t.Errorf("%s: node call:\n%s", tc.node, run) | |
| 113 | + | } | |
| 114 | + | if got := logOf(rec.step(t, "a")); !strings.Contains(got, "exec "+strings.Repeat("0", 63)+"1 sh -c \"cat /etc/*release | grep ^ID\"") { | |
| 115 | + | t.Errorf("probe line missing:\n%s", got) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestCommandPathsFromTheContainerAreTranslated(t *testing.T) { | |
| 121 | + | f := newFakeCtr(t) | |
| 122 | + | // The step prints an annotation for a file at its container path. | |
| 123 | + | f.set(t, "exec_out", "::error file=/__w/hello/hello/src/a.go,line=3::boom\n") | |
| 124 | + | rec, res := ctrRun(t, f, ctrJob, " - name: s\n run: echo\n", nil) | |
| 125 | + | if res.Conclusion != ConclusionSuccess { | |
| 126 | + | t.Fatalf("%+v", res) | |
| 127 | + | } | |
| 128 | + | log := logOf(rec.step(t, "s")) | |
| 129 | + | if !strings.Contains(log, "##[error]boom") { | |
| 130 | + | t.Errorf("log:\n%s", log) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestDockerActionJoinsTheJobNetworkAndContextHasContainerKey(t *testing.T) { | |
| 135 | + | f := newFakeCtr(t) | |
| 136 | + | rec, res := ctrRun(t, f, " services:\n redis:\n image: redis:7\n", | |
| 137 | + | " - uses: docker://alpine:3.20\n - name: ctx\n run: echo \"${{ toJSON(job.container) }}\"\n", nil) | |
| 138 | + | if res.Conclusion != ConclusionSuccess { | |
| 139 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 140 | + | } | |
| 141 | + | var run []string | |
| 142 | + | for _, c := range f.calls(t) { | |
| 143 | + | if c[0] == "run" { | |
| 144 | + | run = c | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | if run == nil || !strings.Contains(strings.Join(run, "\n"), "\n--network\ngithub_network_") { | |
| 148 | + | t.Errorf("docker run without the job network: %v", run) | |
| 149 | + | } | |
| 150 | + | if got := logOf(rec.step(t, "ctx")); !strings.Contains(got, "\"network\": \"github_network_") { | |
| 151 | + | t.Errorf("ctx:\n%s", got) | |
| 152 | + | } | |
| 153 | + | } | |
| @@ -0,0 +1,153 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // execCalls are the `docker exec` calls recorded by the fake. | |||
| 11 | + | func (f *fakeCtr) execCalls(t *testing.T) [][]string { | |||
| 12 | + | var out [][]string | |||
| 13 | + | for _, c := range f.calls(t) { | |||
| 14 | + | if c[0] == "exec" { | |||
| 15 | + | out = append(out, c) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | return out | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestRunStepThroughDockerExec(t *testing.T) { | |||
| 22 | + | f := newFakeCtr(t) | |||
| 23 | + | f.set(t, "exec_out", "hello from the container\n") | |||
| 24 | + | var work string | |||
| 25 | + | rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n CVAR: c\n", | |||
| 26 | + | " - name: s\n working-directory: sub\n env:\n FOO: bar\n run: echo hi\n", func(c *Config) { work = c.Layout.WorkRoot }) | |||
| 27 | + | if res.Conclusion != ConclusionSuccess { | |||
| 28 | + | t.Fatalf("%+v\n%s", res, logOf(rec.step(t, "Initialize containers"))) | |||
| 29 | + | } | |||
| 30 | + | if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|s|Stop containers|Complete job" { | |||
| 31 | + | t.Fatalf("steps %s", got) | |||
| 32 | + | } | |||
| 33 | + | ex := f.execCalls(t) | |||
| 34 | + | if len(ex) != 1 { | |||
| 35 | + | t.Fatalf("exec calls %v", ex) | |||
| 36 | + | } | |||
| 37 | + | c := strings.Join(ex[0], "\n") | |||
| 38 | + | id := strings.Repeat("0", 63) + "1" | |||
| 39 | + | for _, w := range []string{ | |||
| 40 | + | "exec\n-i\n--workdir\n/__w/hello/hello/sub\n-e\nFOO\n", "\n-e\nGITHUB_WORKSPACE\n", "\n-e\nGITHUB_OUTPUT\n", | |||
| 41 | + | "\n" + id + "\nsh\n-e\n/__w/_temp/", | |||
| 42 | + | } { | |||
| 43 | + | if !strings.Contains(c, w) { | |||
| 44 | + | t.Errorf("exec lacks %q:\n%s", w, c) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | if strings.Contains(c, "bar") || strings.Contains(c, work) { | |||
| 48 | + | t.Errorf("values and host paths stay off the command line:\n%s", c) | |||
| 49 | + | } | |||
| 50 | + | log := logOf(rec.step(t, "s")) | |||
| 51 | + | if !strings.Contains(log, "shell: sh -e {0}\n") || !strings.Contains(log, "hello from the container") { | |||
| 52 | + | t.Errorf("log:\n%s", log) | |||
| 53 | + | } | |||
| 54 | + | // The create command of the job container. | |||
| 55 | + | create := logOf(rec.step(t, "Initialize containers")) | |||
| 56 | + | for _, w := range []string{ | |||
| 57 | + | "--workdir /__w/hello/hello --network github_network_", " -e \"CVAR=c\" -e \"HOME=/github/home\" -e GITHUB_ACTIONS=true -e CI=true -v \"/var/run/docker.sock\":\"/var/run/docker.sock\" -v \"" + work + "\":\"/__w\"", | |||
| 58 | + | "\":\"/__e\":ro -v \"" + work + "/_temp\":\"/__w/_temp\"", "\":\"/__t\" -v \"" + work + "/_temp/_github_home\":\"/github/home\" -v \"" + work + "/_temp/_github_workflow\":\"/github/workflow\" --entrypoint \"tail\" ubuntu:24.04 \"-f\" \"/dev/null\"", | |||
| 59 | + | } { | |||
| 60 | + | if !strings.Contains(create, w) { | |||
| 61 | + | t.Errorf("create lacks %q:\n%s", w, create) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestRunEnvIsTranslatedForTheContainer(t *testing.T) { | |||
| 67 | + | f := newFakeCtr(t) | |||
| 68 | + | f.set(t, "exec_env", "1") | |||
| 69 | + | rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", | |||
| 70 | + | " - name: s\n run: echo hi\n", nil) | |||
| 71 | + | if res.Conclusion != ConclusionSuccess { | |||
| 72 | + | t.Fatalf("%+v %v", res, rec.names()) | |||
| 73 | + | } | |||
| 74 | + | // HOME is the container's own (/github/home): it is not passed with -e. | |||
| 75 | + | if c := strings.Join(f.execCalls(t)[0], "\n"); strings.Contains(c, "\nHOME\n") { | |||
| 76 | + | t.Errorf("HOME must not be passed:\n%s", c) | |||
| 77 | + | } | |||
| 78 | + | log := logOf(rec.step(t, "s")) | |||
| 79 | + | for _, w := range []string{ | |||
| 80 | + | "ENV GITHUB_WORKSPACE=/__w/hello/hello", "RUNNER_TEMP=/__w/_temp", | |||
| 81 | + | "GITHUB_OUTPUT=/__w/_temp/_runner_file_commands/set_output_", "GITHUB_EVENT_PATH=/github/workflow/event.json", | |||
| 82 | + | } { | |||
| 83 | + | if !strings.Contains(log, w) { | |||
| 84 | + | t.Errorf("log lacks %q:\n%s", w, log) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestNodeActionInContainerUsesExternalsAndAlpine(t *testing.T) { | |||
| 90 | + | for _, tc := range []struct{ release, node string }{{"ID=ubuntu\nVERSION_ID=\"24.04\"\n", "node24"}, {"ID=alpine\nVERSION_ID=3.23.4\n", "node24_alpine"}} { | |||
| 91 | + | f := newFakeCtr(t) | |||
| 92 | + | f.set(t, "exec_out", tc.release) | |||
| 93 | + | tree := files{ | |||
| 94 | + | ".github/actions/x/action.yml": "name: x\nruns:\n using: node24\n main: index.js\n", | |||
| 95 | + | ".github/actions/x/index.js": "x\n", | |||
| 96 | + | } | |||
| 97 | + | rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - name: a\n uses: ./.github/actions/x\n", func(c *Config) { | |||
| 98 | + | writeFiles(t, c.Layout.Workspace, tree) | |||
| 99 | + | }) | |||
| 100 | + | if res.Conclusion != ConclusionSuccess { | |||
| 101 | + | t.Fatalf("%s: %+v\n%s", tc.node, res, logOf(rec.step(t, "a"))) | |||
| 102 | + | } | |||
| 103 | + | ex := f.execCalls(t) | |||
| 104 | + | if len(ex) != 2 { | |||
| 105 | + | t.Fatalf("%s: exec calls %v", tc.node, ex) | |||
| 106 | + | } | |||
| 107 | + | if got := strings.Join(ex[0][len(ex[0])-4:len(ex[0])-1], " "); got != "sh -c "+alpineCheck { | |||
| 108 | + | t.Errorf("probe: %v", ex[0]) | |||
| 109 | + | } | |||
| 110 | + | run := strings.Join(ex[1], "\n") | |||
| 111 | + | if !strings.Contains(run, "\n/__e/"+tc.node+"/bin/node\n") || !strings.Contains(run, "\n/__w/hello/hello/.github/actions/x/index.js") { | |||
| 112 | + | t.Errorf("%s: node call:\n%s", tc.node, run) | |||
| 113 | + | } | |||
| 114 | + | if got := logOf(rec.step(t, "a")); !strings.Contains(got, "exec "+strings.Repeat("0", 63)+"1 sh -c \"cat /etc/*release | grep ^ID\"") { | |||
| 115 | + | t.Errorf("probe line missing:\n%s", got) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestCommandPathsFromTheContainerAreTranslated(t *testing.T) { | |||
| 121 | + | f := newFakeCtr(t) | |||
| 122 | + | // The step prints an annotation for a file at its container path. | |||
| 123 | + | f.set(t, "exec_out", "::error file=/__w/hello/hello/src/a.go,line=3::boom\n") | |||
| 124 | + | rec, res := ctrRun(t, f, ctrJob, " - name: s\n run: echo\n", nil) | |||
| 125 | + | if res.Conclusion != ConclusionSuccess { | |||
| 126 | + | t.Fatalf("%+v", res) | |||
| 127 | + | } | |||
| 128 | + | log := logOf(rec.step(t, "s")) | |||
| 129 | + | if !strings.Contains(log, "##[error]boom") { | |||
| 130 | + | t.Errorf("log:\n%s", log) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestDockerActionJoinsTheJobNetworkAndContextHasContainerKey(t *testing.T) { | |||
| 135 | + | f := newFakeCtr(t) | |||
| 136 | + | rec, res := ctrRun(t, f, " services:\n redis:\n image: redis:7\n", | |||
| 137 | + | " - uses: docker://alpine:3.20\n - name: ctx\n run: echo \"${{ toJSON(job.container) }}\"\n", nil) | |||
| 138 | + | if res.Conclusion != ConclusionSuccess { | |||
| 139 | + | t.Fatalf("%+v %v", res, rec.names()) | |||
| 140 | + | } | |||
| 141 | + | var run []string | |||
| 142 | + | for _, c := range f.calls(t) { | |||
| 143 | + | if c[0] == "run" { | |||
| 144 | + | run = c | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | if run == nil || !strings.Contains(strings.Join(run, "\n"), "\n--network\ngithub_network_") { | |||
| 148 | + | t.Errorf("docker run without the job network: %v", run) | |||
| 149 | + | } | |||
| 150 | + | if got := logOf(rec.step(t, "ctx")); !strings.Contains(got, "\"network\": \"github_network_") { | |||
| 151 | + | t.Errorf("ctx:\n%s", got) | |||
| 152 | + | } | |||
| 153 | + | } | |||
A internal/executor/containers_fake_test.go +130 -0
| @@ -0,0 +1,130 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // fakeCtrDocker is a docker CLI for the container steps: it records every | |
| 13 | + | // call (one argument per line between "--" markers, like fakeDocker) and | |
| 14 | + | // answers like the real one. Behaviour is chosen by files in its directory: | |
| 15 | + | // | |
| 16 | + | // pull_fail N: the first N pulls fail | |
| 17 | + | // health words, one per inspect: starting, healthy, unhealthy | |
| 18 | + | // create_fail docker create exits 125 | |
| 19 | + | // exec_out what docker exec prints | |
| 20 | + | // hang_exec docker exec waits until it gets a signal | |
| 21 | + | type fakeCtr struct { | |
| 22 | + | dir, bin, log string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func newFakeCtr(t *testing.T) *fakeCtr { | |
| 26 | + | t.Helper() | |
| 27 | + | d := t.TempDir() | |
| 28 | + | f := &fakeCtr{dir: d, bin: filepath.Join(d, "docker"), log: filepath.Join(d, "calls")} | |
| 29 | + | script := `#!/bin/sh | |
| 30 | + | D=` + d + ` | |
| 31 | + | { echo "--"; for a in "$@"; do printf "%s\n" "$a"; done; echo "ENV:CVAR=$CVAR:EMPTYV=${EMPTYV-unset}:PATH=$PATH"; } >> $D/calls | |
| 32 | + | [ "$1" = "--config" ] && { CFG=$2; shift 2; } | |
| 33 | + | hang() { if [ -f $D/hang_ignore ]; then trap '' INT; trap 'exit 143' TERM; else trap 'exit 130' INT TERM; fi; while :; do sleep 0.05; done; } | |
| 34 | + | [ -f $D/hang_$1 ] && hang | |
| 35 | + | if [ -f $D/hang_for ]; then case "$*" in *"$(cat $D/hang_for)"*) hang ;; esac; fi | |
| 36 | + | case "$1" in | |
| 37 | + | version) echo "'1.48'" ;; | |
| 38 | + | ps) case "$*" in *--quiet*) [ -f $D/created ] && cat $D/created ;; *status=running*) id=$(echo "$*" | sed 's/.*id=\([^ ]*\).*/\1/'); echo "$id Up Less than a second" ;; esac ;; | |
| 39 | + | network) if [ "$2" = rm ]; then | |
| 40 | + | [ -f $D/netrm_fail ] && { echo "Error response from daemon: network is busy"; exit 1; } | |
| 41 | + | [ -f $D/netrm_absent ] && { echo "Error response from daemon: network $3 not found"; exit 1; } | |
| 42 | + | fi ;; | |
| 43 | + | login) cat > $D/login_stdin | |
| 44 | + | if [ -f $D/lock_cfg ]; then mkdir -p $CFG/sub; : > $CFG/sub/f; chmod 500 $CFG/sub; fi ;; | |
| 45 | + | pull) if [ -f $D/pull_fail ]; then n=$(cat $D/pull_fail); if [ "$n" -gt 0 ]; then echo $((n-1)) > $D/pull_fail; echo "pull denied"; exit 1; fi; fi; echo "pulled $2" ;; | |
| 46 | + | create) [ -f $D/create_fail ] && { echo "unknown flag"; exit 125; } | |
| 47 | + | [ -f $D/create_out ] && { cat $D/create_out; exit 0; } | |
| 48 | + | n=$(cat $D/seq 2>/dev/null || echo 0); n=$((n+1)); echo $n > $D/seq; id=$(printf "%064d" $n); echo $id >> $D/created | |
| 49 | + | [ -f $D/hang_create_after ] && hang | |
| 50 | + | echo $id ;; | |
| 51 | + | start) echo "$2" ;; | |
| 52 | + | inspect) case "$*" in | |
| 53 | + | *Healthcheck*) if [ -s $D/health ]; then w=$(head -1 $D/health); sed -i 1d $D/health; echo "$w"; fi ;; | |
| 54 | + | *Config.Env*) printf 'HOME=/github/home\nGITHUB_ACTIONS=true\nPATH=/usr/bin:/bin\n' ;; | |
| 55 | + | esac ;; | |
| 56 | + | port) [ -f $D/ports ] && cat $D/ports ;; | |
| 57 | + | logs) echo "service log of $3" ;; | |
| 58 | + | rm) [ -f $D/rm_fail ] && { echo "Error response from daemon: cannot remove container: boom"; exit 1; } | |
| 59 | + | [ -f $D/rm_absent ] && { echo "Error response from daemon: No such container: $3"; exit 1; } | |
| 60 | + | [ -f $D/created ] && { grep -v "^$3$" $D/created > $D/created.n; mv $D/created.n $D/created; } | |
| 61 | + | echo "$3" ;; | |
| 62 | + | exec) if [ -f $D/add_path ]; then f=$(echo "$GITHUB_PATH" | sed "s#^/__w#$(cat $D/add_path)#"); echo /added >> "$f"; fi | |
| 63 | + | [ -f $D/exec_env ] && echo "ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE RUNNER_TEMP=$RUNNER_TEMP HOME=${HOME:-<unset>} GITHUB_OUTPUT=$GITHUB_OUTPUT GITHUB_EVENT_PATH=$GITHUB_EVENT_PATH" | |
| 64 | + | [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; } | |
| 65 | + | [ -f $D/exec_out ] && cat $D/exec_out | |
| 66 | + | if [ -f $D/exec_exit ]; then n=$(cat $D/exec_exit); [ "$n" != 0 ] && echo 0 > $D/exec_exit && exit $n; fi ;; | |
| 67 | + | esac | |
| 68 | + | exit 0 | |
| 69 | + | ` | |
| 70 | + | if err := os.WriteFile(f.bin, []byte(script), 0o755); err != nil { | |
| 71 | + | t.Fatal(err) | |
| 72 | + | } | |
| 73 | + | return f | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (f *fakeCtr) set(t *testing.T, name, content string) { | |
| 77 | + | t.Helper() | |
| 78 | + | if err := os.WriteFile(filepath.Join(f.dir, name), []byte(content), 0o644); err != nil { | |
| 79 | + | t.Fatal(err) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (f *fakeCtr) calls(t *testing.T) [][]string { return calls(t, f.log) } | |
| 84 | + | ||
| 85 | + | // verbs are the docker subcommands called, in order. | |
| 86 | + | func (f *fakeCtr) verbs(t *testing.T) []string { | |
| 87 | + | var out []string | |
| 88 | + | for _, c := range f.calls(t) { | |
| 89 | + | if c[0] == "--config" && len(c) > 2 { | |
| 90 | + | c = c[2:] | |
| 91 | + | } | |
| 92 | + | v := c[0] | |
| 93 | + | if v == "network" && len(c) > 1 { | |
| 94 | + | v += " " + c[1] | |
| 95 | + | } | |
| 96 | + | out = append(out, v) | |
| 97 | + | } | |
| 98 | + | return out | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // ctrRun runs a workflow whose job has the given container/services YAML. | |
| 102 | + | func ctrRun(t *testing.T, f *fakeCtr, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) { | |
| 103 | + | t.Helper() | |
| 104 | + | return ctrRunC(t, f, nil, jobYAML, steps, tweak) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func ctrRunC(t *testing.T, f *fakeCtr, cancel *Canceller, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) { | |
| 108 | + | t.Helper() | |
| 109 | + | needTools(t, "bash") | |
| 110 | + | return runLocalC(t, cancel, wfHead+jobYAML+" steps:\n"+steps, "j", func(c *Config) { | |
| 111 | + | c.Actions.DockerPath = f.bin | |
| 112 | + | c.Actions.Backoff = func() time.Duration { return time.Millisecond } | |
| 113 | + | c.Actions.ExternalsDir = fakeNode(t) | |
| 114 | + | if tweak != nil { | |
| 115 | + | tweak(c) | |
| 116 | + | } | |
| 117 | + | }) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // concl is the conclusion of the step called name. | |
| 121 | + | func (r *recorder) concl(name string) Conclusion { | |
| 122 | + | r.mu.Lock() | |
| 123 | + | defer r.mu.Unlock() | |
| 124 | + | for _, s := range r.steps { | |
| 125 | + | if s.Name == name { | |
| 126 | + | return s.Conclusion | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | return "" | |
| 130 | + | } | |
| @@ -0,0 +1,130 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // fakeCtrDocker is a docker CLI for the container steps: it records every | |||
| 13 | + | // call (one argument per line between "--" markers, like fakeDocker) and | |||
| 14 | + | // answers like the real one. Behaviour is chosen by files in its directory: | |||
| 15 | + | // | |||
| 16 | + | // pull_fail N: the first N pulls fail | |||
| 17 | + | // health words, one per inspect: starting, healthy, unhealthy | |||
| 18 | + | // create_fail docker create exits 125 | |||
| 19 | + | // exec_out what docker exec prints | |||
| 20 | + | // hang_exec docker exec waits until it gets a signal | |||
| 21 | + | type fakeCtr struct { | |||
| 22 | + | dir, bin, log string | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func newFakeCtr(t *testing.T) *fakeCtr { | |||
| 26 | + | t.Helper() | |||
| 27 | + | d := t.TempDir() | |||
| 28 | + | f := &fakeCtr{dir: d, bin: filepath.Join(d, "docker"), log: filepath.Join(d, "calls")} | |||
| 29 | + | script := `#!/bin/sh | |||
| 30 | + | D=` + d + ` | |||
| 31 | + | { echo "--"; for a in "$@"; do printf "%s\n" "$a"; done; echo "ENV:CVAR=$CVAR:EMPTYV=${EMPTYV-unset}:PATH=$PATH"; } >> $D/calls | |||
| 32 | + | [ "$1" = "--config" ] && { CFG=$2; shift 2; } | |||
| 33 | + | hang() { if [ -f $D/hang_ignore ]; then trap '' INT; trap 'exit 143' TERM; else trap 'exit 130' INT TERM; fi; while :; do sleep 0.05; done; } | |||
| 34 | + | [ -f $D/hang_$1 ] && hang | |||
| 35 | + | if [ -f $D/hang_for ]; then case "$*" in *"$(cat $D/hang_for)"*) hang ;; esac; fi | |||
| 36 | + | case "$1" in | |||
| 37 | + | version) echo "'1.48'" ;; | |||
| 38 | + | ps) case "$*" in *--quiet*) [ -f $D/created ] && cat $D/created ;; *status=running*) id=$(echo "$*" | sed 's/.*id=\([^ ]*\).*/\1/'); echo "$id Up Less than a second" ;; esac ;; | |||
| 39 | + | network) if [ "$2" = rm ]; then | |||
| 40 | + | [ -f $D/netrm_fail ] && { echo "Error response from daemon: network is busy"; exit 1; } | |||
| 41 | + | [ -f $D/netrm_absent ] && { echo "Error response from daemon: network $3 not found"; exit 1; } | |||
| 42 | + | fi ;; | |||
| 43 | + | login) cat > $D/login_stdin | |||
| 44 | + | if [ -f $D/lock_cfg ]; then mkdir -p $CFG/sub; : > $CFG/sub/f; chmod 500 $CFG/sub; fi ;; | |||
| 45 | + | pull) if [ -f $D/pull_fail ]; then n=$(cat $D/pull_fail); if [ "$n" -gt 0 ]; then echo $((n-1)) > $D/pull_fail; echo "pull denied"; exit 1; fi; fi; echo "pulled $2" ;; | |||
| 46 | + | create) [ -f $D/create_fail ] && { echo "unknown flag"; exit 125; } | |||
| 47 | + | [ -f $D/create_out ] && { cat $D/create_out; exit 0; } | |||
| 48 | + | n=$(cat $D/seq 2>/dev/null || echo 0); n=$((n+1)); echo $n > $D/seq; id=$(printf "%064d" $n); echo $id >> $D/created | |||
| 49 | + | [ -f $D/hang_create_after ] && hang | |||
| 50 | + | echo $id ;; | |||
| 51 | + | start) echo "$2" ;; | |||
| 52 | + | inspect) case "$*" in | |||
| 53 | + | *Healthcheck*) if [ -s $D/health ]; then w=$(head -1 $D/health); sed -i 1d $D/health; echo "$w"; fi ;; | |||
| 54 | + | *Config.Env*) printf 'HOME=/github/home\nGITHUB_ACTIONS=true\nPATH=/usr/bin:/bin\n' ;; | |||
| 55 | + | esac ;; | |||
| 56 | + | port) [ -f $D/ports ] && cat $D/ports ;; | |||
| 57 | + | logs) echo "service log of $3" ;; | |||
| 58 | + | rm) [ -f $D/rm_fail ] && { echo "Error response from daemon: cannot remove container: boom"; exit 1; } | |||
| 59 | + | [ -f $D/rm_absent ] && { echo "Error response from daemon: No such container: $3"; exit 1; } | |||
| 60 | + | [ -f $D/created ] && { grep -v "^$3$" $D/created > $D/created.n; mv $D/created.n $D/created; } | |||
| 61 | + | echo "$3" ;; | |||
| 62 | + | exec) if [ -f $D/add_path ]; then f=$(echo "$GITHUB_PATH" | sed "s#^/__w#$(cat $D/add_path)#"); echo /added >> "$f"; fi | |||
| 63 | + | [ -f $D/exec_env ] && echo "ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE RUNNER_TEMP=$RUNNER_TEMP HOME=${HOME:-<unset>} GITHUB_OUTPUT=$GITHUB_OUTPUT GITHUB_EVENT_PATH=$GITHUB_EVENT_PATH" | |||
| 64 | + | [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; } | |||
| 65 | + | [ -f $D/exec_out ] && cat $D/exec_out | |||
| 66 | + | if [ -f $D/exec_exit ]; then n=$(cat $D/exec_exit); [ "$n" != 0 ] && echo 0 > $D/exec_exit && exit $n; fi ;; | |||
| 67 | + | esac | |||
| 68 | + | exit 0 | |||
| 69 | + | ` | |||
| 70 | + | if err := os.WriteFile(f.bin, []byte(script), 0o755); err != nil { | |||
| 71 | + | t.Fatal(err) | |||
| 72 | + | } | |||
| 73 | + | return f | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (f *fakeCtr) set(t *testing.T, name, content string) { | |||
| 77 | + | t.Helper() | |||
| 78 | + | if err := os.WriteFile(filepath.Join(f.dir, name), []byte(content), 0o644); err != nil { | |||
| 79 | + | t.Fatal(err) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (f *fakeCtr) calls(t *testing.T) [][]string { return calls(t, f.log) } | |||
| 84 | + | ||||
| 85 | + | // verbs are the docker subcommands called, in order. | |||
| 86 | + | func (f *fakeCtr) verbs(t *testing.T) []string { | |||
| 87 | + | var out []string | |||
| 88 | + | for _, c := range f.calls(t) { | |||
| 89 | + | if c[0] == "--config" && len(c) > 2 { | |||
| 90 | + | c = c[2:] | |||
| 91 | + | } | |||
| 92 | + | v := c[0] | |||
| 93 | + | if v == "network" && len(c) > 1 { | |||
| 94 | + | v += " " + c[1] | |||
| 95 | + | } | |||
| 96 | + | out = append(out, v) | |||
| 97 | + | } | |||
| 98 | + | return out | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // ctrRun runs a workflow whose job has the given container/services YAML. | |||
| 102 | + | func ctrRun(t *testing.T, f *fakeCtr, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) { | |||
| 103 | + | t.Helper() | |||
| 104 | + | return ctrRunC(t, f, nil, jobYAML, steps, tweak) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func ctrRunC(t *testing.T, f *fakeCtr, cancel *Canceller, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) { | |||
| 108 | + | t.Helper() | |||
| 109 | + | needTools(t, "bash") | |||
| 110 | + | return runLocalC(t, cancel, wfHead+jobYAML+" steps:\n"+steps, "j", func(c *Config) { | |||
| 111 | + | c.Actions.DockerPath = f.bin | |||
| 112 | + | c.Actions.Backoff = func() time.Duration { return time.Millisecond } | |||
| 113 | + | c.Actions.ExternalsDir = fakeNode(t) | |||
| 114 | + | if tweak != nil { | |||
| 115 | + | tweak(c) | |||
| 116 | + | } | |||
| 117 | + | }) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // concl is the conclusion of the step called name. | |||
| 121 | + | func (r *recorder) concl(name string) Conclusion { | |||
| 122 | + | r.mu.Lock() | |||
| 123 | + | defer r.mu.Unlock() | |||
| 124 | + | for _, s := range r.steps { | |||
| 125 | + | if s.Name == name { | |||
| 126 | + | return s.Conclusion | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | return "" | |||
| 130 | + | } | |||
A internal/executor/containers_init.go +306 -0
| @@ -0,0 +1,306 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "math/rand/v2" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "regexp" | |
| 11 | + | "slices" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "sync" | |
| 15 | + | "time" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // containerSet is the containers of a job: the job container, if any, then the | |
| 19 | + | // services (JobExtension.cs:322-326: the order of the list the provider gets). | |
| 20 | + | type containerSet struct { | |
| 21 | + | job *container | |
| 22 | + | services []*container | |
| 23 | + | network string | |
| 24 | + | ||
| 25 | + | // What the cleanup needs, recorded BEFORE the command that creates it, so | |
| 26 | + | // that a command whose outcome is not known (a timeout, a cancellation, a | |
| 27 | + | // lost connection) can still be cleaned: the names are chosen here, not by | |
| 28 | + | // docker. mu guards them; cleanupMu serialises the cleanups ("Stop | |
| 29 | + | // containers" and the fallback of the job). | |
| 30 | + | mu sync.Mutex | |
| 31 | + | cleanupMu sync.Mutex | |
| 32 | + | networkTry string // network name, set before docker network create | |
| 33 | + | netRemoved bool // the network is gone | |
| 34 | + | dirs []string // docker --config directories that could not be removed | |
| 35 | + | // deadline is the end of the cleanup budget, fixed by the first cleanup of | |
| 36 | + | // the job: "Stop containers" and the fallback share it, so the cleanup of a | |
| 37 | + | // job is bounded as a whole. | |
| 38 | + | deadline time.Time | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (cs *containerSet) tryNetwork(name string) { cs.mu.Lock(); cs.networkTry = name; cs.mu.Unlock() } | |
| 42 | + | ||
| 43 | + | func (cs *containerSet) addDir(d string) { | |
| 44 | + | cs.mu.Lock() | |
| 45 | + | defer cs.mu.Unlock() | |
| 46 | + | if !slices.Contains(cs.dirs, d) { | |
| 47 | + | cs.dirs = append(cs.dirs, d) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // delDir forgets a directory that is gone. | |
| 52 | + | func (cs *containerSet) delDir(d string) { | |
| 53 | + | cs.mu.Lock() | |
| 54 | + | cs.dirs = slices.DeleteFunc(cs.dirs, func(x string) bool { return x == d }) | |
| 55 | + | cs.mu.Unlock() | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (cs *containerSet) all() []*container { | |
| 59 | + | var l []*container | |
| 60 | + | if cs.job != nil { | |
| 61 | + | l = append(l, cs.job) | |
| 62 | + | } | |
| 63 | + | return append(l, cs.services...) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // minDockerAPI is requiredDockerEngineAPIVersion (ContainerOperationProvider.cs | |
| 67 | + | // :591): Docker-CE 17.12. | |
| 68 | + | var minDockerAPI = [2]int{1, 35} | |
| 69 | + | ||
| 70 | + | var ( | |
| 71 | + | apiVersionRe = regexp.MustCompile(`(?i)\d+\.\d+(\.\d+)?`) | |
| 72 | + | portLineRe = regexp.MustCompile(`^(\d+)/(\w+) -> (.+):(\d+)$`) | |
| 73 | + | ) | |
| 74 | + | ||
| 75 | + | // initContainersStep is the "Initialize containers" step | |
| 76 | + | // (JobExtension.cs:328-331), run if success(). | |
| 77 | + | func (j *job) initContainersStep() qstep { | |
| 78 | + | return qstep{name: "Initialize containers", setup: func(s *stepRun, cancel <-chan struct{}) stepResult { | |
| 79 | + | return j.initContainers(s, cancel) | |
| 80 | + | }} | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // result turns the error of a step body into the result StepsRunner gives it | |
| 84 | + | // (StepsRunner.cs:314-340): a cancellation is logged and cancels the step, | |
| 85 | + | // anything else is logged with its message and fails it. | |
| 86 | + | func (j *job) containerResult(s *stepRun, err error) stepResult { | |
| 87 | + | if err == nil { | |
| 88 | + | return resSucceeded | |
| 89 | + | } | |
| 90 | + | if err == errCancelled { | |
| 91 | + | return j.cancelledStep(s) | |
| 92 | + | } | |
| 93 | + | j.errorf(s.num, "%s", err.Error()) | |
| 94 | + | return resFailed | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // initContainers is ContainerOperationProvider.StartContainersAsync | |
| 98 | + | // (:40-103). The step that stops the containers is registered first, so that | |
| 99 | + | // it runs even when this one fails (:56-60). | |
| 100 | + | func (j *job) initContainers(s *stepRun, cancel <-chan struct{}) stepResult { | |
| 101 | + | j.debug(s.num, "Register post job cleanup for stopping/deleting containers.") | |
| 102 | + | j.mu.Lock() | |
| 103 | + | j.postStack = append(j.postStack, qstep{name: "Stop containers", cond: "always()", setup: j.stopContainers}) | |
| 104 | + | j.mu.Unlock() | |
| 105 | + | return j.containerResult(s, j.startContainers(s, cancel)) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (j *job) startContainers(s *stepRun, cancel <-chan struct{}) error { | |
| 109 | + | num, cs := s.num, j.cs | |
| 110 | + | // AssertCompatibleOS (:559-571) refuses to run in a container because the | |
| 111 | + | // paths it mounts would be those of the container's host. The docker | |
| 112 | + | // daemon of a job runs next to the executor, in the same sandbox, so the | |
| 113 | + | // paths are right and the check is left out. | |
| 114 | + | if err := j.checkDockerVersion(s, cancel); err != nil { | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | j.out(num, "##[group]Clean up resources from previous jobs") | |
| 118 | + | label := j.dockerLabel() | |
| 119 | + | ps := j.newDockerCmd("ps", raw("--all", "--quiet", "--no-trunc"), quotedOpt("--filter", "label="+label)) | |
| 120 | + | stale, err := j.dockerList(s, ps, cancel) | |
| 121 | + | if err != nil { | |
| 122 | + | return err | |
| 123 | + | } | |
| 124 | + | for _, id := range stale { | |
| 125 | + | code, err := j.dockerCode(s, j.newDockerCmd("rm", raw("--force", id)), cancel) | |
| 126 | + | if err != nil { | |
| 127 | + | return err | |
| 128 | + | } | |
| 129 | + | if code != 0 { | |
| 130 | + | j.tagged(num, "##[warning]", fmt.Sprintf("Delete stale containers failed, docker rm fail with exit code %d for container %s", code, id)) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | prune := j.newDockerCmd("network", raw("prune", "--force"), quotedOpt("--filter", "label="+label)) | |
| 134 | + | code, err := j.dockerCode(s, prune, cancel) | |
| 135 | + | if err != nil { | |
| 136 | + | return err | |
| 137 | + | } | |
| 138 | + | if code != 0 { | |
| 139 | + | j.tagged(num, "##[warning]", fmt.Sprintf("Delete stale container networks failed, docker network prune fail with exit code %d", code)) | |
| 140 | + | } | |
| 141 | + | j.out(num, "##[endgroup]") | |
| 142 | + | ||
| 143 | + | j.out(num, "##[group]Create local container network") | |
| 144 | + | network := "github_network_" + strings.ReplaceAll(newImageTag(""), ":", "") | |
| 145 | + | cs.tryNetwork(network) | |
| 146 | + | code, err = j.dockerCode(s, j.newDockerCmd("network", raw("create", "--label", label, network)), cancel) | |
| 147 | + | if err != nil { | |
| 148 | + | return err | |
| 149 | + | } | |
| 150 | + | if code != 0 { | |
| 151 | + | return ghErrorf("Docker network create failed with exit code %d", code) | |
| 152 | + | } | |
| 153 | + | j.mu.Lock() | |
| 154 | + | j.state.SetJobContainer("network", network) | |
| 155 | + | j.mu.Unlock() | |
| 156 | + | cs.network = network | |
| 157 | + | j.out(num, "##[endgroup]") | |
| 158 | + | ||
| 159 | + | for _, c := range cs.all() { | |
| 160 | + | c.network = network | |
| 161 | + | if err := j.startContainer(s, c, cancel); err != nil { | |
| 162 | + | return err | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | return j.healthchecks(s, cs.all(), cancel) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // checkDockerVersion is AssertCompatibleOS' version check (:573-598) with | |
| 169 | + | // DockerCommandManager.DockerVersion (:47-90), including its use of the | |
| 170 | + | // server's text for the client version (:71, the runner's own slip). | |
| 171 | + | func (j *job) checkDockerVersion(s *stepRun, cancel <-chan struct{}) error { | |
| 172 | + | num := s.num | |
| 173 | + | j.out(num, "##[group]Checking docker version") | |
| 174 | + | ver := func(which string) (string, error) { | |
| 175 | + | l, err := j.dockerList(s, j.newDockerCmd("version", raw("--format", "'{{."+which+".APIVersion}}'")), cancel) | |
| 176 | + | if err != nil { | |
| 177 | + | return "", err | |
| 178 | + | } | |
| 179 | + | if len(l) == 0 { | |
| 180 | + | return "", ghErrorf("Value cannot be null. (Parameter 'Docker.%s.Version')", which) | |
| 181 | + | } | |
| 182 | + | return l[0], nil | |
| 183 | + | } | |
| 184 | + | server, err := ver("Server") | |
| 185 | + | if err != nil { | |
| 186 | + | return err | |
| 187 | + | } | |
| 188 | + | j.out(num, "Docker daemon API version: "+server) | |
| 189 | + | client, err := ver("Client") | |
| 190 | + | if err != nil { | |
| 191 | + | return err | |
| 192 | + | } | |
| 193 | + | j.out(num, "Docker client API version: "+client) | |
| 194 | + | j.out(num, "##[endgroup]") | |
| 195 | + | v, ok := parseAPIVersion(server) | |
| 196 | + | if !ok { | |
| 197 | + | return ghErrorf("Value cannot be null. (Parameter 'ServerVersion')") | |
| 198 | + | } | |
| 199 | + | if v[0] < minDockerAPI[0] || (v[0] == minDockerAPI[0] && v[1] < minDockerAPI[1]) { | |
| 200 | + | return ghErrorf("Min required docker engine API server version is '%d.%d', your docker ('%s') server version is '%d.%d'", | |
| 201 | + | minDockerAPI[0], minDockerAPI[1], j.dockerPath(), v[0], v[1]) | |
| 202 | + | } | |
| 203 | + | return nil | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func parseAPIVersion(s string) ([2]int, bool) { | |
| 207 | + | m := apiVersionRe.FindString(s) | |
| 208 | + | parts := strings.Split(m, ".") | |
| 209 | + | if len(parts) < 2 { | |
| 210 | + | return [2]int{}, false | |
| 211 | + | } | |
| 212 | + | a, e1 := strconv.Atoi(parts[0]) | |
| 213 | + | b, e2 := strconv.Atoi(parts[1]) | |
| 214 | + | return [2]int{a, b}, e1 == nil && e2 == nil | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // containerBackoff is BackoffTimerHelper.GetRandomBackoff(1 s, 10 s). | |
| 218 | + | func (j *job) containerBackoff() time.Duration { | |
| 219 | + | if f := j.cfg.Actions.Backoff; f != nil { | |
| 220 | + | return f() | |
| 221 | + | } | |
| 222 | + | return time.Duration(1000+rand.IntN(9000)) * time.Millisecond | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // seconds is the way the runner prints a backoff, TimeSpan.TotalSeconds. | |
| 226 | + | func seconds(d time.Duration) string { return strconv.FormatFloat(d.Seconds(), 'f', -1, 64) } | |
| 227 | + | ||
| 228 | + | // pullArgs builds `docker [--config dir] pull image`. | |
| 229 | + | func (j *job) pullCmd(image, config string) dockerCmd { | |
| 230 | + | if config == "" { | |
| 231 | + | return j.newDockerCmd("pull", raw(image)) | |
| 232 | + | } | |
| 233 | + | p := j.dockerPath() | |
| 234 | + | return dockerCmd{line: p + " --config " + config + " pull " + image, argv: []string{p, "--config", config, "pull", image}} | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // removeConfigDir is ContainerRegistryLogout (:457-470): the credentials | |
| 238 | + | // directory goes, and a failure is the runner's error. A directory that | |
| 239 | + | // cannot be removed is kept for the cleanup of the job, which tries again. | |
| 240 | + | func (j *job) removeConfigDir(dir string) error { | |
| 241 | + | if dir == "" { | |
| 242 | + | return nil | |
| 243 | + | } | |
| 244 | + | if err := os.RemoveAll(dir); err != nil { | |
| 245 | + | j.cs.addDir(dir) | |
| 246 | + | return ghErrorf("Failed to remove directory containing Docker client credentials: %v", err) | |
| 247 | + | } | |
| 248 | + | j.cs.delDir(dir) // unregistered only once it is gone | |
| 249 | + | return nil | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // registryLogin is ContainerRegistryLogin (:394-455): the credentials go to a | |
| 253 | + | // private docker config directory, the password through stdin. It returns the | |
| 254 | + | // directory ("" without credentials); the caller removes it with | |
| 255 | + | // removeConfigDir, whatever happens. | |
| 256 | + | func (j *job) registryLogin(s *stepRun, c *container, cancel <-chan struct{}) (string, error) { | |
| 257 | + | if c.credUser == "" || c.credPass == "" { | |
| 258 | + | return "", nil | |
| 259 | + | } | |
| 260 | + | // The password is a secret for the logs of this job from here on. | |
| 261 | + | j.addMask(c.credPass) | |
| 262 | + | dir := filepath.Join(j.state.Layout().Temp, ".docker_"+strings.ReplaceAll(newImageTag(""), ":", "")) | |
| 263 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 264 | + | return "", ghErrorf("Failed to create directory to store registry client credentials: %v", err) | |
| 265 | + | } | |
| 266 | + | // Registered with the cleanup of the job before anything can fail or panic: | |
| 267 | + | // from here on the fallback removes it whatever happens. | |
| 268 | + | j.cs.addDir(dir) | |
| 269 | + | j.own(dir) | |
| 270 | + | args := []string{j.dockerPath(), "--config", dir, "login"} | |
| 271 | + | line := "--config " + dir + " login" | |
| 272 | + | if c.registryServer != "" { | |
| 273 | + | args, line = append(args, c.registryServer), line+" "+c.registryServer | |
| 274 | + | } | |
| 275 | + | args, line = append(args, "-u", c.credUser, "--password-stdin"), line+" -u "+c.credUser+" --password-stdin" | |
| 276 | + | for try := 1; ; try++ { | |
| 277 | + | j.out(s.num, "##[command]"+j.dockerPath()+" "+line) | |
| 278 | + | bc, timedOut, stop := bound(cancel, j.timeouts().Command) | |
| 279 | + | res, err := runProcess(procSpec{ | |
| 280 | + | Argv: args, Dir: dirOrCwd(j.githubValue("workspace")), Env: j.processEnv(newOrdered()), Credential: j.cfg.Credential, | |
| 281 | + | OnLine: func(string) {}, Stdin: c.credPass + "\n", | |
| 282 | + | Cancel: bc, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal, | |
| 283 | + | }) | |
| 284 | + | stop() | |
| 285 | + | if err != nil { | |
| 286 | + | return dir, err | |
| 287 | + | } | |
| 288 | + | if res.Cancelled { | |
| 289 | + | if timedOut() { | |
| 290 | + | return dir, &cmdTimeoutError{cmd: "login", d: j.timeouts().Command} | |
| 291 | + | } | |
| 292 | + | return dir, errCancelled | |
| 293 | + | } | |
| 294 | + | if res.ExitCode == 0 { | |
| 295 | + | return dir, nil | |
| 296 | + | } | |
| 297 | + | if try == 3 { | |
| 298 | + | return dir, ghErrorf("Docker login for '%s' failed with exit code %d", c.registryServer, res.ExitCode) | |
| 299 | + | } | |
| 300 | + | d := j.containerBackoff() | |
| 301 | + | j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker login for '%s' failed with exit code %d, back off %s seconds before retry.", c.registryServer, res.ExitCode, seconds(d))) | |
| 302 | + | if !j.sleepOrCancel(d, cancel) { | |
| 303 | + | return dir, errCancelled | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | } | |
| @@ -0,0 +1,306 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "math/rand/v2" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "regexp" | |||
| 11 | + | "slices" | |||
| 12 | + | "strconv" | |||
| 13 | + | "strings" | |||
| 14 | + | "sync" | |||
| 15 | + | "time" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // containerSet is the containers of a job: the job container, if any, then the | |||
| 19 | + | // services (JobExtension.cs:322-326: the order of the list the provider gets). | |||
| 20 | + | type containerSet struct { | |||
| 21 | + | job *container | |||
| 22 | + | services []*container | |||
| 23 | + | network string | |||
| 24 | + | ||||
| 25 | + | // What the cleanup needs, recorded BEFORE the command that creates it, so | |||
| 26 | + | // that a command whose outcome is not known (a timeout, a cancellation, a | |||
| 27 | + | // lost connection) can still be cleaned: the names are chosen here, not by | |||
| 28 | + | // docker. mu guards them; cleanupMu serialises the cleanups ("Stop | |||
| 29 | + | // containers" and the fallback of the job). | |||
| 30 | + | mu sync.Mutex | |||
| 31 | + | cleanupMu sync.Mutex | |||
| 32 | + | networkTry string // network name, set before docker network create | |||
| 33 | + | netRemoved bool // the network is gone | |||
| 34 | + | dirs []string // docker --config directories that could not be removed | |||
| 35 | + | // deadline is the end of the cleanup budget, fixed by the first cleanup of | |||
| 36 | + | // the job: "Stop containers" and the fallback share it, so the cleanup of a | |||
| 37 | + | // job is bounded as a whole. | |||
| 38 | + | deadline time.Time | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (cs *containerSet) tryNetwork(name string) { cs.mu.Lock(); cs.networkTry = name; cs.mu.Unlock() } | |||
| 42 | + | ||||
| 43 | + | func (cs *containerSet) addDir(d string) { | |||
| 44 | + | cs.mu.Lock() | |||
| 45 | + | defer cs.mu.Unlock() | |||
| 46 | + | if !slices.Contains(cs.dirs, d) { | |||
| 47 | + | cs.dirs = append(cs.dirs, d) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // delDir forgets a directory that is gone. | |||
| 52 | + | func (cs *containerSet) delDir(d string) { | |||
| 53 | + | cs.mu.Lock() | |||
| 54 | + | cs.dirs = slices.DeleteFunc(cs.dirs, func(x string) bool { return x == d }) | |||
| 55 | + | cs.mu.Unlock() | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (cs *containerSet) all() []*container { | |||
| 59 | + | var l []*container | |||
| 60 | + | if cs.job != nil { | |||
| 61 | + | l = append(l, cs.job) | |||
| 62 | + | } | |||
| 63 | + | return append(l, cs.services...) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // minDockerAPI is requiredDockerEngineAPIVersion (ContainerOperationProvider.cs | |||
| 67 | + | // :591): Docker-CE 17.12. | |||
| 68 | + | var minDockerAPI = [2]int{1, 35} | |||
| 69 | + | ||||
| 70 | + | var ( | |||
| 71 | + | apiVersionRe = regexp.MustCompile(`(?i)\d+\.\d+(\.\d+)?`) | |||
| 72 | + | portLineRe = regexp.MustCompile(`^(\d+)/(\w+) -> (.+):(\d+)$`) | |||
| 73 | + | ) | |||
| 74 | + | ||||
| 75 | + | // initContainersStep is the "Initialize containers" step | |||
| 76 | + | // (JobExtension.cs:328-331), run if success(). | |||
| 77 | + | func (j *job) initContainersStep() qstep { | |||
| 78 | + | return qstep{name: "Initialize containers", setup: func(s *stepRun, cancel <-chan struct{}) stepResult { | |||
| 79 | + | return j.initContainers(s, cancel) | |||
| 80 | + | }} | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // result turns the error of a step body into the result StepsRunner gives it | |||
| 84 | + | // (StepsRunner.cs:314-340): a cancellation is logged and cancels the step, | |||
| 85 | + | // anything else is logged with its message and fails it. | |||
| 86 | + | func (j *job) containerResult(s *stepRun, err error) stepResult { | |||
| 87 | + | if err == nil { | |||
| 88 | + | return resSucceeded | |||
| 89 | + | } | |||
| 90 | + | if err == errCancelled { | |||
| 91 | + | return j.cancelledStep(s) | |||
| 92 | + | } | |||
| 93 | + | j.errorf(s.num, "%s", err.Error()) | |||
| 94 | + | return resFailed | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // initContainers is ContainerOperationProvider.StartContainersAsync | |||
| 98 | + | // (:40-103). The step that stops the containers is registered first, so that | |||
| 99 | + | // it runs even when this one fails (:56-60). | |||
| 100 | + | func (j *job) initContainers(s *stepRun, cancel <-chan struct{}) stepResult { | |||
| 101 | + | j.debug(s.num, "Register post job cleanup for stopping/deleting containers.") | |||
| 102 | + | j.mu.Lock() | |||
| 103 | + | j.postStack = append(j.postStack, qstep{name: "Stop containers", cond: "always()", setup: j.stopContainers}) | |||
| 104 | + | j.mu.Unlock() | |||
| 105 | + | return j.containerResult(s, j.startContainers(s, cancel)) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (j *job) startContainers(s *stepRun, cancel <-chan struct{}) error { | |||
| 109 | + | num, cs := s.num, j.cs | |||
| 110 | + | // AssertCompatibleOS (:559-571) refuses to run in a container because the | |||
| 111 | + | // paths it mounts would be those of the container's host. The docker | |||
| 112 | + | // daemon of a job runs next to the executor, in the same sandbox, so the | |||
| 113 | + | // paths are right and the check is left out. | |||
| 114 | + | if err := j.checkDockerVersion(s, cancel); err != nil { | |||
| 115 | + | return err | |||
| 116 | + | } | |||
| 117 | + | j.out(num, "##[group]Clean up resources from previous jobs") | |||
| 118 | + | label := j.dockerLabel() | |||
| 119 | + | ps := j.newDockerCmd("ps", raw("--all", "--quiet", "--no-trunc"), quotedOpt("--filter", "label="+label)) | |||
| 120 | + | stale, err := j.dockerList(s, ps, cancel) | |||
| 121 | + | if err != nil { | |||
| 122 | + | return err | |||
| 123 | + | } | |||
| 124 | + | for _, id := range stale { | |||
| 125 | + | code, err := j.dockerCode(s, j.newDockerCmd("rm", raw("--force", id)), cancel) | |||
| 126 | + | if err != nil { | |||
| 127 | + | return err | |||
| 128 | + | } | |||
| 129 | + | if code != 0 { | |||
| 130 | + | j.tagged(num, "##[warning]", fmt.Sprintf("Delete stale containers failed, docker rm fail with exit code %d for container %s", code, id)) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | prune := j.newDockerCmd("network", raw("prune", "--force"), quotedOpt("--filter", "label="+label)) | |||
| 134 | + | code, err := j.dockerCode(s, prune, cancel) | |||
| 135 | + | if err != nil { | |||
| 136 | + | return err | |||
| 137 | + | } | |||
| 138 | + | if code != 0 { | |||
| 139 | + | j.tagged(num, "##[warning]", fmt.Sprintf("Delete stale container networks failed, docker network prune fail with exit code %d", code)) | |||
| 140 | + | } | |||
| 141 | + | j.out(num, "##[endgroup]") | |||
| 142 | + | ||||
| 143 | + | j.out(num, "##[group]Create local container network") | |||
| 144 | + | network := "github_network_" + strings.ReplaceAll(newImageTag(""), ":", "") | |||
| 145 | + | cs.tryNetwork(network) | |||
| 146 | + | code, err = j.dockerCode(s, j.newDockerCmd("network", raw("create", "--label", label, network)), cancel) | |||
| 147 | + | if err != nil { | |||
| 148 | + | return err | |||
| 149 | + | } | |||
| 150 | + | if code != 0 { | |||
| 151 | + | return ghErrorf("Docker network create failed with exit code %d", code) | |||
| 152 | + | } | |||
| 153 | + | j.mu.Lock() | |||
| 154 | + | j.state.SetJobContainer("network", network) | |||
| 155 | + | j.mu.Unlock() | |||
| 156 | + | cs.network = network | |||
| 157 | + | j.out(num, "##[endgroup]") | |||
| 158 | + | ||||
| 159 | + | for _, c := range cs.all() { | |||
| 160 | + | c.network = network | |||
| 161 | + | if err := j.startContainer(s, c, cancel); err != nil { | |||
| 162 | + | return err | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | return j.healthchecks(s, cs.all(), cancel) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // checkDockerVersion is AssertCompatibleOS' version check (:573-598) with | |||
| 169 | + | // DockerCommandManager.DockerVersion (:47-90), including its use of the | |||
| 170 | + | // server's text for the client version (:71, the runner's own slip). | |||
| 171 | + | func (j *job) checkDockerVersion(s *stepRun, cancel <-chan struct{}) error { | |||
| 172 | + | num := s.num | |||
| 173 | + | j.out(num, "##[group]Checking docker version") | |||
| 174 | + | ver := func(which string) (string, error) { | |||
| 175 | + | l, err := j.dockerList(s, j.newDockerCmd("version", raw("--format", "'{{."+which+".APIVersion}}'")), cancel) | |||
| 176 | + | if err != nil { | |||
| 177 | + | return "", err | |||
| 178 | + | } | |||
| 179 | + | if len(l) == 0 { | |||
| 180 | + | return "", ghErrorf("Value cannot be null. (Parameter 'Docker.%s.Version')", which) | |||
| 181 | + | } | |||
| 182 | + | return l[0], nil | |||
| 183 | + | } | |||
| 184 | + | server, err := ver("Server") | |||
| 185 | + | if err != nil { | |||
| 186 | + | return err | |||
| 187 | + | } | |||
| 188 | + | j.out(num, "Docker daemon API version: "+server) | |||
| 189 | + | client, err := ver("Client") | |||
| 190 | + | if err != nil { | |||
| 191 | + | return err | |||
| 192 | + | } | |||
| 193 | + | j.out(num, "Docker client API version: "+client) | |||
| 194 | + | j.out(num, "##[endgroup]") | |||
| 195 | + | v, ok := parseAPIVersion(server) | |||
| 196 | + | if !ok { | |||
| 197 | + | return ghErrorf("Value cannot be null. (Parameter 'ServerVersion')") | |||
| 198 | + | } | |||
| 199 | + | if v[0] < minDockerAPI[0] || (v[0] == minDockerAPI[0] && v[1] < minDockerAPI[1]) { | |||
| 200 | + | return ghErrorf("Min required docker engine API server version is '%d.%d', your docker ('%s') server version is '%d.%d'", | |||
| 201 | + | minDockerAPI[0], minDockerAPI[1], j.dockerPath(), v[0], v[1]) | |||
| 202 | + | } | |||
| 203 | + | return nil | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func parseAPIVersion(s string) ([2]int, bool) { | |||
| 207 | + | m := apiVersionRe.FindString(s) | |||
| 208 | + | parts := strings.Split(m, ".") | |||
| 209 | + | if len(parts) < 2 { | |||
| 210 | + | return [2]int{}, false | |||
| 211 | + | } | |||
| 212 | + | a, e1 := strconv.Atoi(parts[0]) | |||
| 213 | + | b, e2 := strconv.Atoi(parts[1]) | |||
| 214 | + | return [2]int{a, b}, e1 == nil && e2 == nil | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // containerBackoff is BackoffTimerHelper.GetRandomBackoff(1 s, 10 s). | |||
| 218 | + | func (j *job) containerBackoff() time.Duration { | |||
| 219 | + | if f := j.cfg.Actions.Backoff; f != nil { | |||
| 220 | + | return f() | |||
| 221 | + | } | |||
| 222 | + | return time.Duration(1000+rand.IntN(9000)) * time.Millisecond | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // seconds is the way the runner prints a backoff, TimeSpan.TotalSeconds. | |||
| 226 | + | func seconds(d time.Duration) string { return strconv.FormatFloat(d.Seconds(), 'f', -1, 64) } | |||
| 227 | + | ||||
| 228 | + | // pullArgs builds `docker [--config dir] pull image`. | |||
| 229 | + | func (j *job) pullCmd(image, config string) dockerCmd { | |||
| 230 | + | if config == "" { | |||
| 231 | + | return j.newDockerCmd("pull", raw(image)) | |||
| 232 | + | } | |||
| 233 | + | p := j.dockerPath() | |||
| 234 | + | return dockerCmd{line: p + " --config " + config + " pull " + image, argv: []string{p, "--config", config, "pull", image}} | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // removeConfigDir is ContainerRegistryLogout (:457-470): the credentials | |||
| 238 | + | // directory goes, and a failure is the runner's error. A directory that | |||
| 239 | + | // cannot be removed is kept for the cleanup of the job, which tries again. | |||
| 240 | + | func (j *job) removeConfigDir(dir string) error { | |||
| 241 | + | if dir == "" { | |||
| 242 | + | return nil | |||
| 243 | + | } | |||
| 244 | + | if err := os.RemoveAll(dir); err != nil { | |||
| 245 | + | j.cs.addDir(dir) | |||
| 246 | + | return ghErrorf("Failed to remove directory containing Docker client credentials: %v", err) | |||
| 247 | + | } | |||
| 248 | + | j.cs.delDir(dir) // unregistered only once it is gone | |||
| 249 | + | return nil | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // registryLogin is ContainerRegistryLogin (:394-455): the credentials go to a | |||
| 253 | + | // private docker config directory, the password through stdin. It returns the | |||
| 254 | + | // directory ("" without credentials); the caller removes it with | |||
| 255 | + | // removeConfigDir, whatever happens. | |||
| 256 | + | func (j *job) registryLogin(s *stepRun, c *container, cancel <-chan struct{}) (string, error) { | |||
| 257 | + | if c.credUser == "" || c.credPass == "" { | |||
| 258 | + | return "", nil | |||
| 259 | + | } | |||
| 260 | + | // The password is a secret for the logs of this job from here on. | |||
| 261 | + | j.addMask(c.credPass) | |||
| 262 | + | dir := filepath.Join(j.state.Layout().Temp, ".docker_"+strings.ReplaceAll(newImageTag(""), ":", "")) | |||
| 263 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 264 | + | return "", ghErrorf("Failed to create directory to store registry client credentials: %v", err) | |||
| 265 | + | } | |||
| 266 | + | // Registered with the cleanup of the job before anything can fail or panic: | |||
| 267 | + | // from here on the fallback removes it whatever happens. | |||
| 268 | + | j.cs.addDir(dir) | |||
| 269 | + | j.own(dir) | |||
| 270 | + | args := []string{j.dockerPath(), "--config", dir, "login"} | |||
| 271 | + | line := "--config " + dir + " login" | |||
| 272 | + | if c.registryServer != "" { | |||
| 273 | + | args, line = append(args, c.registryServer), line+" "+c.registryServer | |||
| 274 | + | } | |||
| 275 | + | args, line = append(args, "-u", c.credUser, "--password-stdin"), line+" -u "+c.credUser+" --password-stdin" | |||
| 276 | + | for try := 1; ; try++ { | |||
| 277 | + | j.out(s.num, "##[command]"+j.dockerPath()+" "+line) | |||
| 278 | + | bc, timedOut, stop := bound(cancel, j.timeouts().Command) | |||
| 279 | + | res, err := runProcess(procSpec{ | |||
| 280 | + | Argv: args, Dir: dirOrCwd(j.githubValue("workspace")), Env: j.processEnv(newOrdered()), Credential: j.cfg.Credential, | |||
| 281 | + | OnLine: func(string) {}, Stdin: c.credPass + "\n", | |||
| 282 | + | Cancel: bc, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal, | |||
| 283 | + | }) | |||
| 284 | + | stop() | |||
| 285 | + | if err != nil { | |||
| 286 | + | return dir, err | |||
| 287 | + | } | |||
| 288 | + | if res.Cancelled { | |||
| 289 | + | if timedOut() { | |||
| 290 | + | return dir, &cmdTimeoutError{cmd: "login", d: j.timeouts().Command} | |||
| 291 | + | } | |||
| 292 | + | return dir, errCancelled | |||
| 293 | + | } | |||
| 294 | + | if res.ExitCode == 0 { | |||
| 295 | + | return dir, nil | |||
| 296 | + | } | |||
| 297 | + | if try == 3 { | |||
| 298 | + | return dir, ghErrorf("Docker login for '%s' failed with exit code %d", c.registryServer, res.ExitCode) | |||
| 299 | + | } | |||
| 300 | + | d := j.containerBackoff() | |||
| 301 | + | j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker login for '%s' failed with exit code %d, back off %s seconds before retry.", c.registryServer, res.ExitCode, seconds(d))) | |||
| 302 | + | if !j.sleepOrCancel(d, cancel) { | |||
| 303 | + | return dir, errCancelled | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | } | |||
A internal/executor/containers_init_test.go +103 -0
| @@ -0,0 +1,103 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestInitAndStopServiceOnly(t *testing.T) { | |
| 11 | + | f := newFakeCtr(t) | |
| 12 | + | f.set(t, "health", "starting\nhealthy\n") | |
| 13 | + | f.set(t, "ports", "6379/tcp -> 0.0.0.0:16379\n6379/tcp -> [::]:16379\n") | |
| 14 | + | rec, res := ctrRun(t, f, " services:\n redis:\n image: redis:7\n ports:\n - 16379:6379\n options: >-\n --health-cmd \"redis-cli ping\"\n --health-retries 10\n", | |
| 15 | + | " - name: ctx\n run: echo \"p=${{ job.services.redis.ports['6379'] }} n=${{ job.services.redis.network != '' }}\"\n", nil) | |
| 16 | + | if res.Conclusion != ConclusionSuccess { | |
| 17 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 18 | + | } | |
| 19 | + | if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|ctx|Stop containers|Complete job" { | |
| 20 | + | t.Fatalf("steps %s", got) | |
| 21 | + | } | |
| 22 | + | init := logOf(rec.step(t, "Initialize containers")) | |
| 23 | + | for _, w := range []string{ | |
| 24 | + | "##[group]Checking docker version\n##[command]", "Docker daemon API version: '1.48'", "Docker client API version: '1.48'", | |
| 25 | + | "##[group]Clean up resources from previous jobs", "##[group]Create local container network", | |
| 26 | + | "##[group]Starting redis service container", " network create --label ", | |
| 27 | + | "--network-alias redis -p 16379:6379 --health-cmd \"redis-cli ping\" --health-retries 10 -e GITHUB_ACTIONS=true -e CI=true redis:7", | |
| 28 | + | "##[group]Waiting for all services to be ready", "redis service is starting, waiting 0 seconds before checking again.", "redis service is healthy.", | |
| 29 | + | } { | |
| 30 | + | if !strings.Contains(init, w) { | |
| 31 | + | t.Errorf("init log lacks %q:\n%s", w, init) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | if strings.Contains(init, "--workdir") || strings.Contains(init, "--entrypoint") { | |
| 35 | + | t.Errorf("a service has neither workdir nor entrypoint:\n%s", init) | |
| 36 | + | } | |
| 37 | + | stop := logOf(rec.step(t, "Stop containers")) | |
| 38 | + | if !strings.Contains(stop, "Print service container logs: ") || !strings.Contains(stop, "Stop and remove container: ") || | |
| 39 | + | !strings.Contains(stop, "Remove container network: github_network_") || !strings.Contains(stop, "network rm github_network_") { | |
| 40 | + | t.Errorf("stop:\n%s", stop) | |
| 41 | + | } | |
| 42 | + | want := "version version ps network prune network create pull create start ps port inspect inspect logs rm network rm" | |
| 43 | + | if got := strings.Join(f.verbs(t), " "); got != want { | |
| 44 | + | t.Errorf("docker calls:\n%s\nwant\n%s", got, want) | |
| 45 | + | } | |
| 46 | + | run := logOf(rec.step(t, "ctx")) | |
| 47 | + | if !strings.Contains(run, "p=16379 n=true") { | |
| 48 | + | t.Errorf("job.services not in the context:\n%s", run) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // GO-004: values of container variables are in the environment of the docker | |
| 53 | + | // process; the arguments carry "-e NAME". The echoed line is the runner's | |
| 54 | + | // (recorded: -e "NAME=value"), masked like any log. | |
| 55 | + | func TestContainerEnvValuesStayOffTheArguments(t *testing.T) { | |
| 56 | + | f := newFakeCtr(t) | |
| 57 | + | rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n CVAR: s3cret-value\n EMPTYV: \"\"\n DOCKER_HOST: tcp://x:1\n", | |
| 58 | + | " - name: p\n run: echo\n - name: q\n env:\n STEPVAR: s3cret-step\n run: echo\n", func(c *Config) { f.set(t, "add_path", c.Layout.WorkRoot) }) | |
| 59 | + | if res.Conclusion != ConclusionSuccess { | |
| 60 | + | t.Fatalf("%+v", res) | |
| 61 | + | } | |
| 62 | + | var create []string | |
| 63 | + | for _, c := range f.calls(t) { | |
| 64 | + | if c[0] == "create" { | |
| 65 | + | create = c | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | joined := strings.Join(create, "\n") | |
| 69 | + | for _, w := range []string{"\n-e\nCVAR\n", "\n-e\nEMPTYV\n", "\n-e\nDOCKER_HOST=tcp://x:1\n", "ENV:CVAR=s3cret-value:EMPTYV=:"} { | |
| 70 | + | if !strings.Contains(joined, w) { | |
| 71 | + | t.Errorf("create lacks %q:\n%s", w, joined) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | if strings.Contains(joined, "CVAR=s3cret") && !strings.Contains(joined, "ENV:CVAR=s3cret") { | |
| 75 | + | t.Errorf("value on the arguments:\n%s", joined) | |
| 76 | + | } | |
| 77 | + | for _, a := range create { | |
| 78 | + | if strings.HasPrefix(a, "CVAR=") || strings.HasPrefix(a, "EMPTYV=") { | |
| 79 | + | t.Errorf("argument %q carries a value", a) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | // What the runner echoes (recorded in ctr-options). | |
| 83 | + | init := logOf(rec.step(t, "Initialize containers")) | |
| 84 | + | if !strings.Contains(init, ` -e "CVAR=s3cret-value" -e "EMPTYV" `) { | |
| 85 | + | t.Errorf("echo:\n%s", init) | |
| 86 | + | } | |
| 87 | + | // exec: no value of a step variable and no PATH on the arguments. | |
| 88 | + | for _, c := range f.execCalls(t) { | |
| 89 | + | for _, a := range c { | |
| 90 | + | if strings.HasPrefix(a, "ENV:") { | |
| 91 | + | continue // what the fake saw in its environment | |
| 92 | + | } | |
| 93 | + | if strings.Contains(a, "s3cret-step") || strings.HasPrefix(a, "PATH=") || strings.Contains(a, "/added") { | |
| 94 | + | t.Errorf("exec argument %q", a) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ex := f.execCalls(t) | |
| 99 | + | last := strings.Join(ex[len(ex)-1], "\n") | |
| 100 | + | if !strings.Contains(last, "\n-e\nPATH\n") || !strings.Contains(last, "ENV:") || !strings.Contains(last, "PATH=/added:") { | |
| 101 | + | t.Errorf("second exec:\n%s", last) | |
| 102 | + | } | |
| 103 | + | } | |
| @@ -0,0 +1,103 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestInitAndStopServiceOnly(t *testing.T) { | |||
| 11 | + | f := newFakeCtr(t) | |||
| 12 | + | f.set(t, "health", "starting\nhealthy\n") | |||
| 13 | + | f.set(t, "ports", "6379/tcp -> 0.0.0.0:16379\n6379/tcp -> [::]:16379\n") | |||
| 14 | + | rec, res := ctrRun(t, f, " services:\n redis:\n image: redis:7\n ports:\n - 16379:6379\n options: >-\n --health-cmd \"redis-cli ping\"\n --health-retries 10\n", | |||
| 15 | + | " - name: ctx\n run: echo \"p=${{ job.services.redis.ports['6379'] }} n=${{ job.services.redis.network != '' }}\"\n", nil) | |||
| 16 | + | if res.Conclusion != ConclusionSuccess { | |||
| 17 | + | t.Fatalf("%+v %v", res, rec.names()) | |||
| 18 | + | } | |||
| 19 | + | if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|ctx|Stop containers|Complete job" { | |||
| 20 | + | t.Fatalf("steps %s", got) | |||
| 21 | + | } | |||
| 22 | + | init := logOf(rec.step(t, "Initialize containers")) | |||
| 23 | + | for _, w := range []string{ | |||
| 24 | + | "##[group]Checking docker version\n##[command]", "Docker daemon API version: '1.48'", "Docker client API version: '1.48'", | |||
| 25 | + | "##[group]Clean up resources from previous jobs", "##[group]Create local container network", | |||
| 26 | + | "##[group]Starting redis service container", " network create --label ", | |||
| 27 | + | "--network-alias redis -p 16379:6379 --health-cmd \"redis-cli ping\" --health-retries 10 -e GITHUB_ACTIONS=true -e CI=true redis:7", | |||
| 28 | + | "##[group]Waiting for all services to be ready", "redis service is starting, waiting 0 seconds before checking again.", "redis service is healthy.", | |||
| 29 | + | } { | |||
| 30 | + | if !strings.Contains(init, w) { | |||
| 31 | + | t.Errorf("init log lacks %q:\n%s", w, init) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | if strings.Contains(init, "--workdir") || strings.Contains(init, "--entrypoint") { | |||
| 35 | + | t.Errorf("a service has neither workdir nor entrypoint:\n%s", init) | |||
| 36 | + | } | |||
| 37 | + | stop := logOf(rec.step(t, "Stop containers")) | |||
| 38 | + | if !strings.Contains(stop, "Print service container logs: ") || !strings.Contains(stop, "Stop and remove container: ") || | |||
| 39 | + | !strings.Contains(stop, "Remove container network: github_network_") || !strings.Contains(stop, "network rm github_network_") { | |||
| 40 | + | t.Errorf("stop:\n%s", stop) | |||
| 41 | + | } | |||
| 42 | + | want := "version version ps network prune network create pull create start ps port inspect inspect logs rm network rm" | |||
| 43 | + | if got := strings.Join(f.verbs(t), " "); got != want { | |||
| 44 | + | t.Errorf("docker calls:\n%s\nwant\n%s", got, want) | |||
| 45 | + | } | |||
| 46 | + | run := logOf(rec.step(t, "ctx")) | |||
| 47 | + | if !strings.Contains(run, "p=16379 n=true") { | |||
| 48 | + | t.Errorf("job.services not in the context:\n%s", run) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // GO-004: values of container variables are in the environment of the docker | |||
| 53 | + | // process; the arguments carry "-e NAME". The echoed line is the runner's | |||
| 54 | + | // (recorded: -e "NAME=value"), masked like any log. | |||
| 55 | + | func TestContainerEnvValuesStayOffTheArguments(t *testing.T) { | |||
| 56 | + | f := newFakeCtr(t) | |||
| 57 | + | rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n CVAR: s3cret-value\n EMPTYV: \"\"\n DOCKER_HOST: tcp://x:1\n", | |||
| 58 | + | " - name: p\n run: echo\n - name: q\n env:\n STEPVAR: s3cret-step\n run: echo\n", func(c *Config) { f.set(t, "add_path", c.Layout.WorkRoot) }) | |||
| 59 | + | if res.Conclusion != ConclusionSuccess { | |||
| 60 | + | t.Fatalf("%+v", res) | |||
| 61 | + | } | |||
| 62 | + | var create []string | |||
| 63 | + | for _, c := range f.calls(t) { | |||
| 64 | + | if c[0] == "create" { | |||
| 65 | + | create = c | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | joined := strings.Join(create, "\n") | |||
| 69 | + | for _, w := range []string{"\n-e\nCVAR\n", "\n-e\nEMPTYV\n", "\n-e\nDOCKER_HOST=tcp://x:1\n", "ENV:CVAR=s3cret-value:EMPTYV=:"} { | |||
| 70 | + | if !strings.Contains(joined, w) { | |||
| 71 | + | t.Errorf("create lacks %q:\n%s", w, joined) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | if strings.Contains(joined, "CVAR=s3cret") && !strings.Contains(joined, "ENV:CVAR=s3cret") { | |||
| 75 | + | t.Errorf("value on the arguments:\n%s", joined) | |||
| 76 | + | } | |||
| 77 | + | for _, a := range create { | |||
| 78 | + | if strings.HasPrefix(a, "CVAR=") || strings.HasPrefix(a, "EMPTYV=") { | |||
| 79 | + | t.Errorf("argument %q carries a value", a) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | // What the runner echoes (recorded in ctr-options). | |||
| 83 | + | init := logOf(rec.step(t, "Initialize containers")) | |||
| 84 | + | if !strings.Contains(init, ` -e "CVAR=s3cret-value" -e "EMPTYV" `) { | |||
| 85 | + | t.Errorf("echo:\n%s", init) | |||
| 86 | + | } | |||
| 87 | + | // exec: no value of a step variable and no PATH on the arguments. | |||
| 88 | + | for _, c := range f.execCalls(t) { | |||
| 89 | + | for _, a := range c { | |||
| 90 | + | if strings.HasPrefix(a, "ENV:") { | |||
| 91 | + | continue // what the fake saw in its environment | |||
| 92 | + | } | |||
| 93 | + | if strings.Contains(a, "s3cret-step") || strings.HasPrefix(a, "PATH=") || strings.Contains(a, "/added") { | |||
| 94 | + | t.Errorf("exec argument %q", a) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ex := f.execCalls(t) | |||
| 99 | + | last := strings.Join(ex[len(ex)-1], "\n") | |||
| 100 | + | if !strings.Contains(last, "\n-e\nPATH\n") || !strings.Contains(last, "ENV:") || !strings.Contains(last, "PATH=/added:") { | |||
| 101 | + | t.Errorf("second exec:\n%s", last) | |||
| 102 | + | } | |||
| 103 | + | } | |||
A internal/executor/containers_life_test.go +193 -0
| @@ -0,0 +1,193 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const ctrJob = " container: ubuntu:24.04\n" | |
| 14 | + | ||
| 15 | + | func TestPullRetriesThenFailsAndStillCleansUp(t *testing.T) { | |
| 16 | + | f := newFakeCtr(t) | |
| 17 | + | f.set(t, "pull_fail", "9") | |
| 18 | + | rec, res := ctrRun(t, f, " container: no-such-image:nope\n", " - name: never\n run: echo never\n", nil) | |
| 19 | + | if res.Conclusion != ConclusionFailure { | |
| 20 | + | t.Fatalf("%+v", res) | |
| 21 | + | } | |
| 22 | + | if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|never|Stop containers|Complete job" { | |
| 23 | + | t.Fatalf("steps %s", got) | |
| 24 | + | } | |
| 25 | + | if c := rec.concl("never"); c != ConclusionSkipped { | |
| 26 | + | t.Errorf("never: %s", c) | |
| 27 | + | } | |
| 28 | + | log := logOf(rec.step(t, "Initialize containers")) | |
| 29 | + | if n := strings.Count(log, "##[command]"+f.bin+" pull no-such-image:nope"); n != 3 { | |
| 30 | + | t.Errorf("%d pulls:\n%s", n, log) | |
| 31 | + | } | |
| 32 | + | for _, w := range []string{"##[warning]Docker pull failed with exit code 1, back off 0.001 seconds before retry.", "##[error]Docker pull failed with exit code 1"} { | |
| 33 | + | if !strings.Contains(log, w) { | |
| 34 | + | t.Errorf("lacks %q:\n%s", w, log) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | // Nothing was created: the stop step has only the network to remove. | |
| 38 | + | stop := logOf(rec.step(t, "Stop containers")) | |
| 39 | + | if strings.Contains(stop, "Stop and remove container") || !strings.Contains(stop, "Remove container network: github_network_") { | |
| 40 | + | t.Errorf("stop:\n%s", stop) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestCreateFailureMessage(t *testing.T) { | |
| 45 | + | f := newFakeCtr(t) | |
| 46 | + | f.set(t, "create_fail", "1") | |
| 47 | + | rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n options: --not-a-docker-flag\n", " - run: echo never\n", nil) | |
| 48 | + | if res.Conclusion != ConclusionFailure { | |
| 49 | + | t.Fatalf("%+v", res) | |
| 50 | + | } | |
| 51 | + | log := logOf(rec.step(t, "Initialize containers")) | |
| 52 | + | if !strings.Contains(log, "##[error]Exit code 125 returned from process: file name '"+f.bin+"', arguments 'create --name ") || | |
| 53 | + | !strings.Contains(log, " --not-a-docker-flag -e \"HOME=/github/home\"") { | |
| 54 | + | t.Errorf("log:\n%s", log) | |
| 55 | + | } | |
| 56 | + | if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network") { | |
| 57 | + | t.Errorf("stop:\n%s", got) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestUnhealthyServiceFailsInit(t *testing.T) { | |
| 62 | + | f := newFakeCtr(t) | |
| 63 | + | f.set(t, "health", "starting\nstarting\nunhealthy\n") | |
| 64 | + | rec, res := ctrRun(t, f, " services:\n broken:\n image: redis:7\n", " - run: echo never\n", nil) | |
| 65 | + | if res.Conclusion != ConclusionFailure { | |
| 66 | + | t.Fatalf("%+v", res) | |
| 67 | + | } | |
| 68 | + | log := logOf(rec.step(t, "Initialize containers")) | |
| 69 | + | for _, w := range []string{ | |
| 70 | + | "##[group]Service container broken failed.\n##[command]" + f.bin + " logs --details ", | |
| 71 | + | "##[error]Failed to initialize container redis:7\n##[endgroup]\n##[error]One or more containers failed to start.", | |
| 72 | + | } { | |
| 73 | + | if !strings.Contains(log, w) { | |
| 74 | + | t.Errorf("lacks %q:\n%s", w, log) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | // A service that failed to initialise has its logs printed once, not twice. | |
| 78 | + | stop := logOf(rec.step(t, "Stop containers")) | |
| 79 | + | if strings.Contains(stop, "Print service container logs") || !strings.Contains(stop, "Stop and remove container: ") { | |
| 80 | + | t.Errorf("stop:\n%s", stop) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestRegistryCredentialsAreMaskedAndRemoved(t *testing.T) { | |
| 85 | + | f := newFakeCtr(t) | |
| 86 | + | var temp string | |
| 87 | + | rec, res := ctrRun(t, f, " container:\n image: ghcr.io/o/img:1\n credentials:\n username: bob\n password: hunter2-secret\n", " - run: echo ok\n", func(c *Config) { temp = c.Layout.Temp }) | |
| 88 | + | if res.Conclusion != ConclusionSuccess { | |
| 89 | + | t.Fatalf("%+v", res) | |
| 90 | + | } | |
| 91 | + | all := "" | |
| 92 | + | for _, s := range rec.steps { | |
| 93 | + | all += logOf(s) | |
| 94 | + | } | |
| 95 | + | if strings.Contains(all, "hunter2-secret") { | |
| 96 | + | t.Errorf("password in the log") | |
| 97 | + | } | |
| 98 | + | if b, _ := os.ReadFile(filepath.Join(f.dir, "login_stdin")); string(b) != "hunter2-secret\n" { | |
| 99 | + | t.Errorf("password not passed through stdin: %q", b) | |
| 100 | + | } | |
| 101 | + | log := logOf(rec.step(t, "Initialize containers")) | |
| 102 | + | if !strings.Contains(log, " login ghcr.io -u bob --password-stdin") || !strings.Contains(log, " pull ghcr.io/o/img:1") || !strings.Contains(log, " --config ") { | |
| 103 | + | t.Errorf("log:\n%s", log) | |
| 104 | + | } | |
| 105 | + | if m, _ := filepath.Glob(filepath.Join(temp, ".docker_*")); len(m) != 0 { | |
| 106 | + | t.Errorf("config left behind: %v", m) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestCancelDuringStepStillStopsContainers(t *testing.T) { | |
| 111 | + | f := newFakeCtr(t) | |
| 112 | + | f.set(t, "hang_exec", "1") | |
| 113 | + | cancel := NewCanceller() | |
| 114 | + | go func() { time.Sleep(600 * time.Millisecond); cancel.Cancel(CancelUser) }() | |
| 115 | + | rec, res := ctrRunC(t, f, cancel, ctrJob, " - name: long\n run: sleep 60\n", nil) | |
| 116 | + | if res.Conclusion != ConclusionCancelled { | |
| 117 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 118 | + | } | |
| 119 | + | stop := rec.step(t, "Stop containers") | |
| 120 | + | if got := logOf(stop); !strings.Contains(got, "Stop and remove container: ") || !strings.Contains(got, "Remove container network: ") { | |
| 121 | + | t.Errorf("stop:\n%s", got) | |
| 122 | + | } | |
| 123 | + | if c := rec.concl("Stop containers"); c != ConclusionSuccess { | |
| 124 | + | t.Errorf("Stop containers: %s", c) | |
| 125 | + | } | |
| 126 | + | if v := f.verbs(t); v[len(v)-1] != "network rm" || v[len(v)-2] != "rm" { | |
| 127 | + | t.Errorf("last calls %v", v) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestStepTimeoutStopsContainersAndIsAFailure(t *testing.T) { | |
| 132 | + | f := newFakeCtr(t) | |
| 133 | + | f.set(t, "hang_exec", "1") | |
| 134 | + | rec, res := ctrRun(t, f, ctrJob, " - name: long\n timeout-minutes: 1\n run: sleep 60\n", func(c *Config) { | |
| 135 | + | c.StepTimeoutSkew = -59*time.Second - 700*time.Millisecond | |
| 136 | + | }) | |
| 137 | + | if res.Conclusion != ConclusionFailure { | |
| 138 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 139 | + | } | |
| 140 | + | if got := logOf(rec.step(t, "long")); !strings.Contains(got, "##[error]The action 'long' has timed out after 1 minutes.") { | |
| 141 | + | t.Errorf("long:\n%s", got) | |
| 142 | + | } | |
| 143 | + | if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network: ") { | |
| 144 | + | t.Errorf("stop:\n%s", got) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestJobTimeoutStopsContainers(t *testing.T) { | |
| 149 | + | f := newFakeCtr(t) | |
| 150 | + | f.set(t, "hang_exec", "1") | |
| 151 | + | rec, res := ctrRun(t, f, ctrJob, " - name: long\n run: sleep 60\n", func(c *Config) { c.Deadline = 700 * time.Millisecond }) | |
| 152 | + | if res.Conclusion != ConclusionTimedOut { | |
| 153 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 154 | + | } | |
| 155 | + | if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Stop and remove container: ") || !strings.Contains(got, "Remove container network: ") { | |
| 156 | + | t.Errorf("stop:\n%s", got) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestStopContainersRunsAfterAFailedStepAndPostStepsRunInTheContainer(t *testing.T) { | |
| 161 | + | f := newFakeCtr(t) | |
| 162 | + | f.set(t, "exec_exit", "1") // the first exec fails | |
| 163 | + | rec, res := ctrRun(t, f, ctrJob, " - name: bad\n run: exit 1\n - name: after\n if: always()\n run: echo after\n", nil) | |
| 164 | + | if res.Conclusion != ConclusionFailure { | |
| 165 | + | t.Fatalf("%+v", res) | |
| 166 | + | } | |
| 167 | + | if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|bad|after|Stop containers|Complete job" { | |
| 168 | + | t.Errorf("steps %s", got) | |
| 169 | + | } | |
| 170 | + | if len(f.execCalls(t)) != 2 { | |
| 171 | + | t.Errorf("both steps run through docker exec: %v", f.execCalls(t)) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // After a failed "Initialize containers" an always() step has no container: | |
| 176 | + | // the runner fails it before its header with a null ContainerId (recorded: | |
| 177 | + | // the ermite-dump step of ctr-errors). | |
| 178 | + | func TestStepAfterFailedInitFailsWithNullContainerId(t *testing.T) { | |
| 179 | + | f := newFakeCtr(t) | |
| 180 | + | f.set(t, "create_fail", "1") | |
| 181 | + | tree := files{".github/actions/x/action.yml": "name: x\nruns:\n using: node20\n main: i.js\n", ".github/actions/x/i.js": "x\n"} | |
| 182 | + | rec, res := ctrRun(t, f, ctrJob, " - name: run\n if: always()\n run: echo no\n - name: act\n if: always()\n uses: ./.github/actions/x\n", func(c *Config) { | |
| 183 | + | writeFiles(t, c.Layout.Workspace, tree) | |
| 184 | + | }) | |
| 185 | + | if res.Conclusion != ConclusionFailure { | |
| 186 | + | t.Fatalf("%+v", res) | |
| 187 | + | } | |
| 188 | + | for _, n := range []string{"run", "act"} { | |
| 189 | + | if got := logOf(rec.step(t, n)); got != "##[error]Value cannot be null. (Parameter 'ContainerId')" || rec.concl(n) != ConclusionFailure { | |
| 190 | + | t.Errorf("%s: %q %s", n, got, rec.concl(n)) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | } | |
| @@ -0,0 +1,193 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const ctrJob = " container: ubuntu:24.04\n" | |||
| 14 | + | ||||
| 15 | + | func TestPullRetriesThenFailsAndStillCleansUp(t *testing.T) { | |||
| 16 | + | f := newFakeCtr(t) | |||
| 17 | + | f.set(t, "pull_fail", "9") | |||
| 18 | + | rec, res := ctrRun(t, f, " container: no-such-image:nope\n", " - name: never\n run: echo never\n", nil) | |||
| 19 | + | if res.Conclusion != ConclusionFailure { | |||
| 20 | + | t.Fatalf("%+v", res) | |||
| 21 | + | } | |||
| 22 | + | if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|never|Stop containers|Complete job" { | |||
| 23 | + | t.Fatalf("steps %s", got) | |||
| 24 | + | } | |||
| 25 | + | if c := rec.concl("never"); c != ConclusionSkipped { | |||
| 26 | + | t.Errorf("never: %s", c) | |||
| 27 | + | } | |||
| 28 | + | log := logOf(rec.step(t, "Initialize containers")) | |||
| 29 | + | if n := strings.Count(log, "##[command]"+f.bin+" pull no-such-image:nope"); n != 3 { | |||
| 30 | + | t.Errorf("%d pulls:\n%s", n, log) | |||
| 31 | + | } | |||
| 32 | + | for _, w := range []string{"##[warning]Docker pull failed with exit code 1, back off 0.001 seconds before retry.", "##[error]Docker pull failed with exit code 1"} { | |||
| 33 | + | if !strings.Contains(log, w) { | |||
| 34 | + | t.Errorf("lacks %q:\n%s", w, log) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | // Nothing was created: the stop step has only the network to remove. | |||
| 38 | + | stop := logOf(rec.step(t, "Stop containers")) | |||
| 39 | + | if strings.Contains(stop, "Stop and remove container") || !strings.Contains(stop, "Remove container network: github_network_") { | |||
| 40 | + | t.Errorf("stop:\n%s", stop) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestCreateFailureMessage(t *testing.T) { | |||
| 45 | + | f := newFakeCtr(t) | |||
| 46 | + | f.set(t, "create_fail", "1") | |||
| 47 | + | rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n options: --not-a-docker-flag\n", " - run: echo never\n", nil) | |||
| 48 | + | if res.Conclusion != ConclusionFailure { | |||
| 49 | + | t.Fatalf("%+v", res) | |||
| 50 | + | } | |||
| 51 | + | log := logOf(rec.step(t, "Initialize containers")) | |||
| 52 | + | if !strings.Contains(log, "##[error]Exit code 125 returned from process: file name '"+f.bin+"', arguments 'create --name ") || | |||
| 53 | + | !strings.Contains(log, " --not-a-docker-flag -e \"HOME=/github/home\"") { | |||
| 54 | + | t.Errorf("log:\n%s", log) | |||
| 55 | + | } | |||
| 56 | + | if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network") { | |||
| 57 | + | t.Errorf("stop:\n%s", got) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestUnhealthyServiceFailsInit(t *testing.T) { | |||
| 62 | + | f := newFakeCtr(t) | |||
| 63 | + | f.set(t, "health", "starting\nstarting\nunhealthy\n") | |||
| 64 | + | rec, res := ctrRun(t, f, " services:\n broken:\n image: redis:7\n", " - run: echo never\n", nil) | |||
| 65 | + | if res.Conclusion != ConclusionFailure { | |||
| 66 | + | t.Fatalf("%+v", res) | |||
| 67 | + | } | |||
| 68 | + | log := logOf(rec.step(t, "Initialize containers")) | |||
| 69 | + | for _, w := range []string{ | |||
| 70 | + | "##[group]Service container broken failed.\n##[command]" + f.bin + " logs --details ", | |||
| 71 | + | "##[error]Failed to initialize container redis:7\n##[endgroup]\n##[error]One or more containers failed to start.", | |||
| 72 | + | } { | |||
| 73 | + | if !strings.Contains(log, w) { | |||
| 74 | + | t.Errorf("lacks %q:\n%s", w, log) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | // A service that failed to initialise has its logs printed once, not twice. | |||
| 78 | + | stop := logOf(rec.step(t, "Stop containers")) | |||
| 79 | + | if strings.Contains(stop, "Print service container logs") || !strings.Contains(stop, "Stop and remove container: ") { | |||
| 80 | + | t.Errorf("stop:\n%s", stop) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestRegistryCredentialsAreMaskedAndRemoved(t *testing.T) { | |||
| 85 | + | f := newFakeCtr(t) | |||
| 86 | + | var temp string | |||
| 87 | + | rec, res := ctrRun(t, f, " container:\n image: ghcr.io/o/img:1\n credentials:\n username: bob\n password: hunter2-secret\n", " - run: echo ok\n", func(c *Config) { temp = c.Layout.Temp }) | |||
| 88 | + | if res.Conclusion != ConclusionSuccess { | |||
| 89 | + | t.Fatalf("%+v", res) | |||
| 90 | + | } | |||
| 91 | + | all := "" | |||
| 92 | + | for _, s := range rec.steps { | |||
| 93 | + | all += logOf(s) | |||
| 94 | + | } | |||
| 95 | + | if strings.Contains(all, "hunter2-secret") { | |||
| 96 | + | t.Errorf("password in the log") | |||
| 97 | + | } | |||
| 98 | + | if b, _ := os.ReadFile(filepath.Join(f.dir, "login_stdin")); string(b) != "hunter2-secret\n" { | |||
| 99 | + | t.Errorf("password not passed through stdin: %q", b) | |||
| 100 | + | } | |||
| 101 | + | log := logOf(rec.step(t, "Initialize containers")) | |||
| 102 | + | if !strings.Contains(log, " login ghcr.io -u bob --password-stdin") || !strings.Contains(log, " pull ghcr.io/o/img:1") || !strings.Contains(log, " --config ") { | |||
| 103 | + | t.Errorf("log:\n%s", log) | |||
| 104 | + | } | |||
| 105 | + | if m, _ := filepath.Glob(filepath.Join(temp, ".docker_*")); len(m) != 0 { | |||
| 106 | + | t.Errorf("config left behind: %v", m) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestCancelDuringStepStillStopsContainers(t *testing.T) { | |||
| 111 | + | f := newFakeCtr(t) | |||
| 112 | + | f.set(t, "hang_exec", "1") | |||
| 113 | + | cancel := NewCanceller() | |||
| 114 | + | go func() { time.Sleep(600 * time.Millisecond); cancel.Cancel(CancelUser) }() | |||
| 115 | + | rec, res := ctrRunC(t, f, cancel, ctrJob, " - name: long\n run: sleep 60\n", nil) | |||
| 116 | + | if res.Conclusion != ConclusionCancelled { | |||
| 117 | + | t.Fatalf("%+v %v", res, rec.names()) | |||
| 118 | + | } | |||
| 119 | + | stop := rec.step(t, "Stop containers") | |||
| 120 | + | if got := logOf(stop); !strings.Contains(got, "Stop and remove container: ") || !strings.Contains(got, "Remove container network: ") { | |||
| 121 | + | t.Errorf("stop:\n%s", got) | |||
| 122 | + | } | |||
| 123 | + | if c := rec.concl("Stop containers"); c != ConclusionSuccess { | |||
| 124 | + | t.Errorf("Stop containers: %s", c) | |||
| 125 | + | } | |||
| 126 | + | if v := f.verbs(t); v[len(v)-1] != "network rm" || v[len(v)-2] != "rm" { | |||
| 127 | + | t.Errorf("last calls %v", v) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func TestStepTimeoutStopsContainersAndIsAFailure(t *testing.T) { | |||
| 132 | + | f := newFakeCtr(t) | |||
| 133 | + | f.set(t, "hang_exec", "1") | |||
| 134 | + | rec, res := ctrRun(t, f, ctrJob, " - name: long\n timeout-minutes: 1\n run: sleep 60\n", func(c *Config) { | |||
| 135 | + | c.StepTimeoutSkew = -59*time.Second - 700*time.Millisecond | |||
| 136 | + | }) | |||
| 137 | + | if res.Conclusion != ConclusionFailure { | |||
| 138 | + | t.Fatalf("%+v %v", res, rec.names()) | |||
| 139 | + | } | |||
| 140 | + | if got := logOf(rec.step(t, "long")); !strings.Contains(got, "##[error]The action 'long' has timed out after 1 minutes.") { | |||
| 141 | + | t.Errorf("long:\n%s", got) | |||
| 142 | + | } | |||
| 143 | + | if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network: ") { | |||
| 144 | + | t.Errorf("stop:\n%s", got) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestJobTimeoutStopsContainers(t *testing.T) { | |||
| 149 | + | f := newFakeCtr(t) | |||
| 150 | + | f.set(t, "hang_exec", "1") | |||
| 151 | + | rec, res := ctrRun(t, f, ctrJob, " - name: long\n run: sleep 60\n", func(c *Config) { c.Deadline = 700 * time.Millisecond }) | |||
| 152 | + | if res.Conclusion != ConclusionTimedOut { | |||
| 153 | + | t.Fatalf("%+v %v", res, rec.names()) | |||
| 154 | + | } | |||
| 155 | + | if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Stop and remove container: ") || !strings.Contains(got, "Remove container network: ") { | |||
| 156 | + | t.Errorf("stop:\n%s", got) | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func TestStopContainersRunsAfterAFailedStepAndPostStepsRunInTheContainer(t *testing.T) { | |||
| 161 | + | f := newFakeCtr(t) | |||
| 162 | + | f.set(t, "exec_exit", "1") // the first exec fails | |||
| 163 | + | rec, res := ctrRun(t, f, ctrJob, " - name: bad\n run: exit 1\n - name: after\n if: always()\n run: echo after\n", nil) | |||
| 164 | + | if res.Conclusion != ConclusionFailure { | |||
| 165 | + | t.Fatalf("%+v", res) | |||
| 166 | + | } | |||
| 167 | + | if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|bad|after|Stop containers|Complete job" { | |||
| 168 | + | t.Errorf("steps %s", got) | |||
| 169 | + | } | |||
| 170 | + | if len(f.execCalls(t)) != 2 { | |||
| 171 | + | t.Errorf("both steps run through docker exec: %v", f.execCalls(t)) | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // After a failed "Initialize containers" an always() step has no container: | |||
| 176 | + | // the runner fails it before its header with a null ContainerId (recorded: | |||
| 177 | + | // the ermite-dump step of ctr-errors). | |||
| 178 | + | func TestStepAfterFailedInitFailsWithNullContainerId(t *testing.T) { | |||
| 179 | + | f := newFakeCtr(t) | |||
| 180 | + | f.set(t, "create_fail", "1") | |||
| 181 | + | tree := files{".github/actions/x/action.yml": "name: x\nruns:\n using: node20\n main: i.js\n", ".github/actions/x/i.js": "x\n"} | |||
| 182 | + | rec, res := ctrRun(t, f, ctrJob, " - name: run\n if: always()\n run: echo no\n - name: act\n if: always()\n uses: ./.github/actions/x\n", func(c *Config) { | |||
| 183 | + | writeFiles(t, c.Layout.Workspace, tree) | |||
| 184 | + | }) | |||
| 185 | + | if res.Conclusion != ConclusionFailure { | |||
| 186 | + | t.Fatalf("%+v", res) | |||
| 187 | + | } | |||
| 188 | + | for _, n := range []string{"run", "act"} { | |||
| 189 | + | if got := logOf(rec.step(t, n)); got != "##[error]Value cannot be null. (Parameter 'ContainerId')" || rec.concl(n) != ConclusionFailure { | |||
| 190 | + | t.Errorf("%s: %q %s", n, got, rec.concl(n)) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | } | |||
A internal/executor/containers_node.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "path/filepath" | |
| 8 | + | "runtime" | |
| 9 | + | "strings" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // alpineCheck is the command of ContainerStepHost.DetermineNodeRuntimeVersion | |
| 13 | + | // (StepHost.cs:176): the distribution is read from the release files. | |
| 14 | + | const alpineCheck = `cat /etc/*release | grep ^ID` | |
| 15 | + | ||
| 16 | + | // nodeInContainer is ContainerStepHost.DetermineNodeRuntimeVersion | |
| 17 | + | // (StepHost.cs:154-196, container hooks off): "<version>_alpine" when the | |
| 18 | + | // image is Alpine (musl, where the glibc builds do not run), else the version | |
| 19 | + | // itself. The output of the probe is not logged, only the command is. | |
| 20 | + | func (j *job) nodeInContainer(s *stepRun, c *container, version string, cancel <-chan struct{}) (string, error) { | |
| 21 | + | cmd := j.newDockerCmd("exec", dockerPart{line: ""}, raw(c.id), | |
| 22 | + | dockerPart{line: `sh -c "` + alpineCheck + `"`, args: []string{"sh", "-c", alpineCheck}}) | |
| 23 | + | j.out(s.num, "##[command]"+cmd.line) | |
| 24 | + | var out []string | |
| 25 | + | res, err := runProcess(procSpec{ | |
| 26 | + | Argv: cmd.argv, Dir: dirOrCwd(j.githubValue("workspace")), Env: j.processEnv(newOrdered()), Credential: j.cfg.Credential, | |
| 27 | + | OnLine: func(l string) { | |
| 28 | + | if l != "" { | |
| 29 | + | out = append(out, l) | |
| 30 | + | } | |
| 31 | + | }, | |
| 32 | + | OnErrLine: func(l string) { | |
| 33 | + | if l != "" { | |
| 34 | + | out = append(out, l) | |
| 35 | + | } | |
| 36 | + | }, | |
| 37 | + | Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal, | |
| 38 | + | }) | |
| 39 | + | if err != nil { | |
| 40 | + | return "", err | |
| 41 | + | } | |
| 42 | + | if res.Cancelled { | |
| 43 | + | return "", errCancelled | |
| 44 | + | } | |
| 45 | + | if res.ExitCode == 0 { | |
| 46 | + | for _, l := range out { | |
| 47 | + | j.debug(s.num, l) | |
| 48 | + | if strings.Contains(strings.ToLower(l), "alpine") { | |
| 49 | + | return j.alpineNode(s, version) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | j.debug(s.num, "Running JavaScript Action with default external tool: "+version) | |
| 54 | + | return version, nil | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // alpineNode is CheckPlatformForAlpineContainer (StepHost.cs:248-279): the | |
| 58 | + | // alpine builds exist for x64 only. | |
| 59 | + | func (j *job) alpineNode(s *stepRun, version string) (string, error) { | |
| 60 | + | if runtime.GOARCH != "amd64" { | |
| 61 | + | arch := map[string]string{"arm64": "Arm64", "arm": "Arm", "386": "X86"}[runtime.GOARCH] | |
| 62 | + | if arch == "" { | |
| 63 | + | arch = runtime.GOARCH | |
| 64 | + | } | |
| 65 | + | return "", fmt.Errorf("JavaScript Actions in Alpine containers are only supported on x64 Linux runners. Detected Linux %s", arch) | |
| 66 | + | } | |
| 67 | + | v := version + "_alpine" | |
| 68 | + | j.debug(s.num, "Container distribution is alpine. Running JavaScript Action with external tool: "+v) | |
| 69 | + | return v, nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // runNodeInContainer is NodeScriptActionHandler.RunAsync (:127-190) with a | |
| 73 | + | // ContainerStepHost: the node of the externals mounted at /__e runs the script | |
| 74 | + | // at its container path, in the container's workspace. | |
| 75 | + | func (j *job) runNodeInContainer(s *stepRun, c *container, script string, env *ordered, cancel <-chan struct{}) stepResult { | |
| 76 | + | num := s.num | |
| 77 | + | fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed } | |
| 78 | + | if err := j.containerReady(); err != nil { | |
| 79 | + | return fail(err) | |
| 80 | + | } | |
| 81 | + | version, err := j.nodeInContainer(s, c, s.nodeVersion, cancel) | |
| 82 | + | if err != nil { | |
| 83 | + | return j.containerResult(s, err) | |
| 84 | + | } | |
| 85 | + | file := c.resolvePath(filepath.Join(j.externalsDir(), version, "bin", "node")) | |
| 86 | + | j.mu.Lock() | |
| 87 | + | wd := j.state.GitHub("workspace") | |
| 88 | + | j.mu.Unlock() | |
| 89 | + | henv := j.handlerEnv(env) | |
| 90 | + | if err := checkEnvNames(henv.Keys()); err != nil { | |
| 91 | + | return fail(err) | |
| 92 | + | } | |
| 93 | + | argv, penv := j.execArgv(c, c.resolvePath(wd), henv, file, []string{c.resolvePath(`"` + script + `"`)}) | |
| 94 | + | s.rawEnvList = penv | |
| 95 | + | return j.runProc(s, file, j.state.Layout().WorkRoot, argv, "Node Action run completed with exit code %d", cancel) | |
| 96 | + | } | |
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "runtime" | |||
| 9 | + | "strings" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // alpineCheck is the command of ContainerStepHost.DetermineNodeRuntimeVersion | |||
| 13 | + | // (StepHost.cs:176): the distribution is read from the release files. | |||
| 14 | + | const alpineCheck = `cat /etc/*release | grep ^ID` | |||
| 15 | + | ||||
| 16 | + | // nodeInContainer is ContainerStepHost.DetermineNodeRuntimeVersion | |||
| 17 | + | // (StepHost.cs:154-196, container hooks off): "<version>_alpine" when the | |||
| 18 | + | // image is Alpine (musl, where the glibc builds do not run), else the version | |||
| 19 | + | // itself. The output of the probe is not logged, only the command is. | |||
| 20 | + | func (j *job) nodeInContainer(s *stepRun, c *container, version string, cancel <-chan struct{}) (string, error) { | |||
| 21 | + | cmd := j.newDockerCmd("exec", dockerPart{line: ""}, raw(c.id), | |||
| 22 | + | dockerPart{line: `sh -c "` + alpineCheck + `"`, args: []string{"sh", "-c", alpineCheck}}) | |||
| 23 | + | j.out(s.num, "##[command]"+cmd.line) | |||
| 24 | + | var out []string | |||
| 25 | + | res, err := runProcess(procSpec{ | |||
| 26 | + | Argv: cmd.argv, Dir: dirOrCwd(j.githubValue("workspace")), Env: j.processEnv(newOrdered()), Credential: j.cfg.Credential, | |||
| 27 | + | OnLine: func(l string) { | |||
| 28 | + | if l != "" { | |||
| 29 | + | out = append(out, l) | |||
| 30 | + | } | |||
| 31 | + | }, | |||
| 32 | + | OnErrLine: func(l string) { | |||
| 33 | + | if l != "" { | |||
| 34 | + | out = append(out, l) | |||
| 35 | + | } | |||
| 36 | + | }, | |||
| 37 | + | Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal, | |||
| 38 | + | }) | |||
| 39 | + | if err != nil { | |||
| 40 | + | return "", err | |||
| 41 | + | } | |||
| 42 | + | if res.Cancelled { | |||
| 43 | + | return "", errCancelled | |||
| 44 | + | } | |||
| 45 | + | if res.ExitCode == 0 { | |||
| 46 | + | for _, l := range out { | |||
| 47 | + | j.debug(s.num, l) | |||
| 48 | + | if strings.Contains(strings.ToLower(l), "alpine") { | |||
| 49 | + | return j.alpineNode(s, version) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | j.debug(s.num, "Running JavaScript Action with default external tool: "+version) | |||
| 54 | + | return version, nil | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // alpineNode is CheckPlatformForAlpineContainer (StepHost.cs:248-279): the | |||
| 58 | + | // alpine builds exist for x64 only. | |||
| 59 | + | func (j *job) alpineNode(s *stepRun, version string) (string, error) { | |||
| 60 | + | if runtime.GOARCH != "amd64" { | |||
| 61 | + | arch := map[string]string{"arm64": "Arm64", "arm": "Arm", "386": "X86"}[runtime.GOARCH] | |||
| 62 | + | if arch == "" { | |||
| 63 | + | arch = runtime.GOARCH | |||
| 64 | + | } | |||
| 65 | + | return "", fmt.Errorf("JavaScript Actions in Alpine containers are only supported on x64 Linux runners. Detected Linux %s", arch) | |||
| 66 | + | } | |||
| 67 | + | v := version + "_alpine" | |||
| 68 | + | j.debug(s.num, "Container distribution is alpine. Running JavaScript Action with external tool: "+v) | |||
| 69 | + | return v, nil | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // runNodeInContainer is NodeScriptActionHandler.RunAsync (:127-190) with a | |||
| 73 | + | // ContainerStepHost: the node of the externals mounted at /__e runs the script | |||
| 74 | + | // at its container path, in the container's workspace. | |||
| 75 | + | func (j *job) runNodeInContainer(s *stepRun, c *container, script string, env *ordered, cancel <-chan struct{}) stepResult { | |||
| 76 | + | num := s.num | |||
| 77 | + | fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed } | |||
| 78 | + | if err := j.containerReady(); err != nil { | |||
| 79 | + | return fail(err) | |||
| 80 | + | } | |||
| 81 | + | version, err := j.nodeInContainer(s, c, s.nodeVersion, cancel) | |||
| 82 | + | if err != nil { | |||
| 83 | + | return j.containerResult(s, err) | |||
| 84 | + | } | |||
| 85 | + | file := c.resolvePath(filepath.Join(j.externalsDir(), version, "bin", "node")) | |||
| 86 | + | j.mu.Lock() | |||
| 87 | + | wd := j.state.GitHub("workspace") | |||
| 88 | + | j.mu.Unlock() | |||
| 89 | + | henv := j.handlerEnv(env) | |||
| 90 | + | if err := checkEnvNames(henv.Keys()); err != nil { | |||
| 91 | + | return fail(err) | |||
| 92 | + | } | |||
| 93 | + | argv, penv := j.execArgv(c, c.resolvePath(wd), henv, file, []string{c.resolvePath(`"` + script + `"`)}) | |||
| 94 | + | s.rawEnvList = penv | |||
| 95 | + | return j.runProc(s, file, j.state.Layout().WorkRoot, argv, "Node Action run completed with exit code %d", cancel) | |||
| 96 | + | } | |||
A internal/executor/containers_real_test.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "os/exec" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Real docker: set ERMITE_RUNNER_IT_DOCKER=1 on a host with a Docker daemon | |
| 14 | + | // and the images alpine:3.20 and busybox:latest (no pulls are needed; the | |
| 15 | + | // tests do pull, so the network must work otherwise). | |
| 16 | + | func realDocker(t *testing.T) string { | |
| 17 | + | t.Helper() | |
| 18 | + | if os.Getenv("ERMITE_RUNNER_IT_DOCKER") != "1" { | |
| 19 | + | t.Skip("set ERMITE_RUNNER_IT_DOCKER=1 (needs a Docker daemon)") | |
| 20 | + | } | |
| 21 | + | p, err := exec.LookPath("docker") | |
| 22 | + | if err != nil { | |
| 23 | + | t.Skip("no docker") | |
| 24 | + | } | |
| 25 | + | return p | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestRealDockerJobContainerAndService(t *testing.T) { | |
| 29 | + | docker := realDocker(t) | |
| 30 | + | needTools(t, "bash") | |
| 31 | + | yaml := ` container: alpine:3.20 | |
| 32 | + | services: | |
| 33 | + | web: | |
| 34 | + | image: busybox:latest | |
| 35 | + | options: >- | |
| 36 | + | --health-cmd "true" --health-interval 1s --health-retries 3 | |
| 37 | + | --entrypoint sh | |
| 38 | + | command: -c "sleep 300" | |
| 39 | + | steps: | |
| 40 | + | - name: where | |
| 41 | + | run: | | |
| 42 | + | echo "pwd=$PWD" | |
| 43 | + | echo "id=$(id -u)" | |
| 44 | + | echo "home=$HOME" | |
| 45 | + | echo "temp=$RUNNER_TEMP" | |
| 46 | + | ls -d /__w /__t /__e /github/home /github/workflow | |
| 47 | + | echo "hi from alpine" >> "$GITHUB_STEP_SUMMARY" | |
| 48 | + | echo "k=v" >> "$GITHUB_OUTPUT" | |
| 49 | + | - name: svc | |
| 50 | + | run: ping -c1 web >/dev/null && echo "web resolves" | |
| 51 | + | ` | |
| 52 | + | rec, res := runLocal(t, wfHead+yaml, "j", func(c *Config) { | |
| 53 | + | c.Actions.DockerPath = docker | |
| 54 | + | c.BaseEnv = append(c.BaseEnv, "ACTIONS_SERVICE_CONTAINER_COMMAND=true", "DOCKER_HOST="+os.Getenv("DOCKER_HOST"), "XDG_RUNTIME_DIR="+os.Getenv("XDG_RUNTIME_DIR")) | |
| 55 | + | c.Actions.ExternalsDir = fakeNode(t) | |
| 56 | + | }) | |
| 57 | + | log := logOf(rec.step(t, "where")) + logOf(rec.step(t, "svc")) | |
| 58 | + | if res.Conclusion != ConclusionSuccess { | |
| 59 | + | t.Fatalf("%+v\n%s\n%s", res, logOf(rec.step(t, "Initialize containers")), log) | |
| 60 | + | } | |
| 61 | + | for _, w := range []string{"pwd=/__w/hello/hello", "home=/github/home", "temp=/__w/_temp", "/github/workflow", "web resolves"} { | |
| 62 | + | if !strings.Contains(log, w) { | |
| 63 | + | t.Errorf("lacks %q:\n%s", w, log) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | if out, _ := exec.Command(docker, "ps", "-a", "--format", "{{.Names}}", "--filter", "name=busybox").Output(); strings.TrimSpace(string(out)) != "" { | |
| 67 | + | t.Errorf("containers left: %s", out) | |
| 68 | + | } | |
| 69 | + | if out, _ := exec.Command(docker, "network", "ls", "--format", "{{.Name}}", "--filter", "name=github_network_").Output(); strings.TrimSpace(string(out)) != "" { | |
| 70 | + | t.Errorf("networks left: %s", out) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestRealDockerCancelRemovesEverything(t *testing.T) { | |
| 75 | + | docker := realDocker(t) | |
| 76 | + | needTools(t, "bash") | |
| 77 | + | cancel := NewCanceller() | |
| 78 | + | go func() { time.Sleep(6 * time.Second); cancel.Cancel(CancelUser) }() | |
| 79 | + | rec, res := runLocalC(t, cancel, wfHead+" container: alpine:3.20\n steps:\n - name: long\n run: sleep 120\n", "j", func(c *Config) { | |
| 80 | + | c.Actions.DockerPath = docker | |
| 81 | + | c.BaseEnv = append(c.BaseEnv, "XDG_RUNTIME_DIR="+os.Getenv("XDG_RUNTIME_DIR")) | |
| 82 | + | }) | |
| 83 | + | if res.Conclusion != ConclusionCancelled { | |
| 84 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 85 | + | } | |
| 86 | + | if out, _ := exec.Command(docker, "network", "ls", "--format", "{{.Name}}", "--filter", "name=github_network_").Output(); strings.TrimSpace(string(out)) != "" { | |
| 87 | + | t.Errorf("networks left: %s", out) | |
| 88 | + | } | |
| 89 | + | } | |
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // Real docker: set ERMITE_RUNNER_IT_DOCKER=1 on a host with a Docker daemon | |||
| 14 | + | // and the images alpine:3.20 and busybox:latest (no pulls are needed; the | |||
| 15 | + | // tests do pull, so the network must work otherwise). | |||
| 16 | + | func realDocker(t *testing.T) string { | |||
| 17 | + | t.Helper() | |||
| 18 | + | if os.Getenv("ERMITE_RUNNER_IT_DOCKER") != "1" { | |||
| 19 | + | t.Skip("set ERMITE_RUNNER_IT_DOCKER=1 (needs a Docker daemon)") | |||
| 20 | + | } | |||
| 21 | + | p, err := exec.LookPath("docker") | |||
| 22 | + | if err != nil { | |||
| 23 | + | t.Skip("no docker") | |||
| 24 | + | } | |||
| 25 | + | return p | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestRealDockerJobContainerAndService(t *testing.T) { | |||
| 29 | + | docker := realDocker(t) | |||
| 30 | + | needTools(t, "bash") | |||
| 31 | + | yaml := ` container: alpine:3.20 | |||
| 32 | + | services: | |||
| 33 | + | web: | |||
| 34 | + | image: busybox:latest | |||
| 35 | + | options: >- | |||
| 36 | + | --health-cmd "true" --health-interval 1s --health-retries 3 | |||
| 37 | + | --entrypoint sh | |||
| 38 | + | command: -c "sleep 300" | |||
| 39 | + | steps: | |||
| 40 | + | - name: where | |||
| 41 | + | run: | | |||
| 42 | + | echo "pwd=$PWD" | |||
| 43 | + | echo "id=$(id -u)" | |||
| 44 | + | echo "home=$HOME" | |||
| 45 | + | echo "temp=$RUNNER_TEMP" | |||
| 46 | + | ls -d /__w /__t /__e /github/home /github/workflow | |||
| 47 | + | echo "hi from alpine" >> "$GITHUB_STEP_SUMMARY" | |||
| 48 | + | echo "k=v" >> "$GITHUB_OUTPUT" | |||
| 49 | + | - name: svc | |||
| 50 | + | run: ping -c1 web >/dev/null && echo "web resolves" | |||
| 51 | + | ` | |||
| 52 | + | rec, res := runLocal(t, wfHead+yaml, "j", func(c *Config) { | |||
| 53 | + | c.Actions.DockerPath = docker | |||
| 54 | + | c.BaseEnv = append(c.BaseEnv, "ACTIONS_SERVICE_CONTAINER_COMMAND=true", "DOCKER_HOST="+os.Getenv("DOCKER_HOST"), "XDG_RUNTIME_DIR="+os.Getenv("XDG_RUNTIME_DIR")) | |||
| 55 | + | c.Actions.ExternalsDir = fakeNode(t) | |||
| 56 | + | }) | |||
| 57 | + | log := logOf(rec.step(t, "where")) + logOf(rec.step(t, "svc")) | |||
| 58 | + | if res.Conclusion != ConclusionSuccess { | |||
| 59 | + | t.Fatalf("%+v\n%s\n%s", res, logOf(rec.step(t, "Initialize containers")), log) | |||
| 60 | + | } | |||
| 61 | + | for _, w := range []string{"pwd=/__w/hello/hello", "home=/github/home", "temp=/__w/_temp", "/github/workflow", "web resolves"} { | |||
| 62 | + | if !strings.Contains(log, w) { | |||
| 63 | + | t.Errorf("lacks %q:\n%s", w, log) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | if out, _ := exec.Command(docker, "ps", "-a", "--format", "{{.Names}}", "--filter", "name=busybox").Output(); strings.TrimSpace(string(out)) != "" { | |||
| 67 | + | t.Errorf("containers left: %s", out) | |||
| 68 | + | } | |||
| 69 | + | if out, _ := exec.Command(docker, "network", "ls", "--format", "{{.Name}}", "--filter", "name=github_network_").Output(); strings.TrimSpace(string(out)) != "" { | |||
| 70 | + | t.Errorf("networks left: %s", out) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestRealDockerCancelRemovesEverything(t *testing.T) { | |||
| 75 | + | docker := realDocker(t) | |||
| 76 | + | needTools(t, "bash") | |||
| 77 | + | cancel := NewCanceller() | |||
| 78 | + | go func() { time.Sleep(6 * time.Second); cancel.Cancel(CancelUser) }() | |||
| 79 | + | rec, res := runLocalC(t, cancel, wfHead+" container: alpine:3.20\n steps:\n - name: long\n run: sleep 120\n", "j", func(c *Config) { | |||
| 80 | + | c.Actions.DockerPath = docker | |||
| 81 | + | c.BaseEnv = append(c.BaseEnv, "XDG_RUNTIME_DIR="+os.Getenv("XDG_RUNTIME_DIR")) | |||
| 82 | + | }) | |||
| 83 | + | if res.Conclusion != ConclusionCancelled { | |||
| 84 | + | t.Fatalf("%+v %v", res, rec.names()) | |||
| 85 | + | } | |||
| 86 | + | if out, _ := exec.Command(docker, "network", "ls", "--format", "{{.Name}}", "--filter", "name=github_network_").Output(); strings.TrimSpace(string(out)) != "" { | |||
| 87 | + | t.Errorf("networks left: %s", out) | |||
| 88 | + | } | |||
| 89 | + | } | |||
A internal/executor/containers_spec.go +457 -0
| @@ -0,0 +1,457 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "path" | |
| 9 | + | "regexp" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite-actions/expr" | |
| 13 | + | "bnema.dev/ermite-actions/template" | |
| 14 | + | "github.com/google/uuid" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // This file is the model of the containers of a job (the job container and | |
| 18 | + | // the service containers): ContainerInfo.cs and the evaluation of | |
| 19 | + | // jobs.<id>.container and jobs.<id>.services | |
| 20 | + | // (PipelineTemplateConverter.ConvertToJobContainer, :248-352). | |
| 21 | + | ||
| 22 | + | // mount is MountVolume (ContainerInfo.cs:246-318). | |
| 23 | + | type mount struct { | |
| 24 | + | source, target string | |
| 25 | + | readOnly bool | |
| 26 | + | // user is the text the workflow gave (UserProvidedValue), empty for the | |
| 27 | + | // mounts of the runner. | |
| 28 | + | user string | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // parseVolume is MountVolume.ParseVolumeString (ContainerInfo.cs:283-318): | |
| 32 | + | // "source:target[:ro]", "target:ro" or "target". The runner does not look at | |
| 33 | + | // the text further; docker rejects what is not valid. | |
| 34 | + | func parseVolume(v string) mount { | |
| 35 | + | parts := strings.Split(v, ":") | |
| 36 | + | switch len(parts) { | |
| 37 | + | case 3: | |
| 38 | + | return mount{source: parts[0], target: parts[1], readOnly: strings.EqualFold(parts[2], "ro")} | |
| 39 | + | case 2: | |
| 40 | + | if strings.EqualFold(parts[1], "ro") { | |
| 41 | + | return mount{target: parts[0], readOnly: true} | |
| 42 | + | } | |
| 43 | + | return mount{source: parts[0], target: parts[1]} | |
| 44 | + | } | |
| 45 | + | return mount{target: v} | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // portMapping is PortMapping: a published port as `docker port` shows it. | |
| 49 | + | type portMapping struct{ host, container, proto string } | |
| 50 | + | ||
| 51 | + | // container is ContainerInfo: what the workflow asked for and what is known | |
| 52 | + | // of the container once it runs. | |
| 53 | + | type container struct { | |
| 54 | + | isJob bool | |
| 55 | + | // alias is the network alias of a service (its key in services:). | |
| 56 | + | alias string | |
| 57 | + | // name is the container name (ContainerDisplayName). | |
| 58 | + | name string | |
| 59 | + | image, options, entrypoint, entryArgs string | |
| 60 | + | env *ordered | |
| 61 | + | ports []string // UserPortMappings, in order, without duplicates | |
| 62 | + | mounts []mount | |
| 63 | + | credUser, credPass string | |
| 64 | + | registryServer string | |
| 65 | + | workDir string | |
| 66 | + | id, network, runtimePath string | |
| 67 | + | failedInit bool | |
| 68 | + | createTried, removed bool | |
| 69 | + | maps []pathMap // path translation, first match wins | |
| 70 | + | portMappings []portMapping | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // userMounts are the mounts the workflow listed. | |
| 74 | + | func (c *container) userMounts() []mount { | |
| 75 | + | var out []mount | |
| 76 | + | for _, m := range c.mounts { | |
| 77 | + | if m.user != "" { | |
| 78 | + | out = append(out, m) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | return out | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // translateToContainer is TranslateToContainerPath (ContainerInfo.cs:190-228, | |
| 85 | + | // Linux): a path equal to a mapped host directory, or below it. | |
| 86 | + | func (c *container) translateToContainer(p string) string { | |
| 87 | + | if p == "" { | |
| 88 | + | return p | |
| 89 | + | } | |
| 90 | + | for _, m := range c.maps { | |
| 91 | + | if p == m.host { | |
| 92 | + | return m.container | |
| 93 | + | } | |
| 94 | + | if strings.HasPrefix(p, m.host+"/") { | |
| 95 | + | return m.container + p[len(m.host):] | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | return p | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // translateToHost is TranslateToHostPath (ContainerInfo.cs:230-268) for a path | |
| 102 | + | // the container printed. An absolute path is normalised first, so that | |
| 103 | + | // "/__w/../outside/x" is "/outside/x" and is not found below /__w: the runner | |
| 104 | + | // joins the text as it is, which would read <work>/../outside/x on the host. | |
| 105 | + | // What comes out of a mapping is the host directory and a cleaned relative | |
| 106 | + | // part, so it is always inside the host directory of that mapping. Paths that | |
| 107 | + | // match no mapping are returned as given (relative ones included). | |
| 108 | + | func (c *container) translateToHost(p string) string { | |
| 109 | + | if p == "" { | |
| 110 | + | return p | |
| 111 | + | } | |
| 112 | + | if strings.HasPrefix(p, "/") { | |
| 113 | + | p = path.Clean(p) | |
| 114 | + | } | |
| 115 | + | for _, m := range c.maps { | |
| 116 | + | if p == m.container { | |
| 117 | + | return m.host | |
| 118 | + | } | |
| 119 | + | if strings.HasPrefix(p, m.container+"/") { | |
| 120 | + | return m.host + p[len(m.container):] | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | return p | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // insideMappings reports whether a host path (cleaned) is a mapped host | |
| 127 | + | // directory or below one: the only host files a path printed by the container | |
| 128 | + | // can stand for. A path of the container that no mount covers (/etc/passwd of | |
| 129 | + | // the image) is not a file of the host. | |
| 130 | + | func (c *container) insideMappings(host string) bool { | |
| 131 | + | host = path.Clean(host) | |
| 132 | + | for _, m := range c.maps { | |
| 133 | + | if host == m.host || strings.HasPrefix(host, m.host+"/") { | |
| 134 | + | return true | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | return false | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // resolvePathForStepHost is ContainerStepHost.ResolvePathForStepHost | |
| 141 | + | // (StepHost.cs:127-152): the path is translated when it starts with the host | |
| 142 | + | // path of one of the mounts (a plain string prefix, as in the runner). | |
| 143 | + | func (c *container) resolvePath(p string) string { | |
| 144 | + | p = strings.Trim(p, `"`) | |
| 145 | + | for _, m := range c.mounts { | |
| 146 | + | if m.source != "" && strings.HasPrefix(p, m.source) { | |
| 147 | + | return c.translateToContainer(p) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | return p | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // parseRegistryHost is DockerUtil.ParseRegistryHostnameFromImageName | |
| 154 | + | // (DockerUtil.cs:55-68). | |
| 155 | + | func parseRegistryHost(image string) string { | |
| 156 | + | parts := strings.Split(image, "/") | |
| 157 | + | if len(parts) == 2 && strings.ContainsAny(parts[0], ":.") { | |
| 158 | + | return parts[0] | |
| 159 | + | } | |
| 160 | + | if len(parts) > 2 { | |
| 161 | + | return parts[0] | |
| 162 | + | } | |
| 163 | + | return "" | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // containerName is the display name `<alias>_<sanitized image>_<6 hex>` | |
| 167 | + | // (ContainerInfo.cs:44) with a fresh 32-hex alias. | |
| 168 | + | func containerName(image string) string { | |
| 169 | + | return strings.ReplaceAll(uuid.NewString(), "-", "") + "_" + sanitizeName(image) + "_" + | |
| 170 | + | strings.ReplaceAll(uuid.NewString(), "-", "")[:6] | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // newContainer is the constructor of ContainerInfo (ContainerInfo.cs:35-96). | |
| 174 | + | func (j *job) newContainer(spec *containerSpec, isJob bool, alias string) *container { | |
| 175 | + | c := &container{ | |
| 176 | + | isJob: isJob, alias: alias, image: spec.image, options: spec.options, | |
| 177 | + | entrypoint: spec.entrypoint, entryArgs: spec.command, env: newOrdered(), | |
| 178 | + | credUser: spec.credUser, credPass: spec.credPass, registryServer: parseRegistryHost(spec.image), | |
| 179 | + | } | |
| 180 | + | c.name = containerName(spec.image) | |
| 181 | + | for _, p := range spec.env { | |
| 182 | + | c.env.Set(p.Key, p.Value) | |
| 183 | + | } | |
| 184 | + | lay := j.state.Layout() | |
| 185 | + | // The order of the mappings is the runner's (ContainerInfo.cs:55-58). | |
| 186 | + | c.maps = []pathMap{{lay.WorkRoot, "/__w"}, {lay.ToolCache, "/__t"}, {j.externalsDir(), "/__e"}} | |
| 187 | + | if isJob { | |
| 188 | + | c.mounts = append(c.mounts, mount{source: "/var/run/docker.sock", target: "/var/run/docker.sock"}) | |
| 189 | + | } | |
| 190 | + | for _, p := range spec.ports { | |
| 191 | + | if !containsString(c.ports, p) { // UserPortMappings[port] = port | |
| 192 | + | c.ports = append(c.ports, p) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | for _, v := range spec.volumes { | |
| 196 | + | m := parseVolume(v) | |
| 197 | + | m.user = v | |
| 198 | + | c.mounts = append(c.mounts, m) | |
| 199 | + | } | |
| 200 | + | c.proxyEnv(j.baseEnv) | |
| 201 | + | return c | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func containsString(l []string, s string) bool { | |
| 205 | + | for _, x := range l { | |
| 206 | + | if x == s { | |
| 207 | + | return true | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | return false | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // proxyEnv is UpdateWebProxyEnv (ContainerInfo.cs:353-374): the proxy | |
| 214 | + | // settings of the runner, unless the container sets them itself. | |
| 215 | + | func (c *container) proxyEnv(base []string) { | |
| 216 | + | pick := func(names ...string) string { | |
| 217 | + | for _, n := range names { | |
| 218 | + | if v := strings.TrimSpace(envValue(base, n)); v != "" { | |
| 219 | + | return v | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | return "" | |
| 223 | + | } | |
| 224 | + | add := func(v string, keys ...string) { | |
| 225 | + | if v == "" { | |
| 226 | + | return | |
| 227 | + | } | |
| 228 | + | for _, k := range keys { | |
| 229 | + | if _, ok := c.env.Get(k); !ok { | |
| 230 | + | c.env.Set(k, v) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | add(pick("http_proxy", "HTTP_PROXY"), "HTTP_PROXY", "http_proxy") | |
| 235 | + | add(pick("https_proxy", "HTTPS_PROXY"), "HTTPS_PROXY", "https_proxy") | |
| 236 | + | add(pick("no_proxy", "NO_PROXY"), "NO_PROXY", "no_proxy") | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // containerSpec is JobContainer: the evaluated container: or service. | |
| 240 | + | type containerSpec struct { | |
| 241 | + | image, options, entrypoint, command string | |
| 242 | + | env []Pair | |
| 243 | + | ports, volumes []string | |
| 244 | + | credUser, credPass string | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // asContainerString is AssertString of the converter: scalars are strings. | |
| 248 | + | func asContainerString(v expr.Value, what string) (string, error) { | |
| 249 | + | switch v.Kind() { | |
| 250 | + | case expr.KindArray, expr.KindDictionary: | |
| 251 | + | return "", ghErrorf("Unexpected type '%s' encountered while reading '%s'. The type 'StringToken' was expected.", v.Kind(), what) | |
| 252 | + | } | |
| 253 | + | return expr.CoerceString(v), nil | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func containerStrings(v expr.Value, what string) ([]string, error) { | |
| 257 | + | a, ok := v.(*expr.Array) | |
| 258 | + | if !ok { | |
| 259 | + | return nil, ghErrorf("Unexpected type '%s' encountered while reading '%s'. The type 'SequenceToken' was expected.", v.Kind(), what) | |
| 260 | + | } | |
| 261 | + | var out []string | |
| 262 | + | for _, it := range a.Items() { | |
| 263 | + | s, err := asContainerString(it, what+" "+expr.CoerceString(it)) | |
| 264 | + | if err != nil { | |
| 265 | + | return nil, err | |
| 266 | + | } | |
| 267 | + | out = append(out, s) | |
| 268 | + | } | |
| 269 | + | return out, nil | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // imageRefRe is the grammar of a docker image reference | |
| 273 | + | // (github.com/distribution/reference): [domain[:port]/]path[:tag][@digest]. | |
| 274 | + | // Path components may be upper case here: docker words that error itself, as | |
| 275 | + | // it does for the image of the recorded ctr-errors scenario. What matters is | |
| 276 | + | // that the text is one argument that cannot be taken for an option (the first | |
| 277 | + | // character is a letter or a digit) and holds no space or control character. | |
| 278 | + | var imageRefRe = regexp.MustCompile( | |
| 279 | + | `^(?:(?:` + domainName + `|` + ipv6Address + `)(?::[0-9]+)?/)?` + | |
| 280 | + | `[A-Za-z0-9]+(?:(?:[._]|__|-+)[A-Za-z0-9]+)*(?:/[A-Za-z0-9]+(?:(?:[._]|__|-+)[A-Za-z0-9]+)*)*` + | |
| 281 | + | `(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?` + | |
| 282 | + | `(?:@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9A-Fa-f]{32,})?$`) | |
| 283 | + | ||
| 284 | + | // domainName and ipv6Address are the domain grammar of distribution/reference | |
| 285 | + | // (reference/regexp.go: domainNameComponent, domainName, ipv6address), | |
| 286 | + | // copied here instead of importing the module. | |
| 287 | + | const ( | |
| 288 | + | domainNameComponent = `(?:[A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])` | |
| 289 | + | domainName = domainNameComponent + `(?:\.` + domainNameComponent + `)*` | |
| 290 | + | ipv6Address = `\[(?:[A-Fa-f0-9:]+)\]` | |
| 291 | + | ) | |
| 292 | + | ||
| 293 | + | // validImageRef reports whether image can be passed to docker pull and create. | |
| 294 | + | func validImageRef(image string) bool { return len(image) <= 1024 && imageRefRe.MatchString(image) } | |
| 295 | + | ||
| 296 | + | // aliasRe is the name of a service: it becomes a network alias and is never | |
| 297 | + | // taken for an option. | |
| 298 | + | var aliasRe = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,62}$`) | |
| 299 | + | ||
| 300 | + | // containerIDRe is the id docker create prints. | |
| 301 | + | var containerIDRe = regexp.MustCompile(`^[0-9a-f]{64}$`) | |
| 302 | + | ||
| 303 | + | // serviceCommandAllowed is the feature flag of entrypoint: and command: of a | |
| 304 | + | // service (ExecutionContext.cs:1545: actions_service_container_command or | |
| 305 | + | // ACTIONS_SERVICE_CONTAINER_COMMAND; ermite has no feature flags). | |
| 306 | + | func (j *job) serviceCommandAllowed() bool { | |
| 307 | + | return convertToBool(envValue(j.baseEnv, "ACTIONS_SERVICE_CONTAINER_COMMAND")) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // convertContainer is ConvertToJobContainer (PipelineTemplateConverter.cs: | |
| 311 | + | // 248-352) on an evaluated value. nil means no container (an empty image). | |
| 312 | + | func (j *job) convertContainer(v expr.Value, service bool) (*containerSpec, error) { | |
| 313 | + | spec := &containerSpec{} | |
| 314 | + | if d, ok := v.(*expr.Dictionary); ok { | |
| 315 | + | for _, p := range d.Pairs() { | |
| 316 | + | var err error | |
| 317 | + | switch p.Key { | |
| 318 | + | case "image": | |
| 319 | + | spec.image, err = asContainerString(p.Value, "container image") | |
| 320 | + | case "env": | |
| 321 | + | spec.env, err = containerEnv(p.Value) | |
| 322 | + | case "options": | |
| 323 | + | spec.options, err = asContainerString(p.Value, "container options") | |
| 324 | + | case "entrypoint", "command": | |
| 325 | + | if !service || !j.serviceCommandAllowed() { | |
| 326 | + | return nil, ghErrorf("The key '%s' is not allowed", p.Key) | |
| 327 | + | } | |
| 328 | + | s, e := asContainerString(p.Value, "container "+p.Key) | |
| 329 | + | err = e | |
| 330 | + | if p.Key == "entrypoint" { | |
| 331 | + | spec.entrypoint = s | |
| 332 | + | } else { | |
| 333 | + | spec.command = s | |
| 334 | + | } | |
| 335 | + | case "ports": | |
| 336 | + | spec.ports, err = containerStrings(p.Value, "container ports") | |
| 337 | + | case "volumes": | |
| 338 | + | spec.volumes, err = containerStrings(p.Value, "container volumes") | |
| 339 | + | case "credentials": | |
| 340 | + | err = containerCredentials(p.Value, spec) | |
| 341 | + | default: | |
| 342 | + | err = ghErrorf("Error while reading 'container key'. Unexpected value '%s'", p.Key) | |
| 343 | + | } | |
| 344 | + | if err != nil { | |
| 345 | + | return nil, err | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | } else { | |
| 349 | + | s, err := asContainerString(v, "container") | |
| 350 | + | if err != nil { | |
| 351 | + | return nil, err | |
| 352 | + | } | |
| 353 | + | spec.image = s | |
| 354 | + | } | |
| 355 | + | spec.image = strings.TrimPrefix(spec.image, "docker://") | |
| 356 | + | if spec.image == "" { | |
| 357 | + | return nil, nil | |
| 358 | + | } | |
| 359 | + | if !validImageRef(spec.image) { | |
| 360 | + | return nil, ghErrorf("Invalid container image '%s'", spec.image) | |
| 361 | + | } | |
| 362 | + | return spec, nil | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func containerEnv(v expr.Value) ([]Pair, error) { | |
| 366 | + | d, ok := v.(*expr.Dictionary) | |
| 367 | + | if !ok { | |
| 368 | + | return nil, ghErrorf("Unexpected type '%s' encountered while reading 'container env'. The type 'MappingToken' was expected.", v.Kind()) | |
| 369 | + | } | |
| 370 | + | var out []Pair | |
| 371 | + | for _, p := range d.Pairs() { | |
| 372 | + | if err := checkEnvNames([]string{p.Key}); err != nil { | |
| 373 | + | return nil, err | |
| 374 | + | } | |
| 375 | + | s, err := asContainerString(p.Value, "container env "+p.Key) | |
| 376 | + | if err != nil { | |
| 377 | + | return nil, err | |
| 378 | + | } | |
| 379 | + | out = append(out, Pair{p.Key, s}) | |
| 380 | + | } | |
| 381 | + | return out, nil | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | func containerCredentials(v expr.Value, spec *containerSpec) error { | |
| 385 | + | d, ok := v.(*expr.Dictionary) | |
| 386 | + | if !ok { | |
| 387 | + | return ghErrorf("Unexpected type '%s' encountered while reading 'credentials'. The type 'MappingToken' was expected.", v.Kind()) | |
| 388 | + | } | |
| 389 | + | for _, p := range d.Pairs() { | |
| 390 | + | s, err := asContainerString(p.Value, p.Key) | |
| 391 | + | if err != nil { | |
| 392 | + | return err | |
| 393 | + | } | |
| 394 | + | switch p.Key { | |
| 395 | + | case "username": | |
| 396 | + | spec.credUser = s | |
| 397 | + | case "password": | |
| 398 | + | spec.credPass = s | |
| 399 | + | default: | |
| 400 | + | return ghErrorf("Error while reading 'credentials key %s'. Unexpected value '%s'", p.Key, p.Key) | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | return nil | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | // evalContainers evaluates container: and services: of the job | |
| 407 | + | // (JobExtension.cs:250-275) with the contexts the job has by then. | |
| 408 | + | func (j *job) evalContainers() (job *container, services []*container, err error) { | |
| 409 | + | ctx := context.Background() | |
| 410 | + | j.mu.Lock() | |
| 411 | + | env := j.state.JobEnv() | |
| 412 | + | j.mu.Unlock() | |
| 413 | + | te := j.evalEnv(env) | |
| 414 | + | if t := j.jb.Container; t != nil { | |
| 415 | + | v, e := template.Evaluate(ctx, t, te) | |
| 416 | + | if e != nil { | |
| 417 | + | return nil, nil, e | |
| 418 | + | } | |
| 419 | + | if _, null := v.(expr.Null); !null { | |
| 420 | + | spec, e := j.convertContainer(v, false) | |
| 421 | + | if e != nil { | |
| 422 | + | return nil, nil, e | |
| 423 | + | } | |
| 424 | + | if spec != nil { | |
| 425 | + | job = j.newContainer(spec, true, "") | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | if t := j.jb.Services; t != nil { | |
| 430 | + | v, e := template.Evaluate(ctx, t, te) | |
| 431 | + | if e != nil { | |
| 432 | + | return nil, nil, e | |
| 433 | + | } | |
| 434 | + | d, ok := v.(*expr.Dictionary) | |
| 435 | + | if !ok { | |
| 436 | + | if _, null := v.(expr.Null); null { | |
| 437 | + | return job, nil, nil | |
| 438 | + | } | |
| 439 | + | return nil, nil, ghErrorf("Unexpected type '%s' encountered while reading 'services'. The type 'MappingToken' was expected.", v.Kind()) | |
| 440 | + | } | |
| 441 | + | for _, p := range d.Pairs() { | |
| 442 | + | if !aliasRe.MatchString(p.Key) { | |
| 443 | + | return nil, nil, ghErrorf("Invalid service name '%s': use letters, digits, '_', '.' and '-', starting with a letter, a digit or '_'", p.Key) | |
| 444 | + | } | |
| 445 | + | spec, e := j.convertContainer(p.Value, true) | |
| 446 | + | if e != nil { | |
| 447 | + | return nil, nil, e | |
| 448 | + | } | |
| 449 | + | if spec == nil { | |
| 450 | + | j.out(1, fmt.Sprintf("The service '%s' will not be started because the container definition has an empty image.", p.Key)) | |
| 451 | + | continue | |
| 452 | + | } | |
| 453 | + | services = append(services, j.newContainer(spec, false, p.Key)) | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | return job, services, nil | |
| 457 | + | } | |
| @@ -0,0 +1,457 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "path" | |||
| 9 | + | "regexp" | |||
| 10 | + | "strings" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite-actions/expr" | |||
| 13 | + | "bnema.dev/ermite-actions/template" | |||
| 14 | + | "github.com/google/uuid" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // This file is the model of the containers of a job (the job container and | |||
| 18 | + | // the service containers): ContainerInfo.cs and the evaluation of | |||
| 19 | + | // jobs.<id>.container and jobs.<id>.services | |||
| 20 | + | // (PipelineTemplateConverter.ConvertToJobContainer, :248-352). | |||
| 21 | + | ||||
| 22 | + | // mount is MountVolume (ContainerInfo.cs:246-318). | |||
| 23 | + | type mount struct { | |||
| 24 | + | source, target string | |||
| 25 | + | readOnly bool | |||
| 26 | + | // user is the text the workflow gave (UserProvidedValue), empty for the | |||
| 27 | + | // mounts of the runner. | |||
| 28 | + | user string | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // parseVolume is MountVolume.ParseVolumeString (ContainerInfo.cs:283-318): | |||
| 32 | + | // "source:target[:ro]", "target:ro" or "target". The runner does not look at | |||
| 33 | + | // the text further; docker rejects what is not valid. | |||
| 34 | + | func parseVolume(v string) mount { | |||
| 35 | + | parts := strings.Split(v, ":") | |||
| 36 | + | switch len(parts) { | |||
| 37 | + | case 3: | |||
| 38 | + | return mount{source: parts[0], target: parts[1], readOnly: strings.EqualFold(parts[2], "ro")} | |||
| 39 | + | case 2: | |||
| 40 | + | if strings.EqualFold(parts[1], "ro") { | |||
| 41 | + | return mount{target: parts[0], readOnly: true} | |||
| 42 | + | } | |||
| 43 | + | return mount{source: parts[0], target: parts[1]} | |||
| 44 | + | } | |||
| 45 | + | return mount{target: v} | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // portMapping is PortMapping: a published port as `docker port` shows it. | |||
| 49 | + | type portMapping struct{ host, container, proto string } | |||
| 50 | + | ||||
| 51 | + | // container is ContainerInfo: what the workflow asked for and what is known | |||
| 52 | + | // of the container once it runs. | |||
| 53 | + | type container struct { | |||
| 54 | + | isJob bool | |||
| 55 | + | // alias is the network alias of a service (its key in services:). | |||
| 56 | + | alias string | |||
| 57 | + | // name is the container name (ContainerDisplayName). | |||
| 58 | + | name string | |||
| 59 | + | image, options, entrypoint, entryArgs string | |||
| 60 | + | env *ordered | |||
| 61 | + | ports []string // UserPortMappings, in order, without duplicates | |||
| 62 | + | mounts []mount | |||
| 63 | + | credUser, credPass string | |||
| 64 | + | registryServer string | |||
| 65 | + | workDir string | |||
| 66 | + | id, network, runtimePath string | |||
| 67 | + | failedInit bool | |||
| 68 | + | createTried, removed bool | |||
| 69 | + | maps []pathMap // path translation, first match wins | |||
| 70 | + | portMappings []portMapping | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // userMounts are the mounts the workflow listed. | |||
| 74 | + | func (c *container) userMounts() []mount { | |||
| 75 | + | var out []mount | |||
| 76 | + | for _, m := range c.mounts { | |||
| 77 | + | if m.user != "" { | |||
| 78 | + | out = append(out, m) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | return out | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // translateToContainer is TranslateToContainerPath (ContainerInfo.cs:190-228, | |||
| 85 | + | // Linux): a path equal to a mapped host directory, or below it. | |||
| 86 | + | func (c *container) translateToContainer(p string) string { | |||
| 87 | + | if p == "" { | |||
| 88 | + | return p | |||
| 89 | + | } | |||
| 90 | + | for _, m := range c.maps { | |||
| 91 | + | if p == m.host { | |||
| 92 | + | return m.container | |||
| 93 | + | } | |||
| 94 | + | if strings.HasPrefix(p, m.host+"/") { | |||
| 95 | + | return m.container + p[len(m.host):] | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | return p | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // translateToHost is TranslateToHostPath (ContainerInfo.cs:230-268) for a path | |||
| 102 | + | // the container printed. An absolute path is normalised first, so that | |||
| 103 | + | // "/__w/../outside/x" is "/outside/x" and is not found below /__w: the runner | |||
| 104 | + | // joins the text as it is, which would read <work>/../outside/x on the host. | |||
| 105 | + | // What comes out of a mapping is the host directory and a cleaned relative | |||
| 106 | + | // part, so it is always inside the host directory of that mapping. Paths that | |||
| 107 | + | // match no mapping are returned as given (relative ones included). | |||
| 108 | + | func (c *container) translateToHost(p string) string { | |||
| 109 | + | if p == "" { | |||
| 110 | + | return p | |||
| 111 | + | } | |||
| 112 | + | if strings.HasPrefix(p, "/") { | |||
| 113 | + | p = path.Clean(p) | |||
| 114 | + | } | |||
| 115 | + | for _, m := range c.maps { | |||
| 116 | + | if p == m.container { | |||
| 117 | + | return m.host | |||
| 118 | + | } | |||
| 119 | + | if strings.HasPrefix(p, m.container+"/") { | |||
| 120 | + | return m.host + p[len(m.container):] | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | return p | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // insideMappings reports whether a host path (cleaned) is a mapped host | |||
| 127 | + | // directory or below one: the only host files a path printed by the container | |||
| 128 | + | // can stand for. A path of the container that no mount covers (/etc/passwd of | |||
| 129 | + | // the image) is not a file of the host. | |||
| 130 | + | func (c *container) insideMappings(host string) bool { | |||
| 131 | + | host = path.Clean(host) | |||
| 132 | + | for _, m := range c.maps { | |||
| 133 | + | if host == m.host || strings.HasPrefix(host, m.host+"/") { | |||
| 134 | + | return true | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | return false | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // resolvePathForStepHost is ContainerStepHost.ResolvePathForStepHost | |||
| 141 | + | // (StepHost.cs:127-152): the path is translated when it starts with the host | |||
| 142 | + | // path of one of the mounts (a plain string prefix, as in the runner). | |||
| 143 | + | func (c *container) resolvePath(p string) string { | |||
| 144 | + | p = strings.Trim(p, `"`) | |||
| 145 | + | for _, m := range c.mounts { | |||
| 146 | + | if m.source != "" && strings.HasPrefix(p, m.source) { | |||
| 147 | + | return c.translateToContainer(p) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | return p | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // parseRegistryHost is DockerUtil.ParseRegistryHostnameFromImageName | |||
| 154 | + | // (DockerUtil.cs:55-68). | |||
| 155 | + | func parseRegistryHost(image string) string { | |||
| 156 | + | parts := strings.Split(image, "/") | |||
| 157 | + | if len(parts) == 2 && strings.ContainsAny(parts[0], ":.") { | |||
| 158 | + | return parts[0] | |||
| 159 | + | } | |||
| 160 | + | if len(parts) > 2 { | |||
| 161 | + | return parts[0] | |||
| 162 | + | } | |||
| 163 | + | return "" | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // containerName is the display name `<alias>_<sanitized image>_<6 hex>` | |||
| 167 | + | // (ContainerInfo.cs:44) with a fresh 32-hex alias. | |||
| 168 | + | func containerName(image string) string { | |||
| 169 | + | return strings.ReplaceAll(uuid.NewString(), "-", "") + "_" + sanitizeName(image) + "_" + | |||
| 170 | + | strings.ReplaceAll(uuid.NewString(), "-", "")[:6] | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // newContainer is the constructor of ContainerInfo (ContainerInfo.cs:35-96). | |||
| 174 | + | func (j *job) newContainer(spec *containerSpec, isJob bool, alias string) *container { | |||
| 175 | + | c := &container{ | |||
| 176 | + | isJob: isJob, alias: alias, image: spec.image, options: spec.options, | |||
| 177 | + | entrypoint: spec.entrypoint, entryArgs: spec.command, env: newOrdered(), | |||
| 178 | + | credUser: spec.credUser, credPass: spec.credPass, registryServer: parseRegistryHost(spec.image), | |||
| 179 | + | } | |||
| 180 | + | c.name = containerName(spec.image) | |||
| 181 | + | for _, p := range spec.env { | |||
| 182 | + | c.env.Set(p.Key, p.Value) | |||
| 183 | + | } | |||
| 184 | + | lay := j.state.Layout() | |||
| 185 | + | // The order of the mappings is the runner's (ContainerInfo.cs:55-58). | |||
| 186 | + | c.maps = []pathMap{{lay.WorkRoot, "/__w"}, {lay.ToolCache, "/__t"}, {j.externalsDir(), "/__e"}} | |||
| 187 | + | if isJob { | |||
| 188 | + | c.mounts = append(c.mounts, mount{source: "/var/run/docker.sock", target: "/var/run/docker.sock"}) | |||
| 189 | + | } | |||
| 190 | + | for _, p := range spec.ports { | |||
| 191 | + | if !containsString(c.ports, p) { // UserPortMappings[port] = port | |||
| 192 | + | c.ports = append(c.ports, p) | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | for _, v := range spec.volumes { | |||
| 196 | + | m := parseVolume(v) | |||
| 197 | + | m.user = v | |||
| 198 | + | c.mounts = append(c.mounts, m) | |||
| 199 | + | } | |||
| 200 | + | c.proxyEnv(j.baseEnv) | |||
| 201 | + | return c | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func containsString(l []string, s string) bool { | |||
| 205 | + | for _, x := range l { | |||
| 206 | + | if x == s { | |||
| 207 | + | return true | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | return false | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // proxyEnv is UpdateWebProxyEnv (ContainerInfo.cs:353-374): the proxy | |||
| 214 | + | // settings of the runner, unless the container sets them itself. | |||
| 215 | + | func (c *container) proxyEnv(base []string) { | |||
| 216 | + | pick := func(names ...string) string { | |||
| 217 | + | for _, n := range names { | |||
| 218 | + | if v := strings.TrimSpace(envValue(base, n)); v != "" { | |||
| 219 | + | return v | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | return "" | |||
| 223 | + | } | |||
| 224 | + | add := func(v string, keys ...string) { | |||
| 225 | + | if v == "" { | |||
| 226 | + | return | |||
| 227 | + | } | |||
| 228 | + | for _, k := range keys { | |||
| 229 | + | if _, ok := c.env.Get(k); !ok { | |||
| 230 | + | c.env.Set(k, v) | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | add(pick("http_proxy", "HTTP_PROXY"), "HTTP_PROXY", "http_proxy") | |||
| 235 | + | add(pick("https_proxy", "HTTPS_PROXY"), "HTTPS_PROXY", "https_proxy") | |||
| 236 | + | add(pick("no_proxy", "NO_PROXY"), "NO_PROXY", "no_proxy") | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // containerSpec is JobContainer: the evaluated container: or service. | |||
| 240 | + | type containerSpec struct { | |||
| 241 | + | image, options, entrypoint, command string | |||
| 242 | + | env []Pair | |||
| 243 | + | ports, volumes []string | |||
| 244 | + | credUser, credPass string | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // asContainerString is AssertString of the converter: scalars are strings. | |||
| 248 | + | func asContainerString(v expr.Value, what string) (string, error) { | |||
| 249 | + | switch v.Kind() { | |||
| 250 | + | case expr.KindArray, expr.KindDictionary: | |||
| 251 | + | return "", ghErrorf("Unexpected type '%s' encountered while reading '%s'. The type 'StringToken' was expected.", v.Kind(), what) | |||
| 252 | + | } | |||
| 253 | + | return expr.CoerceString(v), nil | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func containerStrings(v expr.Value, what string) ([]string, error) { | |||
| 257 | + | a, ok := v.(*expr.Array) | |||
| 258 | + | if !ok { | |||
| 259 | + | return nil, ghErrorf("Unexpected type '%s' encountered while reading '%s'. The type 'SequenceToken' was expected.", v.Kind(), what) | |||
| 260 | + | } | |||
| 261 | + | var out []string | |||
| 262 | + | for _, it := range a.Items() { | |||
| 263 | + | s, err := asContainerString(it, what+" "+expr.CoerceString(it)) | |||
| 264 | + | if err != nil { | |||
| 265 | + | return nil, err | |||
| 266 | + | } | |||
| 267 | + | out = append(out, s) | |||
| 268 | + | } | |||
| 269 | + | return out, nil | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // imageRefRe is the grammar of a docker image reference | |||
| 273 | + | // (github.com/distribution/reference): [domain[:port]/]path[:tag][@digest]. | |||
| 274 | + | // Path components may be upper case here: docker words that error itself, as | |||
| 275 | + | // it does for the image of the recorded ctr-errors scenario. What matters is | |||
| 276 | + | // that the text is one argument that cannot be taken for an option (the first | |||
| 277 | + | // character is a letter or a digit) and holds no space or control character. | |||
| 278 | + | var imageRefRe = regexp.MustCompile( | |||
| 279 | + | `^(?:(?:` + domainName + `|` + ipv6Address + `)(?::[0-9]+)?/)?` + | |||
| 280 | + | `[A-Za-z0-9]+(?:(?:[._]|__|-+)[A-Za-z0-9]+)*(?:/[A-Za-z0-9]+(?:(?:[._]|__|-+)[A-Za-z0-9]+)*)*` + | |||
| 281 | + | `(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?` + | |||
| 282 | + | `(?:@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9A-Fa-f]{32,})?$`) | |||
| 283 | + | ||||
| 284 | + | // domainName and ipv6Address are the domain grammar of distribution/reference | |||
| 285 | + | // (reference/regexp.go: domainNameComponent, domainName, ipv6address), | |||
| 286 | + | // copied here instead of importing the module. | |||
| 287 | + | const ( | |||
| 288 | + | domainNameComponent = `(?:[A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])` | |||
| 289 | + | domainName = domainNameComponent + `(?:\.` + domainNameComponent + `)*` | |||
| 290 | + | ipv6Address = `\[(?:[A-Fa-f0-9:]+)\]` | |||
| 291 | + | ) | |||
| 292 | + | ||||
| 293 | + | // validImageRef reports whether image can be passed to docker pull and create. | |||
| 294 | + | func validImageRef(image string) bool { return len(image) <= 1024 && imageRefRe.MatchString(image) } | |||
| 295 | + | ||||
| 296 | + | // aliasRe is the name of a service: it becomes a network alias and is never | |||
| 297 | + | // taken for an option. | |||
| 298 | + | var aliasRe = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,62}$`) | |||
| 299 | + | ||||
| 300 | + | // containerIDRe is the id docker create prints. | |||
| 301 | + | var containerIDRe = regexp.MustCompile(`^[0-9a-f]{64}$`) | |||
| 302 | + | ||||
| 303 | + | // serviceCommandAllowed is the feature flag of entrypoint: and command: of a | |||
| 304 | + | // service (ExecutionContext.cs:1545: actions_service_container_command or | |||
| 305 | + | // ACTIONS_SERVICE_CONTAINER_COMMAND; ermite has no feature flags). | |||
| 306 | + | func (j *job) serviceCommandAllowed() bool { | |||
| 307 | + | return convertToBool(envValue(j.baseEnv, "ACTIONS_SERVICE_CONTAINER_COMMAND")) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | // convertContainer is ConvertToJobContainer (PipelineTemplateConverter.cs: | |||
| 311 | + | // 248-352) on an evaluated value. nil means no container (an empty image). | |||
| 312 | + | func (j *job) convertContainer(v expr.Value, service bool) (*containerSpec, error) { | |||
| 313 | + | spec := &containerSpec{} | |||
| 314 | + | if d, ok := v.(*expr.Dictionary); ok { | |||
| 315 | + | for _, p := range d.Pairs() { | |||
| 316 | + | var err error | |||
| 317 | + | switch p.Key { | |||
| 318 | + | case "image": | |||
| 319 | + | spec.image, err = asContainerString(p.Value, "container image") | |||
| 320 | + | case "env": | |||
| 321 | + | spec.env, err = containerEnv(p.Value) | |||
| 322 | + | case "options": | |||
| 323 | + | spec.options, err = asContainerString(p.Value, "container options") | |||
| 324 | + | case "entrypoint", "command": | |||
| 325 | + | if !service || !j.serviceCommandAllowed() { | |||
| 326 | + | return nil, ghErrorf("The key '%s' is not allowed", p.Key) | |||
| 327 | + | } | |||
| 328 | + | s, e := asContainerString(p.Value, "container "+p.Key) | |||
| 329 | + | err = e | |||
| 330 | + | if p.Key == "entrypoint" { | |||
| 331 | + | spec.entrypoint = s | |||
| 332 | + | } else { | |||
| 333 | + | spec.command = s | |||
| 334 | + | } | |||
| 335 | + | case "ports": | |||
| 336 | + | spec.ports, err = containerStrings(p.Value, "container ports") | |||
| 337 | + | case "volumes": | |||
| 338 | + | spec.volumes, err = containerStrings(p.Value, "container volumes") | |||
| 339 | + | case "credentials": | |||
| 340 | + | err = containerCredentials(p.Value, spec) | |||
| 341 | + | default: | |||
| 342 | + | err = ghErrorf("Error while reading 'container key'. Unexpected value '%s'", p.Key) | |||
| 343 | + | } | |||
| 344 | + | if err != nil { | |||
| 345 | + | return nil, err | |||
| 346 | + | } | |||
| 347 | + | } | |||
| 348 | + | } else { | |||
| 349 | + | s, err := asContainerString(v, "container") | |||
| 350 | + | if err != nil { | |||
| 351 | + | return nil, err | |||
| 352 | + | } | |||
| 353 | + | spec.image = s | |||
| 354 | + | } | |||
| 355 | + | spec.image = strings.TrimPrefix(spec.image, "docker://") | |||
| 356 | + | if spec.image == "" { | |||
| 357 | + | return nil, nil | |||
| 358 | + | } | |||
| 359 | + | if !validImageRef(spec.image) { | |||
| 360 | + | return nil, ghErrorf("Invalid container image '%s'", spec.image) | |||
| 361 | + | } | |||
| 362 | + | return spec, nil | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func containerEnv(v expr.Value) ([]Pair, error) { | |||
| 366 | + | d, ok := v.(*expr.Dictionary) | |||
| 367 | + | if !ok { | |||
| 368 | + | return nil, ghErrorf("Unexpected type '%s' encountered while reading 'container env'. The type 'MappingToken' was expected.", v.Kind()) | |||
| 369 | + | } | |||
| 370 | + | var out []Pair | |||
| 371 | + | for _, p := range d.Pairs() { | |||
| 372 | + | if err := checkEnvNames([]string{p.Key}); err != nil { | |||
| 373 | + | return nil, err | |||
| 374 | + | } | |||
| 375 | + | s, err := asContainerString(p.Value, "container env "+p.Key) | |||
| 376 | + | if err != nil { | |||
| 377 | + | return nil, err | |||
| 378 | + | } | |||
| 379 | + | out = append(out, Pair{p.Key, s}) | |||
| 380 | + | } | |||
| 381 | + | return out, nil | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | func containerCredentials(v expr.Value, spec *containerSpec) error { | |||
| 385 | + | d, ok := v.(*expr.Dictionary) | |||
| 386 | + | if !ok { | |||
| 387 | + | return ghErrorf("Unexpected type '%s' encountered while reading 'credentials'. The type 'MappingToken' was expected.", v.Kind()) | |||
| 388 | + | } | |||
| 389 | + | for _, p := range d.Pairs() { | |||
| 390 | + | s, err := asContainerString(p.Value, p.Key) | |||
| 391 | + | if err != nil { | |||
| 392 | + | return err | |||
| 393 | + | } | |||
| 394 | + | switch p.Key { | |||
| 395 | + | case "username": | |||
| 396 | + | spec.credUser = s | |||
| 397 | + | case "password": | |||
| 398 | + | spec.credPass = s | |||
| 399 | + | default: | |||
| 400 | + | return ghErrorf("Error while reading 'credentials key %s'. Unexpected value '%s'", p.Key, p.Key) | |||
| 401 | + | } | |||
| 402 | + | } | |||
| 403 | + | return nil | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | // evalContainers evaluates container: and services: of the job | |||
| 407 | + | // (JobExtension.cs:250-275) with the contexts the job has by then. | |||
| 408 | + | func (j *job) evalContainers() (job *container, services []*container, err error) { | |||
| 409 | + | ctx := context.Background() | |||
| 410 | + | j.mu.Lock() | |||
| 411 | + | env := j.state.JobEnv() | |||
| 412 | + | j.mu.Unlock() | |||
| 413 | + | te := j.evalEnv(env) | |||
| 414 | + | if t := j.jb.Container; t != nil { | |||
| 415 | + | v, e := template.Evaluate(ctx, t, te) | |||
| 416 | + | if e != nil { | |||
| 417 | + | return nil, nil, e | |||
| 418 | + | } | |||
| 419 | + | if _, null := v.(expr.Null); !null { | |||
| 420 | + | spec, e := j.convertContainer(v, false) | |||
| 421 | + | if e != nil { | |||
| 422 | + | return nil, nil, e | |||
| 423 | + | } | |||
| 424 | + | if spec != nil { | |||
| 425 | + | job = j.newContainer(spec, true, "") | |||
| 426 | + | } | |||
| 427 | + | } | |||
| 428 | + | } | |||
| 429 | + | if t := j.jb.Services; t != nil { | |||
| 430 | + | v, e := template.Evaluate(ctx, t, te) | |||
| 431 | + | if e != nil { | |||
| 432 | + | return nil, nil, e | |||
| 433 | + | } | |||
| 434 | + | d, ok := v.(*expr.Dictionary) | |||
| 435 | + | if !ok { | |||
| 436 | + | if _, null := v.(expr.Null); null { | |||
| 437 | + | return job, nil, nil | |||
| 438 | + | } | |||
| 439 | + | return nil, nil, ghErrorf("Unexpected type '%s' encountered while reading 'services'. The type 'MappingToken' was expected.", v.Kind()) | |||
| 440 | + | } | |||
| 441 | + | for _, p := range d.Pairs() { | |||
| 442 | + | if !aliasRe.MatchString(p.Key) { | |||
| 443 | + | return nil, nil, ghErrorf("Invalid service name '%s': use letters, digits, '_', '.' and '-', starting with a letter, a digit or '_'", p.Key) | |||
| 444 | + | } | |||
| 445 | + | spec, e := j.convertContainer(p.Value, true) | |||
| 446 | + | if e != nil { | |||
| 447 | + | return nil, nil, e | |||
| 448 | + | } | |||
| 449 | + | if spec == nil { | |||
| 450 | + | j.out(1, fmt.Sprintf("The service '%s' will not be started because the container definition has an empty image.", p.Key)) | |||
| 451 | + | continue | |||
| 452 | + | } | |||
| 453 | + | services = append(services, j.newContainer(spec, false, p.Key)) | |||
| 454 | + | } | |||
| 455 | + | } | |||
| 456 | + | return job, services, nil | |||
| 457 | + | } | |||
A internal/executor/containers_spec_test.go +188 -0
| @@ -0,0 +1,188 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "path" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite-actions/expr" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestParseVolume(t *testing.T) { | |
| 15 | + | for in, want := range map[string]mount{ | |
| 16 | + | "/data": {target: "/data"}, | |
| 17 | + | "vol:/data": {source: "vol", target: "/data"}, | |
| 18 | + | "/data:ro": {target: "/data", readOnly: true}, | |
| 19 | + | "/h:/c:ro": {source: "/h", target: "/c", readOnly: true}, | |
| 20 | + | "/h:/c:rw": {source: "/h", target: "/c"}, | |
| 21 | + | "/h:/c:ro:extra": {target: "/h:/c:ro:extra"}, | |
| 22 | + | "/data:RO": {target: "/data", readOnly: true}, | |
| 23 | + | } { | |
| 24 | + | if got := parseVolume(in); got != want { | |
| 25 | + | t.Errorf("%q: %+v, want %+v", in, got, want) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestContainerPathTranslation(t *testing.T) { | |
| 31 | + | c := &container{maps: []pathMap{{"/h/work", "/__w"}, {"/opt/tc", "/__t"}}} | |
| 32 | + | c.mounts = []mount{{source: "/h/work", target: "/__w"}} | |
| 33 | + | for in, want := range map[string]string{ | |
| 34 | + | "/h/work": "/__w", "/h/work/_temp/x": "/__w/_temp/x", "/h/workx": "/h/workx", "": "", "/etc": "/etc", | |
| 35 | + | } { | |
| 36 | + | if got := c.translateToContainer(in); got != want { | |
| 37 | + | t.Errorf("%q -> %q, want %q", in, got, want) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | if got := c.translateToHost("/__w/a/b"); got != "/h/work/a/b" { | |
| 41 | + | t.Error(got) | |
| 42 | + | } | |
| 43 | + | // ResolvePathForStepHost translates quoted paths under a mount only. | |
| 44 | + | if got := c.resolvePath(`"/h/work/a"`); got != "/__w/a" { | |
| 45 | + | t.Error(got) | |
| 46 | + | } | |
| 47 | + | if got := c.resolvePath("/opt/tc/x"); got != "/opt/tc/x" { | |
| 48 | + | t.Errorf("no mount source covers it: %s", got) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestRegistryHost(t *testing.T) { | |
| 53 | + | for in, want := range map[string]string{ | |
| 54 | + | "ubuntu:24.04": "", "owner/img": "", "ghcr.io/owner/img:1": "ghcr.io", | |
| 55 | + | "localhost:5000/img": "localhost:5000", "a/b/c": "a", | |
| 56 | + | } { | |
| 57 | + | if got := parseRegistryHost(in); got != want { | |
| 58 | + | t.Errorf("%q: %q want %q", in, got, want) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func mustValue(t *testing.T, js string) expr.Value { | |
| 64 | + | t.Helper() | |
| 65 | + | v, err := expr.FromJSON([]byte(js)) | |
| 66 | + | if err != nil { | |
| 67 | + | t.Fatal(err) | |
| 68 | + | } | |
| 69 | + | return v | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestConvertContainerForms(t *testing.T) { | |
| 73 | + | j := &job{} | |
| 74 | + | v := mustValue(t, `{"image":"docker://ubuntu:24.04","env":{"A":"1","B":2},"ports":[8080,"9000:9001"],"volumes":["v:/d"],"options":"--cpus 1","credentials":{"username":"u","password":"p"}}`) | |
| 75 | + | spec, err := j.convertContainer(v, false) | |
| 76 | + | if err != nil || spec.image != "ubuntu:24.04" || len(spec.env) != 2 || spec.env[1].Value != "2" || | |
| 77 | + | strings.Join(spec.ports, ",") != "8080,9000:9001" || spec.credPass != "p" || spec.options != "--cpus 1" { | |
| 78 | + | t.Fatalf("%+v %v", spec, err) | |
| 79 | + | } | |
| 80 | + | if s, err := j.convertContainer(mustValue(t, `""`), false); s != nil || err != nil { | |
| 81 | + | t.Errorf("empty string: %v %v", s, err) | |
| 82 | + | } | |
| 83 | + | if s, err := j.convertContainer(mustValue(t, `{"image":""}`), true); s != nil || err != nil { | |
| 84 | + | t.Errorf("empty image: %v %v", s, err) | |
| 85 | + | } | |
| 86 | + | for _, bad := range []string{`{"entrypoint":"x","image":"i"}`, `{"nope":1,"image":"i"}`, `{"image":["a"]}`} { | |
| 87 | + | if _, err := j.convertContainer(mustValue(t, bad), false); err == nil { | |
| 88 | + | t.Errorf("%s accepted", bad) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | // A service may only set entrypoint and command when the server allows it. | |
| 92 | + | if _, err := j.convertContainer(mustValue(t, `{"image":"i","command":"x"}`), true); err == nil { | |
| 93 | + | t.Error("command without the feature") | |
| 94 | + | } | |
| 95 | + | j.baseEnv = []string{"ACTIONS_SERVICE_CONTAINER_COMMAND=true"} | |
| 96 | + | if s, err := j.convertContainer(mustValue(t, `{"image":"i","command":"x","entrypoint":"e"}`), true); err != nil || s.command != "x" || s.entrypoint != "e" { | |
| 97 | + | t.Errorf("%+v %v", s, err) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestImageReferenceGrammar(t *testing.T) { | |
| 102 | + | good := []string{"ubuntu", "ubuntu:24.04", "ghcr.io/o/img:1", "localhost:5000/a/b:tag", "a/b_c.d-e", "redis@sha256:" + strings.Repeat("a", 64), | |
| 103 | + | "r.io:443/x/y:1@sha256:" + strings.Repeat("0", 64), "Ubuntu:24.04", | |
| 104 | + | "[::1]:5000/x", "[::1]/x/y:tag", "[2001:db8::1]:443/a/b@sha256:" + strings.Repeat("a", 64)} | |
| 105 | + | bad := []string{"", "--help", "-x", "-", " a", "a b", "a\nb", "a;b", "a$(x)", "a:", "a:-x", "a@sha256:zz", "/a", "a//b", "a:b:c", "[::1/x", "[]/x", "[zz]/x", "[::1]x", "[::1]:/x", strings.Repeat("a", 1100)} | |
| 106 | + | for _, g := range good { | |
| 107 | + | if !validImageRef(g) { | |
| 108 | + | t.Errorf("rejected %q", g) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | for _, b := range bad { | |
| 112 | + | if validImageRef(b) { | |
| 113 | + | t.Errorf("accepted %q", b) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | j := &job{} | |
| 117 | + | if _, err := j.convertContainer(mustValue(t, `"--help"`), false); err == nil || !strings.Contains(err.Error(), "Invalid container image '--help'") { | |
| 118 | + | t.Errorf("container: --help: %v", err) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestServiceNamesAndContainerIDs(t *testing.T) { | |
| 123 | + | f := newFakeCtr(t) | |
| 124 | + | for _, name := range []string{"-x", ".y", "a b", "a/b"} { | |
| 125 | + | rec, res := ctrRun(t, f, " services:\n \""+name+"\":\n image: redis:7\n", " - run: echo never\n", nil) | |
| 126 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "Set up job")), "Invalid service name") { | |
| 127 | + | t.Errorf("%q: %+v", name, res) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | for id, ok := range map[string]bool{strings.Repeat("a", 64): true, strings.Repeat("0", 64): true, "Usage: docker create": false, strings.Repeat("a", 63): false, strings.Repeat("A", 64): false, "--help": false} { | |
| 131 | + | if containerIDRe.MatchString(id) != ok { | |
| 132 | + | t.Errorf("id %q", id) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | // A help text printed by create is not taken for an id: nothing is started. | |
| 136 | + | f.set(t, "create_out", "Usage: docker create [OPTIONS] IMAGE\n") | |
| 137 | + | rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - run: echo never\n", nil) | |
| 138 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "Initialize containers")), "##[error]Docker create did not return a container id: 'Usage: docker create [OPTIONS] IMAGE'") { | |
| 139 | + | t.Errorf("%+v\n%s", res, logOf(rec.step(t, "Initialize containers"))) | |
| 140 | + | } | |
| 141 | + | for _, c := range f.calls(t) { | |
| 142 | + | if c[0] == "start" { | |
| 143 | + | t.Errorf("started %v", c) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestTranslateToHostDoesNotEscapeTheMapping(t *testing.T) { | |
| 149 | + | c := &container{maps: []pathMap{{"/h/work", "/__w"}, {"/opt/tc", "/__t"}}} | |
| 150 | + | for in, want := range map[string]string{ | |
| 151 | + | "/__w/a/b": "/h/work/a/b", | |
| 152 | + | "/__w/a/../b": "/h/work/b", | |
| 153 | + | "/__w/../outside/x": "/outside/x", | |
| 154 | + | "/__w/a/../../../etc": "/etc", | |
| 155 | + | "/__w//a///b/": "/h/work/a/b", | |
| 156 | + | "/__w": "/h/work", | |
| 157 | + | "/__t/../__w/x": "/h/work/x", | |
| 158 | + | "/__wx/y": "/__wx/y", | |
| 159 | + | "rel/../x": "rel/../x", | |
| 160 | + | } { | |
| 161 | + | got := c.translateToHost(in) | |
| 162 | + | if got != want { | |
| 163 | + | t.Errorf("%q -> %q, want %q", in, got, want) | |
| 164 | + | } | |
| 165 | + | if strings.HasPrefix(in, "/__w") && !strings.HasPrefix(got, "/h/work") && got != path.Clean(in) { | |
| 166 | + | t.Errorf("%q escaped to %q", in, got) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // A matcher file named by a command of the container is read only when it is | |
| 172 | + | // in a directory shared with the host: ".." cannot reach another host file. | |
| 173 | + | func TestMatcherPathFromTheContainerStaysInside(t *testing.T) { | |
| 174 | + | f := newFakeCtr(t) | |
| 175 | + | var outside string | |
| 176 | + | rec, res := ctrRun(t, f, ctrJob, " - name: s\n run: echo\n", func(c *Config) { | |
| 177 | + | outside = filepath.Join(filepath.Dir(c.Layout.WorkRoot), "outside") | |
| 178 | + | writeFiles(t, filepath.Dir(c.Layout.WorkRoot), files{"outside/m.json": `{"problemMatcher":[{"owner":"leak","pattern":[{"regexp":"x"}]}]}`}) | |
| 179 | + | f.set(t, "exec_out", "::add-matcher::/__w/../outside/m.json\n::add-matcher::"+outside+"/m.json\n::add-matcher::/etc/hostname\n") | |
| 180 | + | }) | |
| 181 | + | if res.Conclusion != ConclusionFailure { | |
| 182 | + | t.Fatalf("%+v", res) | |
| 183 | + | } | |
| 184 | + | log := logOf(rec.step(t, "s")) | |
| 185 | + | if strings.Contains(log, "leak") || strings.Count(log, "##[error]") < 2 || !strings.Contains(log, "Could not find file") && !strings.Contains(log, "ould not find") { | |
| 186 | + | t.Errorf("log:\n%s", log) | |
| 187 | + | } | |
| 188 | + | } | |
| @@ -0,0 +1,188 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "path" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite-actions/expr" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestParseVolume(t *testing.T) { | |||
| 15 | + | for in, want := range map[string]mount{ | |||
| 16 | + | "/data": {target: "/data"}, | |||
| 17 | + | "vol:/data": {source: "vol", target: "/data"}, | |||
| 18 | + | "/data:ro": {target: "/data", readOnly: true}, | |||
| 19 | + | "/h:/c:ro": {source: "/h", target: "/c", readOnly: true}, | |||
| 20 | + | "/h:/c:rw": {source: "/h", target: "/c"}, | |||
| 21 | + | "/h:/c:ro:extra": {target: "/h:/c:ro:extra"}, | |||
| 22 | + | "/data:RO": {target: "/data", readOnly: true}, | |||
| 23 | + | } { | |||
| 24 | + | if got := parseVolume(in); got != want { | |||
| 25 | + | t.Errorf("%q: %+v, want %+v", in, got, want) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestContainerPathTranslation(t *testing.T) { | |||
| 31 | + | c := &container{maps: []pathMap{{"/h/work", "/__w"}, {"/opt/tc", "/__t"}}} | |||
| 32 | + | c.mounts = []mount{{source: "/h/work", target: "/__w"}} | |||
| 33 | + | for in, want := range map[string]string{ | |||
| 34 | + | "/h/work": "/__w", "/h/work/_temp/x": "/__w/_temp/x", "/h/workx": "/h/workx", "": "", "/etc": "/etc", | |||
| 35 | + | } { | |||
| 36 | + | if got := c.translateToContainer(in); got != want { | |||
| 37 | + | t.Errorf("%q -> %q, want %q", in, got, want) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | if got := c.translateToHost("/__w/a/b"); got != "/h/work/a/b" { | |||
| 41 | + | t.Error(got) | |||
| 42 | + | } | |||
| 43 | + | // ResolvePathForStepHost translates quoted paths under a mount only. | |||
| 44 | + | if got := c.resolvePath(`"/h/work/a"`); got != "/__w/a" { | |||
| 45 | + | t.Error(got) | |||
| 46 | + | } | |||
| 47 | + | if got := c.resolvePath("/opt/tc/x"); got != "/opt/tc/x" { | |||
| 48 | + | t.Errorf("no mount source covers it: %s", got) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestRegistryHost(t *testing.T) { | |||
| 53 | + | for in, want := range map[string]string{ | |||
| 54 | + | "ubuntu:24.04": "", "owner/img": "", "ghcr.io/owner/img:1": "ghcr.io", | |||
| 55 | + | "localhost:5000/img": "localhost:5000", "a/b/c": "a", | |||
| 56 | + | } { | |||
| 57 | + | if got := parseRegistryHost(in); got != want { | |||
| 58 | + | t.Errorf("%q: %q want %q", in, got, want) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func mustValue(t *testing.T, js string) expr.Value { | |||
| 64 | + | t.Helper() | |||
| 65 | + | v, err := expr.FromJSON([]byte(js)) | |||
| 66 | + | if err != nil { | |||
| 67 | + | t.Fatal(err) | |||
| 68 | + | } | |||
| 69 | + | return v | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestConvertContainerForms(t *testing.T) { | |||
| 73 | + | j := &job{} | |||
| 74 | + | v := mustValue(t, `{"image":"docker://ubuntu:24.04","env":{"A":"1","B":2},"ports":[8080,"9000:9001"],"volumes":["v:/d"],"options":"--cpus 1","credentials":{"username":"u","password":"p"}}`) | |||
| 75 | + | spec, err := j.convertContainer(v, false) | |||
| 76 | + | if err != nil || spec.image != "ubuntu:24.04" || len(spec.env) != 2 || spec.env[1].Value != "2" || | |||
| 77 | + | strings.Join(spec.ports, ",") != "8080,9000:9001" || spec.credPass != "p" || spec.options != "--cpus 1" { | |||
| 78 | + | t.Fatalf("%+v %v", spec, err) | |||
| 79 | + | } | |||
| 80 | + | if s, err := j.convertContainer(mustValue(t, `""`), false); s != nil || err != nil { | |||
| 81 | + | t.Errorf("empty string: %v %v", s, err) | |||
| 82 | + | } | |||
| 83 | + | if s, err := j.convertContainer(mustValue(t, `{"image":""}`), true); s != nil || err != nil { | |||
| 84 | + | t.Errorf("empty image: %v %v", s, err) | |||
| 85 | + | } | |||
| 86 | + | for _, bad := range []string{`{"entrypoint":"x","image":"i"}`, `{"nope":1,"image":"i"}`, `{"image":["a"]}`} { | |||
| 87 | + | if _, err := j.convertContainer(mustValue(t, bad), false); err == nil { | |||
| 88 | + | t.Errorf("%s accepted", bad) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | // A service may only set entrypoint and command when the server allows it. | |||
| 92 | + | if _, err := j.convertContainer(mustValue(t, `{"image":"i","command":"x"}`), true); err == nil { | |||
| 93 | + | t.Error("command without the feature") | |||
| 94 | + | } | |||
| 95 | + | j.baseEnv = []string{"ACTIONS_SERVICE_CONTAINER_COMMAND=true"} | |||
| 96 | + | if s, err := j.convertContainer(mustValue(t, `{"image":"i","command":"x","entrypoint":"e"}`), true); err != nil || s.command != "x" || s.entrypoint != "e" { | |||
| 97 | + | t.Errorf("%+v %v", s, err) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestImageReferenceGrammar(t *testing.T) { | |||
| 102 | + | good := []string{"ubuntu", "ubuntu:24.04", "ghcr.io/o/img:1", "localhost:5000/a/b:tag", "a/b_c.d-e", "redis@sha256:" + strings.Repeat("a", 64), | |||
| 103 | + | "r.io:443/x/y:1@sha256:" + strings.Repeat("0", 64), "Ubuntu:24.04", | |||
| 104 | + | "[::1]:5000/x", "[::1]/x/y:tag", "[2001:db8::1]:443/a/b@sha256:" + strings.Repeat("a", 64)} | |||
| 105 | + | bad := []string{"", "--help", "-x", "-", " a", "a b", "a\nb", "a;b", "a$(x)", "a:", "a:-x", "a@sha256:zz", "/a", "a//b", "a:b:c", "[::1/x", "[]/x", "[zz]/x", "[::1]x", "[::1]:/x", strings.Repeat("a", 1100)} | |||
| 106 | + | for _, g := range good { | |||
| 107 | + | if !validImageRef(g) { | |||
| 108 | + | t.Errorf("rejected %q", g) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | for _, b := range bad { | |||
| 112 | + | if validImageRef(b) { | |||
| 113 | + | t.Errorf("accepted %q", b) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | j := &job{} | |||
| 117 | + | if _, err := j.convertContainer(mustValue(t, `"--help"`), false); err == nil || !strings.Contains(err.Error(), "Invalid container image '--help'") { | |||
| 118 | + | t.Errorf("container: --help: %v", err) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestServiceNamesAndContainerIDs(t *testing.T) { | |||
| 123 | + | f := newFakeCtr(t) | |||
| 124 | + | for _, name := range []string{"-x", ".y", "a b", "a/b"} { | |||
| 125 | + | rec, res := ctrRun(t, f, " services:\n \""+name+"\":\n image: redis:7\n", " - run: echo never\n", nil) | |||
| 126 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "Set up job")), "Invalid service name") { | |||
| 127 | + | t.Errorf("%q: %+v", name, res) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | for id, ok := range map[string]bool{strings.Repeat("a", 64): true, strings.Repeat("0", 64): true, "Usage: docker create": false, strings.Repeat("a", 63): false, strings.Repeat("A", 64): false, "--help": false} { | |||
| 131 | + | if containerIDRe.MatchString(id) != ok { | |||
| 132 | + | t.Errorf("id %q", id) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | // A help text printed by create is not taken for an id: nothing is started. | |||
| 136 | + | f.set(t, "create_out", "Usage: docker create [OPTIONS] IMAGE\n") | |||
| 137 | + | rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - run: echo never\n", nil) | |||
| 138 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "Initialize containers")), "##[error]Docker create did not return a container id: 'Usage: docker create [OPTIONS] IMAGE'") { | |||
| 139 | + | t.Errorf("%+v\n%s", res, logOf(rec.step(t, "Initialize containers"))) | |||
| 140 | + | } | |||
| 141 | + | for _, c := range f.calls(t) { | |||
| 142 | + | if c[0] == "start" { | |||
| 143 | + | t.Errorf("started %v", c) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestTranslateToHostDoesNotEscapeTheMapping(t *testing.T) { | |||
| 149 | + | c := &container{maps: []pathMap{{"/h/work", "/__w"}, {"/opt/tc", "/__t"}}} | |||
| 150 | + | for in, want := range map[string]string{ | |||
| 151 | + | "/__w/a/b": "/h/work/a/b", | |||
| 152 | + | "/__w/a/../b": "/h/work/b", | |||
| 153 | + | "/__w/../outside/x": "/outside/x", | |||
| 154 | + | "/__w/a/../../../etc": "/etc", | |||
| 155 | + | "/__w//a///b/": "/h/work/a/b", | |||
| 156 | + | "/__w": "/h/work", | |||
| 157 | + | "/__t/../__w/x": "/h/work/x", | |||
| 158 | + | "/__wx/y": "/__wx/y", | |||
| 159 | + | "rel/../x": "rel/../x", | |||
| 160 | + | } { | |||
| 161 | + | got := c.translateToHost(in) | |||
| 162 | + | if got != want { | |||
| 163 | + | t.Errorf("%q -> %q, want %q", in, got, want) | |||
| 164 | + | } | |||
| 165 | + | if strings.HasPrefix(in, "/__w") && !strings.HasPrefix(got, "/h/work") && got != path.Clean(in) { | |||
| 166 | + | t.Errorf("%q escaped to %q", in, got) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // A matcher file named by a command of the container is read only when it is | |||
| 172 | + | // in a directory shared with the host: ".." cannot reach another host file. | |||
| 173 | + | func TestMatcherPathFromTheContainerStaysInside(t *testing.T) { | |||
| 174 | + | f := newFakeCtr(t) | |||
| 175 | + | var outside string | |||
| 176 | + | rec, res := ctrRun(t, f, ctrJob, " - name: s\n run: echo\n", func(c *Config) { | |||
| 177 | + | outside = filepath.Join(filepath.Dir(c.Layout.WorkRoot), "outside") | |||
| 178 | + | writeFiles(t, filepath.Dir(c.Layout.WorkRoot), files{"outside/m.json": `{"problemMatcher":[{"owner":"leak","pattern":[{"regexp":"x"}]}]}`}) | |||
| 179 | + | f.set(t, "exec_out", "::add-matcher::/__w/../outside/m.json\n::add-matcher::"+outside+"/m.json\n::add-matcher::/etc/hostname\n") | |||
| 180 | + | }) | |||
| 181 | + | if res.Conclusion != ConclusionFailure { | |||
| 182 | + | t.Fatalf("%+v", res) | |||
| 183 | + | } | |||
| 184 | + | log := logOf(rec.step(t, "s")) | |||
| 185 | + | if strings.Contains(log, "leak") || strings.Count(log, "##[error]") < 2 || !strings.Contains(log, "Could not find file") && !strings.Contains(log, "ould not find") { | |||
| 186 | + | t.Errorf("log:\n%s", log) | |||
| 187 | + | } | |||
| 188 | + | } | |||
A internal/executor/containers_start.go +241 -0
| @@ -0,0 +1,241 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // createCmd is DockerCommandManager.DockerCreate (DockerCommandManager.cs: | |
| 14 | + | // 119-215), in the order of the runner's options. | |
| 15 | + | func (j *job) createCmd(c *container) dockerCmd { | |
| 16 | + | parts := []dockerPart{raw("--name", c.name), raw("--label", j.dockerLabel())} | |
| 17 | + | if c.workDir != "" { | |
| 18 | + | parts = append(parts, raw("--workdir", c.workDir)) | |
| 19 | + | } | |
| 20 | + | if c.network != "" { | |
| 21 | + | parts = append(parts, raw("--network", c.network)) | |
| 22 | + | } | |
| 23 | + | if c.alias != "" { | |
| 24 | + | parts = append(parts, raw("--network-alias", c.alias)) | |
| 25 | + | } | |
| 26 | + | for _, p := range c.ports { | |
| 27 | + | parts = append(parts, raw("-p", p)) | |
| 28 | + | } | |
| 29 | + | parts = append(parts, optionsParts(c.options)) | |
| 30 | + | for _, k := range c.env.Keys() { | |
| 31 | + | v, _ := c.env.Get(k) | |
| 32 | + | parts = append(parts, j.envKV(k, v)) | |
| 33 | + | } | |
| 34 | + | parts = append(parts, raw("-e", "GITHUB_ACTIONS=true")) | |
| 35 | + | if _, ok := c.env.Get("CI"); !ok { | |
| 36 | + | parts = append(parts, raw("-e", "CI=true")) | |
| 37 | + | } | |
| 38 | + | for _, m := range c.mounts { | |
| 39 | + | parts = append(parts, mountOpt(m)) | |
| 40 | + | } | |
| 41 | + | if c.entrypoint != "" { | |
| 42 | + | parts = append(parts, entrypointOpt(c.entrypoint)) | |
| 43 | + | } | |
| 44 | + | parts = append(parts, raw(c.image), dockerPart{line: c.entryArgs, args: ParseArguments(c.entryArgs)}) | |
| 45 | + | return j.newDockerCmd("create", parts...) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // mountWellKnownDirectories is ContainerOperationProvider.MountWellKnownDirectories | |
| 49 | + | // (:242-283): the job container sees the work directory, the externals, the | |
| 50 | + | // temp, actions and tool cache directories, and a home and a workflow | |
| 51 | + | // directory of its own. | |
| 52 | + | func (j *job) mountWellKnownDirectories(c *container) error { | |
| 53 | + | lay := j.state.Layout() | |
| 54 | + | workspace := j.githubValue("workspace") | |
| 55 | + | tr := c.translateToContainer | |
| 56 | + | c.mounts = append(c.mounts, | |
| 57 | + | mount{source: lay.WorkRoot, target: tr(lay.WorkRoot)}, | |
| 58 | + | mount{source: j.externalsDir(), target: tr(j.externalsDir()), readOnly: true}, | |
| 59 | + | mount{source: lay.Temp, target: tr(lay.Temp)}, | |
| 60 | + | mount{source: lay.Actions, target: tr(lay.Actions)}, | |
| 61 | + | mount{source: lay.ToolCache, target: tr(lay.ToolCache)}) | |
| 62 | + | for _, d := range []struct{ dir, target string }{ | |
| 63 | + | {"_github_home", "/github/home"}, {"_github_workflow", "/github/workflow"}, | |
| 64 | + | } { | |
| 65 | + | host := filepath.Join(lay.Temp, d.dir) | |
| 66 | + | if err := os.MkdirAll(host, 0o755); err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | j.own(host) | |
| 70 | + | c.mounts = append(c.mounts, mount{source: host, target: d.target}) | |
| 71 | + | c.maps = append([]pathMap{{host, d.target}}, c.maps...) // AddPathTranslateMapping inserts first | |
| 72 | + | if d.dir == "_github_home" { | |
| 73 | + | c.env.Set("HOME", tr(host)) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | c.workDir = tr(workspace) | |
| 77 | + | c.entrypoint = "tail" | |
| 78 | + | c.entryArgs = `"-f" "/dev/null"` | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // startContainer is StartContainerAsync (ContainerOperationProvider.cs: | |
| 83 | + | // 123-240). | |
| 84 | + | func (j *job) startContainer(s *stepRun, c *container, cancel <-chan struct{}) error { | |
| 85 | + | num := s.num | |
| 86 | + | group := "Starting job container" | |
| 87 | + | if !c.isJob { | |
| 88 | + | group = "Starting " + c.alias + " service container" | |
| 89 | + | } | |
| 90 | + | j.out(num, "##[group]"+group) | |
| 91 | + | for _, m := range c.userMounts() { | |
| 92 | + | if m.source == "/" { | |
| 93 | + | j.tagged(num, "##[warning]", fmt.Sprintf("Volume mount %s is going to mount '/' into the container which may cause file ownership change in the entire file system and cause Actions Runner to lose permission to access the disk.", m.user)) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | cfg, err := j.registryLogin(s, c, cancel) | |
| 97 | + | if err != nil { | |
| 98 | + | if rerr := j.removeConfigDir(cfg); rerr != nil { | |
| 99 | + | j.errorf(num, "%s", rerr.Error()) | |
| 100 | + | } | |
| 101 | + | return err | |
| 102 | + | } | |
| 103 | + | defer func() { _ = j.removeConfigDir(cfg) }() // also on a panic; idempotent | |
| 104 | + | code, err := j.pullWithRetry(s, c.image, cfg, cancel) | |
| 105 | + | // Credentials are removed after the pull, whatever it did (:171-173). | |
| 106 | + | if rerr := j.removeConfigDir(cfg); rerr != nil { | |
| 107 | + | if err == nil { | |
| 108 | + | err = rerr | |
| 109 | + | } else { | |
| 110 | + | j.errorf(num, "%s", rerr.Error()) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | if err != nil { | |
| 114 | + | return err | |
| 115 | + | } | |
| 116 | + | if code != 0 { | |
| 117 | + | return ghErrorf("Docker pull failed with exit code %d", code) | |
| 118 | + | } | |
| 119 | + | if c.isJob { | |
| 120 | + | if err := j.mountWellKnownDirectories(c); err != nil { | |
| 121 | + | return err | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | if err := checkEnvNames(c.env.Keys()); err != nil { | |
| 125 | + | return err | |
| 126 | + | } | |
| 127 | + | c.createTried = true // the container may exist from here on, named c.name | |
| 128 | + | out, err := j.dockerList(s, j.createCmd(c), cancel) | |
| 129 | + | if err != nil { | |
| 130 | + | var ee *exitError | |
| 131 | + | if errors.As(err, &ee) { | |
| 132 | + | c.createTried = false // docker said no: there is nothing to remove | |
| 133 | + | } | |
| 134 | + | return err | |
| 135 | + | } | |
| 136 | + | if len(out) == 0 || out[0] == "" { | |
| 137 | + | return ghErrorf("Value cannot be null. (Parameter 'container.ContainerId')") | |
| 138 | + | } | |
| 139 | + | // docker create prints the id and nothing else; anything else (the help | |
| 140 | + | // text of an option in options:, say) is not an id to use or to remove. | |
| 141 | + | if !containerIDRe.MatchString(out[0]) { | |
| 142 | + | return ghErrorf("Docker create did not return a container id: '%s'", out[0]) | |
| 143 | + | } | |
| 144 | + | c.id = out[0] | |
| 145 | + | code, err = j.dockerCode(s, j.newDockerCmd("start", raw(c.id)), cancel) | |
| 146 | + | if err != nil { | |
| 147 | + | return err | |
| 148 | + | } | |
| 149 | + | if code != 0 { | |
| 150 | + | return ghErrorf("Docker start fail with exit code %d", code) | |
| 151 | + | } | |
| 152 | + | j.checkRunning(s, c, cancel) | |
| 153 | + | if !c.isJob { | |
| 154 | + | ports, err := j.dockerList(s, j.newDockerCmd("port", raw(c.id)), cancel) | |
| 155 | + | if err != nil { | |
| 156 | + | return err | |
| 157 | + | } | |
| 158 | + | c.portMappings = parseDockerPort(ports) | |
| 159 | + | j.mu.Lock() | |
| 160 | + | j.state.SetJobService(c.alias, c.id, c.network, c.portMappings) | |
| 161 | + | j.mu.Unlock() | |
| 162 | + | } else { | |
| 163 | + | env, err := j.dockerList(s, j.newDockerCmd("inspect", quotedOpt("--format", "{{range .Config.Env}}{{println .}}{{end}}"), raw(c.id)), cancel) | |
| 164 | + | if err != nil { | |
| 165 | + | return err | |
| 166 | + | } | |
| 167 | + | c.runtimePath = parsePathFromConfigEnv(env) | |
| 168 | + | j.mu.Lock() | |
| 169 | + | j.state.SetJobContainer("id", c.id) | |
| 170 | + | j.mu.Unlock() | |
| 171 | + | } | |
| 172 | + | j.out(num, "##[endgroup]") | |
| 173 | + | return nil | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // pullWithRetry is the pull loop of StartContainerAsync (:158-178): three | |
| 177 | + | // tries, a random back off of 1 to 10 s between them. | |
| 178 | + | func (j *job) pullWithRetry(s *stepRun, image, cfg string, cancel <-chan struct{}) (int, error) { | |
| 179 | + | code := 0 | |
| 180 | + | for try := 1; try <= 3; try++ { | |
| 181 | + | var err error | |
| 182 | + | _, code, err = j.dockerDo(j.pullCmd(image, cfg), dockerOpts{s: s, cancel: cancel, all: true, timeout: j.timeouts().Pull}) | |
| 183 | + | if err != nil { | |
| 184 | + | return code, err | |
| 185 | + | } | |
| 186 | + | if code == 0 { | |
| 187 | + | return 0, nil | |
| 188 | + | } | |
| 189 | + | if try < 3 { | |
| 190 | + | d := j.containerBackoff() | |
| 191 | + | j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker pull failed with exit code %d, back off %s seconds before retry.", code, seconds(d))) | |
| 192 | + | if !j.sleepOrCancel(d, cancel) { | |
| 193 | + | return code, errCancelled | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | return code, nil | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // checkRunning is the best-effort check of :193-213. | |
| 201 | + | func (j *job) checkRunning(s *stepRun, c *container, cancel <-chan struct{}) { | |
| 202 | + | fmtOpt := quotedOpt("--format", "{{.ID}} {{.Status}}") | |
| 203 | + | out, err := j.dockerList(s, j.newDockerCmd("ps", raw("--all"), raw("--filter", "id="+c.id), raw("--filter", "status=running"), raw("--no-trunc"), fmtOpt), cancel) | |
| 204 | + | if err != nil { | |
| 205 | + | return | |
| 206 | + | } | |
| 207 | + | for _, l := range out { | |
| 208 | + | if l != "" { | |
| 209 | + | if strings.HasPrefix(l, c.id) { | |
| 210 | + | return | |
| 211 | + | } | |
| 212 | + | break | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | _, _ = j.dockerList(s, j.newDockerCmd("ps", raw("--all"), raw("--filter", "id="+c.id), raw("--no-trunc"), fmtOpt), cancel) | |
| 216 | + | if code, err := j.dockerCode(s, j.newDockerCmd("logs", raw("--details", c.id)), cancel); err == nil && code != 0 { | |
| 217 | + | j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker logs fail with exit code %d", code)) | |
| 218 | + | } | |
| 219 | + | j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker container %s is not in running state.", c.id)) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // parseDockerPort is DockerUtil.ParseDockerPort (DockerUtil.cs:14-37). | |
| 223 | + | func parseDockerPort(lines []string) []portMapping { | |
| 224 | + | var out []portMapping | |
| 225 | + | for _, l := range lines { | |
| 226 | + | if m := portLineRe.FindStringSubmatch(l); m != nil { | |
| 227 | + | out = append(out, portMapping{host: m[4], container: m[1], proto: m[2]}) | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | return out | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // parsePathFromConfigEnv is DockerUtil.ParsePathFromConfigEnv (:39-52). | |
| 234 | + | func parsePathFromConfigEnv(lines []string) string { | |
| 235 | + | for _, l := range lines { | |
| 236 | + | if k, v, ok := strings.Cut(l, "="); ok && k == "PATH" { | |
| 237 | + | return v | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | return "" | |
| 241 | + | } | |
| @@ -0,0 +1,241 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "strings" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // createCmd is DockerCommandManager.DockerCreate (DockerCommandManager.cs: | |||
| 14 | + | // 119-215), in the order of the runner's options. | |||
| 15 | + | func (j *job) createCmd(c *container) dockerCmd { | |||
| 16 | + | parts := []dockerPart{raw("--name", c.name), raw("--label", j.dockerLabel())} | |||
| 17 | + | if c.workDir != "" { | |||
| 18 | + | parts = append(parts, raw("--workdir", c.workDir)) | |||
| 19 | + | } | |||
| 20 | + | if c.network != "" { | |||
| 21 | + | parts = append(parts, raw("--network", c.network)) | |||
| 22 | + | } | |||
| 23 | + | if c.alias != "" { | |||
| 24 | + | parts = append(parts, raw("--network-alias", c.alias)) | |||
| 25 | + | } | |||
| 26 | + | for _, p := range c.ports { | |||
| 27 | + | parts = append(parts, raw("-p", p)) | |||
| 28 | + | } | |||
| 29 | + | parts = append(parts, optionsParts(c.options)) | |||
| 30 | + | for _, k := range c.env.Keys() { | |||
| 31 | + | v, _ := c.env.Get(k) | |||
| 32 | + | parts = append(parts, j.envKV(k, v)) | |||
| 33 | + | } | |||
| 34 | + | parts = append(parts, raw("-e", "GITHUB_ACTIONS=true")) | |||
| 35 | + | if _, ok := c.env.Get("CI"); !ok { | |||
| 36 | + | parts = append(parts, raw("-e", "CI=true")) | |||
| 37 | + | } | |||
| 38 | + | for _, m := range c.mounts { | |||
| 39 | + | parts = append(parts, mountOpt(m)) | |||
| 40 | + | } | |||
| 41 | + | if c.entrypoint != "" { | |||
| 42 | + | parts = append(parts, entrypointOpt(c.entrypoint)) | |||
| 43 | + | } | |||
| 44 | + | parts = append(parts, raw(c.image), dockerPart{line: c.entryArgs, args: ParseArguments(c.entryArgs)}) | |||
| 45 | + | return j.newDockerCmd("create", parts...) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // mountWellKnownDirectories is ContainerOperationProvider.MountWellKnownDirectories | |||
| 49 | + | // (:242-283): the job container sees the work directory, the externals, the | |||
| 50 | + | // temp, actions and tool cache directories, and a home and a workflow | |||
| 51 | + | // directory of its own. | |||
| 52 | + | func (j *job) mountWellKnownDirectories(c *container) error { | |||
| 53 | + | lay := j.state.Layout() | |||
| 54 | + | workspace := j.githubValue("workspace") | |||
| 55 | + | tr := c.translateToContainer | |||
| 56 | + | c.mounts = append(c.mounts, | |||
| 57 | + | mount{source: lay.WorkRoot, target: tr(lay.WorkRoot)}, | |||
| 58 | + | mount{source: j.externalsDir(), target: tr(j.externalsDir()), readOnly: true}, | |||
| 59 | + | mount{source: lay.Temp, target: tr(lay.Temp)}, | |||
| 60 | + | mount{source: lay.Actions, target: tr(lay.Actions)}, | |||
| 61 | + | mount{source: lay.ToolCache, target: tr(lay.ToolCache)}) | |||
| 62 | + | for _, d := range []struct{ dir, target string }{ | |||
| 63 | + | {"_github_home", "/github/home"}, {"_github_workflow", "/github/workflow"}, | |||
| 64 | + | } { | |||
| 65 | + | host := filepath.Join(lay.Temp, d.dir) | |||
| 66 | + | if err := os.MkdirAll(host, 0o755); err != nil { | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | j.own(host) | |||
| 70 | + | c.mounts = append(c.mounts, mount{source: host, target: d.target}) | |||
| 71 | + | c.maps = append([]pathMap{{host, d.target}}, c.maps...) // AddPathTranslateMapping inserts first | |||
| 72 | + | if d.dir == "_github_home" { | |||
| 73 | + | c.env.Set("HOME", tr(host)) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | c.workDir = tr(workspace) | |||
| 77 | + | c.entrypoint = "tail" | |||
| 78 | + | c.entryArgs = `"-f" "/dev/null"` | |||
| 79 | + | return nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // startContainer is StartContainerAsync (ContainerOperationProvider.cs: | |||
| 83 | + | // 123-240). | |||
| 84 | + | func (j *job) startContainer(s *stepRun, c *container, cancel <-chan struct{}) error { | |||
| 85 | + | num := s.num | |||
| 86 | + | group := "Starting job container" | |||
| 87 | + | if !c.isJob { | |||
| 88 | + | group = "Starting " + c.alias + " service container" | |||
| 89 | + | } | |||
| 90 | + | j.out(num, "##[group]"+group) | |||
| 91 | + | for _, m := range c.userMounts() { | |||
| 92 | + | if m.source == "/" { | |||
| 93 | + | j.tagged(num, "##[warning]", fmt.Sprintf("Volume mount %s is going to mount '/' into the container which may cause file ownership change in the entire file system and cause Actions Runner to lose permission to access the disk.", m.user)) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | cfg, err := j.registryLogin(s, c, cancel) | |||
| 97 | + | if err != nil { | |||
| 98 | + | if rerr := j.removeConfigDir(cfg); rerr != nil { | |||
| 99 | + | j.errorf(num, "%s", rerr.Error()) | |||
| 100 | + | } | |||
| 101 | + | return err | |||
| 102 | + | } | |||
| 103 | + | defer func() { _ = j.removeConfigDir(cfg) }() // also on a panic; idempotent | |||
| 104 | + | code, err := j.pullWithRetry(s, c.image, cfg, cancel) | |||
| 105 | + | // Credentials are removed after the pull, whatever it did (:171-173). | |||
| 106 | + | if rerr := j.removeConfigDir(cfg); rerr != nil { | |||
| 107 | + | if err == nil { | |||
| 108 | + | err = rerr | |||
| 109 | + | } else { | |||
| 110 | + | j.errorf(num, "%s", rerr.Error()) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | if err != nil { | |||
| 114 | + | return err | |||
| 115 | + | } | |||
| 116 | + | if code != 0 { | |||
| 117 | + | return ghErrorf("Docker pull failed with exit code %d", code) | |||
| 118 | + | } | |||
| 119 | + | if c.isJob { | |||
| 120 | + | if err := j.mountWellKnownDirectories(c); err != nil { | |||
| 121 | + | return err | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | if err := checkEnvNames(c.env.Keys()); err != nil { | |||
| 125 | + | return err | |||
| 126 | + | } | |||
| 127 | + | c.createTried = true // the container may exist from here on, named c.name | |||
| 128 | + | out, err := j.dockerList(s, j.createCmd(c), cancel) | |||
| 129 | + | if err != nil { | |||
| 130 | + | var ee *exitError | |||
| 131 | + | if errors.As(err, &ee) { | |||
| 132 | + | c.createTried = false // docker said no: there is nothing to remove | |||
| 133 | + | } | |||
| 134 | + | return err | |||
| 135 | + | } | |||
| 136 | + | if len(out) == 0 || out[0] == "" { | |||
| 137 | + | return ghErrorf("Value cannot be null. (Parameter 'container.ContainerId')") | |||
| 138 | + | } | |||
| 139 | + | // docker create prints the id and nothing else; anything else (the help | |||
| 140 | + | // text of an option in options:, say) is not an id to use or to remove. | |||
| 141 | + | if !containerIDRe.MatchString(out[0]) { | |||
| 142 | + | return ghErrorf("Docker create did not return a container id: '%s'", out[0]) | |||
| 143 | + | } | |||
| 144 | + | c.id = out[0] | |||
| 145 | + | code, err = j.dockerCode(s, j.newDockerCmd("start", raw(c.id)), cancel) | |||
| 146 | + | if err != nil { | |||
| 147 | + | return err | |||
| 148 | + | } | |||
| 149 | + | if code != 0 { | |||
| 150 | + | return ghErrorf("Docker start fail with exit code %d", code) | |||
| 151 | + | } | |||
| 152 | + | j.checkRunning(s, c, cancel) | |||
| 153 | + | if !c.isJob { | |||
| 154 | + | ports, err := j.dockerList(s, j.newDockerCmd("port", raw(c.id)), cancel) | |||
| 155 | + | if err != nil { | |||
| 156 | + | return err | |||
| 157 | + | } | |||
| 158 | + | c.portMappings = parseDockerPort(ports) | |||
| 159 | + | j.mu.Lock() | |||
| 160 | + | j.state.SetJobService(c.alias, c.id, c.network, c.portMappings) | |||
| 161 | + | j.mu.Unlock() | |||
| 162 | + | } else { | |||
| 163 | + | env, err := j.dockerList(s, j.newDockerCmd("inspect", quotedOpt("--format", "{{range .Config.Env}}{{println .}}{{end}}"), raw(c.id)), cancel) | |||
| 164 | + | if err != nil { | |||
| 165 | + | return err | |||
| 166 | + | } | |||
| 167 | + | c.runtimePath = parsePathFromConfigEnv(env) | |||
| 168 | + | j.mu.Lock() | |||
| 169 | + | j.state.SetJobContainer("id", c.id) | |||
| 170 | + | j.mu.Unlock() | |||
| 171 | + | } | |||
| 172 | + | j.out(num, "##[endgroup]") | |||
| 173 | + | return nil | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // pullWithRetry is the pull loop of StartContainerAsync (:158-178): three | |||
| 177 | + | // tries, a random back off of 1 to 10 s between them. | |||
| 178 | + | func (j *job) pullWithRetry(s *stepRun, image, cfg string, cancel <-chan struct{}) (int, error) { | |||
| 179 | + | code := 0 | |||
| 180 | + | for try := 1; try <= 3; try++ { | |||
| 181 | + | var err error | |||
| 182 | + | _, code, err = j.dockerDo(j.pullCmd(image, cfg), dockerOpts{s: s, cancel: cancel, all: true, timeout: j.timeouts().Pull}) | |||
| 183 | + | if err != nil { | |||
| 184 | + | return code, err | |||
| 185 | + | } | |||
| 186 | + | if code == 0 { | |||
| 187 | + | return 0, nil | |||
| 188 | + | } | |||
| 189 | + | if try < 3 { | |||
| 190 | + | d := j.containerBackoff() | |||
| 191 | + | j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker pull failed with exit code %d, back off %s seconds before retry.", code, seconds(d))) | |||
| 192 | + | if !j.sleepOrCancel(d, cancel) { | |||
| 193 | + | return code, errCancelled | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | return code, nil | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // checkRunning is the best-effort check of :193-213. | |||
| 201 | + | func (j *job) checkRunning(s *stepRun, c *container, cancel <-chan struct{}) { | |||
| 202 | + | fmtOpt := quotedOpt("--format", "{{.ID}} {{.Status}}") | |||
| 203 | + | out, err := j.dockerList(s, j.newDockerCmd("ps", raw("--all"), raw("--filter", "id="+c.id), raw("--filter", "status=running"), raw("--no-trunc"), fmtOpt), cancel) | |||
| 204 | + | if err != nil { | |||
| 205 | + | return | |||
| 206 | + | } | |||
| 207 | + | for _, l := range out { | |||
| 208 | + | if l != "" { | |||
| 209 | + | if strings.HasPrefix(l, c.id) { | |||
| 210 | + | return | |||
| 211 | + | } | |||
| 212 | + | break | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | _, _ = j.dockerList(s, j.newDockerCmd("ps", raw("--all"), raw("--filter", "id="+c.id), raw("--no-trunc"), fmtOpt), cancel) | |||
| 216 | + | if code, err := j.dockerCode(s, j.newDockerCmd("logs", raw("--details", c.id)), cancel); err == nil && code != 0 { | |||
| 217 | + | j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker logs fail with exit code %d", code)) | |||
| 218 | + | } | |||
| 219 | + | j.tagged(s.num, "##[warning]", fmt.Sprintf("Docker container %s is not in running state.", c.id)) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // parseDockerPort is DockerUtil.ParseDockerPort (DockerUtil.cs:14-37). | |||
| 223 | + | func parseDockerPort(lines []string) []portMapping { | |||
| 224 | + | var out []portMapping | |||
| 225 | + | for _, l := range lines { | |||
| 226 | + | if m := portLineRe.FindStringSubmatch(l); m != nil { | |||
| 227 | + | out = append(out, portMapping{host: m[4], container: m[1], proto: m[2]}) | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | return out | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // parsePathFromConfigEnv is DockerUtil.ParsePathFromConfigEnv (:39-52). | |||
| 234 | + | func parsePathFromConfigEnv(lines []string) string { | |||
| 235 | + | for _, l := range lines { | |||
| 236 | + | if k, v, ok := strings.Cut(l, "="); ok && k == "PATH" { | |||
| 237 | + | return v | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | return "" | |||
| 241 | + | } | |||
A internal/executor/containers_stop.go +324 -0
| @@ -0,0 +1,324 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "math" | |
| 8 | + | "math/rand/v2" | |
| 9 | + | "os" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // healthBackoff is BackoffTimerHelper.GetExponentialBackoff(retry, 2 s, 32 s, | |
| 15 | + | // 2 s) (BackoffTimerHelper.cs:27-36). | |
| 16 | + | func healthBackoff(attempt int) time.Duration { | |
| 17 | + | random := 1600 + rand.Float64()*800 // Random.Next(1600, 2400) ms | |
| 18 | + | add := (math.Pow(2, float64(attempt)) - 1) * random | |
| 19 | + | ms := math.Min(2000+add, 32000) | |
| 20 | + | return time.Duration(ms * float64(time.Millisecond)) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // containerHealth is ContainerHealthcheck (ContainerOperationProvider.cs: | |
| 24 | + | // 372-392): a container without a HEALTHCHECK is healthy; while docker says | |
| 25 | + | // "starting" the check waits with an exponential back off. | |
| 26 | + | func (j *job) containerHealth(s *stepRun, c *container, cancel <-chan struct{}) (bool, error) { | |
| 27 | + | inspect := func() (string, error) { | |
| 28 | + | cmd := j.newDockerCmd("inspect", | |
| 29 | + | eqOpt("--format", "{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}"), raw(c.id)) | |
| 30 | + | l, err := j.dockerList(s, cmd, cancel) | |
| 31 | + | if err != nil || len(l) == 0 { | |
| 32 | + | return "", err | |
| 33 | + | } | |
| 34 | + | return l[0], nil | |
| 35 | + | } | |
| 36 | + | h, err := inspect() | |
| 37 | + | if err != nil { | |
| 38 | + | return false, err | |
| 39 | + | } | |
| 40 | + | if h == "" { | |
| 41 | + | return true, nil | |
| 42 | + | } | |
| 43 | + | for retry := 0; strings.EqualFold(h, "starting"); retry++ { | |
| 44 | + | d := healthBackoff(retry) | |
| 45 | + | if f := j.cfg.Actions.Backoff; f != nil { | |
| 46 | + | d = f() | |
| 47 | + | } | |
| 48 | + | // TimeSpan.Seconds is the seconds component, not the total. | |
| 49 | + | j.out(s.num, fmt.Sprintf("%s service is starting, waiting %d seconds before checking again.", c.alias, int(d.Seconds())%60)) | |
| 50 | + | if !j.sleepOrCancel(d, cancel) { | |
| 51 | + | return false, errCancelled | |
| 52 | + | } | |
| 53 | + | if h, err = inspect(); err != nil { | |
| 54 | + | return false, err | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | return strings.EqualFold(h, "healthy"), nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // healthchecks is RunContainersHealthcheck (:105-139). | |
| 61 | + | func (j *job) healthchecks(s *stepRun, all []*container, cancel <-chan struct{}) error { | |
| 62 | + | num := s.num | |
| 63 | + | j.out(num, "##[group]Waiting for all services to be ready") | |
| 64 | + | var bad []*container | |
| 65 | + | for _, c := range all { | |
| 66 | + | if c.isJob { | |
| 67 | + | continue | |
| 68 | + | } | |
| 69 | + | ok, err := j.containerHealth(s, c, cancel) | |
| 70 | + | if err != nil { | |
| 71 | + | return err | |
| 72 | + | } | |
| 73 | + | if !ok { | |
| 74 | + | bad = append(bad, c) | |
| 75 | + | continue | |
| 76 | + | } | |
| 77 | + | j.out(num, c.alias+" service is healthy.") | |
| 78 | + | } | |
| 79 | + | j.out(num, "##[endgroup]") | |
| 80 | + | if len(bad) == 0 { | |
| 81 | + | return nil | |
| 82 | + | } | |
| 83 | + | for _, c := range bad { | |
| 84 | + | j.out(num, "##[group]Service container "+c.alias+" failed.") | |
| 85 | + | if _, err := j.dockerCode(s, j.newDockerCmd("logs", raw("--details", c.id)), cancel); err != nil { | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | j.errorf(num, "Failed to initialize container %s", c.image) | |
| 89 | + | c.failedInit = true | |
| 90 | + | j.out(num, "##[endgroup]") | |
| 91 | + | } | |
| 92 | + | return ghErrorf("One or more containers failed to start.") | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // stopContainers is "Stop containers" (StopContainersAsync, :105-124 and | |
| 96 | + | // StopContainerAsync, :328-351). It is registered with the condition always(), | |
| 97 | + | // so it runs after a failure, a cancellation and a timeout alike; its commands | |
| 98 | + | // run on a budget of their own (they do not end with the job's cancellation, | |
| 99 | + | // and a stalled daemon does not hold them forever). | |
| 100 | + | func (j *job) stopContainers(s *stepRun, _ <-chan struct{}) stepResult { | |
| 101 | + | if j.cs == nil { | |
| 102 | + | return resSucceeded | |
| 103 | + | } | |
| 104 | + | errs := j.cleanupContainers(s) | |
| 105 | + | for _, e := range errs { | |
| 106 | + | j.errorf(s.num, "%s", e.Error()) | |
| 107 | + | } | |
| 108 | + | if len(errs) > 0 { | |
| 109 | + | return resFailed | |
| 110 | + | } | |
| 111 | + | return resSucceeded | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // fallbackCleanup is the cleanup of the job when "Stop containers" did not (or | |
| 115 | + | // not completely) run: a panic, a runner shutdown that skips the queued post | |
| 116 | + | // steps, a budget that ran out. It does only what is left; with nothing left | |
| 117 | + | // it runs no docker command. Its messages go to the diagnostics of the runner, | |
| 118 | + | // the job log being closed. | |
| 119 | + | func (j *job) fallbackCleanup() { | |
| 120 | + | if j.cs == nil || !j.cs.pending() { | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | for _, e := range j.cleanupContainers(nil) { | |
| 124 | + | j.cfg.Log.Warn().Err(e).Msg("fallback cleanup of the job containers") | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // pending reports whether something created by the job is still to be removed. | |
| 129 | + | func (cs *containerSet) pending() bool { | |
| 130 | + | cs.mu.Lock() | |
| 131 | + | defer cs.mu.Unlock() | |
| 132 | + | if cs.networkTry != "" && !cs.netRemoved || len(cs.dirs) > 0 { | |
| 133 | + | return true | |
| 134 | + | } | |
| 135 | + | for _, c := range cs.all() { | |
| 136 | + | if c.createTried && !c.removed { | |
| 137 | + | return true | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | return false | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // cleanupContainers removes what the job created: every container, then the | |
| 144 | + | // network, then the credentials directories left behind. It attempts all of | |
| 145 | + | // them whatever fails, within CleanupBudget, each command within | |
| 146 | + | // CleanupCommand, and returns the errors. Logs of a service are best effort and | |
| 147 | + | // never stand in the way of its removal. s, when set, gets the runner's lines. | |
| 148 | + | // It is idempotent: what is gone is not tried again. | |
| 149 | + | func (j *job) cleanupContainers(s *stepRun) (errs []error) { | |
| 150 | + | cs := j.cs | |
| 151 | + | cs.cleanupMu.Lock() | |
| 152 | + | defer cs.cleanupMu.Unlock() | |
| 153 | + | t := j.timeouts() | |
| 154 | + | cs.mu.Lock() | |
| 155 | + | if cs.deadline.IsZero() { | |
| 156 | + | cs.deadline = time.Now().Add(t.CleanupBudget) | |
| 157 | + | } | |
| 158 | + | remaining := time.Until(cs.deadline) | |
| 159 | + | cs.mu.Unlock() | |
| 160 | + | budget, budgetOut, stop := bound(nil, max(remaining, time.Nanosecond)) | |
| 161 | + | defer stop() | |
| 162 | + | defer func() { errs = dedupeErrors(errs) }() | |
| 163 | + | out := func(line string) { | |
| 164 | + | if s != nil { | |
| 165 | + | j.out(s.num, line) | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | run := func(c dockerCmd) (int, error) { | |
| 169 | + | _, code, err := j.dockerDoCleanup(c, s, budget, budgetOut, t, t.CleanupCommand) | |
| 170 | + | return code, err | |
| 171 | + | } | |
| 172 | + | // rm runs `docker rm --force target`. It reports whether the target is | |
| 173 | + | // gone: removed, or docker says it does not exist. Any other failure leaves | |
| 174 | + | // it pending, for the fallback of the job to try again. | |
| 175 | + | rm := func(kind string, c dockerCmd) (gone bool) { | |
| 176 | + | lines, code, err := j.dockerDoCleanup(c, s, budget, budgetOut, t, t.CleanupCommand) | |
| 177 | + | switch { | |
| 178 | + | case err != nil: | |
| 179 | + | errs = append(errs, err) | |
| 180 | + | return false | |
| 181 | + | case code == 0: | |
| 182 | + | return true | |
| 183 | + | case absent(lines): | |
| 184 | + | return true | |
| 185 | + | } | |
| 186 | + | j.warnTo(s, fmt.Sprintf("Docker %s fail with exit code %d", kind, code)) | |
| 187 | + | return false | |
| 188 | + | } | |
| 189 | + | // A container whose create did not finish is not known by its id, and its | |
| 190 | + | // name may not be ours (options: can set another --name): it is found by | |
| 191 | + | // the label every container of the job carries (DockerCreate adds it). | |
| 192 | + | var unresolved []*container | |
| 193 | + | for _, c := range cs.all() { | |
| 194 | + | if c.createTried && !c.removed && c.id == "" { | |
| 195 | + | unresolved = append(unresolved, c) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | if len(unresolved) > 0 { | |
| 199 | + | ok := true | |
| 200 | + | ids, code, err := j.dockerDoCleanup(j.newDockerCmd("ps", raw("--all", "--quiet", "--no-trunc"), quotedOpt("--filter", "label="+j.dockerLabel())), nil, budget, budgetOut, t, t.CleanupCommand) | |
| 201 | + | switch { | |
| 202 | + | case err != nil: | |
| 203 | + | errs, ok = append(errs, err), false | |
| 204 | + | case code != 0: | |
| 205 | + | j.warnTo(s, fmt.Sprintf("Docker ps fail with exit code %d", code)) | |
| 206 | + | ok = false | |
| 207 | + | } | |
| 208 | + | known := map[string]bool{} | |
| 209 | + | for _, c := range cs.all() { | |
| 210 | + | if c.id != "" { | |
| 211 | + | known[c.id] = true // those are removed below, after their logs | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | for _, id := range ids { | |
| 215 | + | if containerIDRe.MatchString(id) && !known[id] && !rm("rm", j.newDockerCmd("rm", raw("--force", id))) { | |
| 216 | + | ok = false | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | if ok { // listed, and everything listed is gone: nothing of the job is left | |
| 220 | + | for _, c := range unresolved { | |
| 221 | + | c.removed = true | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | for _, c := range cs.all() { | |
| 226 | + | if !c.createTried || c.removed || c.id == "" { | |
| 227 | + | continue | |
| 228 | + | } | |
| 229 | + | if !c.isJob && !c.failedInit { | |
| 230 | + | out("Print service container logs: " + c.name) | |
| 231 | + | code, err := run(j.newDockerCmd("logs", raw("--details", c.id))) | |
| 232 | + | switch { | |
| 233 | + | case err != nil: | |
| 234 | + | // Best effort, as the runner's warning for a failed logs: it | |
| 235 | + | // neither fails the step nor stops the removal. | |
| 236 | + | j.warnTo(s, "Docker logs failed: "+err.Error()) | |
| 237 | + | case code != 0: | |
| 238 | + | j.warnTo(s, fmt.Sprintf("Docker logs fail with exit code %d", code)) | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | out("Stop and remove container: " + c.name) | |
| 242 | + | if rm("rm", j.newDockerCmd("rm", raw("--force", c.id))) { | |
| 243 | + | c.removed = true | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | cs.mu.Lock() | |
| 247 | + | network, gone := cs.networkTry, cs.netRemoved | |
| 248 | + | cs.mu.Unlock() | |
| 249 | + | if network != "" && !gone { | |
| 250 | + | out("Remove container network: " + network) | |
| 251 | + | if rm("network rm", j.newDockerCmd("network", raw("rm", network))) { | |
| 252 | + | cs.mu.Lock() | |
| 253 | + | cs.netRemoved = true | |
| 254 | + | cs.mu.Unlock() | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | cs.mu.Lock() | |
| 258 | + | dirs := cs.dirs | |
| 259 | + | cs.dirs = nil | |
| 260 | + | cs.mu.Unlock() | |
| 261 | + | for _, d := range dirs { | |
| 262 | + | if err := os.RemoveAll(d); err != nil { | |
| 263 | + | cs.addDir(d) | |
| 264 | + | errs = append(errs, ghErrorf("Failed to remove directory containing Docker client credentials: %v", err)) | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | return errs | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // warnTo logs a warning to the step, or to the diagnostics without one. | |
| 271 | + | func (j *job) warnTo(s *stepRun, msg string) { | |
| 272 | + | if s != nil { | |
| 273 | + | j.tagged(s.num, "##[warning]", msg) | |
| 274 | + | return | |
| 275 | + | } | |
| 276 | + | j.cfg.Log.Warn().Msg(msg) | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // absent reports whether docker's answer to a removal says the object does not | |
| 280 | + | // exist: that is the state the cleanup wants. | |
| 281 | + | func absent(lines []string) bool { | |
| 282 | + | for _, l := range lines { | |
| 283 | + | l = strings.ToLower(l) | |
| 284 | + | if strings.Contains(l, "no such container") || strings.Contains(l, "no such network") || | |
| 285 | + | strings.Contains(l, "no such object") || strings.Contains(l, "network") && strings.Contains(l, "not found") { | |
| 286 | + | return true | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | return false | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // dockerDoCleanup runs a command of the cleanup: bounded by its own limit and | |
| 293 | + | // by the budget of the whole cleanup, its output (stdout and stderr) returned, | |
| 294 | + | // logged to s if any. The budget is checked here, before every command: once | |
| 295 | + | // it is spent nothing is started (a command started with a closed cancel would | |
| 296 | + | // still cost a signal escalation) and the object stays pending. | |
| 297 | + | func (j *job) dockerDoCleanup(c dockerCmd, s *stepRun, budget <-chan struct{}, budgetOut func() bool, t ContainerTimeouts, limit time.Duration) ([]string, int, error) { | |
| 298 | + | spent := func() error { | |
| 299 | + | return ghErrorf("The cleanup of the containers ran out of time (%s): not run: docker %s", t.CleanupBudget, j.dockerArgString(c)) | |
| 300 | + | } | |
| 301 | + | select { | |
| 302 | + | case <-budget: | |
| 303 | + | return nil, 0, spent() | |
| 304 | + | default: | |
| 305 | + | } | |
| 306 | + | lines, code, err := j.dockerDo(c, dockerOpts{s: s, cancel: budget, all: true, timeout: limit, errToOut: true}) | |
| 307 | + | if err == errCancelled && budgetOut() { | |
| 308 | + | return lines, code, ghErrorf("The cleanup of the containers ran out of time (%s): stopped: docker %s", t.CleanupBudget, j.dockerArgString(c)) | |
| 309 | + | } | |
| 310 | + | return lines, code, err | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // dedupeErrors keeps the first of the errors that read the same. | |
| 314 | + | func dedupeErrors(errs []error) []error { | |
| 315 | + | seen := map[string]bool{} | |
| 316 | + | var out []error | |
| 317 | + | for _, e := range errs { | |
| 318 | + | if !seen[e.Error()] { | |
| 319 | + | seen[e.Error()] = true | |
| 320 | + | out = append(out, e) | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | return out | |
| 324 | + | } | |
| @@ -0,0 +1,324 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "math" | |||
| 8 | + | "math/rand/v2" | |||
| 9 | + | "os" | |||
| 10 | + | "strings" | |||
| 11 | + | "time" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // healthBackoff is BackoffTimerHelper.GetExponentialBackoff(retry, 2 s, 32 s, | |||
| 15 | + | // 2 s) (BackoffTimerHelper.cs:27-36). | |||
| 16 | + | func healthBackoff(attempt int) time.Duration { | |||
| 17 | + | random := 1600 + rand.Float64()*800 // Random.Next(1600, 2400) ms | |||
| 18 | + | add := (math.Pow(2, float64(attempt)) - 1) * random | |||
| 19 | + | ms := math.Min(2000+add, 32000) | |||
| 20 | + | return time.Duration(ms * float64(time.Millisecond)) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // containerHealth is ContainerHealthcheck (ContainerOperationProvider.cs: | |||
| 24 | + | // 372-392): a container without a HEALTHCHECK is healthy; while docker says | |||
| 25 | + | // "starting" the check waits with an exponential back off. | |||
| 26 | + | func (j *job) containerHealth(s *stepRun, c *container, cancel <-chan struct{}) (bool, error) { | |||
| 27 | + | inspect := func() (string, error) { | |||
| 28 | + | cmd := j.newDockerCmd("inspect", | |||
| 29 | + | eqOpt("--format", "{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}"), raw(c.id)) | |||
| 30 | + | l, err := j.dockerList(s, cmd, cancel) | |||
| 31 | + | if err != nil || len(l) == 0 { | |||
| 32 | + | return "", err | |||
| 33 | + | } | |||
| 34 | + | return l[0], nil | |||
| 35 | + | } | |||
| 36 | + | h, err := inspect() | |||
| 37 | + | if err != nil { | |||
| 38 | + | return false, err | |||
| 39 | + | } | |||
| 40 | + | if h == "" { | |||
| 41 | + | return true, nil | |||
| 42 | + | } | |||
| 43 | + | for retry := 0; strings.EqualFold(h, "starting"); retry++ { | |||
| 44 | + | d := healthBackoff(retry) | |||
| 45 | + | if f := j.cfg.Actions.Backoff; f != nil { | |||
| 46 | + | d = f() | |||
| 47 | + | } | |||
| 48 | + | // TimeSpan.Seconds is the seconds component, not the total. | |||
| 49 | + | j.out(s.num, fmt.Sprintf("%s service is starting, waiting %d seconds before checking again.", c.alias, int(d.Seconds())%60)) | |||
| 50 | + | if !j.sleepOrCancel(d, cancel) { | |||
| 51 | + | return false, errCancelled | |||
| 52 | + | } | |||
| 53 | + | if h, err = inspect(); err != nil { | |||
| 54 | + | return false, err | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | return strings.EqualFold(h, "healthy"), nil | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // healthchecks is RunContainersHealthcheck (:105-139). | |||
| 61 | + | func (j *job) healthchecks(s *stepRun, all []*container, cancel <-chan struct{}) error { | |||
| 62 | + | num := s.num | |||
| 63 | + | j.out(num, "##[group]Waiting for all services to be ready") | |||
| 64 | + | var bad []*container | |||
| 65 | + | for _, c := range all { | |||
| 66 | + | if c.isJob { | |||
| 67 | + | continue | |||
| 68 | + | } | |||
| 69 | + | ok, err := j.containerHealth(s, c, cancel) | |||
| 70 | + | if err != nil { | |||
| 71 | + | return err | |||
| 72 | + | } | |||
| 73 | + | if !ok { | |||
| 74 | + | bad = append(bad, c) | |||
| 75 | + | continue | |||
| 76 | + | } | |||
| 77 | + | j.out(num, c.alias+" service is healthy.") | |||
| 78 | + | } | |||
| 79 | + | j.out(num, "##[endgroup]") | |||
| 80 | + | if len(bad) == 0 { | |||
| 81 | + | return nil | |||
| 82 | + | } | |||
| 83 | + | for _, c := range bad { | |||
| 84 | + | j.out(num, "##[group]Service container "+c.alias+" failed.") | |||
| 85 | + | if _, err := j.dockerCode(s, j.newDockerCmd("logs", raw("--details", c.id)), cancel); err != nil { | |||
| 86 | + | return err | |||
| 87 | + | } | |||
| 88 | + | j.errorf(num, "Failed to initialize container %s", c.image) | |||
| 89 | + | c.failedInit = true | |||
| 90 | + | j.out(num, "##[endgroup]") | |||
| 91 | + | } | |||
| 92 | + | return ghErrorf("One or more containers failed to start.") | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // stopContainers is "Stop containers" (StopContainersAsync, :105-124 and | |||
| 96 | + | // StopContainerAsync, :328-351). It is registered with the condition always(), | |||
| 97 | + | // so it runs after a failure, a cancellation and a timeout alike; its commands | |||
| 98 | + | // run on a budget of their own (they do not end with the job's cancellation, | |||
| 99 | + | // and a stalled daemon does not hold them forever). | |||
| 100 | + | func (j *job) stopContainers(s *stepRun, _ <-chan struct{}) stepResult { | |||
| 101 | + | if j.cs == nil { | |||
| 102 | + | return resSucceeded | |||
| 103 | + | } | |||
| 104 | + | errs := j.cleanupContainers(s) | |||
| 105 | + | for _, e := range errs { | |||
| 106 | + | j.errorf(s.num, "%s", e.Error()) | |||
| 107 | + | } | |||
| 108 | + | if len(errs) > 0 { | |||
| 109 | + | return resFailed | |||
| 110 | + | } | |||
| 111 | + | return resSucceeded | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // fallbackCleanup is the cleanup of the job when "Stop containers" did not (or | |||
| 115 | + | // not completely) run: a panic, a runner shutdown that skips the queued post | |||
| 116 | + | // steps, a budget that ran out. It does only what is left; with nothing left | |||
| 117 | + | // it runs no docker command. Its messages go to the diagnostics of the runner, | |||
| 118 | + | // the job log being closed. | |||
| 119 | + | func (j *job) fallbackCleanup() { | |||
| 120 | + | if j.cs == nil || !j.cs.pending() { | |||
| 121 | + | return | |||
| 122 | + | } | |||
| 123 | + | for _, e := range j.cleanupContainers(nil) { | |||
| 124 | + | j.cfg.Log.Warn().Err(e).Msg("fallback cleanup of the job containers") | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // pending reports whether something created by the job is still to be removed. | |||
| 129 | + | func (cs *containerSet) pending() bool { | |||
| 130 | + | cs.mu.Lock() | |||
| 131 | + | defer cs.mu.Unlock() | |||
| 132 | + | if cs.networkTry != "" && !cs.netRemoved || len(cs.dirs) > 0 { | |||
| 133 | + | return true | |||
| 134 | + | } | |||
| 135 | + | for _, c := range cs.all() { | |||
| 136 | + | if c.createTried && !c.removed { | |||
| 137 | + | return true | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | return false | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // cleanupContainers removes what the job created: every container, then the | |||
| 144 | + | // network, then the credentials directories left behind. It attempts all of | |||
| 145 | + | // them whatever fails, within CleanupBudget, each command within | |||
| 146 | + | // CleanupCommand, and returns the errors. Logs of a service are best effort and | |||
| 147 | + | // never stand in the way of its removal. s, when set, gets the runner's lines. | |||
| 148 | + | // It is idempotent: what is gone is not tried again. | |||
| 149 | + | func (j *job) cleanupContainers(s *stepRun) (errs []error) { | |||
| 150 | + | cs := j.cs | |||
| 151 | + | cs.cleanupMu.Lock() | |||
| 152 | + | defer cs.cleanupMu.Unlock() | |||
| 153 | + | t := j.timeouts() | |||
| 154 | + | cs.mu.Lock() | |||
| 155 | + | if cs.deadline.IsZero() { | |||
| 156 | + | cs.deadline = time.Now().Add(t.CleanupBudget) | |||
| 157 | + | } | |||
| 158 | + | remaining := time.Until(cs.deadline) | |||
| 159 | + | cs.mu.Unlock() | |||
| 160 | + | budget, budgetOut, stop := bound(nil, max(remaining, time.Nanosecond)) | |||
| 161 | + | defer stop() | |||
| 162 | + | defer func() { errs = dedupeErrors(errs) }() | |||
| 163 | + | out := func(line string) { | |||
| 164 | + | if s != nil { | |||
| 165 | + | j.out(s.num, line) | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | run := func(c dockerCmd) (int, error) { | |||
| 169 | + | _, code, err := j.dockerDoCleanup(c, s, budget, budgetOut, t, t.CleanupCommand) | |||
| 170 | + | return code, err | |||
| 171 | + | } | |||
| 172 | + | // rm runs `docker rm --force target`. It reports whether the target is | |||
| 173 | + | // gone: removed, or docker says it does not exist. Any other failure leaves | |||
| 174 | + | // it pending, for the fallback of the job to try again. | |||
| 175 | + | rm := func(kind string, c dockerCmd) (gone bool) { | |||
| 176 | + | lines, code, err := j.dockerDoCleanup(c, s, budget, budgetOut, t, t.CleanupCommand) | |||
| 177 | + | switch { | |||
| 178 | + | case err != nil: | |||
| 179 | + | errs = append(errs, err) | |||
| 180 | + | return false | |||
| 181 | + | case code == 0: | |||
| 182 | + | return true | |||
| 183 | + | case absent(lines): | |||
| 184 | + | return true | |||
| 185 | + | } | |||
| 186 | + | j.warnTo(s, fmt.Sprintf("Docker %s fail with exit code %d", kind, code)) | |||
| 187 | + | return false | |||
| 188 | + | } | |||
| 189 | + | // A container whose create did not finish is not known by its id, and its | |||
| 190 | + | // name may not be ours (options: can set another --name): it is found by | |||
| 191 | + | // the label every container of the job carries (DockerCreate adds it). | |||
| 192 | + | var unresolved []*container | |||
| 193 | + | for _, c := range cs.all() { | |||
| 194 | + | if c.createTried && !c.removed && c.id == "" { | |||
| 195 | + | unresolved = append(unresolved, c) | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | if len(unresolved) > 0 { | |||
| 199 | + | ok := true | |||
| 200 | + | ids, code, err := j.dockerDoCleanup(j.newDockerCmd("ps", raw("--all", "--quiet", "--no-trunc"), quotedOpt("--filter", "label="+j.dockerLabel())), nil, budget, budgetOut, t, t.CleanupCommand) | |||
| 201 | + | switch { | |||
| 202 | + | case err != nil: | |||
| 203 | + | errs, ok = append(errs, err), false | |||
| 204 | + | case code != 0: | |||
| 205 | + | j.warnTo(s, fmt.Sprintf("Docker ps fail with exit code %d", code)) | |||
| 206 | + | ok = false | |||
| 207 | + | } | |||
| 208 | + | known := map[string]bool{} | |||
| 209 | + | for _, c := range cs.all() { | |||
| 210 | + | if c.id != "" { | |||
| 211 | + | known[c.id] = true // those are removed below, after their logs | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | for _, id := range ids { | |||
| 215 | + | if containerIDRe.MatchString(id) && !known[id] && !rm("rm", j.newDockerCmd("rm", raw("--force", id))) { | |||
| 216 | + | ok = false | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | if ok { // listed, and everything listed is gone: nothing of the job is left | |||
| 220 | + | for _, c := range unresolved { | |||
| 221 | + | c.removed = true | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | for _, c := range cs.all() { | |||
| 226 | + | if !c.createTried || c.removed || c.id == "" { | |||
| 227 | + | continue | |||
| 228 | + | } | |||
| 229 | + | if !c.isJob && !c.failedInit { | |||
| 230 | + | out("Print service container logs: " + c.name) | |||
| 231 | + | code, err := run(j.newDockerCmd("logs", raw("--details", c.id))) | |||
| 232 | + | switch { | |||
| 233 | + | case err != nil: | |||
| 234 | + | // Best effort, as the runner's warning for a failed logs: it | |||
| 235 | + | // neither fails the step nor stops the removal. | |||
| 236 | + | j.warnTo(s, "Docker logs failed: "+err.Error()) | |||
| 237 | + | case code != 0: | |||
| 238 | + | j.warnTo(s, fmt.Sprintf("Docker logs fail with exit code %d", code)) | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | out("Stop and remove container: " + c.name) | |||
| 242 | + | if rm("rm", j.newDockerCmd("rm", raw("--force", c.id))) { | |||
| 243 | + | c.removed = true | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | cs.mu.Lock() | |||
| 247 | + | network, gone := cs.networkTry, cs.netRemoved | |||
| 248 | + | cs.mu.Unlock() | |||
| 249 | + | if network != "" && !gone { | |||
| 250 | + | out("Remove container network: " + network) | |||
| 251 | + | if rm("network rm", j.newDockerCmd("network", raw("rm", network))) { | |||
| 252 | + | cs.mu.Lock() | |||
| 253 | + | cs.netRemoved = true | |||
| 254 | + | cs.mu.Unlock() | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | cs.mu.Lock() | |||
| 258 | + | dirs := cs.dirs | |||
| 259 | + | cs.dirs = nil | |||
| 260 | + | cs.mu.Unlock() | |||
| 261 | + | for _, d := range dirs { | |||
| 262 | + | if err := os.RemoveAll(d); err != nil { | |||
| 263 | + | cs.addDir(d) | |||
| 264 | + | errs = append(errs, ghErrorf("Failed to remove directory containing Docker client credentials: %v", err)) | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | return errs | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | // warnTo logs a warning to the step, or to the diagnostics without one. | |||
| 271 | + | func (j *job) warnTo(s *stepRun, msg string) { | |||
| 272 | + | if s != nil { | |||
| 273 | + | j.tagged(s.num, "##[warning]", msg) | |||
| 274 | + | return | |||
| 275 | + | } | |||
| 276 | + | j.cfg.Log.Warn().Msg(msg) | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | // absent reports whether docker's answer to a removal says the object does not | |||
| 280 | + | // exist: that is the state the cleanup wants. | |||
| 281 | + | func absent(lines []string) bool { | |||
| 282 | + | for _, l := range lines { | |||
| 283 | + | l = strings.ToLower(l) | |||
| 284 | + | if strings.Contains(l, "no such container") || strings.Contains(l, "no such network") || | |||
| 285 | + | strings.Contains(l, "no such object") || strings.Contains(l, "network") && strings.Contains(l, "not found") { | |||
| 286 | + | return true | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | return false | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // dockerDoCleanup runs a command of the cleanup: bounded by its own limit and | |||
| 293 | + | // by the budget of the whole cleanup, its output (stdout and stderr) returned, | |||
| 294 | + | // logged to s if any. The budget is checked here, before every command: once | |||
| 295 | + | // it is spent nothing is started (a command started with a closed cancel would | |||
| 296 | + | // still cost a signal escalation) and the object stays pending. | |||
| 297 | + | func (j *job) dockerDoCleanup(c dockerCmd, s *stepRun, budget <-chan struct{}, budgetOut func() bool, t ContainerTimeouts, limit time.Duration) ([]string, int, error) { | |||
| 298 | + | spent := func() error { | |||
| 299 | + | return ghErrorf("The cleanup of the containers ran out of time (%s): not run: docker %s", t.CleanupBudget, j.dockerArgString(c)) | |||
| 300 | + | } | |||
| 301 | + | select { | |||
| 302 | + | case <-budget: | |||
| 303 | + | return nil, 0, spent() | |||
| 304 | + | default: | |||
| 305 | + | } | |||
| 306 | + | lines, code, err := j.dockerDo(c, dockerOpts{s: s, cancel: budget, all: true, timeout: limit, errToOut: true}) | |||
| 307 | + | if err == errCancelled && budgetOut() { | |||
| 308 | + | return lines, code, ghErrorf("The cleanup of the containers ran out of time (%s): stopped: docker %s", t.CleanupBudget, j.dockerArgString(c)) | |||
| 309 | + | } | |||
| 310 | + | return lines, code, err | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | // dedupeErrors keeps the first of the errors that read the same. | |||
| 314 | + | func dedupeErrors(errs []error) []error { | |||
| 315 | + | seen := map[string]bool{} | |||
| 316 | + | var out []error | |||
| 317 | + | for _, e := range errs { | |||
| 318 | + | if !seen[e.Error()] { | |||
| 319 | + | seen[e.Error()] = true | |||
| 320 | + | out = append(out, e) | |||
| 321 | + | } | |||
| 322 | + | } | |||
| 323 | + | return out | |||
| 324 | + | } | |||
A internal/executor/contexts_containers.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import "bnema.dev/ermite-actions/expr" | |
| 6 | + | ||
| 7 | + | // The job.container and job.services contexts (JobContext.cs:20-47, | |
| 8 | + | // ContainerOperationProvider.cs:96, :215-232, :237-240). The runner's | |
| 9 | + | // JobContext.Container and .Services create their dictionary when they are | |
| 10 | + | // read, which is why a recorded job that ran a docker action has | |
| 11 | + | // "container": {} (act-docker-image). | |
| 12 | + | ||
| 13 | + | // jobSub returns the dictionary at job.<key>, creating it when missing. | |
| 14 | + | func (s *State) jobSub(key string) *expr.Dictionary { | |
| 15 | + | if v, ok := s.job.Get(key); ok { | |
| 16 | + | if d, ok := v.(*expr.Dictionary); ok { | |
| 17 | + | return d | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | d := expr.NewDictionary() | |
| 21 | + | s.job = setKey(s.job, key, d) | |
| 22 | + | return d | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // TouchJobContainer is the read of JobContext.Container: job.container exists | |
| 26 | + | // from then on. | |
| 27 | + | func (s *State) TouchJobContainer() { s.jobSub("container") } | |
| 28 | + | ||
| 29 | + | // SetJobContainer sets job.container.<key> ("network", "id"). | |
| 30 | + | func (s *State) SetJobContainer(key, value string) { | |
| 31 | + | d := s.jobSub("container") | |
| 32 | + | if _, ok := d.Get(key); ok { | |
| 33 | + | s.job = setKey(s.job, "container", replaceKey(d, key, expr.String(value))) | |
| 34 | + | return | |
| 35 | + | } | |
| 36 | + | d.Add(key, expr.String(value)) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // JobContainerNetwork is job.container.network, "" when it is not set. | |
| 40 | + | func (s *State) JobContainerNetwork() string { | |
| 41 | + | if v, ok := s.jobSub("container").Get("network"); ok { | |
| 42 | + | return expr.CoerceString(v) | |
| 43 | + | } | |
| 44 | + | return "" | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // SetJobService sets job.services.<alias> ({id, ports, network}). | |
| 48 | + | func (s *State) SetJobService(alias, id, network string, ports []portMapping) { | |
| 49 | + | svc := expr.NewDictionary() | |
| 50 | + | svc.Add("id", expr.String(id)) | |
| 51 | + | pd := expr.NewDictionary() | |
| 52 | + | for _, p := range ports { | |
| 53 | + | pd.Add(p.container, expr.String(p.host)) | |
| 54 | + | } | |
| 55 | + | svc.Add("ports", pd) | |
| 56 | + | svc.Add("network", expr.String(network)) | |
| 57 | + | d := s.jobSub("services") | |
| 58 | + | s.job = setKey(s.job, "services", replaceKey(d, alias, svc)) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func replaceKey(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary { | |
| 62 | + | return setKey(d, key, v) | |
| 63 | + | } | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import "bnema.dev/ermite-actions/expr" | |||
| 6 | + | ||||
| 7 | + | // The job.container and job.services contexts (JobContext.cs:20-47, | |||
| 8 | + | // ContainerOperationProvider.cs:96, :215-232, :237-240). The runner's | |||
| 9 | + | // JobContext.Container and .Services create their dictionary when they are | |||
| 10 | + | // read, which is why a recorded job that ran a docker action has | |||
| 11 | + | // "container": {} (act-docker-image). | |||
| 12 | + | ||||
| 13 | + | // jobSub returns the dictionary at job.<key>, creating it when missing. | |||
| 14 | + | func (s *State) jobSub(key string) *expr.Dictionary { | |||
| 15 | + | if v, ok := s.job.Get(key); ok { | |||
| 16 | + | if d, ok := v.(*expr.Dictionary); ok { | |||
| 17 | + | return d | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | d := expr.NewDictionary() | |||
| 21 | + | s.job = setKey(s.job, key, d) | |||
| 22 | + | return d | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // TouchJobContainer is the read of JobContext.Container: job.container exists | |||
| 26 | + | // from then on. | |||
| 27 | + | func (s *State) TouchJobContainer() { s.jobSub("container") } | |||
| 28 | + | ||||
| 29 | + | // SetJobContainer sets job.container.<key> ("network", "id"). | |||
| 30 | + | func (s *State) SetJobContainer(key, value string) { | |||
| 31 | + | d := s.jobSub("container") | |||
| 32 | + | if _, ok := d.Get(key); ok { | |||
| 33 | + | s.job = setKey(s.job, "container", replaceKey(d, key, expr.String(value))) | |||
| 34 | + | return | |||
| 35 | + | } | |||
| 36 | + | d.Add(key, expr.String(value)) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // JobContainerNetwork is job.container.network, "" when it is not set. | |||
| 40 | + | func (s *State) JobContainerNetwork() string { | |||
| 41 | + | if v, ok := s.jobSub("container").Get("network"); ok { | |||
| 42 | + | return expr.CoerceString(v) | |||
| 43 | + | } | |||
| 44 | + | return "" | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // SetJobService sets job.services.<alias> ({id, ports, network}). | |||
| 48 | + | func (s *State) SetJobService(alias, id, network string, ports []portMapping) { | |||
| 49 | + | svc := expr.NewDictionary() | |||
| 50 | + | svc.Add("id", expr.String(id)) | |||
| 51 | + | pd := expr.NewDictionary() | |||
| 52 | + | for _, p := range ports { | |||
| 53 | + | pd.Add(p.container, expr.String(p.host)) | |||
| 54 | + | } | |||
| 55 | + | svc.Add("ports", pd) | |||
| 56 | + | svc.Add("network", expr.String(network)) | |||
| 57 | + | d := s.jobSub("services") | |||
| 58 | + | s.job = setKey(s.job, "services", replaceKey(d, alias, svc)) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func replaceKey(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary { | |||
| 62 | + | return setKey(d, key, v) | |||
| 63 | + | } | |||
M internal/executor/corpus_test.go +7 -1
| @@ -155,6 +155,12 @@ func testGitHubContext(job string) string { | |||
| 155 | 155 | // runLocal runs one job of a workflow file on this machine under a temporary | |
| 156 | 156 | // home. The environment is minimal and fixed. | |
| 157 | 157 | func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) { | |
| 158 | + | t.Helper() | |
| 159 | + | return runLocalC(t, nil, yaml, jobKey, tweak) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // runLocalC is runLocal with a canceller. | |
| 163 | + | func runLocalC(t *testing.T, cancel *Canceller, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) { | |
| 158 | 164 | t.Helper() | |
| 159 | 165 | home := t.TempDir() | |
| 160 | 166 | layout := NewLayout(home, "hello", filepath.Join(home, "toolcache")) | |
| @@ -169,7 +175,7 @@ func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder | |||
| 169 | 175 | if tweak != nil { | |
| 170 | 176 | tweak(&cfg) | |
| 171 | 177 | } | |
| 172 | - | res := Run(nil, cfg) | |
| 178 | + | res := Run(cancel, cfg) | |
| 173 | 179 | return rec, res | |
| 174 | 180 | } | |
| 175 | 181 | ||
| @@ -155,6 +155,12 @@ func testGitHubContext(job string) string { | |||||
| 155 | // runLocal runs one job of a workflow file on this machine under a temporary | 155 | // runLocal runs one job of a workflow file on this machine under a temporary | ||
| 156 | // home. The environment is minimal and fixed. | 156 | // home. The environment is minimal and fixed. | ||
| 157 | func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) { | 157 | func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) { | ||
| 158 | + | t.Helper() | |||
| 159 | + | return runLocalC(t, nil, yaml, jobKey, tweak) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // runLocalC is runLocal with a canceller. | |||
| 163 | + | func runLocalC(t *testing.T, cancel *Canceller, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) { | |||
| 158 | t.Helper() | 164 | t.Helper() | ||
| 159 | home := t.TempDir() | 165 | home := t.TempDir() | ||
| 160 | layout := NewLayout(home, "hello", filepath.Join(home, "toolcache")) | 166 | layout := NewLayout(home, "hello", filepath.Join(home, "toolcache")) | ||
| @@ -169,7 +175,7 @@ func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder | |||||
| 169 | if tweak != nil { | 175 | if tweak != nil { | ||
| 170 | tweak(&cfg) | 176 | tweak(&cfg) | ||
| 171 | } | 177 | } | ||
| 172 | - | res := Run(nil, cfg) | 178 | + | res := Run(cancel, cfg) |
| 173 | return rec, res | 179 | return rec, res | ||
| 174 | } | 180 | } | ||
| 175 | 181 | ||||
M internal/executor/exec_step.go +17 -1
| @@ -15,6 +15,9 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||
| 15 | 15 | return j.runAction(s, stepCancel) | |
| 16 | 16 | } | |
| 17 | 17 | fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed } | |
| 18 | + | if err := j.containerReady(); err != nil { | |
| 19 | + | return fail(err) | |
| 20 | + | } | |
| 18 | 21 | failTemplate := func(err error) stepResult { | |
| 19 | 22 | j.errorf(num, "%s", templateNotValid(err, true)) | |
| 20 | 23 | return resFailed | |
| @@ -34,6 +37,13 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||
| 34 | 37 | if err != nil { | |
| 35 | 38 | return fail(err) // the group stays open, like the original | |
| 36 | 39 | } | |
| 40 | + | if c := j.stepContainer(); c != nil { | |
| 41 | + | argv, err := j.containerScript(c, plan, in, s) | |
| 42 | + | if err != nil { | |
| 43 | + | return fail(err) | |
| 44 | + | } | |
| 45 | + | return j.runProc(s, argv[0], j.state.Layout().WorkRoot, argv, "", stepCancel) | |
| 46 | + | } | |
| 37 | 47 | dir, argv, err := j.scriptCommand(plan, in) | |
| 38 | 48 | if err != nil { | |
| 39 | 49 | return fail(err) | |
| @@ -120,7 +130,13 @@ func (j *job) printHeader(s *stepRun, in scriptIn) (ShellPlan, error) { | |||
| 120 | 130 | j.mu.Lock() | |
| 121 | 131 | lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()} | |
| 122 | 132 | j.mu.Unlock() | |
| 123 | - | plan, err := PlanShell(in.shell, lookup) | |
| 133 | + | var plan ShellPlan | |
| 134 | + | var err error | |
| 135 | + | if j.stepContainer() != nil { | |
| 136 | + | plan, err = PlanShellInContainer(in.shell) | |
| 137 | + | } else { | |
| 138 | + | plan, err = PlanShell(in.shell, lookup) | |
| 139 | + | } | |
| 124 | 140 | if err != nil { | |
| 125 | 141 | return plan, err | |
| 126 | 142 | } | |
| @@ -15,6 +15,9 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||||
| 15 | return j.runAction(s, stepCancel) | 15 | return j.runAction(s, stepCancel) | ||
| 16 | } | 16 | } | ||
| 17 | fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed } | 17 | fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed } | ||
| 18 | + | if err := j.containerReady(); err != nil { | |||
| 19 | + | return fail(err) | |||
| 20 | + | } | |||
| 18 | failTemplate := func(err error) stepResult { | 21 | failTemplate := func(err error) stepResult { | ||
| 19 | j.errorf(num, "%s", templateNotValid(err, true)) | 22 | j.errorf(num, "%s", templateNotValid(err, true)) | ||
| 20 | return resFailed | 23 | return resFailed | ||
| @@ -34,6 +37,13 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||||
| 34 | if err != nil { | 37 | if err != nil { | ||
| 35 | return fail(err) // the group stays open, like the original | 38 | return fail(err) // the group stays open, like the original | ||
| 36 | } | 39 | } | ||
| 40 | + | if c := j.stepContainer(); c != nil { | |||
| 41 | + | argv, err := j.containerScript(c, plan, in, s) | |||
| 42 | + | if err != nil { | |||
| 43 | + | return fail(err) | |||
| 44 | + | } | |||
| 45 | + | return j.runProc(s, argv[0], j.state.Layout().WorkRoot, argv, "", stepCancel) | |||
| 46 | + | } | |||
| 37 | dir, argv, err := j.scriptCommand(plan, in) | 47 | dir, argv, err := j.scriptCommand(plan, in) | ||
| 38 | if err != nil { | 48 | if err != nil { | ||
| 39 | return fail(err) | 49 | return fail(err) | ||
| @@ -120,7 +130,13 @@ func (j *job) printHeader(s *stepRun, in scriptIn) (ShellPlan, error) { | |||||
| 120 | j.mu.Lock() | 130 | j.mu.Lock() | ||
| 121 | lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()} | 131 | lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()} | ||
| 122 | j.mu.Unlock() | 132 | j.mu.Unlock() | ||
| 123 | - | plan, err := PlanShell(in.shell, lookup) | 133 | + | var plan ShellPlan |
| 134 | + | var err error | |||
| 135 | + | if j.stepContainer() != nil { | |||
| 136 | + | plan, err = PlanShellInContainer(in.shell) | |||
| 137 | + | } else { | |||
| 138 | + | plan, err = PlanShell(in.shell, lookup) | |||
| 139 | + | } | |||
| 124 | if err != nil { | 140 | if err != nil { | ||
| 125 | return plan, err | 141 | return plan, err | ||
| 126 | } | 142 | } | ||
M internal/executor/issues.go +5 -0
| @@ -290,6 +290,11 @@ func (m *commandManager) commandIssue(level AnnotationLevel, name string, c *act | |||
| 290 | 290 | } | |
| 291 | 291 | validateLinesAndColumns(props, name, h.Debug) | |
| 292 | 292 | if file := props["file"]; file != "" { | |
| 293 | + | // ActionCommandManager.cs:675-681: the path of a container is the host's. | |
| 294 | + | if hp := h.HostPath(file); hp != file { | |
| 295 | + | file = hp | |
| 296 | + | props["file"] = hp | |
| 297 | + | } | |
| 293 | 298 | if rel := makeRelative(file, h.Workspace()); rel != file && rel != "" { | |
| 294 | 299 | props["file"] = rel | |
| 295 | 300 | } | |
| @@ -290,6 +290,11 @@ func (m *commandManager) commandIssue(level AnnotationLevel, name string, c *act | |||||
| 290 | } | 290 | } | ||
| 291 | validateLinesAndColumns(props, name, h.Debug) | 291 | validateLinesAndColumns(props, name, h.Debug) | ||
| 292 | if file := props["file"]; file != "" { | 292 | if file := props["file"]; file != "" { | ||
| 293 | + | // ActionCommandManager.cs:675-681: the path of a container is the host's. | |||
| 294 | + | if hp := h.HostPath(file); hp != file { | |||
| 295 | + | file = hp | |||
| 296 | + | props["file"] = hp | |||
| 297 | + | } | |||
| 293 | if rel := makeRelative(file, h.Workspace()); rel != file && rel != "" { | 298 | if rel := makeRelative(file, h.Workspace()); rel != file && rel != "" { | ||
| 294 | props["file"] = rel | 299 | props["file"] = rel | ||
| 295 | } | 300 | } | ||
M internal/executor/job.go +17 -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 | |
| @@ -211,6 +213,9 @@ func Run(cancel *Canceller, cfg Config) Result { | |||
| 211 | 213 | } | |
| 212 | 214 | ||
| 213 | 215 | func (j *job) run() Result { | |
| 216 | + | // Whatever happens below (a panic, a shutdown that skips the post steps), | |
| 217 | + | // the containers, the network and the credentials directories are removed. | |
| 218 | + | defer j.fallbackCleanup() | |
| 214 | 219 | limit := j.cfg.Deadline | |
| 215 | 220 | if limit <= 0 { | |
| 216 | 221 | limit = time.Duration(j.timeoutMinutes()) * time.Minute | |
| @@ -435,10 +440,6 @@ func (j *job) setUp() ([]qstep, error) { | |||
| 435 | 440 | switch { | |
| 436 | 441 | case jb.IsReusable(): | |
| 437 | 442 | return nil, errors.New("not supported yet: reusable workflows (jobs.<id>.uses)") | |
| 438 | - | case jb.Container != nil: | |
| 439 | - | return nil, errors.New("not supported yet: jobs.<id>.container") | |
| 440 | - | case jb.Services != nil: | |
| 441 | - | return nil, errors.New("not supported yet: jobs.<id>.services") | |
| 442 | 443 | } | |
| 443 | 444 | ||
| 444 | 445 | j.printTokenInfo() | |
| @@ -508,6 +509,18 @@ func (j *job) setUp() ([]qstep, error) { | |||
| 508 | 509 | j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd | |
| 509 | 510 | } | |
| 510 | 511 | ||
| 512 | + | // JobExtension.cs:250-275: the job container and the services are | |
| 513 | + | // evaluated after the job environment, before the actions are prepared. | |
| 514 | + | j.debug(1, "Evaluating job container") | |
| 515 | + | j.debug(1, "Evaluating job service containers") | |
| 516 | + | jc, svcs, err := j.evalContainers() | |
| 517 | + | if err != nil { | |
| 518 | + | return nil, err | |
| 519 | + | } | |
| 520 | + | if jc != nil || len(svcs) > 0 { | |
| 521 | + | j.cs = &containerSet{job: jc, services: svcs} | |
| 522 | + | } | |
| 523 | + | ||
| 511 | 524 | j.out(1, "Prepare all required actions") | |
| 512 | 525 | queue, err := j.prepareJob() | |
| 513 | 526 | 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 | ||
| @@ -211,6 +213,9 @@ func Run(cancel *Canceller, cfg Config) Result { | |||||
| 211 | } | 213 | } | ||
| 212 | 214 | ||||
| 213 | func (j *job) run() Result { | 215 | func (j *job) run() Result { | ||
| 216 | + | // Whatever happens below (a panic, a shutdown that skips the post steps), | |||
| 217 | + | // the containers, the network and the credentials directories are removed. | |||
| 218 | + | defer j.fallbackCleanup() | |||
| 214 | limit := j.cfg.Deadline | 219 | limit := j.cfg.Deadline | ||
| 215 | if limit <= 0 { | 220 | if limit <= 0 { | ||
| 216 | limit = time.Duration(j.timeoutMinutes()) * time.Minute | 221 | limit = time.Duration(j.timeoutMinutes()) * time.Minute | ||
| @@ -435,10 +440,6 @@ func (j *job) setUp() ([]qstep, error) { | |||||
| 435 | switch { | 440 | switch { | ||
| 436 | case jb.IsReusable(): | 441 | case jb.IsReusable(): | ||
| 437 | return nil, errors.New("not supported yet: reusable workflows (jobs.<id>.uses)") | 442 | return nil, errors.New("not supported yet: reusable workflows (jobs.<id>.uses)") | ||
| 438 | - | case jb.Container != nil: | |||
| 439 | - | return nil, errors.New("not supported yet: jobs.<id>.container") | |||
| 440 | - | case jb.Services != nil: | |||
| 441 | - | return nil, errors.New("not supported yet: jobs.<id>.services") | |||
| 442 | } | 443 | } | ||
| 443 | 444 | ||||
| 444 | j.printTokenInfo() | 445 | j.printTokenInfo() | ||
| @@ -508,6 +509,18 @@ func (j *job) setUp() ([]qstep, error) { | |||||
| 508 | j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd | 509 | j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd | ||
| 509 | } | 510 | } | ||
| 510 | 511 | ||||
| 512 | + | // JobExtension.cs:250-275: the job container and the services are | |||
| 513 | + | // evaluated after the job environment, before the actions are prepared. | |||
| 514 | + | j.debug(1, "Evaluating job container") | |||
| 515 | + | j.debug(1, "Evaluating job service containers") | |||
| 516 | + | jc, svcs, err := j.evalContainers() | |||
| 517 | + | if err != nil { | |||
| 518 | + | return nil, err | |||
| 519 | + | } | |||
| 520 | + | if jc != nil || len(svcs) > 0 { | |||
| 521 | + | j.cs = &containerSet{job: jc, services: svcs} | |||
| 522 | + | } | |||
| 523 | + | ||||
| 511 | j.out(1, "Prepare all required actions") | 524 | j.out(1, "Prepare all required actions") | ||
| 512 | queue, err := j.prepareJob() | 525 | queue, err := j.prepareJob() | ||
| 513 | if err != nil { | 526 | if err != nil { | ||
M internal/executor/job_test.go +5 -6
| @@ -192,13 +192,12 @@ jobs: | |||
| 192 | 192 | _ = rec | |
| 193 | 193 | } | |
| 194 | 194 | ||
| 195 | - | func TestNotSupportedYet(t *testing.T) { | |
| 195 | + | // Reusable workflows stay refused; container: and services: are supported. | |
| 196 | + | func TestReusableWorkflowNotSupportedYet(t *testing.T) { | |
| 196 | 197 | needTools(t, "bash") | |
| 197 | - | for _, f := range []string{"container: alpine", "services:\n s:\n image: x"} { | |
| 198 | - | rec2, res2 := runLocal(t, wfHead+" "+f+"\n steps:\n - run: echo hi\n", "j", nil) | |
| 199 | - | if res2.Conclusion != ConclusionFailure || res2.Error == "" || !strings.Contains(logOf(rec2.steps[0]), "not supported yet") { | |
| 200 | - | t.Errorf("%q: %+v %v", f, res2, rec2.names()) | |
| 201 | - | } | |
| 198 | + | rec, res := runLocal(t, "on: push\njobs:\n j:\n uses: ./.github/workflows/x.yml\n", "j", nil) | |
| 199 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[0]), "not supported yet") { | |
| 200 | + | t.Errorf("%+v %v", res, rec.names()) | |
| 202 | 201 | } | |
| 203 | 202 | } | |
| 204 | 203 | ||
| @@ -192,13 +192,12 @@ jobs: | |||||
| 192 | _ = rec | 192 | _ = rec | ||
| 193 | } | 193 | } | ||
| 194 | 194 | ||||
| 195 | - | func TestNotSupportedYet(t *testing.T) { | 195 | + | // Reusable workflows stay refused; container: and services: are supported. |
| 196 | + | func TestReusableWorkflowNotSupportedYet(t *testing.T) { | |||
| 196 | needTools(t, "bash") | 197 | needTools(t, "bash") | ||
| 197 | - | for _, f := range []string{"container: alpine", "services:\n s:\n image: x"} { | 198 | + | rec, res := runLocal(t, "on: push\njobs:\n j:\n uses: ./.github/workflows/x.yml\n", "j", nil) |
| 198 | - | rec2, res2 := runLocal(t, wfHead+" "+f+"\n steps:\n - run: echo hi\n", "j", nil) | 199 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[0]), "not supported yet") { |
| 199 | - | if res2.Conclusion != ConclusionFailure || res2.Error == "" || !strings.Contains(logOf(rec2.steps[0]), "not supported yet") { | 200 | + | t.Errorf("%+v %v", res, rec.names()) |
| 200 | - | t.Errorf("%q: %+v %v", f, res2, rec2.names()) | |||
| 201 | - | } | |||
| 202 | } | 201 | } | ||
| 203 | } | 202 | } | ||
| 204 | 203 | ||||
M internal/executor/outputmgr.go +5 -1
| @@ -343,9 +343,13 @@ func (o *outputManager) convertToIssue(m *issueMatch) (issue, bool) { | |||
| 343 | 343 | file = filepath.Join(dir, file) | |
| 344 | 344 | } | |
| 345 | 345 | } | |
| 346 | - | // Root using workspace. | |
| 346 | + | // Root using workspace; an absolute path is the container's and is | |
| 347 | + | // translated to the host's (OutputManager.cs:281-311). | |
| 347 | 348 | if !filepath.IsAbs(file) { | |
| 348 | 349 | file = filepath.Join(s.Workspace(), file) | |
| 350 | + | } else { | |
| 351 | + | file = filepath.Clean(file) | |
| 352 | + | file = filepath.Clean(s.HostPath(file)) | |
| 349 | 353 | } | |
| 350 | 354 | file = filepath.Clean(file) | |
| 351 | 355 | if st, err := os.Stat(file); err == nil && !st.IsDir() { | |
| @@ -343,9 +343,13 @@ func (o *outputManager) convertToIssue(m *issueMatch) (issue, bool) { | |||||
| 343 | file = filepath.Join(dir, file) | 343 | file = filepath.Join(dir, file) | ||
| 344 | } | 344 | } | ||
| 345 | } | 345 | } | ||
| 346 | - | // Root using workspace. | 346 | + | // Root using workspace; an absolute path is the container's and is |
| 347 | + | // translated to the host's (OutputManager.cs:281-311). | |||
| 347 | if !filepath.IsAbs(file) { | 348 | if !filepath.IsAbs(file) { | ||
| 348 | file = filepath.Join(s.Workspace(), file) | 349 | file = filepath.Join(s.Workspace(), file) | ||
| 350 | + | } else { | |||
| 351 | + | file = filepath.Clean(file) | |||
| 352 | + | file = filepath.Clean(s.HostPath(file)) | |||
| 349 | } | 353 | } | ||
| 350 | file = filepath.Clean(file) | 354 | file = filepath.Clean(file) | ||
| 351 | if st, err := os.Stat(file); err == nil && !st.IsDir() { | 355 | if st, err := os.Stat(file); err == nil && !st.IsDir() { | ||
M internal/executor/proc.go +14 -0
| @@ -125,6 +125,7 @@ const dispatchBatchDelay = 2 * time.Millisecond | |||
| 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/results.go +15 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "io" | |
| 9 | + | "io/fs" | |
| 9 | 10 | "os" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "sync/atomic" | |
| @@ -42,6 +43,14 @@ func (j *job) addIssueFor(s *stepRun, is issue) { | |||
| 42 | 43 | // Workspace is github.workspace. | |
| 43 | 44 | func (s *stepRun) Workspace() string { return s.j.githubValue("workspace") } | |
| 44 | 45 | ||
| 46 | + | // HostPath is TranslateToHostPath of the job container. | |
| 47 | + | func (s *stepRun) HostPath(p string) string { | |
| 48 | + | if c := s.j.stepContainer(); c != nil { | |
| 49 | + | return c.translateToHost(p) | |
| 50 | + | } | |
| 51 | + | return p | |
| 52 | + | } | |
| 53 | + | ||
| 45 | 54 | func (j *job) githubValue(k string) string { | |
| 46 | 55 | j.mu.Lock() | |
| 47 | 56 | defer j.mu.Unlock() | |
| @@ -71,6 +80,12 @@ const maxMatcherFile = 1 << 20 | |||
| 71 | 80 | ||
| 72 | 81 | // ReadMatcherFile reads a problem matcher file. | |
| 73 | 82 | func (s *stepRun) ReadMatcherFile(path string) ([]byte, error) { | |
| 83 | + | // With a job container the path came from a command of the container: it | |
| 84 | + | // is read only when it is a file of a directory the container shares with | |
| 85 | + | // the host, never the host's own file of the same name. | |
| 86 | + | if c := s.j.stepContainer(); c != nil && !c.insideMappings(path) { | |
| 87 | + | return nil, &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist} | |
| 88 | + | } | |
| 74 | 89 | f, err := os.Open(path) | |
| 75 | 90 | if err != nil { | |
| 76 | 91 | return nil, err | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "io" | 8 | "io" | ||
| 9 | + | "io/fs" | |||
| 9 | "os" | 10 | "os" | ||
| 10 | "strings" | 11 | "strings" | ||
| 11 | "sync/atomic" | 12 | "sync/atomic" | ||
| @@ -42,6 +43,14 @@ func (j *job) addIssueFor(s *stepRun, is issue) { | |||||
| 42 | // Workspace is github.workspace. | 43 | // Workspace is github.workspace. | ||
| 43 | func (s *stepRun) Workspace() string { return s.j.githubValue("workspace") } | 44 | func (s *stepRun) Workspace() string { return s.j.githubValue("workspace") } | ||
| 44 | 45 | ||||
| 46 | + | // HostPath is TranslateToHostPath of the job container. | |||
| 47 | + | func (s *stepRun) HostPath(p string) string { | |||
| 48 | + | if c := s.j.stepContainer(); c != nil { | |||
| 49 | + | return c.translateToHost(p) | |||
| 50 | + | } | |||
| 51 | + | return p | |||
| 52 | + | } | |||
| 53 | + | ||||
| 45 | func (j *job) githubValue(k string) string { | 54 | func (j *job) githubValue(k string) string { | ||
| 46 | j.mu.Lock() | 55 | j.mu.Lock() | ||
| 47 | defer j.mu.Unlock() | 56 | defer j.mu.Unlock() | ||
| @@ -71,6 +80,12 @@ const maxMatcherFile = 1 << 20 | |||||
| 71 | 80 | ||||
| 72 | // ReadMatcherFile reads a problem matcher file. | 81 | // ReadMatcherFile reads a problem matcher file. | ||
| 73 | func (s *stepRun) ReadMatcherFile(path string) ([]byte, error) { | 82 | func (s *stepRun) ReadMatcherFile(path string) ([]byte, error) { | ||
| 83 | + | // With a job container the path came from a command of the container: it | |||
| 84 | + | // is read only when it is a file of a directory the container shares with | |||
| 85 | + | // the host, never the host's own file of the same name. | |||
| 86 | + | if c := s.j.stepContainer(); c != nil && !c.insideMappings(path) { | |||
| 87 | + | return nil, &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist} | |||
| 88 | + | } | |||
| 74 | f, err := os.Open(path) | 89 | f, err := os.Open(path) | ||
| 75 | if err != nil { | 90 | if err != nil { | ||
| 76 | return nil, err | 91 | return nil, err | ||
M internal/executor/shell.go +29 -1
| @@ -291,9 +291,21 @@ type Script struct { | |||
| 291 | 291 | // so the file is created through root, exclusively and without following links | |
| 292 | 292 | // (see createExcl). | |
| 293 | 293 | func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) { | |
| 294 | + | return WriteScriptFor(root, p, contents, nil) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // WriteScriptFor is WriteScript for a shell that sees the file under another | |
| 298 | + | // path: tr translates the host path for the arguments (a job container sees | |
| 299 | + | // RUNNER_TEMP as /__w/_temp, ScriptHandler.cs:252). Script.Path stays the | |
| 300 | + | // host's. | |
| 301 | + | func WriteScriptFor(root *os.Root, p ShellPlan, contents string, tr func(string) string) (Script, error) { | |
| 294 | 302 | name := uuid.NewString() + p.Extension() | |
| 295 | 303 | path := filepath.Join(root.Name(), name) | |
| 296 | - | args, err := FormatArguments(p.ArgFormat, path) | |
| 304 | + | shown := path | |
| 305 | + | if tr != nil { | |
| 306 | + | shown = tr(path) | |
| 307 | + | } | |
| 308 | + | args, err := FormatArguments(p.ArgFormat, shown) | |
| 297 | 309 | if err != nil { | |
| 298 | 310 | return Script{}, err | |
| 299 | 311 | } | |
| @@ -315,3 +327,19 @@ func ResolveWorkingDirectory(workspace, dir string) string { | |||
| 315 | 327 | } | |
| 316 | 328 | return strings.TrimRight(workspace, "/") + "/" + dir | |
| 317 | 329 | } | |
| 330 | + | ||
| 331 | + | // PlanShellInContainer is the shell of a step that runs in the job container | |
| 332 | + | // (ScriptHandler.cs:73-117, :182-232 with isContainerStepHost): nothing is | |
| 333 | + | // looked up, the shell is found by the container itself, so the default is | |
| 334 | + | // always "sh -e {0}" even when the image has bash. | |
| 335 | + | func PlanShellInContainer(shell string) (ShellPlan, error) { | |
| 336 | + | if shell == "" { | |
| 337 | + | return checkFormat(ShellPlan{Command: "sh", CommandPath: "sh", ArgFormat: ScriptArgumentsFormat("sh")}) | |
| 338 | + | } | |
| 339 | + | cmd, args := ParseShellOption(shell) | |
| 340 | + | format := strings.TrimLeft(args, " \t\n\v\f\r") | |
| 341 | + | if format == "" { | |
| 342 | + | format = ScriptArgumentsFormat(cmd) | |
| 343 | + | } | |
| 344 | + | return checkFormat(ShellPlan{Command: cmd, CommandPath: cmd, ArgFormat: format}) | |
| 345 | + | } | |
| @@ -291,9 +291,21 @@ type Script struct { | |||||
| 291 | // so the file is created through root, exclusively and without following links | 291 | // so the file is created through root, exclusively and without following links | ||
| 292 | // (see createExcl). | 292 | // (see createExcl). | ||
| 293 | func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) { | 293 | func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) { | ||
| 294 | + | return WriteScriptFor(root, p, contents, nil) | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | // WriteScriptFor is WriteScript for a shell that sees the file under another | |||
| 298 | + | // path: tr translates the host path for the arguments (a job container sees | |||
| 299 | + | // RUNNER_TEMP as /__w/_temp, ScriptHandler.cs:252). Script.Path stays the | |||
| 300 | + | // host's. | |||
| 301 | + | func WriteScriptFor(root *os.Root, p ShellPlan, contents string, tr func(string) string) (Script, error) { | |||
| 294 | name := uuid.NewString() + p.Extension() | 302 | name := uuid.NewString() + p.Extension() | ||
| 295 | path := filepath.Join(root.Name(), name) | 303 | path := filepath.Join(root.Name(), name) | ||
| 296 | - | args, err := FormatArguments(p.ArgFormat, path) | 304 | + | shown := path |
| 305 | + | if tr != nil { | |||
| 306 | + | shown = tr(path) | |||
| 307 | + | } | |||
| 308 | + | args, err := FormatArguments(p.ArgFormat, shown) | |||
| 297 | if err != nil { | 309 | if err != nil { | ||
| 298 | return Script{}, err | 310 | return Script{}, err | ||
| 299 | } | 311 | } | ||
| @@ -315,3 +327,19 @@ func ResolveWorkingDirectory(workspace, dir string) string { | |||||
| 315 | } | 327 | } | ||
| 316 | return strings.TrimRight(workspace, "/") + "/" + dir | 328 | return strings.TrimRight(workspace, "/") + "/" + dir | ||
| 317 | } | 329 | } | ||
| 330 | + | ||||
| 331 | + | // PlanShellInContainer is the shell of a step that runs in the job container | |||
| 332 | + | // (ScriptHandler.cs:73-117, :182-232 with isContainerStepHost): nothing is | |||
| 333 | + | // looked up, the shell is found by the container itself, so the default is | |||
| 334 | + | // always "sh -e {0}" even when the image has bash. | |||
| 335 | + | func PlanShellInContainer(shell string) (ShellPlan, error) { | |||
| 336 | + | if shell == "" { | |||
| 337 | + | return checkFormat(ShellPlan{Command: "sh", CommandPath: "sh", ArgFormat: ScriptArgumentsFormat("sh")}) | |||
| 338 | + | } | |||
| 339 | + | cmd, args := ParseShellOption(shell) | |||
| 340 | + | format := strings.TrimLeft(args, " \t\n\v\f\r") | |||
| 341 | + | if format == "" { | |||
| 342 | + | format = ScriptArgumentsFormat(cmd) | |||
| 343 | + | } | |||
| 344 | + | return checkFormat(ShellPlan{Command: cmd, CommandPath: cmd, ArgFormat: format}) | |||
| 345 | + | } | |||
M internal/executor/step_run.go +2 -2
| @@ -249,10 +249,10 @@ func (j *job) runGuarded(s *stepRun, q qstep, ok bool) stepResult { | |||
| 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 | } | ||