Merge pull request 'feat(runner): executor, host loop and job frames' (#2) from feat/executor into main
53 files · +12165 -87
M .forgejo/workflows/ci.yml +15 -0
| @@ -16,6 +16,10 @@ jobs: | |||||
| 16 | runs-on: ubuntu-latest | 16 | runs-on: ubuntu-latest | ||
| 17 | steps: | 17 | steps: | ||
| 18 | - uses: actions/checkout@v6 | 18 | - uses: actions/checkout@v6 | ||
| 19 | + | # Job containers reach Forgejo through the Gordon edge on the host loopback, | |||
| 20 | + | # in plain HTTP on :8088 (see vps-infra homelab/igor/ci-runner). | |||
| 21 | + | - name: Fetch bnema.dev/ermite* modules through the runner's Forgejo route | |||
| 22 | + | run: git config --global url."http://git.bnema.dev:8088/".insteadOf "https://git.bnema.dev/" | |||
| 19 | - uses: actions/setup-go@v5 | 23 | - uses: actions/setup-go@v5 | ||
| 20 | with: | 24 | with: | ||
| 21 | go-version-file: go.mod | 25 | go-version-file: go.mod | ||
| @@ -29,6 +33,10 @@ jobs: | |||||
| 29 | runs-on: ubuntu-latest | 33 | runs-on: ubuntu-latest | ||
| 30 | steps: | 34 | steps: | ||
| 31 | - uses: actions/checkout@v6 | 35 | - uses: actions/checkout@v6 | ||
| 36 | + | # Job containers reach Forgejo through the Gordon edge on the host loopback, | |||
| 37 | + | # in plain HTTP on :8088 (see vps-infra homelab/igor/ci-runner). | |||
| 38 | + | - name: Fetch bnema.dev/ermite* modules through the runner's Forgejo route | |||
| 39 | + | run: git config --global url."http://git.bnema.dev:8088/".insteadOf "https://git.bnema.dev/" | |||
| 32 | - uses: actions/setup-go@v5 | 40 | - uses: actions/setup-go@v5 | ||
| 33 | with: | 41 | with: | ||
| 34 | go-version-file: go.mod | 42 | go-version-file: go.mod | ||
| @@ -38,6 +46,13 @@ jobs: | |||||
| 38 | go mod tidy | 46 | go mod tidy | ||
| 39 | git diff --exit-code -- go.mod go.sum | 47 | git diff --exit-code -- go.mod go.sum | ||
| 40 | - run: go vet ./... | 48 | - run: go vet ./... | ||
| 49 | + | - name: Static build | |||
| 50 | + | # The runner binary is attached read-only to every sandbox, which has no | |||
| 51 | + | # libc of ours: it must be a static executable (sandbox.CheckStatic | |||
| 52 | + | # refuses anything else at start). | |||
| 53 | + | run: | | |||
| 54 | + | CGO_ENABLED=0 go build -trimpath -o /tmp/ermite-runner . | |||
| 55 | + | file /tmp/ermite-runner | grep -q 'statically linked' | |||
| 41 | - name: Protobuf lint and drift | 56 | - name: Protobuf lint and drift | ||
| 42 | # buf and protoc-gen-go are pinned as `tool` directives in go.mod: the | 57 | # buf and protoc-gen-go are pinned as `tool` directives in go.mod: the | ||
| 43 | # generated code is reproducible and must match the committed one. | 58 | # generated code is reproducible and must match the committed one. | ||
M README.md +198 -0
| @@ -48,6 +48,191 @@ The host sends a `Cancel{reason}` frame (`execstream.Drive` takes a cancel chann | |||||
| 48 | 48 | ||||
| 49 | Everything a job creates (process, TAP or bridge, nftables rules, files, loop mounts, containers, networks, cgroups) is removed by `Stop`, which also runs with a cancelled context, aborts a `Start` in progress, and by `Reap` at start for what a crash left behind. A VM leaves a state record (`<state dir>/vms/<id>.json`) before its first resource. **The network policy and the TAP or bridge are never removed while the VMM or the container may still be running**: when a process cannot be killed or a container cannot be removed, `Stop` returns an error wrapping `sandbox.ErrLeaked`, the sandbox's slot stays taken, and `Driver.Reap` (which returns `sandbox.ErrBusy` while a sandbox is running) finishes the job and frees the slot. The host should stop taking jobs on a leaked slot until then. `Sandbox.ID()` names a sandbox's files and Docker objects. | 49 | Everything a job creates (process, TAP or bridge, nftables rules, files, loop mounts, containers, networks, cgroups) is removed by `Stop`, which also runs with a cancelled context, aborts a `Start` in progress, and by `Reap` at start for what a crash left behind. A VM leaves a state record (`<state dir>/vms/<id>.json`) before its first resource. **The network policy and the TAP or bridge are never removed while the VMM or the container may still be running**: when a process cannot be killed or a container cannot be removed, `Stop` returns an error wrapping `sandbox.ErrLeaked`, the sandbox's slot stays taken, and `Driver.Reap` (which returns `sandbox.ErrBusy` while a sandbox is running) finishes the job and frees the slot. The host should stop taking jobs on a leaked slot until then. `Sandbox.ID()` names a sandbox's files and Docker objects. | ||
| 50 | 50 | ||||
| 51 | + | ## Running jobs | |||
| 52 | + | ||||
| 53 | + | ``` | |||
| 54 | + | printf %s "$TOKEN" | ermite-runner register --token-stdin # once | |||
| 55 | + | ermite-runner run # as a service | |||
| 56 | + | ``` | |||
| 57 | + | ||||
| 58 | + | `register` picks the isolation mode like `run` will, registers the runner with | |||
| 59 | + | ermite (name, labels, version, isolation, architecture) and stores the runner | |||
| 60 | + | token in `$ERMITE_RUNNER_STATE_DIR/runner.json` (mode 0600, in a directory made | |||
| 61 | + | 0700; a file readable by others is refused). `--replace` removes a runner of the | |||
| 62 | + | same name first. | |||
| 63 | + | ||||
| 64 | + | **The token of `register`** (scope `runner:register`: it reads the code and the | |||
| 65 | + | secrets of every repository, see `runner.proto`) is looked up in this order: | |||
| 66 | + | standard input with `--token-stdin`, then `ERMITE_RUNNER_REGISTER_TOKEN`, then | |||
| 67 | + | `--token`. Prefer the first two: **the arguments of a process are visible to | |||
| 68 | + | every user of the host** (`ps`, `/proc/<pid>/cmdline`), the environment and | |||
| 69 | + | standard input are not. | |||
| 70 | + | ||||
| 71 | + | **The server URL** must be `https://`. Plain `http://` is accepted for | |||
| 72 | + | `localhost`, `127.0.0.0/8` and `::1` only; `ERMITE_RUNNER_INSECURE_HTTP=1` allows | |||
| 73 | + | any other `http://` URL (tests, a trusted network) and sends every token in | |||
| 74 | + | clear. The client never follows a redirect, so a token cannot be carried to | |||
| 75 | + | another host. | |||
| 76 | + | ||||
| 77 | + | `run` reaps what a previous run left, then polls `FetchJob` whenever a slot is | |||
| 78 | + | free (`ERMITE_RUNNER_CAPACITY`), with a `poll_id` that is kept when a response | |||
| 79 | + | is lost and obeying `retry_after`. For each job it sends the acknowledging | |||
| 80 | + | heartbeat first, then one every 30 s (a failed one is retried every 5 s), opens | |||
| 81 | + | `WatchJob` (a cancel request reaches the executor, which interrupts, terminates | |||
| 82 | + | and kills like the official runner), forwards the log with `AppendLog` | |||
| 83 | + | (batches of at most 1,000 lines and 64 KiB per step, numbered offsets, retried | |||
| 84 | + | with the same lines), sends step snapshots with `UpdateJob` and ends with an | |||
| 85 | + | idempotent `CompleteJob`. When ermite says the job ended (`JobEnded`) or revokes | |||
| 86 | + | the assignment (`unauthenticated`) the sandbox is destroyed and nothing more is | |||
| 87 | + | sent. On SIGINT or SIGTERM the running jobs are cancelled with | |||
| 88 | + | `runner_shutdown`, reported, and the sandboxes removed. The job token stays in | |||
| 89 | + | the host process; the sandbox receives the workflow and its contexts only. | |||
| 90 | + | ||||
| 91 | + | | Variable | Meaning | | |||
| 92 | + | | --- | --- | | |||
| 93 | + | | `ERMITE_RUNNER_URL` | base URL of ermite | | |||
| 94 | + | | `ERMITE_RUNNER_NAME` | runner name (default: the host name) | | |||
| 95 | + | | `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) | | |||
| 96 | + | | `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` (see above) | | |||
| 97 | + | | `ERMITE_RUNNER_INSECURE_HTTP` | `1` allows a non-loopback `http://` URL (tests only) | | |||
| 98 | + | | `ERMITE_RUNNER_LOG_LEVEL` | `debug`, `info` (default), `warn`, `error` | | |||
| 99 | + | | `ERMITE_RUNNER_LOG_FORMAT` | `console` (default, colours) or `json` | | |||
| 100 | + | ||||
| 101 | + | ## Installing the service | |||
| 102 | + | ||||
| 103 | + | `contrib/ermite-runner.service` is a systemd unit. The runner runs as **root** | |||
| 104 | + | on the host (TAP devices, nftables, loop mounts, the jailer, containers); the | |||
| 105 | + | jobs never do: they run as user `runner` in a micro-VM or a container. | |||
| 106 | + | ||||
| 107 | + | ```sh | |||
| 108 | + | sudo install -m 0755 ermite-runner /usr/local/bin/ermite-runner # a static build: CGO_ENABLED=0 | |||
| 109 | + | sudo install -m 0644 contrib/ermite-runner.service /etc/systemd/system/ | |||
| 110 | + | sudo install -d -m 0700 /etc/ermite-runner | |||
| 111 | + | sudo install -m 0600 /dev/stdin /etc/ermite-runner/env <<'ENV' | |||
| 112 | + | ERMITE_RUNNER_URL=https://ermite.example.org | |||
| 113 | + | ERMITE_RUNNER_NAME=igor | |||
| 114 | + | ERMITE_RUNNER_LABELS=ubuntu-latest,ubuntu-24.04 | |||
| 115 | + | ERMITE_RUNNER_ISOLATION=auto | |||
| 116 | + | ERMITE_RUNNER_CAPACITY=2 | |||
| 117 | + | ERMITE_RUNNER_JOB_CPUS=4 | |||
| 118 | + | ERMITE_RUNNER_JOB_MEMORY=8G | |||
| 119 | + | ERMITE_RUNNER_JOB_DISK=64G | |||
| 120 | + | ERMITE_RUNNER_LOG_FORMAT=json | |||
| 121 | + | ENV | |||
| 122 | + | # Register once, in the same environment as the service (the settings, the state | |||
| 123 | + | # directory), with the token on standard input so that it is not in any | |||
| 124 | + | # command line: | |||
| 125 | + | printf %s "$TOKEN" | sudo systemd-run --pipe --wait -p EnvironmentFile=/etc/ermite-runner/env \ | |||
| 126 | + | -p StateDirectory=ermite-runner -p StateDirectoryMode=0700 \ | |||
| 127 | + | /usr/local/bin/ermite-runner register --token-stdin | |||
| 128 | + | sudo systemctl enable --now ermite-runner | |||
| 129 | + | journalctl -u ermite-runner -f | |||
| 130 | + | ``` | |||
| 131 | + | ||||
| 132 | + | `systemd-run` reads the same `EnvironmentFile` as the unit, so `register` and | |||
| 133 | + | `run` agree on the URL, the name, the labels, the isolation mode and the state | |||
| 134 | + | directory (`/var/lib/ermite-runner`, the default of `ERMITE_RUNNER_STATE_DIR`). | |||
| 135 | + | ||||
| 136 | + | **Logs.** The runner writes to standard error, which journald collects. Set | |||
| 137 | + | `ERMITE_RUNNER_LOG_FORMAT=json` under journald: one JSON event per line, with | |||
| 138 | + | `job_id`, `run_id` and `attempt` on every line about a job, `warn` for what is | |||
| 139 | + | retried and `error` for what is refused or leaked; the colours of the `console` | |||
| 140 | + | format are noise in a journal. `ERMITE_RUNNER_LOG_LEVEL=debug` adds detail. | |||
| 141 | + | Tokens are never logged. | |||
| 142 | + | ||||
| 143 | + | **Stopping.** `systemctl stop` sends SIGTERM: running jobs are cancelled with | |||
| 144 | + | `runner_shutdown`, reported to ermite, and their sandboxes removed before the | |||
| 145 | + | service exits. The budget, with the defaults, is **90 s**: 30 s for the executor | |||
| 146 | + | to stop its steps (SIGINT, SIGTERM after 7.5 s, SIGKILL 2.5 s later, then the | |||
| 147 | + | post-steps), 30 s to destroy the sandbox, 30 s to flush the log and report; jobs | |||
| 148 | + | do this in parallel, so it does not grow with `ERMITE_RUNNER_CAPACITY`. The unit | |||
| 149 | + | sets `TimeoutStopSec=120`, 30 s of margin (a test in `internal/host` fails if the | |||
| 150 | + | two drift apart). A job whose first heartbeat is still pending when the signal | |||
| 151 | + | arrives is not started: ermite gives it to another runner after 30 s. A crash or | |||
| 152 | + | a power cut leaves sandboxes behind; the next start reaps them. Only one runner | |||
| 153 | + | may live on a host (a host-wide lock enforces it), because reaping removes | |||
| 154 | + | everything labelled as the runner's own. | |||
| 155 | + | ||||
| 156 | + | **Sandboxing of the unit.** The runner has to be root (TAP devices, nftables, | |||
| 157 | + | loop mounts, the jailer, Docker), so the unit only takes away what it does not | |||
| 158 | + | use, and only with options that **leave it in the host's mount namespace**: | |||
| 159 | + | `NoNewPrivileges`, `ProtectClock`, `RestrictSUIDSGID`, `LockPersonality`, | |||
| 160 | + | `RestrictRealtime`, `SystemCallArchitectures=native` and `RestrictAddressFamilies` | |||
| 161 | + | (UNIX, INET, INET6, NETLINK, VSOCK). | |||
| 162 | + | ||||
| 163 | + | **Deliberately absent**, and why: | |||
| 164 | + | ||||
| 165 | + | - **Every option that gives the unit a mount namespace of its own:** `PrivateTmp`, | |||
| 166 | + | `ProtectHome`, `ProtectSystem`, `ProtectKernelModules`, `ProtectKernelLogs`, | |||
| 167 | + | `ProtectKernelTunables`, `ProtectControlGroups`, `PrivateDevices`, | |||
| 168 | + | `ReadOnlyPaths`, `ReadWritePaths`, `InaccessiblePaths`, `TemporaryFileSystem`, | |||
| 169 | + | `BindPaths`. In container mode the runner loop-mounts the scratch disk of each | |||
| 170 | + | job and the Docker daemon, which lives in the host's namespace, binds that | |||
| 171 | + | directory into the container; a mount made inside the unit's namespace is | |||
| 172 | + | invisible to it and the container would get an empty directory. (`ProtectKernelModules` | |||
| 173 | + | and `ProtectKernelLogs` look harmless but are implemented with mounts too.) The | |||
| 174 | + | micro-VM mode would tolerate them, but one unit serves both modes, and a second | |||
| 175 | + | unit for VM-only hardening is not worth maintaining. A test in `internal/host` | |||
| 176 | + | fails if the unit sets any of them. | |||
| 177 | + | - `Delegate=`: the runner manages no cgroup of its own (the jailer writes the | |||
| 178 | + | VMs' cgroups as root, Docker has its own). | |||
| 179 | + | - `RestrictNamespaces=`, `PrivateNetwork=`, `CapabilityBoundingSet=`, | |||
| 180 | + | `SystemCallFilter=`: network namespaces and devices (TAP, veth), mounts, | |||
| 181 | + | `net.ipv4.ip_forward`, capabilities handed to the jailer and Docker. | |||
| 182 | + | - `MemoryDenyWriteExecute=`: Firecracker maps guest memory writable and executable. | |||
| 183 | + | - `UMask=`: the drivers create directories that the job's user must enter, with | |||
| 184 | + | explicit modes; the state directory is made 0700 by the runner. | |||
| 185 | + | ||||
| 186 | + | The boundary of a job is its micro-VM or container, not this unit. Checked on a | |||
| 187 | + | workstation (a user manager, no root): the micro-VM job test (`TestVMRunsAJob`: | |||
| 188 | + | user and network namespaces, Firecracker, TAP, vsock) passes under the options | |||
| 189 | + | that remain. Not checked, because they need root on a real host: the jailer, loop | |||
| 190 | + | mounts, nftables in the host namespace, Docker. | |||
| 191 | + | ||||
| 192 | + | ## The image | |||
| 193 | + | ||||
| 194 | + | A job runs in the image named by `ERMITE_RUNNER_IMAGE` (container: an OCI | |||
| 195 | + | reference; vm: the directory with `vmlinux` and `base.ext4`, both built from | |||
| 196 | + | `images/ubuntu-24.04/Dockerfile`). The runner binary is attached to the sandbox | |||
| 197 | + | by the host and is never part of the image. What the executor needs from the | |||
| 198 | + | image: | |||
| 199 | + | ||||
| 200 | + | - a user **`runner`** with uid and gid 1001 and the home `/home/runner`; the | |||
| 201 | + | executor starts as root (init needs it to start `dockerd`) and runs every step | |||
| 202 | + | as this user, with HOME, USER and LOGNAME set. Without such a user the steps | |||
| 203 | + | run as root (a test image only); | |||
| 204 | + | - the directories `/home/runner/work` and `/opt/hostedtoolcache` owned by | |||
| 205 | + | `runner` (`RUNNER_TOOL_CACHE`, which the executor reads from its environment | |||
| 206 | + | like the official runner; each job gets `/home/runner/work/<repo>/<repo>` and | |||
| 207 | + | `/home/runner/work/_temp`, cleaned at the start of the job); | |||
| 208 | + | - **`bash`** and **`sh`** (the default shell is `bash -e {0}` when bash exists, | |||
| 209 | + | else `sh -e {0}`; like on hosted runners `shell: bash` adds | |||
| 210 | + | `--noprofile --norc -eo pipefail`), `python3` and a `python` command for | |||
| 211 | + | `shell: python`; `pwsh` is not part of the CI-1 image (a step that asks for it | |||
| 212 | + | fails with `pwsh: command not found`); | |||
| 213 | + | - `dockerd` (and passwordless `sudo` for `runner`) for jobs that use Docker: the | |||
| 214 | + | job gets its own daemon with its data root on the scratch disk; | |||
| 215 | + | - what workflows commonly use: git, curl, jq, build-essential, ca-certificates, | |||
| 216 | + | tzdata, zip/unzip. Full parity with the hosted images (`actions/runner-images`) | |||
| 217 | + | is a later phase. | |||
| 218 | + | ||||
| 219 | + | ## Job limits | |||
| 220 | + | ||||
| 221 | + | Each job gets its own resources, set for the runner as a whole: | |||
| 222 | + | ||||
| 223 | + | | Variable | Default | Effect | | |||
| 224 | + | |---|---|---| | |||
| 225 | + | | `ERMITE_RUNNER_CAPACITY` | `1` | jobs in parallel: one sandbox, one uid, one `/30` network and one disk per slot. The runner polls ermite only when a slot is free. | | |||
| 226 | + | | `ERMITE_RUNNER_JOB_CPUS` | `2` | vCPUs of a micro-VM; CPU quota of a container | | |||
| 227 | + | | `ERMITE_RUNNER_JOB_MEMORY` | `4G` | guest memory (`M`, `G`, `T`; at least `256M`); a jailed VMM gets this plus 256 MiB of cgroup headroom | | |||
| 228 | + | | `ERMITE_RUNNER_JOB_DISK` | `64G` | size of the sparse scratch disk (at least `1G`): the overlay upper layer of a VM and the Docker data root of a container. Deleted with the job | | |||
| 229 | + | | `ERMITE_RUNNER_JOB_PIDS` | `32768` | process limit of a container | | |||
| 230 | + | | `ERMITE_RUNNER_JOB_DISK_BANDWIDTH`, `_JOB_DISK_IOPS`, `_JOB_NET_BANDWIDTH` | `400`, `20000`, `1000` | VM rate limits (MiB/s, IOPS, Mbit/s); `0` disables | | |||
| 231 | + | ||||
| 232 | + | The job timeout is not a runner setting: it is the workflow's `timeout-minutes` | |||
| 233 | + | (360 by default), which ermite sends with the job and the executor enforces from | |||
| 234 | + | the moment it starts. | |||
| 235 | + | ||||
| 51 | ## Configuration (sandbox part) | 236 | ## Configuration (sandbox part) | ||
| 52 | 237 | ||||
| 53 | | Variable | Default | | | 238 | | Variable | Default | | | ||
| @@ -105,6 +290,19 @@ sudo env GOWORK=off ERMITE_RUNNER_IT_CONTAINER=1 ERMITE_RUNNER_IMAGE=ermite-runn | |||||
| 105 | DOCKER_HOST=unix:///var/run/docker.sock "$(command -v go)" test -count=1 -v ./internal/sandbox/integration -run Container | 290 | DOCKER_HOST=unix:///var/run/docker.sock "$(command -v go)" test -count=1 -v ./internal/sandbox/integration -run Container | ||
| 106 | ``` | 291 | ``` | ||
| 107 | 292 | ||||
| 293 | + | **Jobs end to end** (`ERMITE_RUNNER_IT_E2E=1`, `internal/e2e`): the real executor in the real image, started through the container driver. They also run on a workstation with a *rootless* Docker daemon (the test-only option `UnsafeAllowRootlessForTests`; the nested `dockerd` of the image cannot start there, and the jobs do not need it). `mount` and `umount` are replaced by stand-ins, since loop mounts need root. | |||
| 294 | + | ||||
| 295 | + | ```sh | |||
| 296 | + | IMAGE_TAG=ermite-runner-ubuntu-24.04:dev scripts/build-image.sh tmp/image # or: docker build -t ermite-runner-ubuntu-24.04:dev images/ubuntu-24.04 | |||
| 297 | + | ERMITE_RUNNER_IT_E2E=1 ERMITE_RUNNER_TEST_IMAGE=ermite-runner-ubuntu-24.04:dev GOWORK=off go test -race -count=1 ./internal/e2e | |||
| 298 | + | ``` | |||
| 299 | + | ||||
| 300 | + | - `TestContainerRunsAJobAsTheRunnerUser` and `TestContainerCancelStopsTheJobAndOrphans` drive a job over the stream: steps run as `runner` (1001), file ownership, `GITHUB_PATH` and `GITHUB_ENV`, `python`, cancellation by SIGINT, the `always()` step, and the orphan cleanup of "Complete job". | |||
| 301 | + | - `TestChainCorpus` runs the whole chain: a fake `RunnerService` (`internal/host/hosttest`, which checks the offsets, limits and tokens of `runner.proto`) hands out 22 jobs of the ermite-conformance corpus, the real host loop starts them in containers (two at a time), and what the fake stored (steps, conclusions, outputs, log lines) is compared with the recordings made on github.com. It reads `ERMITE_CONFORMANCE_DIR` (default: a sibling checkout of ermite-conformance) and never writes to it. | |||
| 302 | + | - `TestVMRunsAJob` (`internal/sandbox/integration`, run by `scripts/it-vm-userns.sh`) does the first of these in a micro-VM. | |||
| 303 | + | ||||
| 304 | + | Not covered anywhere yet, because they need root on a real host: the jailer (uid drop, chroot, cgroups), a rootful Docker daemon or sysbox with the nested `dockerd`, and the firewall of container mode. | |||
| 305 | + | ||||
| 108 | `ERMITE_RUNNER_IT_DOCKER_API=1 ERMITE_RUNNER_TEST_IMAGE=<image> go test -run DockerAPIWiring ./internal/sandbox/container` exercises the real Docker Engine calls (labels, networks, volumes, reaping) against any daemon, including a rootless one; it does not test isolation. | 306 | `ERMITE_RUNNER_IT_DOCKER_API=1 ERMITE_RUNNER_TEST_IMAGE=<image> go test -run DockerAPIWiring ./internal/sandbox/container` exercises the real Docker Engine calls (labels, networks, volumes, reaping) against any daemon, including a rootless one; it does not test isolation. | ||
| 109 | 307 | ||||
| 110 | The firewall policy has a packet-level test (`ERMITE_RUNNER_IT_NET=1`, run by `scripts/it-vm-userns.sh`): two guests and an outside host on veth pairs in separate network namespaces, with a forged source and a forged "reply" between guests. | 308 | The firewall policy has a packet-level test (`ERMITE_RUNNER_IT_NET=1`, run by `scripts/it-vm-userns.sh`): two guests and an outside host on veth pairs in separate network namespaces, with a forged source and a forged "reply" between guests. | ||
A contrib/ermite-runner.service +109 -0
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | # SPDX-License-Identifier: MIT | |||
| 2 | + | # | |||
| 3 | + | # systemd unit of the ermite runner. Install the static binary as | |||
| 4 | + | # /usr/local/bin/ermite-runner, write the settings to /etc/ermite-runner/env | |||
| 5 | + | # (mode 0600) and register the runner once; the README has the commands. | |||
| 6 | + | # | |||
| 7 | + | # The runner runs as root: it creates TAP devices and nftables rules (vm), loop | |||
| 8 | + | # mounts and containers (container), and starts the jailer. Jobs never run as | |||
| 9 | + | # root on the host: they run in a micro-VM or a container as user "runner". | |||
| 10 | + | # | |||
| 11 | + | # The sandboxing below is what can be added without breaking those jobs. | |||
| 12 | + | # What is deliberately NOT set, and why, is listed at the end of the file and | |||
| 13 | + | # in the README. | |||
| 14 | + | [Unit] | |||
| 15 | + | Description=ermite CI runner | |||
| 16 | + | Documentation=https://git.bnema.dev/bnema/ermite-runner | |||
| 17 | + | Wants=network-online.target | |||
| 18 | + | After=network-online.target docker.service | |||
| 19 | + | ||||
| 20 | + | [Service] | |||
| 21 | + | Type=simple | |||
| 22 | + | EnvironmentFile=/etc/ermite-runner/env | |||
| 23 | + | ExecStart=/usr/local/bin/ermite-runner run | |||
| 24 | + | Restart=on-failure | |||
| 25 | + | RestartSec=5 | |||
| 26 | + | ||||
| 27 | + | # SIGTERM cancels the running jobs with runner_shutdown, reports them and removes | |||
| 28 | + | # their sandboxes. With the defaults that takes at most 90 s (30 s for the | |||
| 29 | + | # executor to stop its steps, 30 s to destroy the sandbox, 30 s to report; the | |||
| 30 | + | # jobs do this in parallel). A test checks that this value leaves 15 s of margin | |||
| 31 | + | # over host.Timing.ShutdownBudget. | |||
| 32 | + | KillMode=mixed | |||
| 33 | + | KillSignal=SIGTERM | |||
| 34 | + | TimeoutStopSec=120 | |||
| 35 | + | ||||
| 36 | + | # The state directory holds the runner token (0600), the VM images and the | |||
| 37 | + | # per-job files; RuntimeDirectory is for the lock and sockets. | |||
| 38 | + | StateDirectory=ermite-runner | |||
| 39 | + | StateDirectoryMode=0700 | |||
| 40 | + | RuntimeDirectory=ermite-runner | |||
| 41 | + | ||||
| 42 | + | # Jobs are many processes. | |||
| 43 | + | TasksMax=infinity | |||
| 44 | + | LimitNOFILE=1048576 | |||
| 45 | + | ||||
| 46 | + | # ---- sandboxing that leaves the unit in the host's mount namespace ---------- | |||
| 47 | + | # Only options that do not give the unit a mount namespace of its own: in | |||
| 48 | + | # container mode the runner loop-mounts the scratch disk of a job, and the Docker | |||
| 49 | + | # daemon, which lives in the host's namespace, must see that mount to bind it | |||
| 50 | + | # into the container. See the end of the file. | |||
| 51 | + | NoNewPrivileges=yes | |||
| 52 | + | # The runner does not set the clock. | |||
| 53 | + | ProtectClock=yes | |||
| 54 | + | # No setuid/setgid files created by the runner or its children. | |||
| 55 | + | RestrictSUIDSGID=yes | |||
| 56 | + | LockPersonality=yes | |||
| 57 | + | # The runner does not need real-time scheduling. | |||
| 58 | + | RestrictRealtime=yes | |||
| 59 | + | # Syscall architectures: native only. | |||
| 60 | + | SystemCallArchitectures=native | |||
| 61 | + | # Address families the runner and its children use: Unix sockets (Docker, the | |||
| 62 | + | # executor stream), IPv4/IPv6 (the server, the DNS forwarder), netlink (ip, nft, | |||
| 63 | + | # TAP devices), packet (none needed: left out), vsock (the executor stream in a | |||
| 64 | + | # micro-VM). | |||
| 65 | + | RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_VSOCK | |||
| 66 | + | ||||
| 67 | + | [Install] | |||
| 68 | + | WantedBy=multi-user.target | |||
| 69 | + | ||||
| 70 | + | # ---- deliberately absent ----------------------------------------------------- | |||
| 71 | + | # | |||
| 72 | + | # PrivateTmp=, ProtectHome=, ProtectSystem=, ProtectKernelModules=, | |||
| 73 | + | # ProtectKernelLogs=, ProtectKernelTunables=, ProtectControlGroups=, | |||
| 74 | + | # PrivateDevices=, ReadOnlyPaths=, ReadWritePaths=, InaccessiblePaths=, | |||
| 75 | + | # TemporaryFileSystem=, BindPaths= | |||
| 76 | + | # Each of these puts the unit in a mount namespace of its own | |||
| 77 | + | # (systemd sets one up for ProtectKernelModules and | |||
| 78 | + | # ProtectKernelLogs too: it hides /usr/lib/modules and | |||
| 79 | + | # /proc/kmsg with mounts). In container mode the runner | |||
| 80 | + | # loop-mounts the scratch disk of a job below the state | |||
| 81 | + | # directory, and the Docker daemon, in the host's namespace, | |||
| 82 | + | # cannot see a mount made inside the unit's: the bind | |||
| 83 | + | # sources of the container would be empty directories. The | |||
| 84 | + | # micro-VM mode would tolerate them, but one unit serves both | |||
| 85 | + | # modes and a second unit for VM-only hardening is not worth | |||
| 86 | + | # it. test: internal/host TestUnitHasNoMountNamespaceOptions. | |||
| 87 | + | # | |||
| 88 | + | # Delegate=yes The runner does not manage cgroups of its own. The jailer | |||
| 89 | + | # creates the cgroups of the VMs below /sys/fs/cgroup itself | |||
| 90 | + | # (it is root and writes there directly), and Docker has its | |||
| 91 | + | # own. Delegation would hand a subtree to this unit that | |||
| 92 | + | # nothing uses. | |||
| 93 | + | # PrivateNetwork=, RestrictNamespaces=, | |||
| 94 | + | # CapabilityBoundingSet=, MemoryDenyWriteExecute=, SystemCallFilter= | |||
| 95 | + | # The runner creates network namespaces and devices (TAP, | |||
| 96 | + | # veth, bridges) and mounts file systems, enables | |||
| 97 | + | # net.ipv4.ip_forward (the jailer also writes cgroups, /dev/kvm, /dev/net/tun and | |||
| 98 | + | # /dev/loop* are needed), hands capabilities to the jailer and | |||
| 99 | + | # to Docker, and starts Firecracker (which maps guest memory | |||
| 100 | + | # writable and executable). Each of these would break a path | |||
| 101 | + | # that cannot be tested without a rootful host; the | |||
| 102 | + | # boundary of a job is the micro-VM or the container, not | |||
| 103 | + | # this unit. | |||
| 104 | + | # UMask=0077 The state directory is made 0700 by the runner itself, and the | |||
| 105 | + | # drivers create directories that the job's user must be able | |||
| 106 | + | # to enter (the scratch disk's work and tmp directories are | |||
| 107 | + | # made with mode 0755): a restrictive umask would turn them | |||
| 108 | + | # into root-only directories, which cannot be tested without | |||
| 109 | + | # a rootful host. | |||
M go.mod +4 -2
| @@ -3,8 +3,12 @@ module bnema.dev/ermite-runner | |||||
| 3 | go 1.27.1 | 3 | go 1.27.1 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | + | bnema.dev/ermite-actions v0.0.0-20261012165742-b6c4c3a2f7ff | |||
| 7 | + | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c | |||
| 8 | + | connectrpc.com/connect v1.21.0 | |||
| 6 | github.com/bnema/zerowrap v1.4.1 | 9 | github.com/bnema/zerowrap v1.4.1 | ||
| 7 | github.com/containerd/errdefs v1.0.0 | 10 | github.com/containerd/errdefs v1.0.0 | ||
| 11 | + | github.com/google/uuid v1.6.0 | |||
| 8 | github.com/moby/moby/api v1.56.1 | 12 | github.com/moby/moby/api v1.56.1 | ||
| 9 | github.com/moby/moby/client v0.6.2 | 13 | github.com/moby/moby/client v0.6.2 | ||
| 10 | golang.org/x/sys v0.49.0 | 14 | golang.org/x/sys v0.49.0 | ||
| @@ -28,7 +32,6 @@ require ( | |||||
| 28 | buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect | 32 | buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect | ||
| 29 | cel.dev/cel-go v0.32.0 // indirect | 33 | cel.dev/cel-go v0.32.0 // indirect | ||
| 30 | cel.dev/expr v0.25.3 // indirect | 34 | cel.dev/expr v0.25.3 // indirect | ||
| 31 | - | connectrpc.com/connect v1.20.0 // indirect | |||
| 32 | connectrpc.com/otelconnect v0.9.0 // indirect | 35 | connectrpc.com/otelconnect v0.9.0 // indirect | ||
| 33 | github.com/Microsoft/go-winio v0.6.2 // indirect | 36 | github.com/Microsoft/go-winio v0.6.2 // indirect | ||
| 34 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect | 37 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect | ||
| @@ -49,7 +52,6 @@ require ( | |||||
| 49 | github.com/go-logr/stdr v1.2.2 // indirect | 52 | github.com/go-logr/stdr v1.2.2 // indirect | ||
| 50 | github.com/gofrs/flock v0.13.1 // indirect | 53 | github.com/gofrs/flock v0.13.1 // indirect | ||
| 51 | github.com/google/go-containerregistry v0.22.1 // indirect | 54 | github.com/google/go-containerregistry v0.22.1 // indirect | ||
| 52 | - | github.com/google/uuid v1.6.0 // indirect | |||
| 53 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 55 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 54 | github.com/jdx/go-netrc v1.0.0 // indirect | 56 | github.com/jdx/go-netrc v1.0.0 // indirect | ||
| 55 | github.com/klauspost/compress v1.20.0 // indirect | 57 | github.com/klauspost/compress v1.20.0 // indirect | ||
M go.sum +6 -2 generated file: collapsed
A host_cmd.go +134 -0
| @@ -0,0 +1,134 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "flag" | |||
| 9 | + | "fmt" | |||
| 10 | + | "os" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/zerowrap" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite-runner/internal/host" | |||
| 17 | + | "bnema.dev/ermite-runner/internal/sandbox" | |||
| 18 | + | "bnema.dev/ermite-runner/internal/sandbox/drivers" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // loadHostConfig reads the sandbox settings and the host settings together, so | |||
| 22 | + | // that the state directory and the capacity are the same for both. | |||
| 23 | + | func loadHostConfig() (sandbox.Config, host.Config, error) { | |||
| 24 | + | scfg, err := sandbox.LoadConfig(os.Getenv) | |||
| 25 | + | if err != nil { | |||
| 26 | + | return scfg, host.Config{}, err | |||
| 27 | + | } | |||
| 28 | + | name, _ := os.Hostname() | |||
| 29 | + | hcfg, err := host.LoadConfig(os.Getenv, name, scfg.StateDir, scfg.Capacity, version) | |||
| 30 | + | return scfg, hcfg, err | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func buildDriver(ctx context.Context, scfg sandbox.Config) (sandbox.Driver, error) { | |||
| 34 | + | return sandbox.Select(ctx, scfg, drivers.Factories(), sandbox.Prober{}) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // registerCmd is `ermite-runner register [--token-stdin] [--token T] [--replace]`: | |||
| 38 | + | // it chooses the isolation mode like `run` will, registers the runner with it, | |||
| 39 | + | // and stores the runner token (mode 0600) in the state directory. The register | |||
| 40 | + | // token is read from standard input (--token-stdin) or from | |||
| 41 | + | // ERMITE_RUNNER_REGISTER_TOKEN; --token is accepted but the arguments of a | |||
| 42 | + | // process are visible to every user of the host. | |||
| 43 | + | func registerCmd(ctx context.Context, args []string) int { | |||
| 44 | + | fs := flag.NewFlagSet("register", flag.ContinueOnError) | |||
| 45 | + | token := fs.String("token", "", "token with the runner:register scope (visible in the process list: prefer $"+host.EnvRegisterToken+" or --token-stdin)") | |||
| 46 | + | fromStdin := fs.Bool("token-stdin", false, "read the token from standard input") | |||
| 47 | + | replace := fs.Bool("replace", false, "replace a runner of the same name") | |||
| 48 | + | if err := fs.Parse(args); err != nil { | |||
| 49 | + | return 2 | |||
| 50 | + | } | |||
| 51 | + | tok, err := host.ResolveRegisterToken(*token, *fromStdin, os.Stdin, os.Getenv) | |||
| 52 | + | if err != nil { | |||
| 53 | + | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |||
| 54 | + | return 2 | |||
| 55 | + | } | |||
| 56 | + | *token = tok | |||
| 57 | + | scfg, hcfg, err := loadHostConfig() | |||
| 58 | + | if err != nil { | |||
| 59 | + | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |||
| 60 | + | return 2 | |||
| 61 | + | } | |||
| 62 | + | if hcfg.URL == "" { | |||
| 63 | + | fmt.Fprintf(os.Stderr, "ermite-runner: %s is not set\n", host.EnvURL) | |||
| 64 | + | return 2 | |||
| 65 | + | } | |||
| 66 | + | drv, err := buildDriver(ctx, scfg) | |||
| 67 | + | if err != nil { | |||
| 68 | + | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |||
| 69 | + | return 1 | |||
| 70 | + | } | |||
| 71 | + | iso := drv.Isolation() | |||
| 72 | + | _ = drv.Close() | |||
| 73 | + | ||||
| 74 | + | client := hcfg.Client(hcfg.URL, *token) | |||
| 75 | + | st, err := host.Register(ctx, hcfg, client, iso, *replace) | |||
| 76 | + | if err != nil { | |||
| 77 | + | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |||
| 78 | + | return 1 | |||
| 79 | + | } | |||
| 80 | + | fmt.Printf("registered runner %q (%s) with isolation %s; token stored in %s\n", st.Name, st.RunnerID, iso, host.StateFile(hcfg.StateDir)) | |||
| 81 | + | return 0 | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // runCmd is `ermite-runner run`: poll ermite for jobs and run each one in its | |||
| 85 | + | // own sandbox until SIGINT or SIGTERM, then cancel the running jobs with | |||
| 86 | + | // runner_shutdown and report them. | |||
| 87 | + | func runCmd(ctx context.Context) int { | |||
| 88 | + | log := zerowrap.FromCtx(ctx) | |||
| 89 | + | scfg, hcfg, err := loadHostConfig() | |||
| 90 | + | if err != nil { | |||
| 91 | + | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |||
| 92 | + | return 2 | |||
| 93 | + | } | |||
| 94 | + | st, err := host.LoadState(hcfg.StateDir) | |||
| 95 | + | if err != nil { | |||
| 96 | + | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |||
| 97 | + | return 2 | |||
| 98 | + | } | |||
| 99 | + | url := hcfg.URL | |||
| 100 | + | if url == "" { | |||
| 101 | + | url = st.URL | |||
| 102 | + | } | |||
| 103 | + | if url == "" { | |||
| 104 | + | fmt.Fprintf(os.Stderr, "ermite-runner: %s is not set\n", host.EnvURL) | |||
| 105 | + | return 2 | |||
| 106 | + | } | |||
| 107 | + | drv, err := buildDriver(ctx, scfg) | |||
| 108 | + | if err != nil { | |||
| 109 | + | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |||
| 110 | + | return 1 | |||
| 111 | + | } | |||
| 112 | + | defer func() { _ = drv.Close() }() | |||
| 113 | + | log.Info().Str("runner", st.Name).Str("isolation", string(drv.Isolation())).Int("capacity", scfg.Capacity).Msg("runner started") | |||
| 114 | + | ||||
| 115 | + | r := &host.Runner{ | |||
| 116 | + | Cfg: hcfg, | |||
| 117 | + | Driver: drv, | |||
| 118 | + | Poll: hcfg.Client(url, st.RunnerToken), | |||
| 119 | + | Job: func(tok string) ermiteconnect.RunnerServiceClient { | |||
| 120 | + | return hcfg.Client(url, tok) | |||
| 121 | + | }, | |||
| 122 | + | } | |||
| 123 | + | r.Cfg.URL = url | |||
| 124 | + | if err := r.Run(ctx); err != nil { | |||
| 125 | + | if errors.Is(err, host.ErrRemoved) { | |||
| 126 | + | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |||
| 127 | + | return 3 | |||
| 128 | + | } | |||
| 129 | + | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |||
| 130 | + | return 1 | |||
| 131 | + | } | |||
| 132 | + | log.Info().Msg("runner stopped") | |||
| 133 | + | return 0 | |||
| 134 | + | } | |||
M images/ubuntu-24.04/Dockerfile +1 -1
| @@ -12,7 +12,7 @@ ARG DEBIAN_FRONTEND=noninteractive | |||||
| 12 | RUN apt-get update \ | 12 | RUN apt-get update \ | ||
| 13 | && apt-get install -y --no-install-recommends \ | 13 | && apt-get install -y --no-install-recommends \ | ||
| 14 | bash build-essential ca-certificates curl e2fsprogs git gnupg iproute2 \ | 14 | bash build-essential ca-certificates curl e2fsprogs git gnupg iproute2 \ | ||
| 15 | - | iptables jq kmod procps python3 sudo tzdata unzip xz-utils zip \ | 15 | + | iptables jq kmod procps python-is-python3 python3 sudo tzdata unzip xz-utils zip \ |
| 16 | && rm -rf /var/lib/apt/lists/* | 16 | && rm -rf /var/lib/apt/lists/* | ||
| 17 | 17 | ||||
| 18 | # Docker Engine from Docker's apt repository (the job gets its own daemon). | 18 | # Docker Engine from Docker's apt repository (the job gets its own daemon). | ||
A internal/e2e/chain_test.go +328 -0
| @@ -0,0 +1,328 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package e2e | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "fmt" | |||
| 9 | + | "net/http" | |||
| 10 | + | "os" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "regexp" | |||
| 13 | + | "sort" | |||
| 14 | + | "strings" | |||
| 15 | + | "testing" | |||
| 16 | + | "time" | |||
| 17 | + | ||||
| 18 | + | "github.com/bnema/zerowrap" | |||
| 19 | + | ||||
| 20 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 21 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 22 | + | ||||
| 23 | + | "bnema.dev/ermite-runner/internal/host" | |||
| 24 | + | "bnema.dev/ermite-runner/internal/host/hosttest" | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // The chain test runs the whole path of a job: a fake RunnerService hands out a | |||
| 28 | + | // corpus job, the real host loop starts a real sandbox (the runner image in a | |||
| 29 | + | // container), the real executor runs the steps, and the host reports to the | |||
| 30 | + | // fake with AppendLog, UpdateJob and CompleteJob. What the fake stored is | |||
| 31 | + | // compared with the recording of the same job made on github.com. | |||
| 32 | + | // | |||
| 33 | + | // It needs a checkout of ermite-conformance (corpus/ and recordings/), found | |||
| 34 | + | // through ERMITE_CONFORMANCE_DIR or as a sibling of this repository, and is | |||
| 35 | + | // read-only on it. | |||
| 36 | + | ||||
| 37 | + | func conformanceDir(t *testing.T) string { | |||
| 38 | + | t.Helper() | |||
| 39 | + | cands := []string{os.Getenv("ERMITE_CONFORMANCE_DIR")} | |||
| 40 | + | wd, _ := os.Getwd() | |||
| 41 | + | for d := wd; d != "/" && d != "."; d = filepath.Dir(d) { | |||
| 42 | + | cands = append(cands, filepath.Join(d, "ermite-conformance", ".worktrees", "corpus"), filepath.Join(d, "ermite-conformance")) | |||
| 43 | + | } | |||
| 44 | + | for _, c := range cands { | |||
| 45 | + | if c == "" { | |||
| 46 | + | continue | |||
| 47 | + | } | |||
| 48 | + | if st, err := os.Stat(filepath.Join(c, "recordings")); err == nil && st.IsDir() { | |||
| 49 | + | return c | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | t.Skip("ermite-conformance checkout not found (set ERMITE_CONFORMANCE_DIR)") | |||
| 53 | + | return "" | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | type recStep struct { | |||
| 57 | + | Name string `json:"name"` | |||
| 58 | + | Conclusion string `json:"conclusion"` | |||
| 59 | + | Log []string `json:"log"` | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | type recJob struct { | |||
| 63 | + | Name string `json:"name"` | |||
| 64 | + | Conclusion string `json:"conclusion"` | |||
| 65 | + | Steps []recStep `json:"steps"` | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | type recording struct { | |||
| 69 | + | Pushes []struct { | |||
| 70 | + | Runs []struct { | |||
| 71 | + | Jobs []recJob `json:"jobs"` | |||
| 72 | + | } `json:"runs"` | |||
| 73 | + | } `json:"pushes"` | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | var ( | |||
| 77 | + | uuidRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) | |||
| 78 | + | // Machine-dependent parts of the recordings, like the normalisation rules of | |||
| 79 | + | // ermite-conformance: random script names and the repository name. | |||
| 80 | + | repoRe = regexp.MustCompile(`/work/hello/hello`) | |||
| 81 | + | ) | |||
| 82 | + | ||||
| 83 | + | func normalize(l string) string { | |||
| 84 | + | l = repoRe.ReplaceAllString(l, "/work/<repo>/<repo>") | |||
| 85 | + | return uuidRe.ReplaceAllString(l, "<uuid>") | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func chainContexts(job string) string { | |||
| 89 | + | return fmt.Sprintf(`{"github":{"repository":"octo/hello","repository_owner":"octo","run_id":"1","run_number":"1","run_attempt":"1", | |||
| 90 | + | "ref":"refs/heads/scn/x/r1","ref_name":"scn/x/r1","ref_type":"branch","ref_protected":false,"sha":"0123456789abcdef0123456789abcdef01234567", | |||
| 91 | + | "event_name":"push","event":{"ref":"refs/heads/scn/x/r1"},"job":%q,"workflow":"w","actor":"octo","triggering_actor":"octo", | |||
| 92 | + | "server_url":"https://ermite.example","retention_days":"90"}}`, job) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestChainCorpus(t *testing.T) { | |||
| 96 | + | image := needE2E(t) | |||
| 97 | + | dir := conformanceDir(t) | |||
| 98 | + | drv := newDriver(t, image) | |||
| 99 | + | ||||
| 100 | + | // scenario -> jobs to run (jobs with `needs` depend on the server and are left out) | |||
| 101 | + | scenarios := []struct { | |||
| 102 | + | name string | |||
| 103 | + | jobs []string | |||
| 104 | + | }{ | |||
| 105 | + | {"env-precedence", []string{"main", "other"}}, | |||
| 106 | + | {"env-expressions", []string{"main"}}, | |||
| 107 | + | {"out-github-env", []string{"main"}}, | |||
| 108 | + | {"out-github-env-reserved", []string{"node-options", "github-prefix"}}, | |||
| 109 | + | {"out-github-path", []string{"main"}}, | |||
| 110 | + | {"out-step-outputs", []string{"main"}}, | |||
| 111 | + | {"ctx-steps", []string{"main"}}, | |||
| 112 | + | {"if-step-conditions", []string{"main"}}, | |||
| 113 | + | {"shell-builtin", []string{"main"}}, | |||
| 114 | + | {"shell-custom", []string{"main"}}, | |||
| 115 | + | {"shell-exit-codes", nil}, // every job of the workflow | |||
| 116 | + | {"shell-working-directory", nil}, | |||
| 117 | + | {"out-job-outputs", []string{"producer"}}, | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | srv := hosttest.New() | |||
| 121 | + | t.Cleanup(srv.Close) | |||
| 122 | + | ||||
| 123 | + | type want struct { | |||
| 124 | + | scenario string | |||
| 125 | + | job recJob | |||
| 126 | + | } | |||
| 127 | + | wants := map[string]want{} | |||
| 128 | + | var queue []*ermite.Job | |||
| 129 | + | for _, sc := range scenarios { | |||
| 130 | + | files, _ := filepath.Glob(filepath.Join(dir, "corpus", sc.name, "tree", ".github", "workflows", "*.yml")) | |||
| 131 | + | if len(files) != 1 { | |||
| 132 | + | t.Fatalf("%s: workflow files %v", sc.name, files) | |||
| 133 | + | } | |||
| 134 | + | yaml, err := os.ReadFile(files[0]) | |||
| 135 | + | if err != nil { | |||
| 136 | + | t.Fatal(err) | |||
| 137 | + | } | |||
| 138 | + | raw, err := os.ReadFile(filepath.Join(dir, "recordings", sc.name+".json")) | |||
| 139 | + | if err != nil { | |||
| 140 | + | t.Fatal(err) | |||
| 141 | + | } | |||
| 142 | + | var rec recording | |||
| 143 | + | if err := json.Unmarshal(raw, &rec); err != nil { | |||
| 144 | + | t.Fatal(err) | |||
| 145 | + | } | |||
| 146 | + | for _, rj := range rec.Pushes[0].Runs[0].Jobs { | |||
| 147 | + | if sc.jobs != nil && !contains(sc.jobs, rj.Name) { | |||
| 148 | + | continue | |||
| 149 | + | } | |||
| 150 | + | id := sc.name + "/" + rj.Name | |||
| 151 | + | queue = append(queue, &ermite.Job{ | |||
| 152 | + | JobId: id, RunId: 1, Attempt: 1, JobToken: "ermitejob_" + strings.ReplaceAll(id, "/", "_"), | |||
| 153 | + | WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: string(yaml), JobKey: rj.Name, Matrix: "{}", | |||
| 154 | + | Contexts: chainContexts(rj.Name), Repo: "hello", TimeoutMinutes: 10, | |||
| 155 | + | }) | |||
| 156 | + | wants[id] = want{sc.name, rj} | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | srv.Queue(queue...) | |||
| 160 | + | ||||
| 161 | + | r := &host.Runner{ | |||
| 162 | + | Cfg: host.Config{URL: srv.URL, Name: "chain", Capacity: 2, Version: "test"}, | |||
| 163 | + | Driver: drv, | |||
| 164 | + | Poll: host.NewClient(srv.URL, hosttest.RunnerToken, http.DefaultTransport), | |||
| 165 | + | Job: func(tok string) ermiteconnect.RunnerServiceClient { | |||
| 166 | + | return host.NewClient(srv.URL, tok, http.DefaultTransport) | |||
| 167 | + | }, | |||
| 168 | + | Timing: host.Timing{LogFlush: 20 * time.Millisecond, NoJobPause: 100 * time.Millisecond}, | |||
| 169 | + | } | |||
| 170 | + | ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "console", Output: testWriter{t}}))) | |||
| 171 | + | done := make(chan error, 1) | |||
| 172 | + | go func() { done <- r.Run(ctx) }() | |||
| 173 | + | defer func() { | |||
| 174 | + | cancel() | |||
| 175 | + | select { | |||
| 176 | + | case err := <-done: | |||
| 177 | + | if err != nil { | |||
| 178 | + | t.Errorf("Run: %v", err) | |||
| 179 | + | } | |||
| 180 | + | case <-time.After(60 * time.Second): | |||
| 181 | + | t.Error("Run did not stop") | |||
| 182 | + | } | |||
| 183 | + | }() | |||
| 184 | + | ||||
| 185 | + | deadline := time.After(5 * time.Minute) | |||
| 186 | + | for n := 0; n < len(queue); n++ { | |||
| 187 | + | select { | |||
| 188 | + | case <-srv.Done(): | |||
| 189 | + | case <-deadline: | |||
| 190 | + | t.Fatalf("only %d of %d jobs finished", n, len(queue)) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | var ids []string | |||
| 195 | + | for id := range wants { | |||
| 196 | + | ids = append(ids, id) | |||
| 197 | + | } | |||
| 198 | + | sort.Strings(ids) | |||
| 199 | + | for _, id := range ids { | |||
| 200 | + | w := wants[id] | |||
| 201 | + | t.Run(id, func(t *testing.T) { compareJob(t, srv.Result(id), w.job, w.scenario) }) | |||
| 202 | + | } | |||
| 203 | + | for _, e := range srv.Violations() { | |||
| 204 | + | t.Errorf("the fake server saw a contract violation: %s", e) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func contains(l []string, s string) bool { | |||
| 209 | + | for _, x := range l { | |||
| 210 | + | if x == s { | |||
| 211 | + | return true | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | return false | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // compareJob checks the steps, conclusions, outputs and log lines reported for | |||
| 218 | + | // a job against the recording. "Set up job" (runner and image details) and the | |||
| 219 | + | // ermite-dump step that the harness appends to every job are not compared. | |||
| 220 | + | func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string) { | |||
| 221 | + | if got.Complete == nil { | |||
| 222 | + | t.Fatal("the job did not complete") | |||
| 223 | + | } | |||
| 224 | + | // The conclusion of the job as GitHub recorded it, except where the dump | |||
| 225 | + | // step failed on GitHub (it only fails after a failed step in some | |||
| 226 | + | // scenarios and is not part of the job here). | |||
| 227 | + | wantConcl := map[string]ermite.CiConclusion{ | |||
| 228 | + | "success": ermite.CiConclusion_CI_CONCLUSION_SUCCESS, "failure": ermite.CiConclusion_CI_CONCLUSION_FAILURE, | |||
| 229 | + | "cancelled": ermite.CiConclusion_CI_CONCLUSION_CANCELLED, | |||
| 230 | + | } | |||
| 231 | + | jobWant := wantConcl[want.Conclusion] | |||
| 232 | + | for _, s := range want.Steps { | |||
| 233 | + | if s.Name == "ermite-dump" && s.Conclusion != "success" && want.Conclusion == "failure" { | |||
| 234 | + | // the job failed only because of the dump step | |||
| 235 | + | jobWant = ermite.CiConclusion_CI_CONCLUSION_SUCCESS | |||
| 236 | + | for _, o := range want.Steps { | |||
| 237 | + | if o.Name != "ermite-dump" && o.Conclusion == "failure" { | |||
| 238 | + | jobWant = ermite.CiConclusion_CI_CONCLUSION_FAILURE | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | if got.Complete.Conclusion != jobWant { | |||
| 244 | + | t.Errorf("job conclusion %v, want %v", got.Complete.Conclusion, jobWant) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // Steps by number, with their lines. | |||
| 248 | + | byStep := map[int32][]string{} | |||
| 249 | + | for _, l := range got.Lines { | |||
| 250 | + | byStep[l.Step] = append(byStep[l.Step], normalize(l.Text)) | |||
| 251 | + | } | |||
| 252 | + | var steps []int32 | |||
| 253 | + | for n := range got.Steps { | |||
| 254 | + | steps = append(steps, n) | |||
| 255 | + | } | |||
| 256 | + | sort.Slice(steps, func(i, j int) bool { return steps[i] < steps[j] }) | |||
| 257 | + | ||||
| 258 | + | type es = recStep | |||
| 259 | + | var wantSteps, gotSteps []es | |||
| 260 | + | for _, s := range want.Steps { | |||
| 261 | + | if s.Name == "Set up job" || s.Name == "ermite-dump" { | |||
| 262 | + | continue | |||
| 263 | + | } | |||
| 264 | + | wantSteps = append(wantSteps, s) | |||
| 265 | + | } | |||
| 266 | + | conc := map[ermite.CiConclusion]string{ | |||
| 267 | + | ermite.CiConclusion_CI_CONCLUSION_SUCCESS: "success", ermite.CiConclusion_CI_CONCLUSION_FAILURE: "failure", | |||
| 268 | + | ermite.CiConclusion_CI_CONCLUSION_CANCELLED: "cancelled", ermite.CiConclusion_CI_CONCLUSION_SKIPPED: "skipped", | |||
| 269 | + | } | |||
| 270 | + | for _, n := range steps { | |||
| 271 | + | u := got.Steps[n] | |||
| 272 | + | if u.Name == "Set up job" { | |||
| 273 | + | continue | |||
| 274 | + | } | |||
| 275 | + | if u.State != ermite.StepState_STEP_STATE_COMPLETED || u.StartedAt == nil || u.CompletedAt == nil { | |||
| 276 | + | t.Errorf("step %d %q is not a complete snapshot: %v", n, u.Name, u) | |||
| 277 | + | } | |||
| 278 | + | gotSteps = append(gotSteps, es{Name: u.Name, Conclusion: conc[u.Conclusion], Log: byStep[n]}) | |||
| 279 | + | } | |||
| 280 | + | if len(gotSteps) != len(wantSteps) { | |||
| 281 | + | var names []string | |||
| 282 | + | for _, s := range gotSteps { | |||
| 283 | + | names = append(names, s.Name) | |||
| 284 | + | } | |||
| 285 | + | t.Fatalf("steps %q, want %d steps", names, len(wantSteps)) | |||
| 286 | + | } | |||
| 287 | + | for i := range wantSteps { | |||
| 288 | + | w, g := wantSteps[i], gotSteps[i] | |||
| 289 | + | if g.Name != w.Name || g.Conclusion != w.Conclusion { | |||
| 290 | + | t.Errorf("step %d: %q (%s), want %q (%s)", i+1, g.Name, g.Conclusion, w.Name, w.Conclusion) | |||
| 291 | + | continue | |||
| 292 | + | } | |||
| 293 | + | if strings.Join(g.Log, "\n") != strings.Join(w.Log, "\n") { | |||
| 294 | + | t.Errorf("step %q log differs:\n--- got\n%s\n--- want\n%s", w.Name, strings.Join(g.Log, "\n"), strings.Join(w.Log, "\n")) | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | if scenario == "out-job-outputs" { | |||
| 299 | + | checkProducerOutputs(t, got) | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | // checkProducerOutputs compares the outputs of the producer job of | |||
| 304 | + | // out-job-outputs with what the recorded consumer printed from `needs`. | |||
| 305 | + | func checkProducerOutputs(t *testing.T, got hosttest.Result) { | |||
| 306 | + | t.Helper() | |||
| 307 | + | // The values the consumer printed on GitHub (recordings/out-job-outputs.json, | |||
| 308 | + | // step "print needs outputs"): empty outputs are absent. | |||
| 309 | + | want := map[string]string{ | |||
| 310 | + | "composed": "hello-push", "from-env": "env-value", "from-failed": "written-before-failure", | |||
| 311 | + | "overwritten": "second", "simple": "hello", "multi": "line one\nline two", "literal": "fixed-text", | |||
| 312 | + | } | |||
| 313 | + | if len(got.Complete.Outputs) != len(want) { | |||
| 314 | + | t.Errorf("outputs %v, want %v", got.Complete.Outputs, want) | |||
| 315 | + | } | |||
| 316 | + | for k, v := range want { | |||
| 317 | + | if got.Complete.Outputs[k] != v { | |||
| 318 | + | t.Errorf("output %q = %q, want %q", k, got.Complete.Outputs[k], v) | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | type testWriter struct{ t *testing.T } | |||
| 324 | + | ||||
| 325 | + | func (w testWriter) Write(p []byte) (int, error) { | |||
| 326 | + | w.t.Log(strings.TrimRight(string(p), "\n")) | |||
| 327 | + | return len(p), nil | |||
| 328 | + | } | |||
A internal/e2e/e2e_test.go +271 -0
| @@ -0,0 +1,271 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | // Package e2e runs jobs through the real executor in a real sandbox. The tests | |||
| 4 | + | // are opt-in because they need Docker (and, for the VM, KVM and Firecracker): | |||
| 5 | + | // | |||
| 6 | + | // ERMITE_RUNNER_IT_E2E=1 ERMITE_RUNNER_TEST_IMAGE=ermite-runner-ubuntu-24.04:dev go test ./internal/e2e | |||
| 7 | + | // | |||
| 8 | + | // On a workstation with a rootless Docker daemon the container driver is used | |||
| 9 | + | // with UnsafeAllowRootlessForTests (a test-only option): the jobs run as in production except | |||
| 10 | + | // that the nested dockerd of the image cannot start, which these tests do not | |||
| 11 | + | // need. The production paths that need root (jailer, rootful Docker, sysbox) | |||
| 12 | + | // are not covered here. | |||
| 13 | + | package e2e | |||
| 14 | + | ||||
| 15 | + | import ( | |||
| 16 | + | "context" | |||
| 17 | + | "fmt" | |||
| 18 | + | "io" | |||
| 19 | + | "os" | |||
| 20 | + | "os/exec" | |||
| 21 | + | "path/filepath" | |||
| 22 | + | "strings" | |||
| 23 | + | "sync" | |||
| 24 | + | "testing" | |||
| 25 | + | "time" | |||
| 26 | + | ||||
| 27 | + | "bnema.dev/ermite-runner/internal/execstream" | |||
| 28 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 29 | + | "bnema.dev/ermite-runner/internal/sandbox" | |||
| 30 | + | "bnema.dev/ermite-runner/internal/sandbox/container" | |||
| 31 | + | "bnema.dev/ermite-runner/internal/sandbox/disk" | |||
| 32 | + | "bnema.dev/ermite-runner/internal/sandbox/sandboxtest" | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | func needE2E(t *testing.T) string { | |||
| 36 | + | t.Helper() | |||
| 37 | + | if os.Getenv("ERMITE_RUNNER_IT_E2E") != "1" { | |||
| 38 | + | t.Skip("set ERMITE_RUNNER_IT_E2E=1 and ERMITE_RUNNER_TEST_IMAGE (needs Docker; see the package comment)") | |||
| 39 | + | } | |||
| 40 | + | image := os.Getenv("ERMITE_RUNNER_TEST_IMAGE") | |||
| 41 | + | if image == "" { | |||
| 42 | + | t.Fatal("set ERMITE_RUNNER_TEST_IMAGE to the runner image") | |||
| 43 | + | } | |||
| 44 | + | if _, err := exec.LookPath("docker"); err != nil { | |||
| 45 | + | t.Skip("docker CLI not found") | |||
| 46 | + | } | |||
| 47 | + | return image | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // buildRunner compiles the static runner binary that is attached to the sandbox. | |||
| 51 | + | func buildRunner(t *testing.T, dir string) string { | |||
| 52 | + | t.Helper() | |||
| 53 | + | exe := filepath.Join(dir, "ermite-runner") | |||
| 54 | + | root, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}").Output() | |||
| 55 | + | if err != nil { | |||
| 56 | + | t.Fatal(err) | |||
| 57 | + | } | |||
| 58 | + | cmd := exec.Command("go", "build", "-o", exe, "bnema.dev/ermite-runner") | |||
| 59 | + | cmd.Env = append(os.Environ(), "CGO_ENABLED=0") | |||
| 60 | + | cmd.Dir = strings.TrimSpace(string(root)) | |||
| 61 | + | if out, err := cmd.CombinedOutput(); err != nil { | |||
| 62 | + | t.Fatalf("build the runner: %v\n%s", err, out) | |||
| 63 | + | } | |||
| 64 | + | return exe | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // newDriver starts the container driver with test-only settings: no firewall | |||
| 68 | + | // plumbing, no loop mounts, a rootless daemon allowed. | |||
| 69 | + | func newDriver(t *testing.T, image string) sandbox.Driver { | |||
| 70 | + | t.Helper() | |||
| 71 | + | dir := t.TempDir() | |||
| 72 | + | cfg := sandbox.Config{ | |||
| 73 | + | Isolation: sandbox.Container, StateDir: filepath.Join(dir, "state"), Capacity: 2, Image: image, | |||
| 74 | + | JobCPUs: 2, JobMemoryMiB: 1024, JobDiskMiB: 2048, UIDBase: 900000, | |||
| 75 | + | Network: netPrefix("10.214.0.0/16"), Executable: buildRunner(t, dir), DNSPort: 0, | |||
| 76 | + | LockFile: filepath.Join(dir, "host.lock"), JobPids: 4096, | |||
| 77 | + | } | |||
| 78 | + | empty := filepath.Join(dir, "mountinfo") | |||
| 79 | + | _ = os.WriteFile(empty, nil, 0o644) | |||
| 80 | + | run := rootlessMountRunner{image: image, mountinfo: empty, mu: &sync.Mutex{}} | |||
| 81 | + | d, err := container.New(context.Background(), cfg, container.Options{ | |||
| 82 | + | Net: sandboxtest.NewPlumbing(), UnsafeAllowRootlessForTests: true, ConnectTimeout: 90 * time.Second, Run: run, | |||
| 83 | + | Mounter: &disk.Mounter{Run: run, Mountinfo: empty}, | |||
| 84 | + | }) | |||
| 85 | + | if err != nil { | |||
| 86 | + | t.Fatal(err) | |||
| 87 | + | } | |||
| 88 | + | t.Cleanup(func() { _ = d.Close() }) | |||
| 89 | + | if err := d.Reap(context.Background()); err != nil { | |||
| 90 | + | t.Fatal(err) | |||
| 91 | + | } | |||
| 92 | + | return d | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // rootlessMountRunner stands in for mount and umount, which need root. The | |||
| 96 | + | // scratch directory stays a plain directory; "mount" records it in a fake | |||
| 97 | + | // mountinfo file so that the driver believes it is mounted and calls "umount" | |||
| 98 | + | // at the end. On a rootless daemon the files that the runner user (uid 1001 in | |||
| 99 | + | // the container) creates belong to a subordinate uid that the host user cannot | |||
| 100 | + | // delete, so "umount" empties the directory from inside a container, where | |||
| 101 | + | // root can. With the real loop mount the scratch image is simply unmounted and | |||
| 102 | + | // deleted. | |||
| 103 | + | type rootlessMountRunner struct { | |||
| 104 | + | image string | |||
| 105 | + | mountinfo string | |||
| 106 | + | mu *sync.Mutex | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (r rootlessMountRunner) Run(ctx context.Context, in io.Reader, name string, args ...string) ([]byte, error) { | |||
| 110 | + | switch name { | |||
| 111 | + | case "mount": | |||
| 112 | + | r.mu.Lock() | |||
| 113 | + | defer r.mu.Unlock() | |||
| 114 | + | f, err := os.OpenFile(r.mountinfo, os.O_APPEND|os.O_WRONLY, 0o644) | |||
| 115 | + | if err != nil { | |||
| 116 | + | return nil, err | |||
| 117 | + | } | |||
| 118 | + | defer func() { _ = f.Close() }() | |||
| 119 | + | _, err = fmt.Fprintf(f, "1 1 0:1 / %s rw - ext4 fake rw\n", args[len(args)-1]) | |||
| 120 | + | return nil, err | |||
| 121 | + | case "umount": | |||
| 122 | + | dir := args[len(args)-1] | |||
| 123 | + | out, err := exec.CommandContext(ctx, "docker", "run", "--rm", "--entrypoint", "sh", "-v", dir+":/d", r.image, | |||
| 124 | + | "-c", "rm -rf /d/* /d/.[!.]* 2>/dev/null; true").CombinedOutput() | |||
| 125 | + | r.mu.Lock() | |||
| 126 | + | defer r.mu.Unlock() | |||
| 127 | + | b, _ := os.ReadFile(r.mountinfo) | |||
| 128 | + | var keep []string | |||
| 129 | + | for _, l := range strings.Split(string(b), "\n") { | |||
| 130 | + | if l != "" && !strings.Contains(l, " "+dir+" ") { | |||
| 131 | + | keep = append(keep, l) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | _ = os.WriteFile(r.mountinfo, []byte(strings.Join(keep, "\n")+"\n"), 0o644) | |||
| 135 | + | return out, err | |||
| 136 | + | } | |||
| 137 | + | return sandboxtest.NoMountRunner{}.Run(ctx, in, name, args...) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // session is the outcome of one job driven through the stream. | |||
| 141 | + | type session struct { | |||
| 142 | + | Steps []string | |||
| 143 | + | Concl map[string]execpb.StepConclusion | |||
| 144 | + | Logs []string // text of every log line, in order | |||
| 145 | + | LogStep []int32 | |||
| 146 | + | Result *execpb.JobResult | |||
| 147 | + | Exit *execpb.Exit | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // runJob starts a sandbox, drives the job and stops the sandbox. | |||
| 151 | + | func runJob(t *testing.T, d sandbox.Driver, spec *execpb.JobSpec, cancel <-chan *execpb.Cancel) *session { | |||
| 152 | + | t.Helper() | |||
| 153 | + | sb, err := d.New() | |||
| 154 | + | if err != nil { | |||
| 155 | + | t.Fatal(err) | |||
| 156 | + | } | |||
| 157 | + | defer func() { | |||
| 158 | + | if err := sb.Stop(context.Background()); err != nil { | |||
| 159 | + | t.Errorf("stopping the sandbox: %v", err) | |||
| 160 | + | } | |||
| 161 | + | }() | |||
| 162 | + | conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "e2e"}) | |||
| 163 | + | if err != nil { | |||
| 164 | + | t.Fatal(err) | |||
| 165 | + | } | |||
| 166 | + | s := &session{Concl: map[string]execpb.StepConclusion{}} | |||
| 167 | + | _, exit, err := execstream.DriveJob(conn, &execpb.Run{Job: spec}, cancel, execstream.JobHandler{ | |||
| 168 | + | StepStarted: func(x *execpb.StepStarted) { s.Steps = append(s.Steps, x.Name) }, | |||
| 169 | + | StepCompleted: func(x *execpb.StepCompleted) { | |||
| 170 | + | s.Concl[x.Name] = x.Conclusion | |||
| 171 | + | }, | |||
| 172 | + | Log: func(l *execpb.Log) { s.Logs = append(s.Logs, l.Text); s.LogStep = append(s.LogStep, l.Step) }, | |||
| 173 | + | JobResult: func(r *execpb.JobResult) { s.Result = r }, | |||
| 174 | + | }) | |||
| 175 | + | if err != nil { | |||
| 176 | + | t.Fatalf("driving the job: %v", err) | |||
| 177 | + | } | |||
| 178 | + | s.Exit = exit | |||
| 179 | + | return s | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func jobSpec(yaml string) *execpb.JobSpec { | |||
| 183 | + | return &execpb.JobSpec{ | |||
| 184 | + | WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello", | |||
| 185 | + | Contexts: `{"github":{"repository":"octo/hello","repository_owner":"octo","event_name":"push","job":"j","ref":"refs/heads/main","ref_name":"main","ref_type":"branch","sha":"0123456789abcdef0123456789abcdef01234567","run_id":"1","run_number":"1","run_attempt":"1","server_url":"https://ermite.example","workflow":"w","actor":"octo","event":{"ref":"refs/heads/main"}}}`, | |||
| 186 | + | Matrix: "{}", RunnerName: "e2e", RunnerVersion: "test", RunnerEnvironment: "self-hosted", TimeoutMinutes: 10, | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func TestContainerRunsAJobAsTheRunnerUser(t *testing.T) { | |||
| 191 | + | image := needE2E(t) | |||
| 192 | + | d := newDriver(t, image) | |||
| 193 | + | s := runJob(t, d, jobSpec(`on: push | |||
| 194 | + | jobs: | |||
| 195 | + | j: | |||
| 196 | + | runs-on: x | |||
| 197 | + | outputs: | |||
| 198 | + | who: ${{ steps.id.outputs.who }} | |||
| 199 | + | steps: | |||
| 200 | + | - id: id | |||
| 201 | + | run: | | |||
| 202 | + | echo "who=$(id -un):$(id -u):$(id -g)" >> "$GITHUB_OUTPUT" | |||
| 203 | + | echo "home=$HOME pwd=$PWD" | |||
| 204 | + | echo "ws=$GITHUB_WORKSPACE temp=$RUNNER_TEMP tool=$RUNNER_TOOL_CACHE" | |||
| 205 | + | touch "$RUNNER_TEMP/t" "$GITHUB_WORKSPACE/w" "$HOME/h" | |||
| 206 | + | echo "script=$0" | |||
| 207 | + | - name: env and path | |||
| 208 | + | run: | | |||
| 209 | + | mkdir -p "$RUNNER_TEMP/bin" && printf '#!/bin/sh\necho from-path\n' > "$RUNNER_TEMP/bin/mytool" && chmod +x "$RUNNER_TEMP/bin/mytool" | |||
| 210 | + | echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" | |||
| 211 | + | echo "FOO=bar" >> "$GITHUB_ENV" | |||
| 212 | + | - run: mytool; echo "FOO=$FOO CI=$CI GITHUB_ACTIONS=$GITHUB_ACTIONS ERMITE_CONNECT=${ERMITE_CONNECT-unset}" | |||
| 213 | + | - name: python | |||
| 214 | + | shell: python3 {0} | |||
| 215 | + | run: print("py", 6*7) | |||
| 216 | + | `), nil) | |||
| 217 | + | if s.Result == nil || s.Result.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS || s.Exit.Code != 0 { | |||
| 218 | + | t.Fatalf("result %v exit %v\n%s", s.Result, s.Exit, strings.Join(s.Logs, "\n")) | |||
| 219 | + | } | |||
| 220 | + | if got := s.Result.Outputs["who"]; got != "runner:1001:1001" { | |||
| 221 | + | t.Errorf("steps run as %q, want runner:1001:1001", got) | |||
| 222 | + | } | |||
| 223 | + | all := strings.Join(s.Logs, "\n") | |||
| 224 | + | for _, want := range []string{ | |||
| 225 | + | "home=/home/runner pwd=/home/runner/work/hello/hello", | |||
| 226 | + | "ws=/home/runner/work/hello/hello temp=/home/runner/work/_temp tool=/opt/hostedtoolcache", | |||
| 227 | + | "from-path", "FOO=bar CI=true GITHUB_ACTIONS=true ERMITE_CONNECT=unset", "py 42", | |||
| 228 | + | } { | |||
| 229 | + | if !strings.Contains(all, want) { | |||
| 230 | + | t.Errorf("log lacks %q\n%s", want, all) | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | if !strings.HasPrefix(strings.Join(s.Steps, "|"), "Set up job|Run echo") || s.Steps[len(s.Steps)-1] != "Complete job" { | |||
| 234 | + | t.Errorf("steps %v", s.Steps) | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func TestContainerCancelStopsTheJobAndOrphans(t *testing.T) { | |||
| 239 | + | image := needE2E(t) | |||
| 240 | + | d := newDriver(t, image) | |||
| 241 | + | cancel := make(chan *execpb.Cancel, 1) | |||
| 242 | + | time.AfterFunc(4*time.Second, func() { cancel <- &execpb.Cancel{Reason: "test", Kind: execpb.CancelKind_CANCEL_KIND_USER} }) | |||
| 243 | + | start := time.Now() | |||
| 244 | + | s := runJob(t, d, jobSpec(`on: push | |||
| 245 | + | jobs: | |||
| 246 | + | j: | |||
| 247 | + | runs-on: x | |||
| 248 | + | steps: | |||
| 249 | + | - run: | | |||
| 250 | + | (sleep 300 &) | |||
| 251 | + | trap 'echo got-int; exit 130' INT | |||
| 252 | + | echo ready | |||
| 253 | + | while :; do sleep 0.2; done | |||
| 254 | + | - if: always() | |||
| 255 | + | run: echo cleanup-ran | |||
| 256 | + | `), cancel) | |||
| 257 | + | if s.Result == nil || s.Result.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_CANCELLED { | |||
| 258 | + | t.Fatalf("result %v\n%s", s.Result, strings.Join(s.Logs, "\n")) | |||
| 259 | + | } | |||
| 260 | + | all := strings.Join(s.Logs, "\n") | |||
| 261 | + | if !strings.Contains(all, "got-int") || !strings.Contains(all, "cleanup-ran") { | |||
| 262 | + | t.Errorf("SIGINT or the always() step missing:\n%s", all) | |||
| 263 | + | } | |||
| 264 | + | // "Complete job" kills the background sleep that the step left behind. | |||
| 265 | + | if !strings.Contains(all, "Terminate orphan process: pid (") || !strings.Contains(all, ") (sleep)") { | |||
| 266 | + | t.Errorf("orphan cleanup missing:\n%s", all) | |||
| 267 | + | } | |||
| 268 | + | if time.Since(start) > 40*time.Second { | |||
| 269 | + | t.Errorf("took %v", time.Since(start)) | |||
| 270 | + | } | |||
| 271 | + | } | |||
A internal/e2e/util_test.go +7 -0
| @@ -0,0 +1,7 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package e2e | |||
| 4 | + | ||||
| 5 | + | import "net/netip" | |||
| 6 | + | ||||
| 7 | + | func netPrefix(s string) netip.Prefix { return netip.MustParsePrefix(s) } | |||
M internal/execstream/exec.proto +85 -2
| @@ -21,6 +21,10 @@ message Frame { | |||||
| 21 | Output output = 3; // executor -> host | 21 | Output output = 3; // executor -> host | ||
| 22 | Exit exit = 4; // executor -> host, last frame | 22 | Exit exit = 4; // executor -> host, last frame | ||
| 23 | Cancel cancel = 5; // host -> executor | 23 | Cancel cancel = 5; // host -> executor | ||
| 24 | + | StepStarted step_started = 6; // executor -> host | |||
| 25 | + | StepCompleted step_completed = 7; // executor -> host | |||
| 26 | + | Log log = 8; // executor -> host | |||
| 27 | + | JobResult job_result = 9; // executor -> host, before Exit | |||
| 24 | } | 28 | } | ||
| 25 | } | 29 | } | ||
| 26 | 30 | ||||
| @@ -30,11 +34,38 @@ message Hello { | |||||
| 30 | string hostname = 3; | 34 | string hostname = 3; | ||
| 31 | } | 35 | } | ||
| 32 | 36 | ||||
| 33 | - | // Run asks the executor to run a script. CI-1 stub executor: the script is run | 37 | + | // Run asks the executor to run a script or a job. Without job it is the stub |
| 34 | - | // with `sh -c`; an empty script means the built-in diagnostics script. | 38 | + | // executor of sandbox-check: the script is run with `sh -c`; an empty script |
| 39 | + | // means the built-in diagnostics script. With job the executor runs the job | |||
| 40 | + | // like a GitHub Actions runner and reports it with StepStarted, Log, | |||
| 41 | + | // StepCompleted and JobResult frames, then Exit. | |||
| 35 | message Run { | 42 | message Run { | ||
| 36 | string script = 1; | 43 | string script = 1; | ||
| 37 | map<string, string> env = 2; | 44 | map<string, string> env = 2; | ||
| 45 | + | JobSpec job = 3; | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // JobSpec is the job to run: the fields of the job that ermite handed to the | |||
| 49 | + | // host process, minus the tokens (the sandbox never sees them). | |||
| 50 | + | message JobSpec { | |||
| 51 | + | string workflow_path = 1; | |||
| 52 | + | string workflow_yaml = 2; | |||
| 53 | + | string job_key = 3; | |||
| 54 | + | string matrix = 4; // JSON object | |||
| 55 | + | string contexts = 5; // JSON object | |||
| 56 | + | string repo = 6; // repository name | |||
| 57 | + | int32 timeout_minutes = 7; | |||
| 58 | + | string runner_name = 8; | |||
| 59 | + | string runner_version = 9; | |||
| 60 | + | // runner_environment is the runner.environment value ("self-hosted"). | |||
| 61 | + | string runner_environment = 10; | |||
| 62 | + | bool debug = 11; // ACTIONS_STEP_DEBUG | |||
| 63 | + | // deadline_ms is what is left of the job timeout when the host sends this | |||
| 64 | + | // Run, in milliseconds: the timeout counts from the moment the host received | |||
| 65 | + | // the job (runner.proto: Job.timeout_minutes), not from the start of the | |||
| 66 | + | // executor, and booting the sandbox takes time. 0 means "use | |||
| 67 | + | // timeout_minutes from now". | |||
| 68 | + | int64 deadline_ms = 12; | |||
| 38 | } | 69 | } | ||
| 39 | 70 | ||||
| 40 | enum StreamKind { | 71 | enum StreamKind { | ||
| @@ -61,4 +92,56 @@ message Cancel { | |||||
| 61 | // reason is for the job log and diagnostics ("cancelled by the user", | 92 | // reason is for the job log and diagnostics ("cancelled by the user", | ||
| 62 | // "job timeout", ...); it never changes the behaviour. | 93 | // "job timeout", ...); it never changes the behaviour. | ||
| 63 | string reason = 1; | 94 | string reason = 1; | ||
| 95 | + | // kind tells the job executor how the job ends: cancelled, timed out, or | |||
| 96 | + | // cancelled because the runner shuts down. The stub executor ignores it. | |||
| 97 | + | CancelKind kind = 2; | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | enum CancelKind { | |||
| 101 | + | CANCEL_KIND_UNSPECIFIED = 0; // same as USER | |||
| 102 | + | CANCEL_KIND_USER = 1; | |||
| 103 | + | CANCEL_KIND_TIMEOUT = 2; | |||
| 104 | + | CANCEL_KIND_SHUTDOWN = 3; | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Steps are numbered like GitHub's log: "Set up job" is 1, the steps of the | |||
| 108 | + | // workflow follow, "Complete job" is last. | |||
| 109 | + | message StepStarted { | |||
| 110 | + | int32 number = 1; | |||
| 111 | + | string name = 2; | |||
| 112 | + | int64 unix_nano = 3; | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | message StepCompleted { | |||
| 116 | + | int32 number = 1; | |||
| 117 | + | string name = 2; | |||
| 118 | + | StepConclusion conclusion = 3; | |||
| 119 | + | int64 unix_nano = 4; | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | enum StepConclusion { | |||
| 123 | + | STEP_CONCLUSION_UNSPECIFIED = 0; | |||
| 124 | + | STEP_CONCLUSION_SUCCESS = 1; | |||
| 125 | + | STEP_CONCLUSION_FAILURE = 2; | |||
| 126 | + | STEP_CONCLUSION_CANCELLED = 3; | |||
| 127 | + | STEP_CONCLUSION_SKIPPED = 4; | |||
| 128 | + | STEP_CONCLUSION_TIMED_OUT = 5; // jobs only | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // Log is one line of the job log, without terminator, valid UTF-8. A line of | |||
| 132 | + | // more than 64 KiB is cut by the executor into pieces at character boundaries; | |||
| 133 | + | // every piece but the first has continued set (the AppendLog rule). | |||
| 134 | + | message Log { | |||
| 135 | + | int32 step = 1; | |||
| 136 | + | int64 unix_nano = 2; | |||
| 137 | + | string text = 3; | |||
| 138 | + | bool continued = 4; | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // JobResult is the outcome of the job; Exit follows. | |||
| 142 | + | message JobResult { | |||
| 143 | + | StepConclusion conclusion = 1; | |||
| 144 | + | map<string, string> outputs = 2; | |||
| 145 | + | string error = 3; | |||
| 146 | + | bool runner_shutdown = 4; | |||
| 64 | } | 147 | } | ||
M internal/execstream/execpb/exec.pb.go +732 -44 generated file: collapsed
M internal/execstream/frame.go +42 -3
| @@ -25,7 +25,9 @@ import ( | |||||
| 25 | ) | 25 | ) | ||
| 26 | 26 | ||||
| 27 | // MaxFrame is the largest accepted serialized frame. | 27 | // MaxFrame is the largest accepted serialized frame. | ||
| 28 | - | const MaxFrame = 1 << 20 | 28 | + | // It leaves room for the largest job result: outputs of 1 MiB in total (the |
| 29 | + | // limit of ermite) plus the overhead of the encoding. | |||
| 30 | + | const MaxFrame = 2 << 20 | |||
| 29 | 31 | ||||
| 30 | // ChunkSize is the largest output payload placed in one Output frame. | 32 | // ChunkSize is the largest output payload placed in one Output frame. | ||
| 31 | const ChunkSize = 32 * 1024 | 33 | const ChunkSize = 32 * 1024 | ||
| @@ -100,6 +102,24 @@ func (c *Conn) Recv() (*execpb.Frame, error) { | |||||
| 100 | // a Cancel; the caller bounds that wait by stopping the sandbox, which closes | 102 | // a Cancel; the caller bounds that wait by stopping the sandbox, which closes | ||
| 101 | // the stream and makes Drive return an error. | 103 | // the stream and makes Drive return an error. | ||
| 102 | func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOutput func(*execpb.Output)) (*execpb.Hello, *execpb.Exit, error) { | 104 | func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOutput func(*execpb.Output)) (*execpb.Hello, *execpb.Exit, error) { | ||
| 105 | + | return DriveJob(rw, run, cancel, JobHandler{Output: onOutput}) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // JobHandler receives the frames of a session. Nil functions ignore their | |||
| 109 | + | // frames. The functions are called from the goroutine of DriveJob, in the | |||
| 110 | + | // order of the stream. | |||
| 111 | + | type JobHandler struct { | |||
| 112 | + | Hello func(*execpb.Hello) | |||
| 113 | + | Output func(*execpb.Output) | |||
| 114 | + | StepStarted func(*execpb.StepStarted) | |||
| 115 | + | StepCompleted func(*execpb.StepCompleted) | |||
| 116 | + | Log func(*execpb.Log) | |||
| 117 | + | JobResult func(*execpb.JobResult) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // DriveJob is Drive for a session that carries a job: besides Output frames it | |||
| 121 | + | // delivers the step, log and result frames to h. | |||
| 122 | + | func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h JobHandler) (*execpb.Hello, *execpb.Exit, error) { | |||
| 103 | c := NewConn(rw) | 123 | c := NewConn(rw) | ||
| 104 | first, err := c.Recv() | 124 | first, err := c.Recv() | ||
| 105 | if err != nil { | 125 | if err != nil { | ||
| @@ -109,6 +129,9 @@ func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOu | |||||
| 109 | if hello == nil { | 129 | if hello == nil { | ||
| 110 | return nil, nil, fmt.Errorf("execstream: first frame is not hello") | 130 | return nil, nil, fmt.Errorf("execstream: first frame is not hello") | ||
| 111 | } | 131 | } | ||
| 132 | + | if h.Hello != nil { | |||
| 133 | + | h.Hello(hello) | |||
| 134 | + | } | |||
| 112 | if err := c.Send(&execpb.Frame{Body: &execpb.Frame_Run{Run: run}}); err != nil { | 135 | if err := c.Send(&execpb.Frame{Body: &execpb.Frame_Run{Run: run}}); err != nil { | ||
| 113 | return hello, nil, fmt.Errorf("execstream: sending run: %w", err) | 136 | return hello, nil, fmt.Errorf("execstream: sending run: %w", err) | ||
| 114 | } | 137 | } | ||
| @@ -137,8 +160,24 @@ func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOu | |||||
| 137 | } | 160 | } | ||
| 138 | switch b := f.Body.(type) { | 161 | switch b := f.Body.(type) { | ||
| 139 | case *execpb.Frame_Output: | 162 | case *execpb.Frame_Output: | ||
| 140 | - | if onOutput != nil { | 163 | + | if h.Output != nil { |
| 141 | - | onOutput(b.Output) | 164 | + | h.Output(b.Output) |
| 165 | + | } | |||
| 166 | + | case *execpb.Frame_StepStarted: | |||
| 167 | + | if h.StepStarted != nil { | |||
| 168 | + | h.StepStarted(b.StepStarted) | |||
| 169 | + | } | |||
| 170 | + | case *execpb.Frame_StepCompleted: | |||
| 171 | + | if h.StepCompleted != nil { | |||
| 172 | + | h.StepCompleted(b.StepCompleted) | |||
| 173 | + | } | |||
| 174 | + | case *execpb.Frame_Log: | |||
| 175 | + | if h.Log != nil { | |||
| 176 | + | h.Log(b.Log) | |||
| 177 | + | } | |||
| 178 | + | case *execpb.Frame_JobResult: | |||
| 179 | + | if h.JobResult != nil { | |||
| 180 | + | h.JobResult(b.JobResult) | |||
| 142 | } | 181 | } | ||
| 143 | case *execpb.Frame_Exit: | 182 | case *execpb.Frame_Exit: | ||
| 144 | return hello, b.Exit, nil | 183 | return hello, b.Exit, nil | ||
M internal/execstream/frame_test.go +36 -0
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "errors" | 8 | "errors" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | "net" | 10 | "net" | ||
| 11 | + | "strings" | |||
| 11 | "testing" | 12 | "testing" | ||
| 12 | "testing/iotest" | 13 | "testing/iotest" | ||
| 13 | "time" | 14 | "time" | ||
| @@ -136,3 +137,38 @@ func TestDriveHelloMissing(t *testing.T) { | |||||
| 136 | t.Fatal("want error when the first frame is not hello") | 137 | t.Fatal("want error when the first frame is not hello") | ||
| 137 | } | 138 | } | ||
| 138 | } | 139 | } | ||
| 140 | + | ||||
| 141 | + | func TestDriveJobDeliversJobFrames(t *testing.T) { | |||
| 142 | + | host, guest := net.Pipe() | |||
| 143 | + | go func() { | |||
| 144 | + | g := NewConn(guest) | |||
| 145 | + | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{Version: "v"}}}) | |||
| 146 | + | r, _ := g.Recv() | |||
| 147 | + | if r.GetRun().GetJob().GetJobKey() != "build" { | |||
| 148 | + | t.Errorf("run: %v", r) | |||
| 149 | + | } | |||
| 150 | + | for _, b := range []*execpb.Frame{ | |||
| 151 | + | {Body: &execpb.Frame_StepStarted{StepStarted: &execpb.StepStarted{Number: 1, Name: "Set up job"}}}, | |||
| 152 | + | {Body: &execpb.Frame_Log{Log: &execpb.Log{Step: 1, Text: "hello", UnixNano: 5}}}, | |||
| 153 | + | {Body: &execpb.Frame_StepCompleted{StepCompleted: &execpb.StepCompleted{Number: 1, Conclusion: execpb.StepConclusion_STEP_CONCLUSION_SUCCESS}}}, | |||
| 154 | + | {Body: &execpb.Frame_JobResult{JobResult: &execpb.JobResult{Conclusion: execpb.StepConclusion_STEP_CONCLUSION_SUCCESS, Outputs: map[string]string{"a": "b"}}}}, | |||
| 155 | + | {Body: &execpb.Frame_Exit{Exit: &execpb.Exit{}}}, | |||
| 156 | + | } { | |||
| 157 | + | _ = g.Send(b) | |||
| 158 | + | } | |||
| 159 | + | }() | |||
| 160 | + | var got []string | |||
| 161 | + | _, exit, err := DriveJob(host, &execpb.Run{Job: &execpb.JobSpec{JobKey: "build"}}, nil, JobHandler{ | |||
| 162 | + | StepStarted: func(s *execpb.StepStarted) { got = append(got, "start:"+s.Name) }, | |||
| 163 | + | Log: func(l *execpb.Log) { got = append(got, "log:"+l.Text) }, | |||
| 164 | + | StepCompleted: func(s *execpb.StepCompleted) { got = append(got, "done:"+s.Conclusion.String()) }, | |||
| 165 | + | JobResult: func(r *execpb.JobResult) { got = append(got, "result:"+r.Outputs["a"]) }, | |||
| 166 | + | }) | |||
| 167 | + | if err != nil || exit == nil { | |||
| 168 | + | t.Fatal(err) | |||
| 169 | + | } | |||
| 170 | + | want := "start:Set up job log:hello done:STEP_CONCLUSION_SUCCESS result:b" | |||
| 171 | + | if strings.Join(got, " ") != want { | |||
| 172 | + | t.Fatalf("%v", got) | |||
| 173 | + | } | |||
| 174 | + | } | |||
A internal/execstream/split.go +38 -0
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package execstream | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "unicode/utf8" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // MaxLogLine is the largest piece of a log line that fits an AppendLog line | |||
| 11 | + | // (64 KiB); longer lines are cut by SplitLine. | |||
| 12 | + | const MaxLogLine = 64 * 1024 | |||
| 13 | + | ||||
| 14 | + | // SplitLine cuts text into pieces of at most max bytes at UTF-8 character | |||
| 15 | + | // boundaries. The result has one piece for a short line, and always at least | |||
| 16 | + | // one (an empty line is one empty piece). Invalid UTF-8 in text is replaced | |||
| 17 | + | // with U+FFFD first, because the protocol only carries valid text. | |||
| 18 | + | func SplitLine(text string, max int) []string { | |||
| 19 | + | if !utf8.ValidString(text) { | |||
| 20 | + | text = strings.ToValidUTF8(text, "\ufffd") | |||
| 21 | + | } | |||
| 22 | + | if len(text) <= max { | |||
| 23 | + | return []string{text} | |||
| 24 | + | } | |||
| 25 | + | var out []string | |||
| 26 | + | for len(text) > max { | |||
| 27 | + | cut := max | |||
| 28 | + | for cut > 0 && !utf8.RuneStart(text[cut]) { | |||
| 29 | + | cut-- | |||
| 30 | + | } | |||
| 31 | + | if cut == 0 { // cannot happen with max >= utf8.UTFMax | |||
| 32 | + | cut = max | |||
| 33 | + | } | |||
| 34 | + | out = append(out, text[:cut]) | |||
| 35 | + | text = text[cut:] | |||
| 36 | + | } | |||
| 37 | + | return append(out, text) | |||
| 38 | + | } | |||
A internal/execstream/split_test.go +41 -0
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package execstream | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "unicode/utf8" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestSplitLine(t *testing.T) { | |||
| 12 | + | if got := SplitLine("", 10); len(got) != 1 || got[0] != "" { | |||
| 13 | + | t.Fatalf("empty: %q", got) | |||
| 14 | + | } | |||
| 15 | + | if got := SplitLine("short", 10); len(got) != 1 || got[0] != "short" { | |||
| 16 | + | t.Fatalf("%q", got) | |||
| 17 | + | } | |||
| 18 | + | // Cuts at character boundaries and joins back exactly. | |||
| 19 | + | in := strings.Repeat("é€😀a", 40) // 2, 3, 4, 1 bytes | |||
| 20 | + | for _, max := range []int{4, 5, 7, 10, 33} { | |||
| 21 | + | parts := SplitLine(in, max) | |||
| 22 | + | var b strings.Builder | |||
| 23 | + | for _, p := range parts { | |||
| 24 | + | if len(p) > max || !utf8.ValidString(p) || p == "" { | |||
| 25 | + | t.Fatalf("max %d: bad piece %q", max, p) | |||
| 26 | + | } | |||
| 27 | + | b.WriteString(p) | |||
| 28 | + | } | |||
| 29 | + | if b.String() != in { | |||
| 30 | + | t.Fatalf("max %d: pieces do not rebuild the line", max) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | // Invalid UTF-8 is replaced before cutting. | |||
| 34 | + | if got := SplitLine("a\xffb", 10); got[0] != "a\ufffdb" { | |||
| 35 | + | t.Fatalf("%q", got) | |||
| 36 | + | } | |||
| 37 | + | big := SplitLine(strings.Repeat("x", 3*MaxLogLine+5), MaxLogLine) | |||
| 38 | + | if len(big) != 4 || len(big[0]) != MaxLogLine || len(big[3]) != 5 { | |||
| 39 | + | t.Fatalf("%d pieces", len(big)) | |||
| 40 | + | } | |||
| 41 | + | } | |||
A internal/executor/cancel.go +52 -0
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import "sync" | |||
| 6 | + | ||||
| 7 | + | // CancelReason says why a job is stopped. | |||
| 8 | + | type CancelReason int | |||
| 9 | + | ||||
| 10 | + | // Cancel reasons. | |||
| 11 | + | const ( | |||
| 12 | + | // CancelUser is a cancellation by the owner or by fail-fast: the job ends | |||
| 13 | + | // cancelled. | |||
| 14 | + | CancelUser CancelReason = iota + 1 | |||
| 15 | + | // CancelTimeout is the job timeout: the job ends timed_out. | |||
| 16 | + | CancelTimeout | |||
| 17 | + | // CancelShutdown is the runner stopping: the job ends cancelled with | |||
| 18 | + | // RunnerShutdown set, and the steps that did not start are skipped. | |||
| 19 | + | CancelShutdown | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // Canceller stops a job. The first Cancel wins; later calls are ignored (the | |||
| 23 | + | // server only sends the first reason too). | |||
| 24 | + | type Canceller struct { | |||
| 25 | + | once sync.Once | |||
| 26 | + | ch chan struct{} | |||
| 27 | + | mu sync.Mutex | |||
| 28 | + | reason CancelReason | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // NewCanceller returns a Canceller that has not fired. | |||
| 32 | + | func NewCanceller() *Canceller { return &Canceller{ch: make(chan struct{})} } | |||
| 33 | + | ||||
| 34 | + | // Cancel stops the job for reason. | |||
| 35 | + | func (c *Canceller) Cancel(reason CancelReason) { | |||
| 36 | + | c.once.Do(func() { | |||
| 37 | + | c.mu.Lock() | |||
| 38 | + | c.reason = reason | |||
| 39 | + | c.mu.Unlock() | |||
| 40 | + | close(c.ch) | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // Done is closed when the job is cancelled. | |||
| 45 | + | func (c *Canceller) Done() <-chan struct{} { return c.ch } | |||
| 46 | + | ||||
| 47 | + | // Reason returns why the job was cancelled; zero before Done is closed. | |||
| 48 | + | func (c *Canceller) Reason() CancelReason { | |||
| 49 | + | c.mu.Lock() | |||
| 50 | + | defer c.mu.Unlock() | |||
| 51 | + | return c.reason | |||
| 52 | + | } | |||
A internal/executor/commands.go +297 -0
| @@ -0,0 +1,297 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | "unicode" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Workflow commands in the output of a step: a port of ActionCommand.cs | |||
| 12 | + | // (Runner.Common) and of the parts of ActionCommandManager.cs that CI-1 needs. | |||
| 13 | + | // Masking (add-mask) and problem matchers are CI-2; they are announced in the | |||
| 14 | + | // log and otherwise ignored. | |||
| 15 | + | ||||
| 16 | + | type escapeMapping struct{ token, replacement string } | |||
| 17 | + | ||||
| 18 | + | // _escapeMappings, _escapeDataMappings, _escapePropertyMappings of | |||
| 19 | + | // ActionCommand.cs:11-35, in order: the replacements run one after the other. | |||
| 20 | + | var ( | |||
| 21 | + | escapeMappings = []escapeMapping{{";", "%3B"}, {"\r", "%0D"}, {"\n", "%0A"}, {"]", "%5D"}, {"%", "%25"}} | |||
| 22 | + | escapeData = []escapeMapping{{"\r", "%0D"}, {"\n", "%0A"}, {"%", "%25"}} | |||
| 23 | + | escapeProperty = []escapeMapping{{"\r", "%0D"}, {"\n", "%0A"}, {":", "%3A"}, {",", "%2C"}, {"%", "%25"}} | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | func unescape(s string, maps []escapeMapping) string { | |||
| 27 | + | for _, m := range maps { | |||
| 28 | + | s = strings.ReplaceAll(s, m.replacement, m.token) | |||
| 29 | + | } | |||
| 30 | + | return s | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // actionCommand is a parsed workflow command. | |||
| 34 | + | type actionCommand struct { | |||
| 35 | + | Name string | |||
| 36 | + | Props map[string]string // keys compared case-insensitively (lower-cased) | |||
| 37 | + | Data string | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (c *actionCommand) prop(name string) string { return c.Props[strings.ToLower(name)] } | |||
| 41 | + | ||||
| 42 | + | const commandKey = "::" | |||
| 43 | + | ||||
| 44 | + | // parseCommandV2 is ActionCommand.TryParseV2 (ActionCommand.cs:57): "::name | |||
| 45 | + | // key=value,key=value::data". | |||
| 46 | + | func parseCommandV2(message string, registered func(string) bool) (*actionCommand, bool) { | |||
| 47 | + | // .NET TrimStart() trims Unicode white space. | |||
| 48 | + | message = strings.TrimLeftFunc(message, unicode.IsSpace) | |||
| 49 | + | if !strings.HasPrefix(message, commandKey) { | |||
| 50 | + | return nil, false | |||
| 51 | + | } | |||
| 52 | + | end := strings.Index(message[len(commandKey):], commandKey) | |||
| 53 | + | if end < 0 { | |||
| 54 | + | return nil, false | |||
| 55 | + | } | |||
| 56 | + | end += len(commandKey) | |||
| 57 | + | info := message[len(commandKey):end] | |||
| 58 | + | name, rest, hasSpace := strings.Cut(info, " ") | |||
| 59 | + | if !registered(name) { | |||
| 60 | + | return nil, false | |||
| 61 | + | } | |||
| 62 | + | cmd := &actionCommand{Name: name, Props: map[string]string{}} | |||
| 63 | + | if hasSpace && len(info) > len(name) { // spaceIndex > 0 | |||
| 64 | + | for _, p := range strings.Split(strings.TrimSpace(rest), ",") { | |||
| 65 | + | if p == "" { | |||
| 66 | + | continue | |||
| 67 | + | } | |||
| 68 | + | k, v, ok := strings.Cut(p, "=") | |||
| 69 | + | if ok && k != "" && v != "" { // Split(RemoveEmptyEntries): both parts non-empty | |||
| 70 | + | cmd.Props[strings.ToLower(k)] = unescape(v, escapeProperty) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | cmd.Data = unescape(message[end+len(commandKey):], escapeData) | |||
| 75 | + | return cmd, true | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // parseCommandV1 is ActionCommand.TryParse (ActionCommand.cs:127): the legacy | |||
| 79 | + | // "##[name key=value;key=value]data". | |||
| 80 | + | func parseCommandV1(message string, registered func(string) bool) (*actionCommand, bool) { | |||
| 81 | + | const prefix = "##[" | |||
| 82 | + | pi := strings.Index(message, prefix) | |||
| 83 | + | if pi < 0 { | |||
| 84 | + | return nil, false | |||
| 85 | + | } | |||
| 86 | + | rb := strings.IndexByte(message[pi:], ']') | |||
| 87 | + | if rb < 0 { | |||
| 88 | + | return nil, false | |||
| 89 | + | } | |||
| 90 | + | rb += pi | |||
| 91 | + | info := message[pi+len(prefix) : rb] | |||
| 92 | + | name, rest, hasSpace := strings.Cut(info, " ") | |||
| 93 | + | if !registered(name) { | |||
| 94 | + | return nil, false | |||
| 95 | + | } | |||
| 96 | + | cmd := &actionCommand{Name: name, Props: map[string]string{}} | |||
| 97 | + | if hasSpace && len(info) > len(name) { | |||
| 98 | + | for _, p := range strings.Split(rest, ";") { | |||
| 99 | + | if p == "" { | |||
| 100 | + | continue | |||
| 101 | + | } | |||
| 102 | + | k, v, ok := strings.Cut(p, "=") | |||
| 103 | + | if ok && k != "" && v != "" { | |||
| 104 | + | cmd.Props[strings.ToLower(k)] = unescape(v, escapeMappings) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | cmd.Data = unescape(message[rb+1:], escapeMappings) | |||
| 109 | + | return cmd, true | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Messages of Constants.cs:234-236. | |||
| 113 | + | const ( | |||
| 114 | + | unsupportedCommandMessage = "The `%s` command is deprecated and will be disabled soon. Please upgrade to using Environment Files. For more information see: https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/" | |||
| 115 | + | unsupportedCommandMessageDisabled = "The `%s` command is disabled. Please upgrade to using Environment Files or opt into unsecure command execution by setting the `ACTIONS_ALLOW_UNSECURE_COMMANDS` environment variable to `true`. For more information see: https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/" | |||
| 116 | + | unsupportedStopTokenDisabled = "You cannot use a endToken that is an empty string, the string 'pause-logging', or another workflow command. For more information see: https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#example-stopping-and-starting-workflow-commands or opt into insecure command execution by setting the `ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS` environment variable to `true`." | |||
| 117 | + | ) | |||
| 118 | + | ||||
| 119 | + | // commandNames are the commands the executor registers, in ActionCommandManager | |||
| 120 | + | // (the names of the extensions at ActionCommandManager.cs). add-mask, | |||
| 121 | + | // add-matcher and remove-matcher are recognised and reported as not supported | |||
| 122 | + | // yet. | |||
| 123 | + | var commandNames = []string{ | |||
| 124 | + | "set-env", "set-output", "save-state", "add-mask", "add-path", "add-matcher", "remove-matcher", | |||
| 125 | + | "debug", "warning", "error", "notice", "group", "endgroup", "echo", "stop-commands", | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // commandHost is what commands act on: the step that is running. | |||
| 129 | + | type commandHost interface { | |||
| 130 | + | // Out writes a log line (several when it contains line feeds). | |||
| 131 | + | Out(text string) | |||
| 132 | + | // Tagged writes a ##[tag]message line. | |||
| 133 | + | Tagged(tag, message string) | |||
| 134 | + | Debug(msg string) | |||
| 135 | + | SetOutput(name, value string) | |||
| 136 | + | SaveState(name, value string) | |||
| 137 | + | SetEnv(name, value string) | |||
| 138 | + | AddPath(path string) | |||
| 139 | + | CommandFailed() | |||
| 140 | + | // EnvFlag reads a variable of the env context, then of the process. | |||
| 141 | + | EnvFlag(name string) bool | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // commandManager is ActionCommandManager: one per step. | |||
| 145 | + | type commandManager struct { | |||
| 146 | + | host commandHost | |||
| 147 | + | registered map[string]bool | |||
| 148 | + | stopped bool | |||
| 149 | + | stopToken string | |||
| 150 | + | echo bool | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func newCommandManager(h commandHost, echo bool) *commandManager { | |||
| 154 | + | m := &commandManager{host: h, registered: map[string]bool{}, echo: echo} | |||
| 155 | + | for _, n := range commandNames { | |||
| 156 | + | m.registered[strings.ToLower(n)] = true | |||
| 157 | + | } | |||
| 158 | + | return m | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func (m *commandManager) isRegistered(name string) bool { return m.registered[strings.ToLower(name)] } | |||
| 162 | + | ||||
| 163 | + | // TryProcess handles one line of output. It returns true when the line was a | |||
| 164 | + | // command and must not be logged as plain text (ActionCommandManager.cs:61). | |||
| 165 | + | func (m *commandManager) TryProcess(input string) bool { | |||
| 166 | + | if input == "" { | |||
| 167 | + | return false | |||
| 168 | + | } | |||
| 169 | + | cmd, ok := parseCommandV2(input, m.isRegistered) | |||
| 170 | + | if !ok { | |||
| 171 | + | cmd, ok = parseCommandV1(input, m.isRegistered) | |||
| 172 | + | } | |||
| 173 | + | if !ok { | |||
| 174 | + | return false | |||
| 175 | + | } | |||
| 176 | + | name := strings.ToLower(cmd.Name) | |||
| 177 | + | if m.stopped { | |||
| 178 | + | if m.stopToken != "" && strings.EqualFold(cmd.Name, m.stopToken) { | |||
| 179 | + | m.host.Out(input) | |||
| 180 | + | m.host.Debug("Resume processing commands") | |||
| 181 | + | delete(m.registered, strings.ToLower(m.stopToken)) | |||
| 182 | + | m.stopped, m.stopToken = false, "" | |||
| 183 | + | return true | |||
| 184 | + | } | |||
| 185 | + | m.host.Debug(fmt.Sprintf("Process commands has been stopped and waiting for '##[%s]' to resume.", m.stopToken)) | |||
| 186 | + | return false | |||
| 187 | + | } | |||
| 188 | + | if name == "stop-commands" { | |||
| 189 | + | if err := m.validateStopToken(cmd.Data); err != nil { | |||
| 190 | + | m.fail(input, false, err) | |||
| 191 | + | return true | |||
| 192 | + | } | |||
| 193 | + | m.stopToken, m.stopped = cmd.Data, true | |||
| 194 | + | m.registered[strings.ToLower(cmd.Data)] = true | |||
| 195 | + | m.host.Out(input) | |||
| 196 | + | m.host.Debug("Paused processing commands until the token you called ::stopCommands:: with is received") | |||
| 197 | + | return true | |||
| 198 | + | } | |||
| 199 | + | omitEcho := name == "debug" || name == "warning" || name == "error" || name == "notice" || name == "add-mask" | |||
| 200 | + | if m.echo && !omitEcho { | |||
| 201 | + | m.host.Out(input) | |||
| 202 | + | } | |||
| 203 | + | if err := m.run(name, cmd); err != nil { | |||
| 204 | + | m.fail(input, omitEcho, err) | |||
| 205 | + | } | |||
| 206 | + | return true | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func (m *commandManager) fail(input string, omitEcho bool, err error) { | |||
| 210 | + | info := input | |||
| 211 | + | if omitEcho { | |||
| 212 | + | info = "" | |||
| 213 | + | } | |||
| 214 | + | m.host.Tagged("##[error]", fmt.Sprintf("Unable to process command '%s' successfully.", info)) | |||
| 215 | + | m.host.Tagged("##[error]", err.Error()) | |||
| 216 | + | m.host.CommandFailed() | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func (m *commandManager) validateStopToken(token string) error { | |||
| 220 | + | invalid := m.isRegistered(token) || token == "" || strings.EqualFold(token, "pause-logging") | |||
| 221 | + | if invalid && !m.host.EnvFlag("ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS") { | |||
| 222 | + | return msgError(unsupportedStopTokenDisabled) | |||
| 223 | + | } | |||
| 224 | + | return nil | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func (m *commandManager) run(name string, c *actionCommand) error { | |||
| 228 | + | h := m.host | |||
| 229 | + | switch name { | |||
| 230 | + | case "group": | |||
| 231 | + | h.Out("##[group]" + c.Data) // GroupingCommandExtension, ActionCommandManager.cs:802 | |||
| 232 | + | case "endgroup": | |||
| 233 | + | h.Out("##[endgroup]") | |||
| 234 | + | case "debug": | |||
| 235 | + | h.Debug(c.Data) | |||
| 236 | + | case "warning", "error", "notice": | |||
| 237 | + | // IssueCommandExtension: the annotation is logged; ermite does not store | |||
| 238 | + | // annotations yet (CI-2), so file, line and title are dropped. | |||
| 239 | + | h.Tagged("##["+name+"]", c.Data) | |||
| 240 | + | case "echo": | |||
| 241 | + | if c.Data == "" { | |||
| 242 | + | return ghErrorf("Value cannot be null or empty. (Parameter 'value')") | |||
| 243 | + | } | |||
| 244 | + | switch strings.ToUpper(strings.TrimSpace(c.Data)) { | |||
| 245 | + | case "ON": | |||
| 246 | + | m.echo = true | |||
| 247 | + | h.Debug("Setting echo command value to 'on'") | |||
| 248 | + | case "OFF": | |||
| 249 | + | m.echo = false | |||
| 250 | + | h.Debug("Setting echo command value to 'off'") | |||
| 251 | + | default: | |||
| 252 | + | return ghErrorf("Invalid echo command value. Possible values can be: 'on', 'off'. Current value is: '%s'.", c.Data) | |||
| 253 | + | } | |||
| 254 | + | case "set-output": | |||
| 255 | + | h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name)) | |||
| 256 | + | n := c.prop("name") | |||
| 257 | + | if n == "" { | |||
| 258 | + | return ghErrorf("Required field 'name' is missing in ##[set-output] command.") | |||
| 259 | + | } | |||
| 260 | + | h.SetOutput(n, c.Data) | |||
| 261 | + | h.Debug(fmt.Sprintf("steps.<id>.outputs.%s='%s'", n, c.Data)) | |||
| 262 | + | case "save-state": | |||
| 263 | + | h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name)) | |||
| 264 | + | n := c.prop("name") | |||
| 265 | + | if n == "" { | |||
| 266 | + | return ghErrorf("Required field 'name' is missing in ##[save-state] command.") | |||
| 267 | + | } | |||
| 268 | + | h.SaveState(n, c.Data) | |||
| 269 | + | h.Debug(fmt.Sprintf("Save intra-action state %s = %s", n, c.Data)) | |||
| 270 | + | case "set-env": | |||
| 271 | + | if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") { | |||
| 272 | + | return ghErrorf(unsupportedCommandMessageDisabled, name) | |||
| 273 | + | } | |||
| 274 | + | n := c.prop("name") | |||
| 275 | + | if n == "" { | |||
| 276 | + | return ghErrorf("Required field 'name' is missing in ##[set-env] command.") | |||
| 277 | + | } | |||
| 278 | + | if b := blockedEnvName(n); b != "" { | |||
| 279 | + | h.Tagged("##[error]", fmt.Sprintf("Can't update %s environment variable using ::set-env:: command.", b)) | |||
| 280 | + | return nil | |||
| 281 | + | } | |||
| 282 | + | h.SetEnv(n, c.Data) | |||
| 283 | + | h.Debug(fmt.Sprintf("%s='%s'", n, c.Data)) | |||
| 284 | + | case "add-path": | |||
| 285 | + | if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") { | |||
| 286 | + | return ghErrorf(unsupportedCommandMessageDisabled, name) | |||
| 287 | + | } | |||
| 288 | + | if c.Data == "" { | |||
| 289 | + | return ghErrorf("Value cannot be null or empty. (Parameter 'path')") | |||
| 290 | + | } | |||
| 291 | + | h.AddPath(c.Data) | |||
| 292 | + | case "add-mask", "add-matcher", "remove-matcher": | |||
| 293 | + | // Not supported yet (CI-2). The value of add-mask is not echoed. | |||
| 294 | + | h.Tagged("##[warning]", fmt.Sprintf("The `%s` command is not supported yet by this runner.", name)) | |||
| 295 | + | } | |||
| 296 | + | return nil | |||
| 297 | + | } | |||
A internal/executor/contexts.go +465 -0
| @@ -0,0 +1,465 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "runtime" | |||
| 9 | + | "slices" | |||
| 10 | + | "strings" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite-actions/expr" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // ordered is a string map that keeps the position of a key when it is set | |||
| 16 | + | // again, like the Dictionary<string,string> the official runner uses for | |||
| 17 | + | // Global.EnvironmentVariables and the step environment (the order shows in the | |||
| 18 | + | // "env:" block of the log). Keys are case-sensitive, as on Linux | |||
| 19 | + | // (VarUtil.EnvironmentVariableKeyComparer). | |||
| 20 | + | type ordered struct { | |||
| 21 | + | keys []string | |||
| 22 | + | vals map[string]string | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func newOrdered() *ordered { return &ordered{vals: map[string]string{}} } | |||
| 26 | + | ||||
| 27 | + | func (o *ordered) Set(k, v string) { | |||
| 28 | + | if _, ok := o.vals[k]; !ok { | |||
| 29 | + | o.keys = append(o.keys, k) | |||
| 30 | + | } | |||
| 31 | + | o.vals[k] = v | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (o *ordered) Get(k string) (string, bool) { v, ok := o.vals[k]; return v, ok } | |||
| 35 | + | func (o *ordered) Len() int { return len(o.keys) } | |||
| 36 | + | func (o *ordered) Keys() []string { return slices.Clone(o.keys) } | |||
| 37 | + | ||||
| 38 | + | func (o *ordered) Clone() *ordered { | |||
| 39 | + | c := newOrdered() | |||
| 40 | + | for _, k := range o.keys { | |||
| 41 | + | c.Set(k, o.vals[k]) | |||
| 42 | + | } | |||
| 43 | + | return c | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Dict is the value of the env context. The context of the official runner is | |||
| 47 | + | // case-sensitive on Linux (CaseSensitiveDictionaryContextData); package expr | |||
| 48 | + | // has only case-insensitive dictionaries, so two names that differ by case | |||
| 49 | + | // collapse to the first in expressions (they stay distinct in the process | |||
| 50 | + | // environment). A known deviation. | |||
| 51 | + | func (o *ordered) Dict() *expr.Dictionary { | |||
| 52 | + | d := expr.NewDictionary() | |||
| 53 | + | for _, k := range o.keys { | |||
| 54 | + | d.Add(k, expr.String(o.vals[k])) | |||
| 55 | + | } | |||
| 56 | + | return d | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // setKey returns d with key set to v: the value is replaced in place when the | |||
| 60 | + | // key (compared case-insensitively) exists, else appended. | |||
| 61 | + | func setKey(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary { | |||
| 62 | + | out := expr.NewDictionary() | |||
| 63 | + | done := false | |||
| 64 | + | for _, p := range d.Pairs() { | |||
| 65 | + | if strings.EqualFold(p.Key, key) { | |||
| 66 | + | if !done { | |||
| 67 | + | out.Add(p.Key, v) | |||
| 68 | + | done = true | |||
| 69 | + | } | |||
| 70 | + | continue | |||
| 71 | + | } | |||
| 72 | + | out.Add(p.Key, p.Value) | |||
| 73 | + | } | |||
| 74 | + | if !done { | |||
| 75 | + | out.Add(key, v) | |||
| 76 | + | } | |||
| 77 | + | return out | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func str(d *expr.Dictionary, key string) string { | |||
| 81 | + | if v, ok := d.Get(key); ok { | |||
| 82 | + | if s, ok := v.(expr.String); ok { | |||
| 83 | + | return string(s) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | return "" | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Layout is the directory layout of a job, like a hosted runner's | |||
| 90 | + | // (/home/runner/work/<repo>/<repo>, RUNNER_TEMP, RUNNER_TOOL_CACHE). | |||
| 91 | + | type Layout struct { | |||
| 92 | + | Home string // HOME of the runner user | |||
| 93 | + | // WorkRoot is the _work directory: <Home>/work. | |||
| 94 | + | WorkRoot string | |||
| 95 | + | // Temp is RUNNER_TEMP: <WorkRoot>/_temp. | |||
| 96 | + | Temp string | |||
| 97 | + | // ToolCache is RUNNER_TOOL_CACHE. | |||
| 98 | + | ToolCache string | |||
| 99 | + | // RunnerWorkspace is RUNNER_WORKSPACE: <WorkRoot>/<repo>. | |||
| 100 | + | RunnerWorkspace string | |||
| 101 | + | // Workspace is GITHUB_WORKSPACE: <WorkRoot>/<repo>/<repo>. | |||
| 102 | + | Workspace string | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // DefaultToolCache is RUNNER_TOOL_CACHE on the ermite image. | |||
| 106 | + | const DefaultToolCache = "/opt/hostedtoolcache" | |||
| 107 | + | ||||
| 108 | + | // NewLayout lays out a job of the repository repo (its name, without owner) | |||
| 109 | + | // under home. An empty toolCache means DefaultToolCache. | |||
| 110 | + | func NewLayout(home, repo, toolCache string) Layout { | |||
| 111 | + | if toolCache == "" { | |||
| 112 | + | toolCache = DefaultToolCache | |||
| 113 | + | } | |||
| 114 | + | work := filepath.Join(home, "work") | |||
| 115 | + | return Layout{ | |||
| 116 | + | Home: home, WorkRoot: work, Temp: filepath.Join(work, "_temp"), ToolCache: toolCache, | |||
| 117 | + | RunnerWorkspace: filepath.Join(work, repo), Workspace: filepath.Join(work, repo, repo), | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // RunnerInfo describes the runner for the runner context. | |||
| 122 | + | type RunnerInfo struct { | |||
| 123 | + | Name string | |||
| 124 | + | // Environment is "self-hosted" (the default) or "github-hosted". | |||
| 125 | + | Environment string | |||
| 126 | + | // Arch is "X64" or "ARM64"; empty means the architecture of this process. | |||
| 127 | + | Arch string | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func (r RunnerInfo) arch() string { | |||
| 131 | + | if r.Arch != "" { | |||
| 132 | + | return r.Arch | |||
| 133 | + | } | |||
| 134 | + | switch runtime.GOARCH { | |||
| 135 | + | case "amd64": | |||
| 136 | + | return "X64" | |||
| 137 | + | case "arm64": | |||
| 138 | + | return "ARM64" | |||
| 139 | + | case "arm": | |||
| 140 | + | return "ARM" | |||
| 141 | + | } | |||
| 142 | + | return "X86" | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // repoName returns the repository name of "owner/name". | |||
| 146 | + | func repoName(full string) string { | |||
| 147 | + | if i := strings.LastIndexByte(full, '/'); i >= 0 { | |||
| 148 | + | return full[i+1:] | |||
| 149 | + | } | |||
| 150 | + | return full | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // stepCtx is one entry of the steps context. | |||
| 154 | + | type stepCtx struct { | |||
| 155 | + | outputs *ordered | |||
| 156 | + | outcome string // "" until the step completes | |||
| 157 | + | concl string | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // State holds the contexts of a job as the official runner does | |||
| 161 | + | // (ExecutionContext.ExpressionValues): the contexts the server computed, and | |||
| 162 | + | // github, runner, job, env and steps, which the runner owns. | |||
| 163 | + | type State struct { | |||
| 164 | + | layout Layout | |||
| 165 | + | github *expr.Dictionary | |||
| 166 | + | runner *expr.Dictionary | |||
| 167 | + | job *expr.Dictionary | |||
| 168 | + | // other holds needs, strategy, matrix, inputs, vars: copied from the server. | |||
| 169 | + | other map[string]expr.Value | |||
| 170 | + | // env is Global.EnvironmentVariables: the job-level environment and what | |||
| 171 | + | // GITHUB_ENV adds. A step's own env is not in it. | |||
| 172 | + | env *ordered | |||
| 173 | + | // steps is the steps context, in order of first mention. | |||
| 174 | + | stepOrder []string | |||
| 175 | + | steps map[string]*stepCtx | |||
| 176 | + | // prepend is Global.PrependPath in the order of the GITHUB_PATH entries. | |||
| 177 | + | prepend []string | |||
| 178 | + | // status is job.status: "success", "failure" or "cancelled". | |||
| 179 | + | status string | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // serverContexts are the keys of the contexts JSON that the runner reads. | |||
| 183 | + | var serverContexts = []string{"needs", "strategy", "matrix", "inputs", "vars"} | |||
| 184 | + | ||||
| 185 | + | // NewState builds the contexts of a job from the JSON the server sent (the | |||
| 186 | + | // "contexts" field of the job) and the matrix JSON, with the context | |||
| 187 | + | // initialisation of JobRunner.cs:142-177 and ExecutionContext.InitializeJob | |||
| 188 | + | // (ExecutionContext.cs:1001-1056). | |||
| 189 | + | func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallback string) (*State, Layout, error) { | |||
| 190 | + | root := expr.NewDictionary() | |||
| 191 | + | if strings.TrimSpace(contextsJSON) != "" { | |||
| 192 | + | v, err := expr.FromJSON([]byte(contextsJSON)) | |||
| 193 | + | if err != nil { | |||
| 194 | + | return nil, Layout{}, fmt.Errorf("job contexts: %w", err) | |||
| 195 | + | } | |||
| 196 | + | d, ok := v.(*expr.Dictionary) | |||
| 197 | + | if !ok { | |||
| 198 | + | return nil, Layout{}, fmt.Errorf("job contexts: want a JSON object") | |||
| 199 | + | } | |||
| 200 | + | root = d | |||
| 201 | + | } | |||
| 202 | + | s := &State{other: map[string]expr.Value{}, env: newOrdered(), steps: map[string]*stepCtx{}, status: "success"} | |||
| 203 | + | ||||
| 204 | + | // github: token first, then what the server gave (ExecutionContext.cs:1023-1037). | |||
| 205 | + | gh := expr.NewDictionary() | |||
| 206 | + | gh.Add("token", expr.String("")) | |||
| 207 | + | if v, ok := root.Get("github"); ok { | |||
| 208 | + | d, ok := v.(*expr.Dictionary) | |||
| 209 | + | if !ok { | |||
| 210 | + | return nil, Layout{}, fmt.Errorf("job contexts: github must be an object") | |||
| 211 | + | } | |||
| 212 | + | for _, p := range d.Pairs() { | |||
| 213 | + | if strings.EqualFold(p.Key, "token") { | |||
| 214 | + | continue | |||
| 215 | + | } | |||
| 216 | + | gh.Add(p.Key, p.Value) | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | if str(gh, "job") == "" && jobKey != "" { | |||
| 220 | + | gh = setKey(gh, "job", expr.String(jobKey)) | |||
| 221 | + | } | |||
| 222 | + | repo := repoName(str(gh, "repository")) | |||
| 223 | + | if repo == "" { | |||
| 224 | + | repo = repoFallback | |||
| 225 | + | } | |||
| 226 | + | if repo == "" { | |||
| 227 | + | return nil, Layout{}, fmt.Errorf("job contexts: github.repository is missing") | |||
| 228 | + | } | |||
| 229 | + | home := "/home/runner" | |||
| 230 | + | s.layout = NewLayout(home, repo, "") | |||
| 231 | + | s.github = setKey(gh, "workspace", expr.String(s.layout.Workspace)) | |||
| 232 | + | return s, s.layout, s.initRest(root, matrixJSON, r) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // WithLayout replaces the directory layout (tests and non-default homes). It | |||
| 236 | + | // must be called before the first step. | |||
| 237 | + | func (s *State) WithLayout(l Layout) { | |||
| 238 | + | s.layout = l | |||
| 239 | + | s.github = setKey(s.github, "workspace", expr.String(l.Workspace)) | |||
| 240 | + | s.runner = setKey(s.runner, "temp", expr.String(l.Temp)) | |||
| 241 | + | s.runner = setKey(s.runner, "tool_cache", expr.String(l.ToolCache)) | |||
| 242 | + | s.runner = setKey(s.runner, "workspace", expr.String(l.RunnerWorkspace)) | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func (s *State) initRest(root *expr.Dictionary, matrixJSON string, r RunnerInfo) error { | |||
| 246 | + | for _, k := range serverContexts { | |||
| 247 | + | if v, ok := root.Get(k); ok { | |||
| 248 | + | s.other[k] = v | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | if matrixJSON != "" { | |||
| 252 | + | if v, err := expr.FromJSON([]byte(matrixJSON)); err == nil { | |||
| 253 | + | if d, ok := v.(*expr.Dictionary); ok && d.Len() > 0 { | |||
| 254 | + | s.other["matrix"] = d | |||
| 255 | + | } | |||
| 256 | + | } else { | |||
| 257 | + | return fmt.Errorf("job matrix: %w", err) | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | // A context the job does not have is an empty object, like the server's | |||
| 261 | + | // ("needs": {}, "inputs": {}, "vars": {}); matrix and strategy are null | |||
| 262 | + | // when absent (recordings: "matrix": null). | |||
| 263 | + | for _, k := range []string{"needs", "inputs", "vars"} { | |||
| 264 | + | if _, ok := s.other[k]; !ok { | |||
| 265 | + | s.other[k] = expr.NewDictionary() | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | // runner: os, arch, name, environment, tool_cache, temp (JobRunner.cs:165-176, | |||
| 270 | + | // TempDirectoryManager.cs:31) and workspace (JobExtension.cs:215). | |||
| 271 | + | env := r.Environment | |||
| 272 | + | if env == "" { | |||
| 273 | + | env = "self-hosted" | |||
| 274 | + | } | |||
| 275 | + | rc := expr.NewDictionary() | |||
| 276 | + | rc.Add("os", expr.String("Linux")) | |||
| 277 | + | rc.Add("arch", expr.String(r.arch())) | |||
| 278 | + | rc.Add("name", expr.String(r.Name)) | |||
| 279 | + | rc.Add("environment", expr.String(env)) | |||
| 280 | + | rc.Add("tool_cache", expr.String(s.layout.ToolCache)) | |||
| 281 | + | rc.Add("temp", expr.String(s.layout.Temp)) | |||
| 282 | + | rc.Add("workspace", expr.String(s.layout.RunnerWorkspace)) | |||
| 283 | + | s.runner = rc | |||
| 284 | + | ||||
| 285 | + | // job: status first (StepsRunner.cs:53), then what the server gave. | |||
| 286 | + | jc := expr.NewDictionary() | |||
| 287 | + | jc.Add("status", expr.String("success")) | |||
| 288 | + | if v, ok := root.Get("job"); ok { | |||
| 289 | + | if d, ok := v.(*expr.Dictionary); ok { | |||
| 290 | + | for _, p := range d.Pairs() { | |||
| 291 | + | if !strings.EqualFold(p.Key, "status") { | |||
| 292 | + | jc.Add(p.Key, p.Value) | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | s.job = jc | |||
| 298 | + | return nil | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | // Layout returns the directory layout. | |||
| 302 | + | func (s *State) Layout() Layout { return s.layout } | |||
| 303 | + | ||||
| 304 | + | // SetGitHub sets a key of the github context (SetGitHubContext). | |||
| 305 | + | func (s *State) SetGitHub(k, v string) { s.github = setKey(s.github, k, expr.String(v)) } | |||
| 306 | + | ||||
| 307 | + | // GitHub returns a string key of the github context ("" when absent). | |||
| 308 | + | func (s *State) GitHub(k string) string { return str(s.github, k) } | |||
| 309 | + | ||||
| 310 | + | // Event returns github.event, or nil. | |||
| 311 | + | func (s *State) Event() expr.Value { v, _ := s.github.Get("event"); return v } | |||
| 312 | + | ||||
| 313 | + | // SetEnv sets a variable of the job environment (Global.EnvironmentVariables | |||
| 314 | + | // and the env context). | |||
| 315 | + | func (s *State) SetEnv(k, v string) { s.env.Set(k, v) } | |||
| 316 | + | ||||
| 317 | + | // JobEnv returns a copy of the job environment. | |||
| 318 | + | func (s *State) JobEnv() *ordered { return s.env.Clone() } | |||
| 319 | + | ||||
| 320 | + | // AddPath records a GITHUB_PATH entry: a repeated entry moves to the end | |||
| 321 | + | // (AddPathFileCommand, FileCommandManager.cs:152). | |||
| 322 | + | func (s *State) AddPath(p string) { | |||
| 323 | + | s.prepend = slices.DeleteFunc(s.prepend, func(x string) bool { return x == p }) | |||
| 324 | + | s.prepend = append(s.prepend, p) | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | // Prepend returns the GITHUB_PATH entries in the order added. | |||
| 328 | + | func (s *State) Prepend() []string { return slices.Clone(s.prepend) } | |||
| 329 | + | ||||
| 330 | + | // SetStatus sets job.status ("success", "failure" or "cancelled"). | |||
| 331 | + | func (s *State) SetStatus(status string) { | |||
| 332 | + | s.status = status | |||
| 333 | + | s.job = setKey(s.job, "status", expr.String(status)) | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | // Status returns job.status. | |||
| 337 | + | func (s *State) Status() string { return s.status } | |||
| 338 | + | ||||
| 339 | + | // stepIDVisible reports whether a step with this id appears in the steps | |||
| 340 | + | // context: generated ids start with "__" and do not (ExecutionContext.cs:644). | |||
| 341 | + | func stepIDVisible(id string) bool { return id != "" && !strings.HasPrefix(id, "__") } | |||
| 342 | + | ||||
| 343 | + | func (s *State) step(id string) *stepCtx { | |||
| 344 | + | c, ok := s.steps[id] | |||
| 345 | + | if !ok { | |||
| 346 | + | c = &stepCtx{outputs: newOrdered()} | |||
| 347 | + | s.steps[id] = c | |||
| 348 | + | s.stepOrder = append(s.stepOrder, id) | |||
| 349 | + | } | |||
| 350 | + | return c | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | // SetOutput sets an output of step id (StepsContext.SetOutput). Steps with a | |||
| 354 | + | // generated id keep no outputs. | |||
| 355 | + | func (s *State) SetOutput(id, name, value string) { | |||
| 356 | + | if !stepIDVisible(id) { | |||
| 357 | + | return | |||
| 358 | + | } | |||
| 359 | + | s.step(id).outputs.Set(name, value) | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | // SetStepResult records outcome and conclusion of a completed step | |||
| 363 | + | // (ExecutionContext.UpdateGlobalStepsContext). | |||
| 364 | + | func (s *State) SetStepResult(id, outcome, conclusion string) { | |||
| 365 | + | if !stepIDVisible(id) { | |||
| 366 | + | return | |||
| 367 | + | } | |||
| 368 | + | c := s.step(id) | |||
| 369 | + | c.outcome, c.concl = outcome, conclusion | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | // Output returns an output of a step ("" when absent). | |||
| 373 | + | func (s *State) Output(id, name string) string { | |||
| 374 | + | if c, ok := s.steps[id]; ok { | |||
| 375 | + | v, _ := c.outputs.Get(name) | |||
| 376 | + | return v | |||
| 377 | + | } | |||
| 378 | + | return "" | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | func (s *State) stepsValue() *expr.Dictionary { | |||
| 382 | + | d := expr.NewDictionary() | |||
| 383 | + | for _, id := range s.stepOrder { | |||
| 384 | + | c := s.steps[id] | |||
| 385 | + | sd := expr.NewDictionary() | |||
| 386 | + | sd.Add("outputs", c.outputs.Dict()) | |||
| 387 | + | if c.outcome != "" { | |||
| 388 | + | sd.Add("outcome", expr.String(c.outcome)) | |||
| 389 | + | sd.Add("conclusion", expr.String(c.concl)) | |||
| 390 | + | } | |||
| 391 | + | d.Add(id, sd) | |||
| 392 | + | } | |||
| 393 | + | return d | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | // Values returns the contexts for expression evaluation. stepEnv, when not | |||
| 397 | + | // nil, is the env context of a step (job environment with the step's own | |||
| 398 | + | // variables); otherwise the env context is the job environment. | |||
| 399 | + | func (s *State) Values(env *ordered) map[string]expr.Value { | |||
| 400 | + | if env == nil { | |||
| 401 | + | env = s.env | |||
| 402 | + | } | |||
| 403 | + | m := map[string]expr.Value{ | |||
| 404 | + | "github": s.github, | |||
| 405 | + | "runner": s.runner, | |||
| 406 | + | "job": s.job, | |||
| 407 | + | "env": env.Dict(), | |||
| 408 | + | "steps": s.stepsValue(), | |||
| 409 | + | "secrets": expr.NewDictionary(), | |||
| 410 | + | } | |||
| 411 | + | for k, v := range s.other { | |||
| 412 | + | m[k] = v | |||
| 413 | + | } | |||
| 414 | + | return m | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | // githubEnvAllowlist is GitHubContext._contextEnvAllowlist (GitHubContext.cs:9). | |||
| 418 | + | var githubEnvAllowlist = map[string]bool{ | |||
| 419 | + | "action_path": true, "action_ref": true, "action_repository": true, "action": true, "actor": true, | |||
| 420 | + | "actor_id": true, "api_url": true, "artifacts": true, "artifacts_list": true, "base_ref": true, | |||
| 421 | + | "env": true, "event_name": true, "event_path": true, "graphql_url": true, "head_ref": true, | |||
| 422 | + | "job": true, "output": true, "path": true, "ref_name": true, "ref_protected": true, "ref_type": true, | |||
| 423 | + | "ref": true, "repository": true, "repository_id": true, "repository_owner": true, | |||
| 424 | + | "repository_owner_id": true, "retention_days": true, "run_attempt": true, "run_id": true, | |||
| 425 | + | "run_number": true, "server_url": true, "sha": true, "state": true, "step_summary": true, | |||
| 426 | + | "triggering_actor": true, "workflow": true, "workflow_ref": true, "workflow_sha": true, "workspace": true, | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | // RuntimeEnv is the environment that the contexts add to every step: the | |||
| 430 | + | // allow-listed keys of github as GITHUB_<KEY> (strings and booleans only, as in | |||
| 431 | + | // GitHubContext.GetRuntimeEnvironmentVariables), and every key of runner as | |||
| 432 | + | // RUNNER_<KEY> (RunnerContext.cs:11). Numbers and objects are skipped: the | |||
| 433 | + | // server must send GitHub's string forms ("run_id": "123"). | |||
| 434 | + | func (s *State) RuntimeEnv() *ordered { | |||
| 435 | + | out := newOrdered() | |||
| 436 | + | for _, p := range s.github.Pairs() { | |||
| 437 | + | if !githubEnvAllowlist[strings.ToLower(p.Key)] { | |||
| 438 | + | continue | |||
| 439 | + | } | |||
| 440 | + | switch v := p.Value.(type) { | |||
| 441 | + | case expr.String: | |||
| 442 | + | out.Set("GITHUB_"+strings.ToUpper(p.Key), string(v)) | |||
| 443 | + | case expr.Boolean: | |||
| 444 | + | out.Set("GITHUB_"+strings.ToUpper(p.Key), map[bool]string{true: "true", false: "false"}[bool(v)]) | |||
| 445 | + | } | |||
| 446 | + | } | |||
| 447 | + | for _, p := range s.runner.Pairs() { | |||
| 448 | + | if v, ok := p.Value.(expr.String); ok { | |||
| 449 | + | out.Set("RUNNER_"+strings.ToUpper(p.Key), string(v)) | |||
| 450 | + | } | |||
| 451 | + | } | |||
| 452 | + | return out | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | // EventJSON returns github.event as JSON for event.json, or "" without event. | |||
| 456 | + | func (s *State) EventJSON() string { | |||
| 457 | + | v := s.Event() | |||
| 458 | + | if v == nil { | |||
| 459 | + | return "" | |||
| 460 | + | } | |||
| 461 | + | if _, isNull := v.(expr.Null); isNull { | |||
| 462 | + | return "" | |||
| 463 | + | } | |||
| 464 | + | return expr.ToJSON(v) | |||
| 465 | + | } | |||
A internal/executor/contexts_test.go +245 -0
| @@ -0,0 +1,245 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "reflect" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite-actions/expr" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const testContexts = `{ | |||
| 14 | + | "github": {"repository":"octo/hello","repository_owner":"octo","run_id":"42","run_number":"7","run_attempt":"1", | |||
| 15 | + | "ref":"refs/heads/main","ref_name":"main","ref_type":"branch","sha":"abc","event_name":"push","job":"build", | |||
| 16 | + | "ref_protected":false,"workflow":"CI","actor":"octo","server_url":"https://ermite.example", | |||
| 17 | + | "event":{"ref":"refs/heads/main","n":1},"retention_days":"90","unknown_key":"x"}, | |||
| 18 | + | "needs": {"a":{"result":"success","outputs":{"o":"1"}}}, | |||
| 19 | + | "strategy": {"fail-fast":true,"job-index":0,"job-total":1,"max-parallel":1}, | |||
| 20 | + | "matrix": {"os":"linux"}, | |||
| 21 | + | "inputs": {}, | |||
| 22 | + | "vars": {"V":"1"} | |||
| 23 | + | }` | |||
| 24 | + | ||||
| 25 | + | func newTestState(t *testing.T) *State { | |||
| 26 | + | t.Helper() | |||
| 27 | + | s, _, err := NewState(testContexts, `{"os":"linux"}`, "build", RunnerInfo{Name: "r1"}, "") | |||
| 28 | + | if err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | return s | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestNewStateLayoutAndGitHub(t *testing.T) { | |||
| 35 | + | s := newTestState(t) | |||
| 36 | + | l := s.Layout() | |||
| 37 | + | if l.Workspace != "/home/runner/work/hello/hello" || l.RunnerWorkspace != "/home/runner/work/hello" || | |||
| 38 | + | l.Temp != "/home/runner/work/_temp" || l.ToolCache != "/opt/hostedtoolcache" || l.Home != "/home/runner" { | |||
| 39 | + | t.Fatalf("%+v", l) | |||
| 40 | + | } | |||
| 41 | + | if s.GitHub("workspace") != l.Workspace || s.GitHub("job") != "build" { | |||
| 42 | + | t.Fatalf("workspace %q job %q", s.GitHub("workspace"), s.GitHub("job")) | |||
| 43 | + | } | |||
| 44 | + | // token comes first (ExecutionContext.cs:1023): an empty token, no GITHUB_TOKEN yet. | |||
| 45 | + | gh := s.Values(nil)["github"].(*expr.Dictionary) | |||
| 46 | + | if gh.Keys()[0] != "token" { | |||
| 47 | + | t.Fatalf("keys %v", gh.Keys()) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestRuntimeEnv(t *testing.T) { | |||
| 52 | + | s := newTestState(t) | |||
| 53 | + | env := s.RuntimeEnv() | |||
| 54 | + | want := map[string]string{ | |||
| 55 | + | "GITHUB_REPOSITORY": "octo/hello", "GITHUB_REPOSITORY_OWNER": "octo", "GITHUB_RUN_ID": "42", | |||
| 56 | + | "GITHUB_RUN_NUMBER": "7", "GITHUB_RUN_ATTEMPT": "1", "GITHUB_REF": "refs/heads/main", | |||
| 57 | + | "GITHUB_REF_NAME": "main", "GITHUB_REF_TYPE": "branch", "GITHUB_SHA": "abc", "GITHUB_EVENT_NAME": "push", | |||
| 58 | + | "GITHUB_JOB": "build", "GITHUB_REF_PROTECTED": "false", "GITHUB_WORKFLOW": "CI", "GITHUB_ACTOR": "octo", | |||
| 59 | + | "GITHUB_SERVER_URL": "https://ermite.example", "GITHUB_RETENTION_DAYS": "90", | |||
| 60 | + | "GITHUB_WORKSPACE": "/home/runner/work/hello/hello", | |||
| 61 | + | "RUNNER_OS": "Linux", "RUNNER_NAME": "r1", "RUNNER_ENVIRONMENT": "self-hosted", | |||
| 62 | + | "RUNNER_TEMP": "/home/runner/work/_temp", "RUNNER_TOOL_CACHE": "/opt/hostedtoolcache", | |||
| 63 | + | "RUNNER_WORKSPACE": "/home/runner/work/hello", | |||
| 64 | + | } | |||
| 65 | + | for k, v := range want { | |||
| 66 | + | if got, ok := env.Get(k); !ok || got != v { | |||
| 67 | + | t.Errorf("%s = %q (%v), want %q", k, got, ok, v) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | // Only allow-listed string/boolean keys; objects are not exported. | |||
| 71 | + | for _, k := range []string{"GITHUB_EVENT", "GITHUB_UNKNOWN_KEY", "GITHUB_TOKEN"} { | |||
| 72 | + | if _, ok := env.Get(k); ok { | |||
| 73 | + | t.Errorf("%s must not be set", k) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | if a, _ := env.Get("RUNNER_ARCH"); a != "X64" && a != "ARM64" { | |||
| 77 | + | t.Errorf("RUNNER_ARCH = %q", a) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestRunnerContextOrderAndValues(t *testing.T) { | |||
| 82 | + | s := newTestState(t) | |||
| 83 | + | r := s.Values(nil)["runner"].(*expr.Dictionary) | |||
| 84 | + | if got := strings.Join(r.Keys(), ","); got != "os,arch,name,environment,tool_cache,temp,workspace" { | |||
| 85 | + | t.Fatal(got) | |||
| 86 | + | } | |||
| 87 | + | s2, _, _ := NewState(testContexts, "", "build", RunnerInfo{Name: "h", Environment: "github-hosted", Arch: "ARM64"}, "") | |||
| 88 | + | r2 := s2.Values(nil)["runner"].(*expr.Dictionary) | |||
| 89 | + | if str(r2, "environment") != "github-hosted" || str(r2, "arch") != "ARM64" { | |||
| 90 | + | t.Fatal(expr.ToJSON(r2)) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestServerContextsAndDefaults(t *testing.T) { | |||
| 95 | + | s := newTestState(t) | |||
| 96 | + | v := s.Values(nil) | |||
| 97 | + | if expr.ToJSON(v["matrix"]) != "{\n \"os\": \"linux\"\n}" { | |||
| 98 | + | t.Fatal(expr.ToJSON(v["matrix"])) | |||
| 99 | + | } | |||
| 100 | + | for _, k := range []string{"needs", "strategy", "inputs", "vars", "steps", "env", "job", "secrets"} { | |||
| 101 | + | if v[k] == nil { | |||
| 102 | + | t.Errorf("context %s missing", k) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | // Absent contexts: needs/inputs/vars are empty objects, matrix is not set | |||
| 106 | + | // (null in expressions), as the recordings show. | |||
| 107 | + | s2, _, err := NewState(`{"github":{"repository":"o/r"}}`, "{}", "j", RunnerInfo{}, "") | |||
| 108 | + | if err != nil { | |||
| 109 | + | t.Fatal(err) | |||
| 110 | + | } | |||
| 111 | + | v2 := s2.Values(nil) | |||
| 112 | + | if _, ok := v2["matrix"]; ok { | |||
| 113 | + | t.Error("matrix must be absent without a matrix") | |||
| 114 | + | } | |||
| 115 | + | if d, ok := v2["needs"].(*expr.Dictionary); !ok || d.Len() != 0 { | |||
| 116 | + | t.Errorf("needs %v", v2["needs"]) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestNewStateErrors(t *testing.T) { | |||
| 121 | + | for _, tc := range []struct{ ctx, matrix, repo, want string }{ | |||
| 122 | + | {`[]`, "", "", "want a JSON object"}, | |||
| 123 | + | {`{`, "", "", "job contexts"}, | |||
| 124 | + | {`{"github":1}`, "", "", "github must be an object"}, | |||
| 125 | + | {`{"github":{}}`, "", "", "github.repository is missing"}, | |||
| 126 | + | {`{"github":{"repository":"o/r"}}`, "{", "", "job matrix"}, | |||
| 127 | + | } { | |||
| 128 | + | _, _, err := NewState(tc.ctx, tc.matrix, "j", RunnerInfo{}, tc.repo) | |||
| 129 | + | if err == nil || !strings.Contains(err.Error(), tc.want) { | |||
| 130 | + | t.Errorf("%q: %v, want %q", tc.ctx, err, tc.want) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | // The repository name can come from the job when the context lacks it. | |||
| 134 | + | if _, _, err := NewState(`{"github":{}}`, "", "j", RunnerInfo{}, "hello"); err != nil { | |||
| 135 | + | t.Fatal(err) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestJobStatus(t *testing.T) { | |||
| 140 | + | s := newTestState(t) | |||
| 141 | + | job := func() *expr.Dictionary { return s.Values(nil)["job"].(*expr.Dictionary) } | |||
| 142 | + | if str(job(), "status") != "success" || job().Keys()[0] != "status" { | |||
| 143 | + | t.Fatal(expr.ToJSON(job())) | |||
| 144 | + | } | |||
| 145 | + | s.SetStatus("failure") | |||
| 146 | + | if str(job(), "status") != "failure" || s.Status() != "failure" { | |||
| 147 | + | t.Fatal(expr.ToJSON(job())) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestEnvOrderAndOverride(t *testing.T) { | |||
| 152 | + | o := newOrdered() | |||
| 153 | + | o.Set("A", "1") | |||
| 154 | + | o.Set("B", "2") | |||
| 155 | + | o.Set("A", "3") // keeps its position | |||
| 156 | + | if !reflect.DeepEqual(o.Keys(), []string{"A", "B"}) || o.vals["A"] != "3" { | |||
| 157 | + | t.Fatalf("%v", o) | |||
| 158 | + | } | |||
| 159 | + | c := o.Clone() | |||
| 160 | + | c.Set("C", "4") | |||
| 161 | + | if o.Len() != 2 || c.Len() != 3 { | |||
| 162 | + | t.Fatal("Clone shares state") | |||
| 163 | + | } | |||
| 164 | + | // Case-sensitive keys, like Linux. | |||
| 165 | + | o.Set("a", "x") | |||
| 166 | + | if o.Len() != 3 { | |||
| 167 | + | t.Fatal("keys must be case-sensitive") | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestStepsContext(t *testing.T) { | |||
| 172 | + | s := newTestState(t) | |||
| 173 | + | s.SetOutput("one", "out", "v") | |||
| 174 | + | s.SetOutput("__run", "ignored", "x") // generated ids are not in the context | |||
| 175 | + | s.SetStepResult("one", "failure", "success") | |||
| 176 | + | s.SetStepResult("__run_2", "success", "success") | |||
| 177 | + | s.SetStepResult("skipped", "skipped", "skipped") | |||
| 178 | + | steps := s.Values(nil)["steps"].(*expr.Dictionary) | |||
| 179 | + | if got := strings.Join(steps.Keys(), ","); got != "one,skipped" { | |||
| 180 | + | t.Fatalf("keys %s", got) | |||
| 181 | + | } | |||
| 182 | + | one, _ := steps.Get("one") | |||
| 183 | + | od := one.(*expr.Dictionary) | |||
| 184 | + | if str(od, "outcome") != "failure" || str(od, "conclusion") != "success" { | |||
| 185 | + | t.Fatal(expr.ToJSON(one)) | |||
| 186 | + | } | |||
| 187 | + | outs, _ := od.Get("outputs") | |||
| 188 | + | if str(outs.(*expr.Dictionary), "out") != "v" || s.Output("one", "out") != "v" || s.Output("none", "x") != "" { | |||
| 189 | + | t.Fatal(expr.ToJSON(outs)) | |||
| 190 | + | } | |||
| 191 | + | // A step that has output but has not completed has no outcome yet. | |||
| 192 | + | s.SetOutput("running", "k", "v") | |||
| 193 | + | r, _ := s.Values(nil)["steps"].(*expr.Dictionary).Get("running") | |||
| 194 | + | if _, ok := r.(*expr.Dictionary).Get("outcome"); ok { | |||
| 195 | + | t.Fatal("outcome before completion") | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func TestAddPathMovesRepeatedEntryToTheEnd(t *testing.T) { | |||
| 200 | + | s := newTestState(t) | |||
| 201 | + | for _, p := range []string{"/a", "/b", "/a"} { | |||
| 202 | + | s.AddPath(p) | |||
| 203 | + | } | |||
| 204 | + | if !reflect.DeepEqual(s.Prepend(), []string{"/b", "/a"}) { | |||
| 205 | + | t.Fatal(s.Prepend()) | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func TestEnvContextForSteps(t *testing.T) { | |||
| 210 | + | s := newTestState(t) | |||
| 211 | + | s.SetEnv("JOB", "j") | |||
| 212 | + | step := s.JobEnv() | |||
| 213 | + | step.Set("STEP", "s") | |||
| 214 | + | env := s.Values(step)["env"].(*expr.Dictionary) | |||
| 215 | + | if str(env, "JOB") != "j" || str(env, "STEP") != "s" { | |||
| 216 | + | t.Fatal(expr.ToJSON(env)) | |||
| 217 | + | } | |||
| 218 | + | // The job environment is untouched by the step's copy. | |||
| 219 | + | if _, ok := s.JobEnv().Get("STEP"); ok { | |||
| 220 | + | t.Fatal("step env leaked into the job env") | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func TestEventJSON(t *testing.T) { | |||
| 225 | + | s := newTestState(t) | |||
| 226 | + | if got := s.EventJSON(); !strings.Contains(got, `"ref": "refs/heads/main"`) { | |||
| 227 | + | t.Fatal(got) | |||
| 228 | + | } | |||
| 229 | + | s2, _, _ := NewState(`{"github":{"repository":"o/r"}}`, "", "j", RunnerInfo{}, "") | |||
| 230 | + | if s2.EventJSON() != "" { | |||
| 231 | + | t.Fatal("no event, no event.json") | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestWithLayout(t *testing.T) { | |||
| 236 | + | s := newTestState(t) | |||
| 237 | + | l := NewLayout("/tmp/h", "hello", "/tmp/tc") | |||
| 238 | + | s.WithLayout(l) | |||
| 239 | + | env := s.RuntimeEnv() | |||
| 240 | + | for k, v := range map[string]string{"GITHUB_WORKSPACE": "/tmp/h/work/hello/hello", "RUNNER_TEMP": "/tmp/h/work/_temp", "RUNNER_TOOL_CACHE": "/tmp/tc"} { | |||
| 241 | + | if got, _ := env.Get(k); got != v { | |||
| 242 | + | t.Errorf("%s = %q", k, got) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | } | |||
A internal/executor/corpus_test.go +327 -0
| @@ -0,0 +1,327 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "os/exec" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "regexp" | |||
| 12 | + | "strings" | |||
| 13 | + | "sync" | |||
| 14 | + | "testing" | |||
| 15 | + | "time" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // These tests run jobs of the ermite-conformance corpus (the CI-1 scenarios) | |||
| 19 | + | // through the executor on this machine and compare the steps, their | |||
| 20 | + | // conclusions and their logs with the recordings made on github.com. They need | |||
| 21 | + | // a checkout of ermite-conformance (the corpus and recordings directories), | |||
| 22 | + | // found through ERMITE_CONFORMANCE_DIR or as a sibling of this repository; | |||
| 23 | + | // they skip without it. | |||
| 24 | + | ||||
| 25 | + | func conformanceDir(t *testing.T) string { | |||
| 26 | + | t.Helper() | |||
| 27 | + | cands := []string{os.Getenv("ERMITE_CONFORMANCE_DIR")} | |||
| 28 | + | wd, _ := os.Getwd() | |||
| 29 | + | for d := wd; d != "/" && d != "."; d = filepath.Dir(d) { | |||
| 30 | + | cands = append(cands, | |||
| 31 | + | filepath.Join(d, "ermite-conformance", ".worktrees", "corpus"), | |||
| 32 | + | filepath.Join(d, "ermite-conformance")) | |||
| 33 | + | } | |||
| 34 | + | for _, c := range cands { | |||
| 35 | + | if c == "" { | |||
| 36 | + | continue | |||
| 37 | + | } | |||
| 38 | + | if st, err := os.Stat(filepath.Join(c, "corpus")); err == nil && st.IsDir() { | |||
| 39 | + | return c | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | t.Skip("ermite-conformance checkout not found (set ERMITE_CONFORMANCE_DIR)") | |||
| 43 | + | return "" | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func needTools(t *testing.T, names ...string) { | |||
| 47 | + | t.Helper() | |||
| 48 | + | for _, n := range names { | |||
| 49 | + | if _, err := exec.LookPath(n); err != nil { | |||
| 50 | + | t.Skipf("%s not installed", n) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // recorder is a Reporter that keeps everything. | |||
| 56 | + | type recorder struct { | |||
| 57 | + | mu sync.Mutex | |||
| 58 | + | steps []*recStep | |||
| 59 | + | byNum map[int]*recStep | |||
| 60 | + | result Result | |||
| 61 | + | done bool | |||
| 62 | + | lastLogTime time.Time | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | type recStep struct { | |||
| 66 | + | Num int | |||
| 67 | + | Name string | |||
| 68 | + | Conclusion Conclusion | |||
| 69 | + | Log []string | |||
| 70 | + | Started time.Time | |||
| 71 | + | Completed time.Time | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func newRecorder() *recorder { return &recorder{byNum: map[int]*recStep{}} } | |||
| 75 | + | ||||
| 76 | + | func (r *recorder) StepStarted(n int, name string, at time.Time) { | |||
| 77 | + | r.mu.Lock() | |||
| 78 | + | defer r.mu.Unlock() | |||
| 79 | + | s := &recStep{Num: n, Name: name, Started: at} | |||
| 80 | + | r.steps = append(r.steps, s) | |||
| 81 | + | r.byNum[n] = s | |||
| 82 | + | } | |||
| 83 | + | func (r *recorder) Log(n int, at time.Time, line string) { | |||
| 84 | + | r.mu.Lock() | |||
| 85 | + | defer r.mu.Unlock() | |||
| 86 | + | if s := r.byNum[n]; s != nil { | |||
| 87 | + | s.Log = append(s.Log, line) | |||
| 88 | + | } | |||
| 89 | + | r.lastLogTime = at | |||
| 90 | + | } | |||
| 91 | + | func (r *recorder) StepCompleted(n int, name string, c Conclusion, at time.Time) { | |||
| 92 | + | r.mu.Lock() | |||
| 93 | + | defer r.mu.Unlock() | |||
| 94 | + | s := r.byNum[n] | |||
| 95 | + | s.Conclusion, s.Completed = c, at | |||
| 96 | + | if name != "" { | |||
| 97 | + | s.Name = name | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | func (r *recorder) JobCompleted(res Result) { | |||
| 101 | + | r.mu.Lock() | |||
| 102 | + | defer r.mu.Unlock() | |||
| 103 | + | r.result, r.done = res, true | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func (r *recorder) step(t *testing.T, name string) *recStep { | |||
| 107 | + | t.Helper() | |||
| 108 | + | for _, s := range r.steps { | |||
| 109 | + | if s.Name == name { | |||
| 110 | + | return s | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | t.Fatalf("no step %q in %v", name, r.names()) | |||
| 114 | + | return nil | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func (r *recorder) names() []string { | |||
| 118 | + | var out []string | |||
| 119 | + | for _, s := range r.steps { | |||
| 120 | + | out = append(out, s.Name) | |||
| 121 | + | } | |||
| 122 | + | return out | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // testGitHubContext is a push on a branch, with the keys the corpus reads. | |||
| 126 | + | func testGitHubContext(job string) string { | |||
| 127 | + | return fmt.Sprintf(`{"github":{"repository":"octo/hello","repository_owner":"octo","run_id":"1","run_number":"1","run_attempt":"1", | |||
| 128 | + | "ref":"refs/heads/scn/x/r1","ref_name":"scn/x/r1","ref_type":"branch","ref_protected":false,"sha":"0123456789abcdef0123456789abcdef01234567", | |||
| 129 | + | "event_name":"push","event":{"ref":"refs/heads/scn/x/r1"},"job":%q,"workflow":"w","actor":"octo","triggering_actor":"octo", | |||
| 130 | + | "server_url":"https://ermite.example","retention_days":"90"}}`, job) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // runLocal runs one job of a workflow file on this machine under a temporary | |||
| 134 | + | // home. The environment is minimal and fixed. | |||
| 135 | + | func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) { | |||
| 136 | + | t.Helper() | |||
| 137 | + | home := t.TempDir() | |||
| 138 | + | layout := NewLayout(home, "hello", filepath.Join(home, "toolcache")) | |||
| 139 | + | rec := newRecorder() | |||
| 140 | + | cfg := Config{ | |||
| 141 | + | WorkflowPath: ".github/workflows/ci.yml", WorkflowYAML: []byte(yaml), JobKey: jobKey, | |||
| 142 | + | ContextsJSON: testGitHubContext(jobKey), Runner: RunnerInfo{Name: "test-runner", Environment: "github-hosted"}, | |||
| 143 | + | RunnerVersion: "test", Layout: &layout, Reporter: rec, | |||
| 144 | + | BaseEnv: []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home, "LANG=C.UTF-8"}, | |||
| 145 | + | TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 500 * time.Millisecond, | |||
| 146 | + | } | |||
| 147 | + | if tweak != nil { | |||
| 148 | + | tweak(&cfg) | |||
| 149 | + | } | |||
| 150 | + | res := Run(nil, cfg) | |||
| 151 | + | return rec, res | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func readFile(t *testing.T, p string) string { | |||
| 155 | + | t.Helper() | |||
| 156 | + | b, err := os.ReadFile(p) | |||
| 157 | + | if err != nil { | |||
| 158 | + | t.Fatal(err) | |||
| 159 | + | } | |||
| 160 | + | return string(b) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | type recording struct { | |||
| 164 | + | Pushes []struct { | |||
| 165 | + | Runs []struct { | |||
| 166 | + | Conclusion string `json:"conclusion"` | |||
| 167 | + | Jobs []struct { | |||
| 168 | + | Name string `json:"name"` | |||
| 169 | + | Conclusion string `json:"conclusion"` | |||
| 170 | + | Steps []struct { | |||
| 171 | + | Name string `json:"name"` | |||
| 172 | + | Conclusion string `json:"conclusion"` | |||
| 173 | + | Log []string `json:"log"` | |||
| 174 | + | } `json:"steps"` | |||
| 175 | + | } `json:"jobs"` | |||
| 176 | + | } `json:"runs"` | |||
| 177 | + | } `json:"pushes"` | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | var ( | |||
| 181 | + | shellPathRe = regexp.MustCompile(`^shell: \S*/(\w+) `) | |||
| 182 | + | startPathRe = regexp.MustCompile(`process '\S*/(\w+)'`) | |||
| 183 | + | uuidRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) | |||
| 184 | + | ) | |||
| 185 | + | ||||
| 186 | + | // normalizeLine maps the machine-dependent parts of a line to the form of the | |||
| 187 | + | // recordings, like the normalisation rules of ermite-conformance: the | |||
| 188 | + | // directory of the shell binary, the home directory, the repository name and | |||
| 189 | + | // the random names of script files. | |||
| 190 | + | func normalizeLine(l, home string) string { | |||
| 191 | + | l = shellPathRe.ReplaceAllString(l, "shell: /usr/bin/$1 ") | |||
| 192 | + | l = startPathRe.ReplaceAllString(l, "process '/usr/bin/$1'") | |||
| 193 | + | l = strings.ReplaceAll(l, home, "/home/runner") | |||
| 194 | + | l = strings.ReplaceAll(l, "/work/hello/hello", "/work/<repo>/<repo>") | |||
| 195 | + | return uuidRe.ReplaceAllString(l, "<uuid>") | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // shIsBash reports whether sh on this machine is bash (Arch, Fedora): the | |||
| 199 | + | // image of ermite has dash, so a step that checks BASH_VERSION under sh would | |||
| 200 | + | // differ here for a reason that has nothing to do with the executor. | |||
| 201 | + | func shIsBash() bool { | |||
| 202 | + | out, err := exec.Command("sh", "-c", `echo "${BASH_VERSION-unset}"`).Output() | |||
| 203 | + | return err == nil && strings.TrimSpace(string(out)) != "unset" | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // compareWithRecording runs every job without needs of a scenario and checks | |||
| 207 | + | // that the steps, conclusions and logs equal the recording of github.com. Set | |||
| 208 | + | // up job (runner and image details) and the dump step the harness appends are | |||
| 209 | + | // not compared. | |||
| 210 | + | func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) { | |||
| 211 | + | t.Helper() | |||
| 212 | + | needTools(t, "bash", "sh") | |||
| 213 | + | dir := conformanceDir(t) | |||
| 214 | + | files, _ := filepath.Glob(filepath.Join(dir, "corpus", scenario, "tree", ".github", "workflows", "*.yml")) | |||
| 215 | + | if len(files) != 1 { | |||
| 216 | + | t.Fatalf("scenario %s: workflow files %v", scenario, files) | |||
| 217 | + | } | |||
| 218 | + | yaml := readFile(t, files[0]) | |||
| 219 | + | var rec recording | |||
| 220 | + | if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, "recordings", scenario+".json"))), &rec); err != nil { | |||
| 221 | + | t.Fatal(err) | |||
| 222 | + | } | |||
| 223 | + | if len(rec.Pushes) == 0 || len(rec.Pushes[0].Runs) == 0 { | |||
| 224 | + | t.Skip("scenario not recorded") | |||
| 225 | + | } | |||
| 226 | + | skip := map[string]bool{} | |||
| 227 | + | for _, s := range skipJobs { | |||
| 228 | + | skip[s] = true | |||
| 229 | + | } | |||
| 230 | + | n := 0 | |||
| 231 | + | for _, jr := range rec.Pushes[0].Runs[0].Jobs { | |||
| 232 | + | if skip[jr.Name] { | |||
| 233 | + | continue | |||
| 234 | + | } | |||
| 235 | + | jr := jr | |||
| 236 | + | t.Run(jr.Name, func(t *testing.T) { | |||
| 237 | + | var home string | |||
| 238 | + | got, res := runLocal(t, yaml, jr.Name, func(c *Config) { home = c.Layout.Home }) | |||
| 239 | + | // Expected steps: the recording without Set up job and the dump step. | |||
| 240 | + | type es struct { | |||
| 241 | + | Name, Conclusion string | |||
| 242 | + | Log []string | |||
| 243 | + | } | |||
| 244 | + | var want []es | |||
| 245 | + | for _, s := range jr.Steps { | |||
| 246 | + | if s.Name == SetUpJobName || s.Name == "ermite-dump" { | |||
| 247 | + | continue | |||
| 248 | + | } | |||
| 249 | + | want = append(want, es{s.Name, s.Conclusion, s.Log}) | |||
| 250 | + | } | |||
| 251 | + | var have []es | |||
| 252 | + | for _, s := range got.steps { | |||
| 253 | + | if s.Name == SetUpJobName { | |||
| 254 | + | continue | |||
| 255 | + | } | |||
| 256 | + | var log []string | |||
| 257 | + | for _, l := range s.Log { | |||
| 258 | + | log = append(log, normalizeLine(l, home)) | |||
| 259 | + | } | |||
| 260 | + | have = append(have, es{s.Name, string(s.Conclusion), log}) | |||
| 261 | + | } | |||
| 262 | + | if len(have) != len(want) { | |||
| 263 | + | t.Fatalf("steps: got %v, want %d steps", got.names(), len(want)) | |||
| 264 | + | } | |||
| 265 | + | for i := range want { | |||
| 266 | + | w, h := want[i], have[i] | |||
| 267 | + | if h.Name != w.Name || h.Conclusion != w.Conclusion { | |||
| 268 | + | t.Errorf("step %d: got %q (%s), want %q (%s)", i+1, h.Name, h.Conclusion, w.Name, w.Conclusion) | |||
| 269 | + | continue | |||
| 270 | + | } | |||
| 271 | + | if shIsBash() && strings.Contains(strings.Join(w.Log, "\n"), "shell: /usr/bin/sh ") { | |||
| 272 | + | continue // sh is bash here, dash on the image: the output is not comparable | |||
| 273 | + | } | |||
| 274 | + | if strings.Join(h.Log, "\n") != strings.Join(w.Log, "\n") { | |||
| 275 | + | t.Errorf("step %q log differs:\n--- got\n%s\n--- want\n%s", w.Name, strings.Join(h.Log, "\n"), strings.Join(w.Log, "\n")) | |||
| 276 | + | } | |||
| 277 | + | } | |||
| 278 | + | if string(res.Conclusion) != jr.Conclusion && jr.Conclusion != "" { | |||
| 279 | + | // The dump step fails on GitHub when an earlier step failed in | |||
| 280 | + | // some scenarios; the job conclusion is still the one of the steps. | |||
| 281 | + | t.Errorf("job conclusion %s, recording %s", res.Conclusion, jr.Conclusion) | |||
| 282 | + | } | |||
| 283 | + | }) | |||
| 284 | + | n++ | |||
| 285 | + | } | |||
| 286 | + | if n == 0 { | |||
| 287 | + | t.Fatal("no job compared") | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // Scenarios whose jobs have no dependencies and whose logs do not depend on | |||
| 292 | + | // the machine, in the order of the plan's list (shells, env, outputs, file | |||
| 293 | + | // commands, conditions). | |||
| 294 | + | func TestCorpusEnvPrecedence(t *testing.T) { compareWithRecording(t, "env-precedence") } | |||
| 295 | + | func TestCorpusEnvExpressions(t *testing.T) { compareWithRecording(t, "env-expressions") } | |||
| 296 | + | func TestCorpusEnvSpecialValues(t *testing.T) { compareWithRecording(t, "env-special-values") } | |||
| 297 | + | func TestCorpusOutGithubEnv(t *testing.T) { compareWithRecording(t, "out-github-env") } | |||
| 298 | + | func TestCorpusOutGithubEnvReserved(t *testing.T) { compareWithRecording(t, "out-github-env-reserved") } | |||
| 299 | + | func TestCorpusOutGithubPath(t *testing.T) { compareWithRecording(t, "out-github-path") } | |||
| 300 | + | func TestCorpusOutStepOutputs(t *testing.T) { compareWithRecording(t, "out-step-outputs") } | |||
| 301 | + | func TestCorpusCtxSteps(t *testing.T) { compareWithRecording(t, "ctx-steps") } | |||
| 302 | + | func TestCorpusIfStepConditions(t *testing.T) { compareWithRecording(t, "if-step-conditions") } | |||
| 303 | + | func TestCorpusShellWorkingDirectory(t *testing.T) { | |||
| 304 | + | compareWithRecording(t, "shell-working-directory") | |||
| 305 | + | } | |||
| 306 | + | func TestCorpusShellExitCodes(t *testing.T) { compareWithRecording(t, "shell-exit-codes") } | |||
| 307 | + | func TestCorpusIfJobLevel(t *testing.T) { | |||
| 308 | + | compareWithRecording(t, "if-job-level-fail-effects", "after-job-continue") | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | func envPath() string { return os.Getenv("PATH") } | |||
| 312 | + | ||||
| 313 | + | func TestCorpusShellBuiltin(t *testing.T) { compareWithRecording(t, "shell-builtin") } | |||
| 314 | + | func TestCorpusShellCustom(t *testing.T) { compareWithRecording(t, "shell-custom") } | |||
| 315 | + | func TestCorpusStatusFunctions(t *testing.T) { compareWithRecording(t, "expr-status-functions") } | |||
| 316 | + | ||||
| 317 | + | // GitHub rejects the workflow of expr-status-in-env (status functions are not | |||
| 318 | + | // allowed in a step's env): the recorded run has no jobs. The executor must | |||
| 319 | + | // refuse it at "Set up job" too. | |||
| 320 | + | func TestCorpusStatusInEnvIsRejected(t *testing.T) { | |||
| 321 | + | dir := conformanceDir(t) | |||
| 322 | + | yaml := readFile(t, filepath.Join(dir, "corpus", "expr-status-in-env", "tree", ".github", "workflows", "ci.yml")) | |||
| 323 | + | rec, res := runLocal(t, yaml, "main", nil) | |||
| 324 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "The workflow is not valid") || len(rec.steps) != 1 { | |||
| 325 | + | t.Fatalf("%+v %v", res, rec.names()) | |||
| 326 | + | } | |||
| 327 | + | } | |||
A internal/executor/exec_step.go +248 -0
| @@ -0,0 +1,248 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite-actions/workflow" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // execute is ActionRunner.RunAsync plus ScriptHandler for one run step. | |||
| 14 | + | func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||
| 15 | + | num, st := s.num, s.st | |||
| 16 | + | if msg := notSupported(st); msg != "" { | |||
| 17 | + | if st.Uses != nil { | |||
| 18 | + | j.out(num, "##[group]Run "+st.Uses.Value) | |||
| 19 | + | j.out(num, "##[endgroup]") | |||
| 20 | + | } | |||
| 21 | + | j.errorf(num, "%s", msg) | |||
| 22 | + | return resFailed | |||
| 23 | + | } | |||
| 24 | + | fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed } | |||
| 25 | + | ||||
| 26 | + | if err := j.prepareFileCommands(st); err != nil { | |||
| 27 | + | return fail(err) | |||
| 28 | + | } | |||
| 29 | + | // ProcessFiles runs after the handler, even when it failed (a finally block). | |||
| 30 | + | defer j.fc.Process(s) | |||
| 31 | + | ||||
| 32 | + | in, err := j.scriptInputs(s) | |||
| 33 | + | if err != nil { | |||
| 34 | + | return fail(err) | |||
| 35 | + | } | |||
| 36 | + | plan, err := j.printHeader(s, in) | |||
| 37 | + | if err != nil { | |||
| 38 | + | return fail(err) // the group stays open, like the original | |||
| 39 | + | } | |||
| 40 | + | dir, argv, err := j.scriptCommand(plan, in) | |||
| 41 | + | if err != nil { | |||
| 42 | + | return fail(err) | |||
| 43 | + | } | |||
| 44 | + | return j.startProcess(s, plan, dir, argv, stepCancel) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // scriptIn is what a run step evaluates before it prints its header. | |||
| 48 | + | type scriptIn struct { | |||
| 49 | + | script, shell, workingDirectory string | |||
| 50 | + | hasWorkingDirectory bool | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // scriptInputs evaluates the run, shell and working-directory keys of the step | |||
| 54 | + | // (ActionRunner.cs:174-185). The shell and the directory fall back to | |||
| 55 | + | // defaults.run (ScriptHandler.cs:155-180). | |||
| 56 | + | func (j *job) scriptInputs(s *stepRun) (scriptIn, error) { | |||
| 57 | + | st := s.st | |||
| 58 | + | var in scriptIn | |||
| 59 | + | var err error | |||
| 60 | + | if in.script, err = j.evalString(st.Run, s.env); err != nil { | |||
| 61 | + | return in, err | |||
| 62 | + | } | |||
| 63 | + | if in.shell, err = j.evalString(st.Shell, s.env); err != nil { | |||
| 64 | + | return in, err | |||
| 65 | + | } | |||
| 66 | + | if in.shell == "" { | |||
| 67 | + | in.shell = j.runDefaults.shell | |||
| 68 | + | } | |||
| 69 | + | if st.WorkingDirectory != nil { | |||
| 70 | + | in.hasWorkingDirectory = true | |||
| 71 | + | if in.workingDirectory, err = j.evalString(st.WorkingDirectory, s.env); err != nil { | |||
| 72 | + | return in, err | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | if !in.hasWorkingDirectory { | |||
| 76 | + | in.workingDirectory = j.runDefaults.workingDirectory | |||
| 77 | + | } | |||
| 78 | + | return in, nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // prepareFileCommands creates the files of the step, puts their paths in the | |||
| 82 | + | // github context (FileCommandManager.InitializeFiles) and writes event.json | |||
| 83 | + | // (ExecutionContext.WriteWebhookPayload). | |||
| 84 | + | func (j *job) prepareFileCommands(st *workflow.Step) error { | |||
| 85 | + | j.mu.Lock() | |||
| 86 | + | defer j.mu.Unlock() | |||
| 87 | + | files, err := j.fc.Init() | |||
| 88 | + | if err != nil { | |||
| 89 | + | return err | |||
| 90 | + | } | |||
| 91 | + | for _, c := range fileCommands { | |||
| 92 | + | j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)]) | |||
| 93 | + | } | |||
| 94 | + | j.state.SetGitHub("action", st.ID) | |||
| 95 | + | j.state.SetGitHub("action_repository", "") | |||
| 96 | + | j.state.SetGitHub("action_ref", "") | |||
| 97 | + | if ev := j.state.EventJSON(); ev != "" { | |||
| 98 | + | if p, err := j.writeEvent(ev); err == nil { | |||
| 99 | + | j.state.SetGitHub("event_path", p) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // printHeader prints the group of the step as ScriptHandler.PrintActionDetails | |||
| 106 | + | // does (ScriptHandler.cs:27-137): the first line of the script, the script in | |||
| 107 | + | // bright cyan, the shell, the env block of the step, and the end of the group. | |||
| 108 | + | func (j *job) printHeader(s *stepRun, in scriptIn) (ShellPlan, error) { | |||
| 109 | + | num := s.num | |||
| 110 | + | j.out(num, "##[group]Run "+firstLine(in.script)) | |||
| 111 | + | for _, l := range strings.Split(strings.TrimRight(strings.ReplaceAll(in.script, "\r\n", "\n"), "\n"), "\n") { | |||
| 112 | + | j.write(num, "\x1b[36;1m"+l+"\x1b[0m") | |||
| 113 | + | } | |||
| 114 | + | j.mu.Lock() | |||
| 115 | + | lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()} | |||
| 116 | + | j.mu.Unlock() | |||
| 117 | + | plan, err := PlanShell(in.shell, lookup) | |||
| 118 | + | if err != nil { | |||
| 119 | + | return plan, err | |||
| 120 | + | } | |||
| 121 | + | j.out(num, "shell: "+plan.ShellLine()) | |||
| 122 | + | if s.env.Len() > 0 { | |||
| 123 | + | j.write(num, "env:") | |||
| 124 | + | for _, k := range s.env.Keys() { | |||
| 125 | + | v, _ := s.env.Get(k) | |||
| 126 | + | j.out(num, fmt.Sprintf(" %s: %s", k, v)) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | j.write(num, "##[endgroup]") | |||
| 130 | + | return plan, nil | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // scriptCommand writes the script file and returns the working directory and | |||
| 134 | + | // the command line (ScriptHandler.RunAsync, :140-285). | |||
| 135 | + | func (j *job) scriptCommand(plan ShellPlan, in scriptIn) (dir string, argv []string, err error) { | |||
| 136 | + | j.mu.Lock() | |||
| 137 | + | workspace := j.state.GitHub("workspace") | |||
| 138 | + | j.mu.Unlock() | |||
| 139 | + | sc, err := WriteScript(j.tempRoot, plan, in.script) | |||
| 140 | + | if err != nil { | |||
| 141 | + | return "", nil, err | |||
| 142 | + | } | |||
| 143 | + | return ResolveWorkingDirectory(workspace, in.workingDirectory), sc.Argv, nil | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // processEnv builds the environment of the process: the inherited one, the | |||
| 147 | + | // step's environment, PATH with GITHUB_PATH in front, then the runtime variables | |||
| 148 | + | // of the contexts, GITHUB_ACTIONS, CI and the tracking id. | |||
| 149 | + | func (j *job) processEnv(stepEnv *ordered) []string { | |||
| 150 | + | j.mu.Lock() | |||
| 151 | + | runtime := j.state.RuntimeEnv() | |||
| 152 | + | prepend := j.state.Prepend() | |||
| 153 | + | j.mu.Unlock() | |||
| 154 | + | penv := newOrdered() | |||
| 155 | + | for _, kv := range j.baseEnv { | |||
| 156 | + | if k, v, ok := strings.Cut(kv, "="); ok { | |||
| 157 | + | penv.Set(k, v) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | for _, k := range stepEnv.Keys() { | |||
| 161 | + | v, _ := stepEnv.Get(k) | |||
| 162 | + | penv.Set(k, v) | |||
| 163 | + | } | |||
| 164 | + | if len(prepend) > 0 { | |||
| 165 | + | orig, ok := stepEnv.Get("PATH") | |||
| 166 | + | if !ok { | |||
| 167 | + | orig = j.lookupPath | |||
| 168 | + | } | |||
| 169 | + | penv.Set("PATH", PrependedPath(prepend, orig)) | |||
| 170 | + | } | |||
| 171 | + | for _, k := range runtime.Keys() { | |||
| 172 | + | v, _ := runtime.Get(k) | |||
| 173 | + | penv.Set(k, v) | |||
| 174 | + | } | |||
| 175 | + | penv.Set("GITHUB_ACTIONS", "true") // ProcessInvoker.cs:283 | |||
| 176 | + | if _, ok := penv.Get("CI"); !ok { | |||
| 177 | + | penv.Set("CI", "true") | |||
| 178 | + | } | |||
| 179 | + | penv.Set(ProcessTrackingID, j.trackID) | |||
| 180 | + | var list []string | |||
| 181 | + | for _, k := range penv.Keys() { | |||
| 182 | + | v, _ := penv.Get(k) | |||
| 183 | + | list = append(list, k+"="+v) | |||
| 184 | + | } | |||
| 185 | + | return list | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // startProcess runs the script and turns how it ended into the result of the | |||
| 189 | + | // step. | |||
| 190 | + | func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string, stepCancel <-chan struct{}) stepResult { | |||
| 191 | + | num := s.num | |||
| 192 | + | res, err := runProcess(procSpec{ | |||
| 193 | + | Argv: argv, Dir: dir, Env: j.processEnv(s.env), Credential: j.cfg.Credential, | |||
| 194 | + | OnLine: func(line string) { | |||
| 195 | + | if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") { | |||
| 196 | + | line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`) | |||
| 197 | + | } | |||
| 198 | + | if (strings.Contains(line, "##[") || strings.Contains(line, "::")) && s.cmds.TryProcess(line) { | |||
| 199 | + | return | |||
| 200 | + | } | |||
| 201 | + | j.write(num, line) | |||
| 202 | + | }, | |||
| 203 | + | Cancel: stepCancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, | |||
| 204 | + | WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal, | |||
| 205 | + | }) | |||
| 206 | + | if err != nil { | |||
| 207 | + | j.errorf(num, "%s", startError(plan.CommandPath, dir, err)) | |||
| 208 | + | return resFailed | |||
| 209 | + | } | |||
| 210 | + | if res.Cancelled { | |||
| 211 | + | // The process was stopped on cancellation: ExecuteAsync throws | |||
| 212 | + | // (ProcessInvoker.cs:369) and StepsRunner logs the exception | |||
| 213 | + | // (StepsRunner.cs:333-337). | |||
| 214 | + | j.errorf(num, "The operation was canceled.") | |||
| 215 | + | return resCanceled | |||
| 216 | + | } | |||
| 217 | + | if res.ExitCode != 0 { | |||
| 218 | + | j.errorf(num, "Process completed with exit code %d.", res.ExitCode) | |||
| 219 | + | return resFailed | |||
| 220 | + | } | |||
| 221 | + | return resSucceeded | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // writeEvent writes the webhook payload to RUNNER_TEMP/_github_workflow/event.json | |||
| 225 | + | // (ExecutionContext.WriteWebhookPayload) and returns its path. The directory | |||
| 226 | + | // is the user's: the file is replaced, never followed (see FileCommands). | |||
| 227 | + | func (j *job) writeEvent(payload string) (string, error) { | |||
| 228 | + | const dir, name = "_github_workflow", "event.json" | |||
| 229 | + | if err := j.tempRoot.MkdirAll(dir, 0o755); err != nil { | |||
| 230 | + | return "", err | |||
| 231 | + | } | |||
| 232 | + | if c := j.cfg.Credential; c != nil { | |||
| 233 | + | _ = j.tempRoot.Lchown(dir, int(c.Uid), int(c.Gid)) | |||
| 234 | + | } | |||
| 235 | + | sub, err := j.tempRoot.OpenRoot(dir) | |||
| 236 | + | if err != nil { | |||
| 237 | + | return "", err | |||
| 238 | + | } | |||
| 239 | + | defer func() { _ = sub.Close() }() | |||
| 240 | + | var owner *[2]int | |||
| 241 | + | if c := j.cfg.Credential; c != nil { | |||
| 242 | + | owner = &[2]int{int(c.Uid), int(c.Gid)} | |||
| 243 | + | } | |||
| 244 | + | if err := createExcl(sub, name, []byte(payload), 0o644, owner); err != nil { | |||
| 245 | + | return "", err | |||
| 246 | + | } | |||
| 247 | + | return filepath.Join(j.state.Layout().Temp, dir, name), nil | |||
| 248 | + | } | |||
A internal/executor/filecmd.go +453 -0
| @@ -0,0 +1,453 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | // Package executor runs the steps of a GitHub Actions job inside a sandbox. | |||
| 4 | + | // It is a port of the parts of the official runner (actions/runner, C#, MIT) | |||
| 5 | + | // that CI-1 needs: file commands, shells, contexts and the steps runner. The | |||
| 6 | + | // package does no sandboxing and no networking: it runs local processes and | |||
| 7 | + | // reports to a Reporter. | |||
| 8 | + | package executor | |||
| 9 | + | ||||
| 10 | + | import ( | |||
| 11 | + | "bytes" | |||
| 12 | + | "errors" | |||
| 13 | + | "fmt" | |||
| 14 | + | "io" | |||
| 15 | + | "os" | |||
| 16 | + | "path/filepath" | |||
| 17 | + | "slices" | |||
| 18 | + | "strings" | |||
| 19 | + | "syscall" | |||
| 20 | + | "unicode/utf8" | |||
| 21 | + | ||||
| 22 | + | "github.com/google/uuid" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // Names of the file commands (the GitHub context names) and their | |||
| 26 | + | // environment variables. | |||
| 27 | + | const ( | |||
| 28 | + | cmdPath = "path" | |||
| 29 | + | cmdEnv = "env" | |||
| 30 | + | cmdStepSummary = "step_summary" | |||
| 31 | + | cmdState = "state" | |||
| 32 | + | cmdOutput = "output" | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | // fileCommand describes one file command like the IFileCommandExtension of | |||
| 36 | + | // FileCommandManager.cs. | |||
| 37 | + | type fileCommand struct { | |||
| 38 | + | ContextName string // github context name; the variable is GITHUB_<UPPER> | |||
| 39 | + | FilePrefix string | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // fileCommands is in the order of the official runner's extension list, which | |||
| 43 | + | // is the order ProcessFiles runs them. | |||
| 44 | + | var fileCommands = []fileCommand{ | |||
| 45 | + | {cmdPath, "add_path_"}, // AddPathFileCommand (FileCommandManager.cs:127) | |||
| 46 | + | {cmdEnv, "set_env_"}, // SetEnvFileCommand (:160) | |||
| 47 | + | {cmdStepSummary, "step_summary_"}, // CreateStepSummaryCommand (:221) | |||
| 48 | + | {cmdState, "save_state_"}, // SaveStateFileCommand (:295) | |||
| 49 | + | {cmdOutput, "set_output_"}, // SetOutputFileCommand (:328) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // EnvVarName returns the environment variable that holds the file of a command: | |||
| 53 | + | // GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH, GITHUB_STATE, GITHUB_STEP_SUMMARY | |||
| 54 | + | // (GitHubContext.GetRuntimeEnvironmentVariables, GitHubContext.cs:52). | |||
| 55 | + | func envVarName(contextName string) string { return "GITHUB_" + strings.ToUpper(contextName) } | |||
| 56 | + | ||||
| 57 | + | // stepSummaryLimit is CreateStepSummaryCommand.AttachmentSizeLimit | |||
| 58 | + | // (FileCommandManager.cs:223). | |||
| 59 | + | const stepSummaryLimit = 1024 * 1024 | |||
| 60 | + | ||||
| 61 | + | // setEnvBlockList is SetEnvFileCommand._setEnvBlockList (FileCommandManager.cs:215). | |||
| 62 | + | var setEnvBlockList = []string{"NODE_OPTIONS"} | |||
| 63 | + | ||||
| 64 | + | // msgError is an error whose text is GitHub's own message, capitalised and | |||
| 65 | + | // punctuated like the official runner's. | |||
| 66 | + | type msgError string | |||
| 67 | + | ||||
| 68 | + | func (e msgError) Error() string { return string(e) } | |||
| 69 | + | ||||
| 70 | + | func ghErrorf(format string, a ...any) error { return msgError(fmt.Sprintf(format, a...)) } | |||
| 71 | + | ||||
| 72 | + | // Pair is one name and value read from an environment-style file. | |||
| 73 | + | type Pair struct{ Key, Value string } | |||
| 74 | + | ||||
| 75 | + | // Sink receives the effects of the file commands of a step. | |||
| 76 | + | type Sink interface { | |||
| 77 | + | // SetEnv sets an environment variable of the job (GITHUB_ENV). | |||
| 78 | + | SetEnv(name, value string) | |||
| 79 | + | // SetOutput sets an output of the step (GITHUB_OUTPUT). | |||
| 80 | + | SetOutput(name, value string) | |||
| 81 | + | // AddPath prepends a directory to PATH for the next steps (GITHUB_PATH). The | |||
| 82 | + | // caller keeps the list like Global.PrependPath: the last added is first | |||
| 83 | + | // in PATH, and a repeated entry moves to the end of the list. | |||
| 84 | + | AddPath(path string) | |||
| 85 | + | // SaveState keeps a value for the step's own post phase (GITHUB_STATE). | |||
| 86 | + | SaveState(name, value string) | |||
| 87 | + | // Error writes an error issue to the log (##[error]). | |||
| 88 | + | Error(msg string) | |||
| 89 | + | // Debug writes a ##[debug] line when step debugging is on. | |||
| 90 | + | Debug(msg string) | |||
| 91 | + | // CommandFailed marks the step as failed (context.CommandResult = Failed). | |||
| 92 | + | CommandFailed() | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // FileCommands manages the per-step files, like FileCommandManager. | |||
| 96 | + | // | |||
| 97 | + | // The directory belongs to the user of the steps, and the executor reads the | |||
| 98 | + | // files as root after each step, so every access goes through an os.Root opened | |||
| 99 | + | // before the first step ran: replacing the directory or a file by a symbolic | |||
| 100 | + | // link cannot make the executor read or write anything outside it. Files are | |||
| 101 | + | // opened with O_NOFOLLOW and must be regular files. | |||
| 102 | + | type FileCommands struct { | |||
| 103 | + | dir string | |||
| 104 | + | root *os.Root | |||
| 105 | + | suffix string | |||
| 106 | + | // owner, when set, is the user the files are given to. | |||
| 107 | + | owner *[2]int | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // NewFileCommands uses dir (RUNNER_TEMP/_runner_file_commands), creating it. | |||
| 111 | + | // Call Close when the job is over. | |||
| 112 | + | func NewFileCommands(dir string) (*FileCommands, error) { | |||
| 113 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |||
| 114 | + | return nil, err | |||
| 115 | + | } | |||
| 116 | + | root, err := os.OpenRoot(dir) | |||
| 117 | + | if err != nil { | |||
| 118 | + | return nil, err | |||
| 119 | + | } | |||
| 120 | + | return &FileCommands{dir: dir, root: root}, nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // Close releases the directory. | |||
| 124 | + | func (f *FileCommands) Close() error { return f.root.Close() } | |||
| 125 | + | ||||
| 126 | + | // SetOwner gives the files of the next steps to uid:gid. | |||
| 127 | + | func (f *FileCommands) SetOwner(uid, gid int) { f.owner = &[2]int{uid, gid} } | |||
| 128 | + | ||||
| 129 | + | func (f *FileCommands) name(c fileCommand, suffix string) string { return c.FilePrefix + suffix } | |||
| 130 | + | ||||
| 131 | + | func (f *FileCommands) path(c fileCommand, suffix string) string { | |||
| 132 | + | return filepath.Join(f.dir, f.name(c, suffix)) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // Init creates fresh, empty files for the next step (after deleting the ones | |||
| 136 | + | // of the previous step) and returns the environment variables that name them | |||
| 137 | + | // (InitializeFiles, FileCommandManager.cs:42). | |||
| 138 | + | func (f *FileCommands) Init() (map[string]string, error) { | |||
| 139 | + | old := f.suffix | |||
| 140 | + | f.suffix = uuid.NewString() | |||
| 141 | + | env := make(map[string]string, len(fileCommands)) | |||
| 142 | + | for _, c := range fileCommands { | |||
| 143 | + | if old != "" { | |||
| 144 | + | _ = f.root.Remove(f.name(c, old)) | |||
| 145 | + | } | |||
| 146 | + | if err := createExcl(f.root, f.name(c, f.suffix), nil, 0o644, f.owner); err != nil { | |||
| 147 | + | return nil, err | |||
| 148 | + | } | |||
| 149 | + | env[envVarName(c.ContextName)] = f.path(c, f.suffix) | |||
| 150 | + | } | |||
| 151 | + | return env, nil | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // Process reads the files of the step that just ran and applies them to sink. | |||
| 155 | + | // A command that fails does not stop the others (ProcessFiles, :75). | |||
| 156 | + | func (f *FileCommands) Process(sink Sink) { | |||
| 157 | + | for _, c := range fileCommands { | |||
| 158 | + | p := f.name(c, f.suffix) | |||
| 159 | + | var err error | |||
| 160 | + | switch c.ContextName { | |||
| 161 | + | case cmdPath: | |||
| 162 | + | err = processAddPath(sink, f.root, p) | |||
| 163 | + | case cmdEnv: | |||
| 164 | + | err = processSetEnv(sink, f.root, p) | |||
| 165 | + | case cmdStepSummary: | |||
| 166 | + | err = processStepSummary(sink, f.root, p) | |||
| 167 | + | case cmdState: | |||
| 168 | + | err = processPairs(sink, f.root, p, sink.SaveState, func(k, v string) { sink.Debug(fmt.Sprintf("Save intra-action state %s = %s", k, v)) }) | |||
| 169 | + | case cmdOutput: | |||
| 170 | + | err = processPairs(sink, f.root, p, sink.SetOutput, func(k, v string) { sink.Debug(fmt.Sprintf("Set output %s = %s", k, v)) }) | |||
| 171 | + | } | |||
| 172 | + | if err != nil { | |||
| 173 | + | sink.Error(fmt.Sprintf("Unable to process file command '%s' successfully.", c.ContextName)) | |||
| 174 | + | sink.Error(err.Error()) | |||
| 175 | + | sink.CommandFailed() | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // errNotRegular is the error of a file command file that was replaced by | |||
| 181 | + | // something else (a symbolic link, a FIFO, a directory). | |||
| 182 | + | var errNotRegular = ghErrorf("The file is not a regular file") | |||
| 183 | + | ||||
| 184 | + | // openRegular opens name in root for reading without following a symbolic link | |||
| 185 | + | // and without blocking on a FIFO, and checks that it is a regular file. | |||
| 186 | + | func openRegular(root *os.Root, name string) (*os.File, error) { | |||
| 187 | + | fh, err := root.OpenFile(name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) | |||
| 188 | + | if err != nil { | |||
| 189 | + | if errors.Is(err, syscall.ELOOP) || errors.Is(err, syscall.EMLINK) { | |||
| 190 | + | return nil, errNotRegular | |||
| 191 | + | } | |||
| 192 | + | return nil, err | |||
| 193 | + | } | |||
| 194 | + | st, err := fh.Stat() | |||
| 195 | + | if err != nil { | |||
| 196 | + | _ = fh.Close() | |||
| 197 | + | return nil, err | |||
| 198 | + | } | |||
| 199 | + | if !st.Mode().IsRegular() { | |||
| 200 | + | _ = fh.Close() | |||
| 201 | + | return nil, errNotRegular | |||
| 202 | + | } | |||
| 203 | + | return fh, nil | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // readText is File.ReadAllText: a UTF-8 BOM is dropped and invalid bytes | |||
| 207 | + | // become U+FFFD. A missing file gives ok=false and no error (the callers | |||
| 208 | + | // debug-log it; AddPath and StepSummary skip silently); a file that is not a | |||
| 209 | + | // regular file is an error. | |||
| 210 | + | func readText(root *os.Root, name string) (text string, ok bool, err error) { | |||
| 211 | + | fh, err := openRegular(root, name) | |||
| 212 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 213 | + | return "", false, nil | |||
| 214 | + | } | |||
| 215 | + | if err != nil { | |||
| 216 | + | return "", false, err | |||
| 217 | + | } | |||
| 218 | + | defer func() { _ = fh.Close() }() | |||
| 219 | + | b, err := io.ReadAll(fh) | |||
| 220 | + | if err != nil { | |||
| 221 | + | return "", false, err | |||
| 222 | + | } | |||
| 223 | + | b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf")) | |||
| 224 | + | if !utf8.Valid(b) { | |||
| 225 | + | b = bytes.ToValidUTF8(b, []byte("\ufffd")) | |||
| 226 | + | } | |||
| 227 | + | return string(b), true, nil | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // createExcl creates name in root with the content, failing if it exists (an | |||
| 231 | + | // existing entry, a symbolic link included, is removed first, never followed), | |||
| 232 | + | // and gives it to owner when set. The creation uses O_EXCL|O_NOFOLLOW, so | |||
| 233 | + | // something planted between the removal and the creation makes it fail. The | |||
| 234 | + | // owner is set on the open file, not by name: the user of the steps owns the | |||
| 235 | + | // directory and could swap the name for something else between the close and a | |||
| 236 | + | // chown by name. | |||
| 237 | + | func createExcl(root *os.Root, name string, content []byte, perm os.FileMode, owner *[2]int) error { | |||
| 238 | + | if err := root.Remove(name); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 239 | + | return err | |||
| 240 | + | } | |||
| 241 | + | fh, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, perm) | |||
| 242 | + | if err != nil { | |||
| 243 | + | return err | |||
| 244 | + | } | |||
| 245 | + | if _, err := fh.Write(content); err != nil { | |||
| 246 | + | _ = fh.Close() | |||
| 247 | + | return err | |||
| 248 | + | } | |||
| 249 | + | if owner != nil { | |||
| 250 | + | if err := fh.Chown(owner[0], owner[1]); err != nil { | |||
| 251 | + | _ = fh.Close() | |||
| 252 | + | return err | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | return fh.Close() | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // processAddPath is AddPathFileCommand.ProcessCommand (FileCommandManager.cs:134). | |||
| 259 | + | func processAddPath(sink Sink, root *os.Root, name string) error { | |||
| 260 | + | text, ok, err := readText(root, name) | |||
| 261 | + | if err != nil || !ok { | |||
| 262 | + | return err | |||
| 263 | + | } | |||
| 264 | + | // File.ReadAllLines splits on \r\n, \n and \r. | |||
| 265 | + | for _, line := range splitLines(text) { | |||
| 266 | + | if line == "" { | |||
| 267 | + | continue | |||
| 268 | + | } | |||
| 269 | + | sink.AddPath(line) | |||
| 270 | + | } | |||
| 271 | + | return nil | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | // splitLines is File.ReadAllLines / StreamReader.ReadLine: \r\n, \r and \n | |||
| 275 | + | // end a line; no empty last line is produced. | |||
| 276 | + | func splitLines(s string) []string { | |||
| 277 | + | var out []string | |||
| 278 | + | for len(s) > 0 { | |||
| 279 | + | i := strings.IndexAny(s, "\r\n") | |||
| 280 | + | if i < 0 { | |||
| 281 | + | out = append(out, s) | |||
| 282 | + | break | |||
| 283 | + | } | |||
| 284 | + | out = append(out, s[:i]) | |||
| 285 | + | if s[i] == '\r' && i+1 < len(s) && s[i+1] == '\n' { | |||
| 286 | + | i++ | |||
| 287 | + | } | |||
| 288 | + | s = s[i+1:] | |||
| 289 | + | } | |||
| 290 | + | return out | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // processSetEnv is SetEnvFileCommand.ProcessCommand (:167). Pairs read before | |||
| 294 | + | // a format error are applied, like the lazy enumeration of the original. | |||
| 295 | + | func processSetEnv(sink Sink, root *os.Root, name string) error { | |||
| 296 | + | pairs, err := readEnvFile(sink, root, name) | |||
| 297 | + | for _, p := range pairs { | |||
| 298 | + | if blocked := blockedEnvName(p.Key); blocked != "" { | |||
| 299 | + | sink.Error(fmt.Sprintf("Can't store %s output parameter using '$GITHUB_ENV' command.", blocked)) | |||
| 300 | + | continue | |||
| 301 | + | } | |||
| 302 | + | sink.SetEnv(p.Key, p.Value) | |||
| 303 | + | sink.Debug(fmt.Sprintf("%s='%s'", p.Key, p.Value)) | |||
| 304 | + | } | |||
| 305 | + | return err | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | func blockedEnvName(name string) string { | |||
| 309 | + | for _, b := range setEnvBlockList { | |||
| 310 | + | if strings.EqualFold(b, name) { | |||
| 311 | + | return b | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | return "" | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // processPairs serves GITHUB_STATE and GITHUB_OUTPUT. | |||
| 318 | + | func processPairs(sink Sink, root *os.Root, name string, apply func(k, v string), debug func(k, v string)) error { | |||
| 319 | + | pairs, err := readEnvFile(sink, root, name) | |||
| 320 | + | for _, p := range pairs { | |||
| 321 | + | apply(p.Key, p.Value) | |||
| 322 | + | debug(p.Key, p.Value) | |||
| 323 | + | } | |||
| 324 | + | return err | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | func readEnvFile(sink Sink, root *os.Root, name string) ([]Pair, error) { | |||
| 328 | + | text, ok, err := readText(root, name) | |||
| 329 | + | if err != nil { | |||
| 330 | + | return nil, err | |||
| 331 | + | } | |||
| 332 | + | if !ok { | |||
| 333 | + | sink.Debug(fmt.Sprintf("File does not exist '%s'", filepath.Join(root.Name(), name))) | |||
| 334 | + | return nil, nil | |||
| 335 | + | } | |||
| 336 | + | return ParseEnvFile(text) | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | // processStepSummary is CreateStepSummaryCommand.ProcessCommand (:230) for | |||
| 340 | + | // CI-1: the file is only checked (an empty file is ignored, a file above 1 MiB | |||
| 341 | + | // is an error); ermite does not store summaries yet. | |||
| 342 | + | func processStepSummary(sink Sink, root *os.Root, name string) error { | |||
| 343 | + | fh, err := openRegular(root, name) | |||
| 344 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 345 | + | return nil | |||
| 346 | + | } | |||
| 347 | + | if err != nil { | |||
| 348 | + | return err | |||
| 349 | + | } | |||
| 350 | + | st, err := fh.Stat() | |||
| 351 | + | _ = fh.Close() | |||
| 352 | + | if err != nil || st.Size() == 0 { | |||
| 353 | + | return nil | |||
| 354 | + | } | |||
| 355 | + | if st.Size() > stepSummaryLimit { | |||
| 356 | + | sink.Error(fmt.Sprintf("$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of %dk, got %dk. For more information see: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary", stepSummaryLimit/1024, st.Size()/1024)) | |||
| 357 | + | } | |||
| 358 | + | return nil | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | // ParseEnvFile parses the contents of GITHUB_ENV, GITHUB_OUTPUT or | |||
| 362 | + | // GITHUB_STATE: EnvFileKeyValuePairs.GetEnumerator (FileCommandManager.cs:364). | |||
| 363 | + | // | |||
| 364 | + | // Lines are NAME=VALUE (split at the first "=") or heredocs NAME<<DELIMITER up | |||
| 365 | + | // to a line equal to DELIMITER; the form whose marker comes first wins. Empty | |||
| 366 | + | // lines between entries are skipped. Only "\n" ends a line (on Linux). The | |||
| 367 | + | // pairs read before an error are returned with it, because the original | |||
| 368 | + | // applies them as it enumerates. | |||
| 369 | + | func ParseEnvFile(text string) ([]Pair, error) { | |||
| 370 | + | var out []Pair | |||
| 371 | + | index := 0 | |||
| 372 | + | line, _, ok := readLine(text, &index) | |||
| 373 | + | for ok { | |||
| 374 | + | if line != "" { | |||
| 375 | + | var key, output string | |||
| 376 | + | equals := strings.Index(line, "=") | |||
| 377 | + | heredoc := strings.Index(line, "<<") | |||
| 378 | + | switch { | |||
| 379 | + | case equals >= 0 && (heredoc < 0 || equals < heredoc): | |||
| 380 | + | // The original tests string.IsNullOrEmpty(line) here, which cannot | |||
| 381 | + | // be true; the intent (the message says so) is an empty name, and | |||
| 382 | + | // an empty variable name is useless: we reject it. | |||
| 383 | + | k, v, _ := strings.Cut(line, "=") | |||
| 384 | + | if k == "" { | |||
| 385 | + | return out, ghErrorf("Invalid format '%s'. Name must not be empty", line) | |||
| 386 | + | } | |||
| 387 | + | key, output = k, v | |||
| 388 | + | case heredoc >= 0 && (equals < 0 || heredoc < equals): | |||
| 389 | + | k, delim, _ := strings.Cut(line, "<<") | |||
| 390 | + | if k == "" || delim == "" { | |||
| 391 | + | return out, ghErrorf("Invalid format '%s'. Name must not be empty and delimiter must not be empty", line) | |||
| 392 | + | } | |||
| 393 | + | key = k | |||
| 394 | + | start, end := index, index | |||
| 395 | + | temp, newline, tok := readLine(text, &index) | |||
| 396 | + | for temp != delim || !tok { | |||
| 397 | + | if !tok { | |||
| 398 | + | return out, ghErrorf("Invalid value. Matching delimiter not found '%s'", delim) | |||
| 399 | + | } | |||
| 400 | + | if newline == "" { | |||
| 401 | + | return out, ghErrorf("Invalid value. EOF marker missing new line.") | |||
| 402 | + | } | |||
| 403 | + | end = index - len(newline) | |||
| 404 | + | temp, newline, tok = readLine(text, &index) | |||
| 405 | + | } | |||
| 406 | + | if end > start { | |||
| 407 | + | output = text[start:end] | |||
| 408 | + | } | |||
| 409 | + | default: | |||
| 410 | + | return out, ghErrorf("Invalid format '%s'", line) | |||
| 411 | + | } | |||
| 412 | + | out = append(out, Pair{key, output}) | |||
| 413 | + | } | |||
| 414 | + | line, _, ok = readLine(text, &index) | |||
| 415 | + | } | |||
| 416 | + | return out, nil | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | // readLine is the private ReadLine of EnvFileKeyValuePairs (:462): it returns | |||
| 420 | + | // the line without its "\n", the newline ("\n" or "" at the end of the text) | |||
| 421 | + | // and false once the text is exhausted. | |||
| 422 | + | func readLine(text string, index *int) (line, newline string, ok bool) { | |||
| 423 | + | if *index >= len(text) { | |||
| 424 | + | return "", "", false | |||
| 425 | + | } | |||
| 426 | + | orig := *index | |||
| 427 | + | lf := strings.IndexByte(text[orig:], '\n') | |||
| 428 | + | if lf < 0 { | |||
| 429 | + | *index = len(text) | |||
| 430 | + | return text[orig:], "", true | |||
| 431 | + | } | |||
| 432 | + | *index = orig + lf + 1 | |||
| 433 | + | return text[orig : orig+lf], "\n", true | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | // PrependedPath returns the PATH value that results from the AddPath calls | |||
| 437 | + | // recorded in list (in call order) in front of orig, like | |||
| 438 | + | // Handler.AddPrependPathToEnvironment (Handler.cs:205) with PathUtil.PrependPath. | |||
| 439 | + | func PrependedPath(list []string, orig string) string { | |||
| 440 | + | if len(list) == 0 { | |||
| 441 | + | return orig | |||
| 442 | + | } | |||
| 443 | + | rev := slices.Clone(list) | |||
| 444 | + | slices.Reverse(rev) | |||
| 445 | + | prepend := strings.Join(rev, ":") | |||
| 446 | + | if orig == "" { | |||
| 447 | + | return prepend | |||
| 448 | + | } | |||
| 449 | + | if strings.HasPrefix(orig, prepend+":") { | |||
| 450 | + | return orig | |||
| 451 | + | } | |||
| 452 | + | return prepend + ":" + orig | |||
| 453 | + | } | |||
A internal/executor/filecmd_test.go +378 -0
| @@ -0,0 +1,378 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "reflect" | |||
| 9 | + | "strings" | |||
| 10 | + | "syscall" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Cases ported from SetEnvFileCommandL0, SetOutputFileCommandL0 and | |||
| 16 | + | // SaveStateFileCommandL0 (actions/runner, src/Test/L0/Worker). The C# tests | |||
| 17 | + | // write their files with a UTF-8 BOM and join lines with the platform newline; | |||
| 18 | + | // "\n" is used here and the BOM is added by writeEnvFile. | |||
| 19 | + | ||||
| 20 | + | func TestParseEnvFile(t *testing.T) { | |||
| 21 | + | type kv = Pair | |||
| 22 | + | tests := []struct { | |||
| 23 | + | name string | |||
| 24 | + | in string | |||
| 25 | + | want []kv | |||
| 26 | + | wantErr string | |||
| 27 | + | }{ | |||
| 28 | + | {"empty file", "", nil, ""}, | |||
| 29 | + | {"simple", "MY_ENV=MY VALUE", []kv{{"MY_ENV", "MY VALUE"}}, ""}, | |||
| 30 | + | {"skip empty lines", "\nMY_ENV=my value\n\nMY_ENV_2=my second value\n", []kv{{"MY_ENV", "my value"}, {"MY_ENV_2", "my second value"}}, ""}, | |||
| 31 | + | {"empty value", "MY_ENV=", []kv{{"MY_ENV", ""}}, ""}, | |||
| 32 | + | {"multiple values", "MY_ENV=my value\nMY_ENV_2=\nMY_ENV_3=my third value", []kv{{"MY_ENV", "my value"}, {"MY_ENV_2", ""}, {"MY_ENV_3", "my third value"}}, ""}, | |||
| 33 | + | {"special characters", "MY_ENV==abc\nMY_ENV_2=def=ghi\nMY_ENV_3=jkl=", []kv{{"MY_ENV", "=abc"}, {"MY_ENV_2", "def=ghi"}, {"MY_ENV_3", "jkl="}}, ""}, | |||
| 34 | + | {"heredoc", "MY_ENV<<EOF\nline one\nline two\nline three\nEOF", []kv{{"MY_ENV", "line one\nline two\nline three"}}, ""}, | |||
| 35 | + | {"heredoc trailing newline", "MY_ENV<<EOF\nhello\nworld\nEOF\n", []kv{{"MY_ENV", "hello\nworld"}}, ""}, | |||
| 36 | + | {"heredoc empty value", "MY_ENV<<EOF\nEOF", []kv{{"MY_ENV", ""}}, ""}, | |||
| 37 | + | {"heredoc skip empty lines", "\nMY_ENV<<EOF\nhello\nworld\nEOF\n\nMY_ENV_2<<EOF\nHELLO\nAGAIN\nEOF\n", []kv{{"MY_ENV", "hello\nworld"}, {"MY_ENV_2", "HELLO\nAGAIN"}}, ""}, | |||
| 38 | + | {"heredoc special characters", | |||
| 39 | + | "MY_ENV<<=EOF\nhello\none\n=EOF\nMY_ENV_2<<<EOF\nhello\ntwo\n<EOF\nMY_ENV_3<<EOF\nhello\n\nthree\n\nEOF\nMY_ENV_4<<EOF\nhello=four\nEOF\nMY_ENV_5<<EOF\n EOF\nEOF", | |||
| 40 | + | []kv{{"MY_ENV", "hello\none"}, {"MY_ENV_2", "hello\ntwo"}, {"MY_ENV_3", "hello\n\nthree\n"}, {"MY_ENV_4", "hello=four"}, {"MY_ENV_5", " EOF"}}, ""}, | |||
| 41 | + | {"heredoc delimiter missing", "MY_ENV<<EOF line one line two line three EOF", nil, "Matching delimiter not found"}, | |||
| 42 | + | {"heredoc missing delimiter at end of text", "MY_ENV<<EOF\nline one\nline two", nil, "EOF marker missing new line"}, | |||
| 43 | + | {"heredoc multi-line missing newline", "MY_ENV<<EOF line one\n line two\n line three EOF", nil, "EOF marker missing new line"}, | |||
| 44 | + | {"equals first wins over heredoc", "A=b<<C", []kv{{"A", "b<<C"}}, ""}, | |||
| 45 | + | {"heredoc first wins over equals", "A<<B=C\nx\nB=C", []kv{{"A", "x"}}, ""}, | |||
| 46 | + | {"no separator", "just text", nil, "Invalid format 'just text'"}, | |||
| 47 | + | {"empty name", "=value", nil, "Name must not be empty"}, | |||
| 48 | + | {"empty heredoc name", "<<EOF\nx\nEOF", nil, "Name must not be empty and delimiter must not be empty"}, | |||
| 49 | + | {"empty delimiter", "A<<\nx", nil, "delimiter must not be empty"}, | |||
| 50 | + | {"pairs before the error are kept", "A=1\nbroken", []kv{{"A", "1"}}, "Invalid format 'broken'"}, | |||
| 51 | + | {"CR is data", "A=1\r\n", []kv{{"A", "1\r"}}, ""}, | |||
| 52 | + | } | |||
| 53 | + | for _, tc := range tests { | |||
| 54 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 55 | + | got, err := ParseEnvFile(tc.in) | |||
| 56 | + | if tc.wantErr != "" { | |||
| 57 | + | if err == nil || !strings.Contains(err.Error(), tc.wantErr) { | |||
| 58 | + | t.Fatalf("error %v, want %q", err, tc.wantErr) | |||
| 59 | + | } | |||
| 60 | + | } else if err != nil { | |||
| 61 | + | t.Fatal(err) | |||
| 62 | + | } | |||
| 63 | + | if !reflect.DeepEqual(got, tc.want) { | |||
| 64 | + | t.Fatalf("got %q, want %q", got, tc.want) | |||
| 65 | + | } | |||
| 66 | + | }) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // fakeSink records the effects of file commands. | |||
| 71 | + | type fakeSink struct { | |||
| 72 | + | env, outputs, state map[string]string | |||
| 73 | + | path []string | |||
| 74 | + | errors, debugs []string | |||
| 75 | + | failed bool | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func newSink() *fakeSink { | |||
| 79 | + | return &fakeSink{env: map[string]string{}, outputs: map[string]string{}, state: map[string]string{}} | |||
| 80 | + | } | |||
| 81 | + | func (s *fakeSink) SetEnv(k, v string) { s.env[k] = v } | |||
| 82 | + | func (s *fakeSink) SetOutput(k, v string) { s.outputs[k] = v } | |||
| 83 | + | func (s *fakeSink) SaveState(k, v string) { s.state[k] = v } | |||
| 84 | + | func (s *fakeSink) AddPath(p string) { s.path = append(s.path, p) } | |||
| 85 | + | func (s *fakeSink) Error(m string) { s.errors = append(s.errors, m) } | |||
| 86 | + | func (s *fakeSink) Debug(m string) { s.debugs = append(s.debugs, m) } | |||
| 87 | + | func (s *fakeSink) CommandFailed() { s.failed = true } | |||
| 88 | + | ||||
| 89 | + | func writeEnvFile(t *testing.T, path, content string) { | |||
| 90 | + | t.Helper() | |||
| 91 | + | // The C# tests write a BOM; ReadAllText drops it. | |||
| 92 | + | if err := os.WriteFile(path, []byte("\xef\xbb\xbf"+content), 0o644); err != nil { | |||
| 93 | + | t.Fatal(err) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestFileCommandsInit(t *testing.T) { | |||
| 98 | + | dir := filepath.Join(t.TempDir(), "_runner_file_commands") | |||
| 99 | + | fc, err := NewFileCommands(dir) | |||
| 100 | + | if err != nil { | |||
| 101 | + | t.Fatal(err) | |||
| 102 | + | } | |||
| 103 | + | env, err := fc.Init() | |||
| 104 | + | if err != nil { | |||
| 105 | + | t.Fatal(err) | |||
| 106 | + | } | |||
| 107 | + | for _, name := range []string{"GITHUB_PATH", "GITHUB_ENV", "GITHUB_STEP_SUMMARY", "GITHUB_STATE", "GITHUB_OUTPUT"} { | |||
| 108 | + | p := env[name] | |||
| 109 | + | if p == "" || filepath.Dir(p) != dir { | |||
| 110 | + | t.Fatalf("%s = %q", name, p) | |||
| 111 | + | } | |||
| 112 | + | if st, err := os.Stat(p); err != nil || st.Size() != 0 { | |||
| 113 | + | t.Fatalf("%s: %v %v", name, st, err) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | if !strings.HasPrefix(filepath.Base(env["GITHUB_ENV"]), "set_env_") || !strings.HasPrefix(filepath.Base(env["GITHUB_PATH"]), "add_path_") { | |||
| 117 | + | t.Fatalf("prefixes: %v", env) | |||
| 118 | + | } | |||
| 119 | + | // A second Init deletes the previous files and uses new names. | |||
| 120 | + | env2, _ := fc.Init() | |||
| 121 | + | if env2["GITHUB_ENV"] == env["GITHUB_ENV"] { | |||
| 122 | + | t.Fatal("same file for two steps") | |||
| 123 | + | } | |||
| 124 | + | if _, err := os.Stat(env["GITHUB_ENV"]); !os.IsNotExist(err) { | |||
| 125 | + | t.Fatalf("old file kept: %v", err) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestProcessAllCommands(t *testing.T) { | |||
| 130 | + | fc, _ := NewFileCommands(t.TempDir()) | |||
| 131 | + | env, _ := fc.Init() | |||
| 132 | + | writeEnvFile(t, env["GITHUB_ENV"], "A=1\nM<<X\nl1\nl2\nX\n") | |||
| 133 | + | writeEnvFile(t, env["GITHUB_OUTPUT"], "o=2\n") | |||
| 134 | + | writeEnvFile(t, env["GITHUB_STATE"], "s=3\n") | |||
| 135 | + | writeEnvFile(t, env["GITHUB_PATH"], "/a\n\n/b\n/a\n") | |||
| 136 | + | writeEnvFile(t, env["GITHUB_STEP_SUMMARY"], "# hi") | |||
| 137 | + | s := newSink() | |||
| 138 | + | fc.Process(s) | |||
| 139 | + | if !reflect.DeepEqual(s.env, map[string]string{"A": "1", "M": "l1\nl2"}) || s.outputs["o"] != "2" || s.state["s"] != "3" { | |||
| 140 | + | t.Fatalf("%+v", s) | |||
| 141 | + | } | |||
| 142 | + | if !reflect.DeepEqual(s.path, []string{"/a", "/b", "/a"}) { | |||
| 143 | + | t.Fatalf("path %v", s.path) | |||
| 144 | + | } | |||
| 145 | + | if s.failed || len(s.errors) != 0 { | |||
| 146 | + | t.Fatalf("errors %v", s.errors) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func TestProcessBlockedEnvNames(t *testing.T) { | |||
| 151 | + | // SetEnvFileCommand_BlockListItemsFiltered and _Heredoc. | |||
| 152 | + | for _, content := range []string{"NODE_OPTIONS=asdf", "NODE_OPTIONS<<EOF\nasdf\nEOF", "node_options=asdf"} { | |||
| 153 | + | fc, _ := NewFileCommands(t.TempDir()) | |||
| 154 | + | env, _ := fc.Init() | |||
| 155 | + | writeEnvFile(t, env["GITHUB_ENV"], content) | |||
| 156 | + | s := newSink() | |||
| 157 | + | fc.Process(s) | |||
| 158 | + | if len(s.env) != 0 || len(s.errors) != 1 || s.errors[0] != "Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command." || s.failed { | |||
| 159 | + | t.Fatalf("%q: env=%v errors=%v failed=%v", content, s.env, s.errors, s.failed) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func TestProcessFormatErrorFailsTheStep(t *testing.T) { | |||
| 165 | + | fc, _ := NewFileCommands(t.TempDir()) | |||
| 166 | + | env, _ := fc.Init() | |||
| 167 | + | writeEnvFile(t, env["GITHUB_OUTPUT"], "good=1\nbad line\n") | |||
| 168 | + | writeEnvFile(t, env["GITHUB_ENV"], "E=1\n") | |||
| 169 | + | s := newSink() | |||
| 170 | + | fc.Process(s) | |||
| 171 | + | // Pairs before the error are applied; other commands still run. | |||
| 172 | + | if s.outputs["good"] != "1" || s.env["E"] != "1" || !s.failed { | |||
| 173 | + | t.Fatalf("%+v", s) | |||
| 174 | + | } | |||
| 175 | + | want := []string{"Unable to process file command 'output' successfully.", "Invalid format 'bad line'"} | |||
| 176 | + | if !reflect.DeepEqual(s.errors, want) { | |||
| 177 | + | t.Fatalf("errors %q", s.errors) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestProcessMissingFilesAreSilent(t *testing.T) { | |||
| 182 | + | // *_DirectoryNotFound and *_NotFound: nothing is set, no issue. | |||
| 183 | + | fc, _ := NewFileCommands(t.TempDir()) | |||
| 184 | + | env, _ := fc.Init() | |||
| 185 | + | for _, p := range env { | |||
| 186 | + | _ = os.Remove(p) | |||
| 187 | + | } | |||
| 188 | + | s := newSink() | |||
| 189 | + | fc.Process(s) | |||
| 190 | + | if len(s.env)+len(s.outputs)+len(s.state)+len(s.path) != 0 || len(s.errors) != 0 || s.failed { | |||
| 191 | + | t.Fatalf("%+v", s) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func TestStepSummarySize(t *testing.T) { | |||
| 196 | + | fc, _ := NewFileCommands(t.TempDir()) | |||
| 197 | + | env, _ := fc.Init() | |||
| 198 | + | if err := os.WriteFile(env["GITHUB_STEP_SUMMARY"], make([]byte, 1024*1024+1), 0o644); err != nil { | |||
| 199 | + | t.Fatal(err) | |||
| 200 | + | } | |||
| 201 | + | s := newSink() | |||
| 202 | + | fc.Process(s) | |||
| 203 | + | if len(s.errors) != 1 || !strings.HasPrefix(s.errors[0], "$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of 1024k, got 1024k.") { | |||
| 204 | + | t.Fatalf("%q", s.errors) | |||
| 205 | + | } | |||
| 206 | + | if s.failed { | |||
| 207 | + | t.Fatal("an oversized summary only logs an error") | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func TestSplitLines(t *testing.T) { | |||
| 212 | + | got := splitLines("a\r\nb\rc\n\nd") | |||
| 213 | + | if !reflect.DeepEqual(got, []string{"a", "b", "c", "", "d"}) { | |||
| 214 | + | t.Fatalf("%q", got) | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func TestPrependedPath(t *testing.T) { | |||
| 219 | + | // PathUtil.PrependPath: the last added entry comes first; no trailing | |||
| 220 | + | // separator on an empty PATH; nothing changes when already first. | |||
| 221 | + | if got := PrependedPath([]string{"/a", "/b"}, "/usr/bin"); got != "/b:/a:/usr/bin" { | |||
| 222 | + | t.Fatal(got) | |||
| 223 | + | } | |||
| 224 | + | if got := PrependedPath([]string{"/a"}, ""); got != "/a" { | |||
| 225 | + | t.Fatal(got) | |||
| 226 | + | } | |||
| 227 | + | if got := PrependedPath([]string{"/a"}, "/a:/usr/bin"); got != "/a:/usr/bin" { | |||
| 228 | + | t.Fatal(got) | |||
| 229 | + | } | |||
| 230 | + | if got := PrependedPath(nil, "/usr/bin"); got != "/usr/bin" { | |||
| 231 | + | t.Fatal(got) | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // ---- the directory belongs to the user of the steps: nothing may be followed | |||
| 236 | + | ||||
| 237 | + | func TestFileCommandSymlinkIsNotFollowed(t *testing.T) { | |||
| 238 | + | for _, name := range []string{"GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_PATH", "GITHUB_STATE", "GITHUB_STEP_SUMMARY"} { | |||
| 239 | + | t.Run(name, func(t *testing.T) { | |||
| 240 | + | secret := filepath.Join(t.TempDir(), "secret") | |||
| 241 | + | if err := os.WriteFile(secret, []byte("STOLEN=1\n/stolen/bin\n"), 0o600); err != nil { | |||
| 242 | + | t.Fatal(err) | |||
| 243 | + | } | |||
| 244 | + | fc, err := NewFileCommands(t.TempDir()) | |||
| 245 | + | if err != nil { | |||
| 246 | + | t.Fatal(err) | |||
| 247 | + | } | |||
| 248 | + | defer func() { _ = fc.Close() }() | |||
| 249 | + | env, _ := fc.Init() | |||
| 250 | + | // The step replaces its file by a link to a file only root may read. | |||
| 251 | + | p := env[name] | |||
| 252 | + | if err := os.Remove(p); err != nil { | |||
| 253 | + | t.Fatal(err) | |||
| 254 | + | } | |||
| 255 | + | if err := os.Symlink(secret, p); err != nil { | |||
| 256 | + | t.Fatal(err) | |||
| 257 | + | } | |||
| 258 | + | s := newSink() | |||
| 259 | + | fc.Process(s) | |||
| 260 | + | if len(s.env)+len(s.outputs)+len(s.state)+len(s.path) != 0 { | |||
| 261 | + | t.Fatalf("the content of the target was read: %+v", s) | |||
| 262 | + | } | |||
| 263 | + | if !s.failed || len(s.errors) == 0 { | |||
| 264 | + | t.Fatalf("a replaced file must fail the step: %+v", s) | |||
| 265 | + | } | |||
| 266 | + | }) | |||
| 267 | + | } | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func TestFileCommandFifoDoesNotBlock(t *testing.T) { | |||
| 271 | + | fc, _ := NewFileCommands(t.TempDir()) | |||
| 272 | + | defer func() { _ = fc.Close() }() | |||
| 273 | + | env, _ := fc.Init() | |||
| 274 | + | p := env["GITHUB_OUTPUT"] | |||
| 275 | + | _ = os.Remove(p) | |||
| 276 | + | if err := syscall.Mkfifo(p, 0o644); err != nil { | |||
| 277 | + | t.Skip(err) | |||
| 278 | + | } | |||
| 279 | + | done := make(chan *fakeSink, 1) | |||
| 280 | + | go func() { s := newSink(); fc.Process(s); done <- s }() | |||
| 281 | + | select { | |||
| 282 | + | case s := <-done: | |||
| 283 | + | if !s.failed { | |||
| 284 | + | t.Fatalf("%+v", s) | |||
| 285 | + | } | |||
| 286 | + | case <-time.After(5 * time.Second): | |||
| 287 | + | t.Fatal("reading a FIFO blocked the executor") | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | func TestFileCommandDirectoryReplacedByASymlink(t *testing.T) { | |||
| 292 | + | outside := t.TempDir() | |||
| 293 | + | if err := os.WriteFile(filepath.Join(outside, "set_env_x"), []byte("LEAK=1\n"), 0o600); err != nil { | |||
| 294 | + | t.Fatal(err) | |||
| 295 | + | } | |||
| 296 | + | parent := t.TempDir() | |||
| 297 | + | dir := filepath.Join(parent, "cmds") | |||
| 298 | + | fc, err := NewFileCommands(dir) | |||
| 299 | + | if err != nil { | |||
| 300 | + | t.Fatal(err) | |||
| 301 | + | } | |||
| 302 | + | defer func() { _ = fc.Close() }() | |||
| 303 | + | if _, err := fc.Init(); err != nil { | |||
| 304 | + | t.Fatal(err) | |||
| 305 | + | } | |||
| 306 | + | // The step swaps the directory for a link to another one. | |||
| 307 | + | if err := os.Rename(dir, dir+".old"); err != nil { | |||
| 308 | + | t.Fatal(err) | |||
| 309 | + | } | |||
| 310 | + | if err := os.Symlink(outside, dir); err != nil { | |||
| 311 | + | t.Fatal(err) | |||
| 312 | + | } | |||
| 313 | + | fc.suffix = "x" | |||
| 314 | + | s := newSink() | |||
| 315 | + | fc.Process(s) | |||
| 316 | + | if len(s.env) != 0 { | |||
| 317 | + | t.Fatalf("a file of the swapped directory was read: %v", s.env) | |||
| 318 | + | } | |||
| 319 | + | // Init keeps working on the original directory, not on the link's target. | |||
| 320 | + | env, err := fc.Init() | |||
| 321 | + | if err != nil { | |||
| 322 | + | t.Fatal(err) | |||
| 323 | + | } | |||
| 324 | + | if _, err := os.Stat(filepath.Join(outside, filepath.Base(env["GITHUB_ENV"]))); err == nil { | |||
| 325 | + | t.Fatal("a file was created through the swapped directory") | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func TestCreateExclReplacesAndNeverFollows(t *testing.T) { | |||
| 330 | + | dir := t.TempDir() | |||
| 331 | + | root, err := os.OpenRoot(dir) | |||
| 332 | + | if err != nil { | |||
| 333 | + | t.Fatal(err) | |||
| 334 | + | } | |||
| 335 | + | defer func() { _ = root.Close() }() | |||
| 336 | + | victim := filepath.Join(t.TempDir(), "victim") | |||
| 337 | + | if err := os.WriteFile(victim, []byte("keep"), 0o600); err != nil { | |||
| 338 | + | t.Fatal(err) | |||
| 339 | + | } | |||
| 340 | + | if err := os.Symlink(victim, filepath.Join(dir, "f")); err != nil { | |||
| 341 | + | t.Fatal(err) | |||
| 342 | + | } | |||
| 343 | + | if err := createExcl(root, "f", []byte("new"), 0o644, nil); err != nil { | |||
| 344 | + | t.Fatal(err) | |||
| 345 | + | } | |||
| 346 | + | if b, _ := os.ReadFile(victim); string(b) != "keep" { | |||
| 347 | + | t.Fatalf("the target of the link was written: %q", b) | |||
| 348 | + | } | |||
| 349 | + | if b, _ := os.ReadFile(filepath.Join(dir, "f")); string(b) != "new" { | |||
| 350 | + | t.Fatalf("%q", b) | |||
| 351 | + | } | |||
| 352 | + | if fi, _ := os.Lstat(filepath.Join(dir, "f")); fi.Mode()&os.ModeSymlink != 0 { | |||
| 353 | + | t.Fatal("still a link") | |||
| 354 | + | } | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | // The owner is applied to the open file (GO-003): chown by name after the close | |||
| 358 | + | // would follow a name that the user of the steps could have swapped. Own uid and | |||
| 359 | + | // gid are accepted by an unprivileged process, so the path runs here. | |||
| 360 | + | func TestCreateExclChownsTheOpenFile(t *testing.T) { | |||
| 361 | + | dir := t.TempDir() | |||
| 362 | + | root, err := os.OpenRoot(dir) | |||
| 363 | + | if err != nil { | |||
| 364 | + | t.Fatal(err) | |||
| 365 | + | } | |||
| 366 | + | defer func() { _ = root.Close() }() | |||
| 367 | + | owner := &[2]int{os.Getuid(), os.Getgid()} | |||
| 368 | + | if err := createExcl(root, "f", []byte("x"), 0o644, owner); err != nil { | |||
| 369 | + | t.Fatal(err) | |||
| 370 | + | } | |||
| 371 | + | // A uid that this process may not give files to makes the creation fail, and | |||
| 372 | + | // the half-made file is not reported as success. | |||
| 373 | + | if os.Getuid() != 0 { | |||
| 374 | + | if err := createExcl(root, "g", []byte("x"), 0o644, &[2]int{0, 0}); err == nil { | |||
| 375 | + | t.Fatal("chown to root by an unprivileged process succeeded") | |||
| 376 | + | } | |||
| 377 | + | } | |||
| 378 | + | } | |||
A internal/executor/job.go +554 -0
| @@ -0,0 +1,554 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "strings" | |||
| 12 | + | "sync" | |||
| 13 | + | "syscall" | |||
| 14 | + | "time" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite-actions/expr" | |||
| 17 | + | "bnema.dev/ermite-actions/template" | |||
| 18 | + | "bnema.dev/ermite-actions/workflow" | |||
| 19 | + | "github.com/google/uuid" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // Names of the implicit steps (JobExtension.cs:70 and :15). | |||
| 23 | + | const ( | |||
| 24 | + | SetUpJobName = "Set up job" | |||
| 25 | + | CompleteJobName = "Complete job" | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | // DefaultTimeoutMinutes is the timeout of a job without timeout-minutes. | |||
| 29 | + | const DefaultTimeoutMinutes = 360 | |||
| 30 | + | ||||
| 31 | + | // MaxOutputBytes is the limit of ermite on the job outputs, names and values | |||
| 32 | + | // together (1 MiB). | |||
| 33 | + | const MaxOutputBytes = 1 << 20 | |||
| 34 | + | ||||
| 35 | + | // maxIssueMessageLength is ExecutionContext._maxIssueMessageLength. | |||
| 36 | + | const maxIssueMessageLength = 4096 | |||
| 37 | + | ||||
| 38 | + | // Config describes the job to run. | |||
| 39 | + | type Config struct { | |||
| 40 | + | // WorkflowPath and WorkflowYAML are the workflow file, which the executor | |||
| 41 | + | // parses itself; JobKey selects the job. | |||
| 42 | + | WorkflowPath string | |||
| 43 | + | WorkflowYAML []byte | |||
| 44 | + | JobKey string | |||
| 45 | + | // JobName is the display name of the job, for "Complete job name: ...". | |||
| 46 | + | // Empty means the job key. | |||
| 47 | + | JobName string | |||
| 48 | + | // ContextsJSON and MatrixJSON are the fields of the job message. | |||
| 49 | + | ContextsJSON string | |||
| 50 | + | MatrixJSON string | |||
| 51 | + | // Repo is the repository name used when the github context lacks one. | |||
| 52 | + | Repo string | |||
| 53 | + | // TimeoutMinutes is the job timeout; 0 means DefaultTimeoutMinutes. It is | |||
| 54 | + | // used for the message and, without Deadline, for the timer. | |||
| 55 | + | TimeoutMinutes int | |||
| 56 | + | // Deadline is the time left of the job timeout when Run is called, when the | |||
| 57 | + | // timeout started counting earlier (at the receipt of the job by the host); | |||
| 58 | + | // 0 means TimeoutMinutes from now. | |||
| 59 | + | Deadline time.Duration | |||
| 60 | + | ||||
| 61 | + | Runner RunnerInfo | |||
| 62 | + | RunnerVersion string | |||
| 63 | + | // Layout overrides the directory layout (tests); nil means the hosted layout | |||
| 64 | + | // under Home. | |||
| 65 | + | Layout *Layout | |||
| 66 | + | // Home is the home directory of the user of the steps; empty means | |||
| 67 | + | // /home/runner. | |||
| 68 | + | Home string | |||
| 69 | + | // ToolCache is RUNNER_TOOL_CACHE; empty means DefaultToolCache. The official | |||
| 70 | + | // runner reads it from its own environment (HostContext.cs:411). | |||
| 71 | + | ToolCache string | |||
| 72 | + | // BaseEnv is the environment steps inherit; nil means os.Environ(). | |||
| 73 | + | BaseEnv []string | |||
| 74 | + | // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG). | |||
| 75 | + | Debug bool | |||
| 76 | + | ||||
| 77 | + | Reporter Reporter | |||
| 78 | + | ||||
| 79 | + | // Credential, when set, is the user the steps run as. | |||
| 80 | + | Credential *syscall.Credential | |||
| 81 | + | // Cancellation timings and test hooks, see procSpec. | |||
| 82 | + | TermAfter, KillAfter, WaitDelay time.Duration | |||
| 83 | + | Signal func(pid int, sig syscall.Signal) | |||
| 84 | + | // Now returns the current time; nil means time.Now. | |||
| 85 | + | Now func() time.Time | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | type stepResult int | |||
| 89 | + | ||||
| 90 | + | // Values are ordered like TaskResult so that merge picks the worst | |||
| 91 | + | // (TaskResultUtil.MergeTaskResults). | |||
| 92 | + | const ( | |||
| 93 | + | resSucceeded stepResult = iota | |||
| 94 | + | resFailed | |||
| 95 | + | resCanceled | |||
| 96 | + | resSkipped | |||
| 97 | + | ) | |||
| 98 | + | ||||
| 99 | + | func (r stepResult) conclusion() Conclusion { | |||
| 100 | + | switch r { | |||
| 101 | + | case resFailed: | |||
| 102 | + | return ConclusionFailure | |||
| 103 | + | case resCanceled: | |||
| 104 | + | return ConclusionCancelled | |||
| 105 | + | case resSkipped: | |||
| 106 | + | return ConclusionSkipped | |||
| 107 | + | } | |||
| 108 | + | return ConclusionSuccess | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // merge is TaskResultUtil.MergeTaskResults (Runner.Common/Util/TaskResultUtil.cs:40). | |||
| 112 | + | func merge(cur *stepResult, coming stepResult) stepResult { | |||
| 113 | + | if cur == nil { | |||
| 114 | + | return coming | |||
| 115 | + | } | |||
| 116 | + | if *cur > resFailed { | |||
| 117 | + | return *cur | |||
| 118 | + | } | |||
| 119 | + | if coming >= *cur { | |||
| 120 | + | return coming | |||
| 121 | + | } | |||
| 122 | + | return *cur | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | type job struct { | |||
| 126 | + | cfg Config | |||
| 127 | + | cancel *Canceller | |||
| 128 | + | wf *workflow.Workflow | |||
| 129 | + | jb *workflow.Job | |||
| 130 | + | fc *FileCommands | |||
| 131 | + | // tempRoot is RUNNER_TEMP, opened before the first step (see FileCommands). | |||
| 132 | + | tempRoot *os.Root | |||
| 133 | + | trackID string | |||
| 134 | + | ||||
| 135 | + | mu sync.Mutex // serialises the Reporter and guards state and result | |||
| 136 | + | state *State | |||
| 137 | + | result *stepResult // nil until a step fails or the job is cancelled (Global result) | |||
| 138 | + | cancelled bool | |||
| 139 | + | runDefaults struct{ shell, workingDirectory string } | |||
| 140 | + | baseEnv []string | |||
| 141 | + | lookupPath string | |||
| 142 | + | before map[string]bool | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (j *job) now() time.Time { | |||
| 146 | + | if j.cfg.Now != nil { | |||
| 147 | + | return j.cfg.Now() | |||
| 148 | + | } | |||
| 149 | + | return time.Now() | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // Run executes one job and reports to cfg.Reporter. It always ends with a | |||
| 153 | + | // JobCompleted call and returns the same Result. cancel may be nil. | |||
| 154 | + | func Run(cancel *Canceller, cfg Config) Result { | |||
| 155 | + | if cancel == nil { | |||
| 156 | + | cancel = NewCanceller() | |||
| 157 | + | } | |||
| 158 | + | j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString()} | |||
| 159 | + | defer func() { | |||
| 160 | + | if j.fc != nil { | |||
| 161 | + | _ = j.fc.Close() | |||
| 162 | + | } | |||
| 163 | + | if j.tempRoot != nil { | |||
| 164 | + | _ = j.tempRoot.Close() | |||
| 165 | + | } | |||
| 166 | + | }() | |||
| 167 | + | res := j.run() | |||
| 168 | + | cfg.Reporter.JobCompleted(res) | |||
| 169 | + | return res | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func (j *job) run() Result { | |||
| 173 | + | limit := j.cfg.Deadline | |||
| 174 | + | if limit <= 0 { | |||
| 175 | + | limit = time.Duration(j.timeoutMinutes()) * time.Minute | |||
| 176 | + | } | |||
| 177 | + | timer := time.AfterFunc(limit, func() { j.cancel.Cancel(CancelTimeout) }) | |||
| 178 | + | defer timer.Stop() | |||
| 179 | + | ||||
| 180 | + | // ---- Set up job (JobExtension.InitializeJob) ---- | |||
| 181 | + | rep := j.cfg.Reporter | |||
| 182 | + | rep.StepStarted(1, SetUpJobName, j.now()) | |||
| 183 | + | steps, err := j.setUp() | |||
| 184 | + | if err != nil { | |||
| 185 | + | j.errorf(1, "%s", err.Error()) | |||
| 186 | + | rep.StepCompleted(1, SetUpJobName, ConclusionFailure, j.now()) | |||
| 187 | + | // The job ends here, without "Complete job" (JobRunner.cs:107). | |||
| 188 | + | return Result{Conclusion: ConclusionFailure, Error: err.Error()} | |||
| 189 | + | } | |||
| 190 | + | rep.StepCompleted(1, SetUpJobName, ConclusionSuccess, j.now()) | |||
| 191 | + | ||||
| 192 | + | // ---- the steps (StepsRunner) ---- | |||
| 193 | + | for i, st := range steps { | |||
| 194 | + | j.runStep(i+2, st) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // ---- Complete job (JobExtension.FinalizeJob) ---- | |||
| 198 | + | last := len(steps) + 2 | |||
| 199 | + | outputs := j.completeJob(last) | |||
| 200 | + | ||||
| 201 | + | return j.finish(outputs) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func (j *job) finish(outputs map[string]string) Result { | |||
| 205 | + | res := Result{Outputs: outputs} | |||
| 206 | + | j.mu.Lock() | |||
| 207 | + | defer j.mu.Unlock() | |||
| 208 | + | r := resSucceeded | |||
| 209 | + | if j.result != nil { | |||
| 210 | + | r = *j.result | |||
| 211 | + | } | |||
| 212 | + | switch { | |||
| 213 | + | case j.cancel.Reason() == CancelTimeout && j.cancelled: | |||
| 214 | + | res.Conclusion = ConclusionTimedOut | |||
| 215 | + | res.Error = fmt.Sprintf("The job has exceeded the maximum execution time of %d minutes.", j.timeoutMinutes()) | |||
| 216 | + | case r == resCanceled: | |||
| 217 | + | res.Conclusion = ConclusionCancelled | |||
| 218 | + | res.RunnerShutdown = j.cancel.Reason() == CancelShutdown | |||
| 219 | + | case r == resFailed: | |||
| 220 | + | res.Conclusion = ConclusionFailure | |||
| 221 | + | default: | |||
| 222 | + | res.Conclusion = ConclusionSuccess | |||
| 223 | + | } | |||
| 224 | + | return res | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func (j *job) timeoutMinutes() int { | |||
| 228 | + | if j.cfg.TimeoutMinutes > 0 { | |||
| 229 | + | return j.cfg.TimeoutMinutes | |||
| 230 | + | } | |||
| 231 | + | return DefaultTimeoutMinutes | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | // ---------------------------------------------------------------- logging | |||
| 235 | + | ||||
| 236 | + | // write sends one log line to the Reporter, in order. | |||
| 237 | + | func (j *job) write(num int, line string) { | |||
| 238 | + | j.mu.Lock() | |||
| 239 | + | defer j.mu.Unlock() | |||
| 240 | + | j.cfg.Reporter.Log(num, j.now(), line) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // out writes a message that may hold several lines (ExecutionContext.Output). | |||
| 244 | + | func (j *job) out(num int, text string) { | |||
| 245 | + | for _, l := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") { | |||
| 246 | + | j.write(num, l) | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | // tagged writes tag+message; only the first line carries the tag. | |||
| 251 | + | func (j *job) tagged(num int, tag, msg string) { | |||
| 252 | + | if len(msg) > maxIssueMessageLength && tag != "" { | |||
| 253 | + | msg = msg[:maxIssueMessageLength] | |||
| 254 | + | } | |||
| 255 | + | lines := strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n") | |||
| 256 | + | j.write(num, tag+lines[0]) | |||
| 257 | + | for _, l := range lines[1:] { | |||
| 258 | + | j.write(num, l) | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func (j *job) errorf(num int, format string, a ...any) { | |||
| 263 | + | j.tagged(num, "##[error]", fmt.Sprintf(format, a...)) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func (j *job) debug(num int, msg string) { | |||
| 267 | + | if j.cfg.Debug { | |||
| 268 | + | for _, l := range strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n") { | |||
| 269 | + | j.write(num, "##[debug]"+l) | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | // ---------------------------------------------------------------- set up | |||
| 275 | + | ||||
| 276 | + | // evalEnv is the environment for expressions of the job and step tokens. | |||
| 277 | + | func (j *job) evalEnv(env *ordered) template.Env { | |||
| 278 | + | return template.Env{ | |||
| 279 | + | Contexts: j.state.Values(env), | |||
| 280 | + | Funcs: map[string]expr.Func{ | |||
| 281 | + | // hashFiles is a CI-2 feature: the schema allows it in steps, so it | |||
| 282 | + | // parses, and fails when evaluated. | |||
| 283 | + | "hashFiles": func(context.Context, []expr.Value) (expr.Value, error) { | |||
| 284 | + | return nil, errors.New("not supported yet: hashFiles") | |||
| 285 | + | }, | |||
| 286 | + | }, | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func (j *job) evalString(tok template.Token, env *ordered) (string, error) { | |||
| 291 | + | if tok == nil { | |||
| 292 | + | return "", nil | |||
| 293 | + | } | |||
| 294 | + | return template.EvaluateString(context.Background(), tok, j.evalEnv(env)) | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | // evalMap evaluates a mapping of strings (EvaluateStepEnvironment): values | |||
| 298 | + | // are converted like GitHub converts them (null is the empty string). | |||
| 299 | + | func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) { | |||
| 300 | + | if m == nil { | |||
| 301 | + | return nil, nil | |||
| 302 | + | } | |||
| 303 | + | v, err := template.Evaluate(context.Background(), m, j.evalEnv(env)) | |||
| 304 | + | if err != nil { | |||
| 305 | + | return nil, err | |||
| 306 | + | } | |||
| 307 | + | d, ok := v.(*expr.Dictionary) | |||
| 308 | + | if !ok { | |||
| 309 | + | return nil, nil | |||
| 310 | + | } | |||
| 311 | + | var out []Pair | |||
| 312 | + | for _, p := range d.Pairs() { | |||
| 313 | + | if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary { | |||
| 314 | + | return nil, ghErrorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k) | |||
| 315 | + | } | |||
| 316 | + | out = append(out, Pair{p.Key, expr.CoerceString(p.Value)}) | |||
| 317 | + | } | |||
| 318 | + | return out, nil | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | // setUp parses the workflow and prepares the job: JobExtension.InitializeJob | |||
| 322 | + | // (JobExtension.cs:63-182) and the job-start code of JobRunner.cs:142-177. Its | |||
| 323 | + | // log lines belong to step 1. | |||
| 324 | + | func (j *job) setUp() ([]*workflow.Step, error) { | |||
| 325 | + | cfg := j.cfg | |||
| 326 | + | j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion)) | |||
| 327 | + | if cfg.Runner.Name != "" { | |||
| 328 | + | j.out(1, fmt.Sprintf("Runner name: '%s'", cfg.Runner.Name)) | |||
| 329 | + | } | |||
| 330 | + | if host, err := os.Hostname(); err == nil { | |||
| 331 | + | j.out(1, fmt.Sprintf("Machine name: '%s'", host)) | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | wf, errs := workflow.Parse(cfg.WorkflowPath, cfg.WorkflowYAML) | |||
| 335 | + | if len(errs) > 0 { | |||
| 336 | + | var msgs []string | |||
| 337 | + | for _, e := range errs { | |||
| 338 | + | msgs = append(msgs, e.Error()) | |||
| 339 | + | } | |||
| 340 | + | return nil, ghErrorf("The workflow is not valid. %s", strings.Join(msgs, "\n")) | |||
| 341 | + | } | |||
| 342 | + | jb := wf.Job(cfg.JobKey) | |||
| 343 | + | if jb == nil { | |||
| 344 | + | return nil, ghErrorf("The workflow has no job '%s'.", cfg.JobKey) | |||
| 345 | + | } | |||
| 346 | + | j.wf, j.jb = wf, jb | |||
| 347 | + | ||||
| 348 | + | state, layout, err := NewState(cfg.ContextsJSON, cfg.MatrixJSON, cfg.JobKey, cfg.Runner, cfg.Repo) | |||
| 349 | + | if err != nil { | |||
| 350 | + | return nil, err | |||
| 351 | + | } | |||
| 352 | + | switch { | |||
| 353 | + | case cfg.Layout != nil: | |||
| 354 | + | state.WithLayout(*cfg.Layout) | |||
| 355 | + | layout = *cfg.Layout | |||
| 356 | + | case (cfg.Home != "" && cfg.Home != layout.Home) || cfg.ToolCache != "": | |||
| 357 | + | home := cfg.Home | |||
| 358 | + | if home == "" { | |||
| 359 | + | home = layout.Home | |||
| 360 | + | } | |||
| 361 | + | layout = NewLayout(home, filepath.Base(layout.Workspace), cfg.ToolCache) | |||
| 362 | + | state.WithLayout(layout) | |||
| 363 | + | } | |||
| 364 | + | j.state = state | |||
| 365 | + | ||||
| 366 | + | // Features that arrive in later phases fail the job with a clear message. | |||
| 367 | + | switch { | |||
| 368 | + | case jb.IsReusable(): | |||
| 369 | + | return nil, errors.New("not supported yet: reusable workflows (jobs.<id>.uses)") | |||
| 370 | + | case jb.Container != nil: | |||
| 371 | + | return nil, errors.New("not supported yet: jobs.<id>.container") | |||
| 372 | + | case jb.Services != nil: | |||
| 373 | + | return nil, errors.New("not supported yet: jobs.<id>.services") | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | // Directories (TempDirectoryManager, PipelineDirectoryManager). | |||
| 377 | + | j.out(1, "Prepare workflow directory") | |||
| 378 | + | for _, d := range []string{layout.Temp, layout.Workspace, layout.ToolCache} { | |||
| 379 | + | if err := os.MkdirAll(d, 0o755); err != nil { | |||
| 380 | + | return nil, fmt.Errorf("create %s: %w", d, err) | |||
| 381 | + | } | |||
| 382 | + | } | |||
| 383 | + | clearDir(layout.Temp) | |||
| 384 | + | if err := os.MkdirAll(layout.Temp, 0o755); err != nil { | |||
| 385 | + | return nil, err | |||
| 386 | + | } | |||
| 387 | + | fc, err := NewFileCommands(filepath.Join(layout.Temp, "_runner_file_commands")) | |||
| 388 | + | if err != nil { | |||
| 389 | + | return nil, err | |||
| 390 | + | } | |||
| 391 | + | if c := cfg.Credential; c != nil { | |||
| 392 | + | fc.SetOwner(int(c.Uid), int(c.Gid)) | |||
| 393 | + | } | |||
| 394 | + | j.fc = fc | |||
| 395 | + | // Everything the executor writes below the temp directory later goes through | |||
| 396 | + | // this root (see FileCommands): the user of the steps owns the directory. | |||
| 397 | + | if j.tempRoot, err = os.OpenRoot(layout.Temp); err != nil { | |||
| 398 | + | return nil, err | |||
| 399 | + | } | |||
| 400 | + | for _, d := range []string{layout.WorkRoot, layout.RunnerWorkspace, layout.Workspace, layout.Temp, fc.dir} { | |||
| 401 | + | j.own(d) | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | j.baseEnv = cfg.BaseEnv | |||
| 405 | + | if j.baseEnv == nil { | |||
| 406 | + | j.baseEnv = os.Environ() | |||
| 407 | + | } | |||
| 408 | + | if !hasEnv(j.baseEnv, "HOME") { | |||
| 409 | + | j.baseEnv = append(append([]string(nil), j.baseEnv...), "HOME="+layout.Home) | |||
| 410 | + | } | |||
| 411 | + | j.lookupPath = envValue(j.baseEnv, "PATH") | |||
| 412 | + | ||||
| 413 | + | // Job-level environment: workflow env, then job env, each evaluated with an | |||
| 414 | + | // empty env context (JobExtension.cs:38-49, ExecutionContext.cs:1048). | |||
| 415 | + | for _, m := range []*template.Mapping{wf.Env, jb.Env} { | |||
| 416 | + | pairs, err := j.evalMap(m, newOrdered()) | |||
| 417 | + | if err != nil { | |||
| 418 | + | return nil, err | |||
| 419 | + | } | |||
| 420 | + | for _, p := range pairs { | |||
| 421 | + | j.state.SetEnv(p.Key, p.Value) | |||
| 422 | + | } | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | // defaults.run: a job-level "run" replaces the workflow's whole (JobExtension.cs:78-96). | |||
| 426 | + | for _, d := range []*workflow.Defaults{wf.Defaults, jb.Defaults} { | |||
| 427 | + | if d == nil || d.Run == nil { | |||
| 428 | + | continue | |||
| 429 | + | } | |||
| 430 | + | shell, err := j.evalString(d.Run.Shell, newOrdered()) | |||
| 431 | + | if err != nil { | |||
| 432 | + | return nil, err | |||
| 433 | + | } | |||
| 434 | + | wd, err := j.evalString(d.Run.WorkingDirectory, newOrdered()) | |||
| 435 | + | if err != nil { | |||
| 436 | + | return nil, err | |||
| 437 | + | } | |||
| 438 | + | j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | j.out(1, "Prepare all required actions") | |||
| 442 | + | name := cfg.JobName | |||
| 443 | + | if name == "" { | |||
| 444 | + | name = cfg.JobKey | |||
| 445 | + | } | |||
| 446 | + | if strings.TrimSpace(name) != "" { | |||
| 447 | + | j.out(1, "Complete job name: "+name) | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | // Process tracking for the orphan cleanup of "Complete job". | |||
| 451 | + | j.before = map[string]bool{} | |||
| 452 | + | for _, p := range snapshotProcesses() { | |||
| 453 | + | j.before[p.key()] = true | |||
| 454 | + | } | |||
| 455 | + | return jb.Steps, nil | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | // own gives a file or directory created by the executor to the user the steps | |||
| 459 | + | // run as, so that scripts can write to RUNNER_TEMP, the workspace and the file | |||
| 460 | + | // command files. Best effort: without Credential nothing is done. | |||
| 461 | + | func (j *job) own(path string) { | |||
| 462 | + | if c := j.cfg.Credential; c != nil { | |||
| 463 | + | _ = os.Lchown(path, int(c.Uid), int(c.Gid)) | |||
| 464 | + | } | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | func clearDir(dir string) { | |||
| 468 | + | ents, err := os.ReadDir(dir) | |||
| 469 | + | if err != nil { | |||
| 470 | + | return | |||
| 471 | + | } | |||
| 472 | + | for _, e := range ents { | |||
| 473 | + | _ = os.RemoveAll(filepath.Join(dir, e.Name())) | |||
| 474 | + | } | |||
| 475 | + | } | |||
| 476 | + | ||||
| 477 | + | func hasEnv(env []string, key string) bool { | |||
| 478 | + | for _, kv := range env { | |||
| 479 | + | if strings.HasPrefix(kv, key+"=") { | |||
| 480 | + | return true | |||
| 481 | + | } | |||
| 482 | + | } | |||
| 483 | + | return false | |||
| 484 | + | } | |||
| 485 | + | ||||
| 486 | + | func envValue(env []string, key string) string { | |||
| 487 | + | v := "" | |||
| 488 | + | for _, kv := range env { | |||
| 489 | + | if k, val, ok := strings.Cut(kv, "="); ok && k == key { | |||
| 490 | + | v = val | |||
| 491 | + | } | |||
| 492 | + | } | |||
| 493 | + | return v | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | // ---------------------------------------------------------------- complete | |||
| 497 | + | ||||
| 498 | + | // completeJob is JobExtension.FinalizeJob (JobExtension.cs:9-190): job outputs | |||
| 499 | + | // and the cleanup of orphan processes. | |||
| 500 | + | func (j *job) completeJob(num int) map[string]string { | |||
| 501 | + | rep := j.cfg.Reporter | |||
| 502 | + | rep.StepStarted(num, CompleteJobName, j.now()) | |||
| 503 | + | result := resSucceeded | |||
| 504 | + | outputs := map[string]string{} | |||
| 505 | + | ||||
| 506 | + | if j.jb.Outputs != nil { | |||
| 507 | + | // The runner of the official service prints nothing here: the recordings | |||
| 508 | + | // of github.com show only "Cleaning up orphan processes" in "Complete | |||
| 509 | + | // job", although the jobs have outputs (the log lines of | |||
| 510 | + | // JobExtension.cs:48,68 belong to the legacy message that carries | |||
| 511 | + | // JobOutputs). | |||
| 512 | + | j.mu.Lock() | |||
| 513 | + | env := j.state.JobEnv() | |||
| 514 | + | j.mu.Unlock() | |||
| 515 | + | pairs, err := j.evalMap(j.jb.Outputs, env) | |||
| 516 | + | if err != nil { | |||
| 517 | + | result = resFailed | |||
| 518 | + | j.errorf(num, "Fail to evaluate job outputs") | |||
| 519 | + | j.errorf(num, "%s", err.Error()) | |||
| 520 | + | j.mu.Lock() | |||
| 521 | + | m := merge(j.result, resFailed) | |||
| 522 | + | j.result = &m | |||
| 523 | + | j.mu.Unlock() | |||
| 524 | + | } | |||
| 525 | + | total := 0 | |||
| 526 | + | for _, p := range pairs { | |||
| 527 | + | if p.Value == "" { | |||
| 528 | + | j.debug(num, fmt.Sprintf("Skip output '%s' since it's empty", p.Key)) | |||
| 529 | + | continue | |||
| 530 | + | } | |||
| 531 | + | // ermite refuses outputs above 1 MiB in total (runner.proto, | |||
| 532 | + | // CompleteJobRequest.outputs), and they would not fit a frame of the | |||
| 533 | + | // stream either: the job fails without outputs. | |||
| 534 | + | if total += len(p.Key) + len(p.Value); total > MaxOutputBytes { | |||
| 535 | + | result = resFailed | |||
| 536 | + | j.errorf(num, "Fail to evaluate job outputs") | |||
| 537 | + | j.errorf(num, "The job outputs are larger than 1 MiB in total.") | |||
| 538 | + | j.mu.Lock() | |||
| 539 | + | m := merge(j.result, resFailed) | |||
| 540 | + | j.result = &m | |||
| 541 | + | j.mu.Unlock() | |||
| 542 | + | outputs = map[string]string{} | |||
| 543 | + | break | |||
| 544 | + | } | |||
| 545 | + | outputs[p.Key] = p.Value | |||
| 546 | + | } | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | j.out(num, "Cleaning up orphan processes") | |||
| 550 | + | killOrphans(j.trackID, j.before, func(l string) { j.out(num, l) }) | |||
| 551 | + | ||||
| 552 | + | rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now()) | |||
| 553 | + | return outputs | |||
| 554 | + | } | |||
A internal/executor/job_test.go +621 -0
| @@ -0,0 +1,621 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "syscall" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const wfHead = "on: push\njobs:\n j:\n runs-on: x\n" | |||
| 15 | + | ||||
| 16 | + | func logOf(s *recStep) string { return strings.Join(s.Log, "\n") } | |||
| 17 | + | ||||
| 18 | + | func TestImplicitStepsAndNumbering(t *testing.T) { | |||
| 19 | + | needTools(t, "bash") | |||
| 20 | + | rec, res := runLocal(t, wfHead+" steps:\n - run: echo hi\n - name: second\n run: echo two\n", "j", nil) | |||
| 21 | + | if res.Conclusion != ConclusionSuccess || !rec.done { | |||
| 22 | + | t.Fatalf("%+v", res) | |||
| 23 | + | } | |||
| 24 | + | want := []string{"Set up job", "Run echo hi", "second", "Complete job"} | |||
| 25 | + | if got := rec.names(); strings.Join(got, "|") != strings.Join(want, "|") { | |||
| 26 | + | t.Fatalf("steps %v", got) | |||
| 27 | + | } | |||
| 28 | + | for i, s := range rec.steps { | |||
| 29 | + | if s.Num != i+1 || s.Conclusion != ConclusionSuccess { | |||
| 30 | + | t.Errorf("%+v", s) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | setup := logOf(rec.steps[0]) | |||
| 34 | + | for _, w := range []string{"Current runner version: 'test'", "Runner name: 'test-runner'", "Prepare workflow directory", "Prepare all required actions", "Complete job name: j"} { | |||
| 35 | + | if !strings.Contains(setup, w) { | |||
| 36 | + | t.Errorf("Set up job lacks %q:\n%s", w, setup) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | if got := logOf(rec.steps[3]); got != "Cleaning up orphan processes" { | |||
| 40 | + | t.Errorf("Complete job: %q", got) | |||
| 41 | + | } | |||
| 42 | + | if got := logOf(rec.steps[1]); !strings.HasSuffix(got, "##[endgroup]\nhi") { | |||
| 43 | + | t.Errorf("log %q", got) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestFailureSkipsAndStatusFunctions(t *testing.T) { | |||
| 48 | + | needTools(t, "bash") | |||
| 49 | + | rec, res := runLocal(t, wfHead+` steps: | |||
| 50 | + | - id: boom | |||
| 51 | + | run: exit 3 | |||
| 52 | + | - name: skipped by default success() | |||
| 53 | + | run: echo NO | |||
| 54 | + | - name: always | |||
| 55 | + | if: always() | |||
| 56 | + | run: echo ran-always | |||
| 57 | + | - name: failure | |||
| 58 | + | if: failure() | |||
| 59 | + | run: echo ran-failure | |||
| 60 | + | - name: cancelled | |||
| 61 | + | if: cancelled() | |||
| 62 | + | run: echo NO | |||
| 63 | + | - name: outcome | |||
| 64 | + | if: always() && steps.boom.outcome == 'failure' && steps.boom.conclusion == 'failure' | |||
| 65 | + | run: echo ran-outcome | |||
| 66 | + | `, "j", nil) | |||
| 67 | + | if res.Conclusion != ConclusionFailure { | |||
| 68 | + | t.Fatalf("%+v", res) | |||
| 69 | + | } | |||
| 70 | + | for name, c := range map[string]Conclusion{ | |||
| 71 | + | "Run exit 3": ConclusionFailure, "skipped by default success()": ConclusionSkipped, "always": ConclusionSuccess, | |||
| 72 | + | "failure": ConclusionSuccess, "cancelled": ConclusionSkipped, "outcome": ConclusionSuccess, | |||
| 73 | + | } { | |||
| 74 | + | if got := rec.step(t, name).Conclusion; got != c { | |||
| 75 | + | t.Errorf("%s: %s, want %s", name, got, c) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | if got := logOf(rec.step(t, "Run exit 3")); !strings.HasSuffix(got, "##[error]Process completed with exit code 3.") { | |||
| 79 | + | t.Errorf("%q", got) | |||
| 80 | + | } | |||
| 81 | + | if len(rec.step(t, "skipped by default success()").Log) != 0 { | |||
| 82 | + | t.Error("a skipped step logs nothing") | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestEnvPrecedenceAndExpressions(t *testing.T) { | |||
| 87 | + | needTools(t, "bash") | |||
| 88 | + | rec, res := runLocal(t, `on: push | |||
| 89 | + | env: | |||
| 90 | + | A: wf | |||
| 91 | + | B: ${{ github.event_name }} | |||
| 92 | + | jobs: | |||
| 93 | + | j: | |||
| 94 | + | runs-on: x | |||
| 95 | + | env: | |||
| 96 | + | A: job | |||
| 97 | + | C: ${{ github.ref_type }}-c | |||
| 98 | + | steps: | |||
| 99 | + | - env: | |||
| 100 | + | D: ${{ env.A }}-${{ env.C }} | |||
| 101 | + | A: step | |||
| 102 | + | run: echo "A=$A B=$B C=$C D=$D" | |||
| 103 | + | - run: echo "A=$A D=${D-unset}" | |||
| 104 | + | `, "j", nil) | |||
| 105 | + | if res.Conclusion != ConclusionSuccess { | |||
| 106 | + | t.Fatalf("%+v", res) | |||
| 107 | + | } | |||
| 108 | + | // The step env sees the job env (env.A is "job", not "step"), and the | |||
| 109 | + | // workflow and job env are evaluated before the steps. | |||
| 110 | + | if got := logOf(rec.steps[1]); !strings.HasSuffix(got, "A=step B=push C=branch-c D=job-branch-c") { | |||
| 111 | + | t.Errorf("first step:\n%s", got) | |||
| 112 | + | } | |||
| 113 | + | if got := logOf(rec.steps[2]); !strings.HasSuffix(got, "A=job D=unset") { | |||
| 114 | + | t.Errorf("second step:\n%s", got) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestWorkflowCommands(t *testing.T) { | |||
| 119 | + | needTools(t, "bash") | |||
| 120 | + | rec, res := runLocal(t, wfHead+` steps: | |||
| 121 | + | - id: s | |||
| 122 | + | run: | | |||
| 123 | + | echo "::group::My group" | |||
| 124 | + | echo inside | |||
| 125 | + | echo "::endgroup::" | |||
| 126 | + | echo "::warning file=a.txt,line=1::careful" | |||
| 127 | + | echo "::error::broken" | |||
| 128 | + | echo "::debug::hidden" | |||
| 129 | + | echo "::set-output name=old::v" | |||
| 130 | + | echo "::add-mask::secret" | |||
| 131 | + | echo "::not-a-command::x" | |||
| 132 | + | - run: echo "old=${{ steps.s.outputs.old }}" | |||
| 133 | + | `, "j", nil) | |||
| 134 | + | if res.Conclusion != ConclusionSuccess { | |||
| 135 | + | t.Fatalf("%+v", res) | |||
| 136 | + | } | |||
| 137 | + | got := logOf(rec.steps[1]) | |||
| 138 | + | for _, w := range []string{"##[group]My group\ninside\n##[endgroup]", "##[warning]careful", "##[error]broken", "##[warning]The `set-output` command is deprecated", "::not-a-command::x"} { | |||
| 139 | + | if !strings.Contains(got, w) { | |||
| 140 | + | t.Errorf("log lacks %q:\n%s", w, got) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | _, body, _ := strings.Cut(got, "##[endgroup]\n") // after the header, which echoes the script | |||
| 144 | + | if strings.Contains(body, "hidden") || strings.Contains(body, "::add-mask") || strings.Contains(body, "secret") { | |||
| 145 | + | t.Errorf("debug and add-mask must not show:\n%s", body) | |||
| 146 | + | } | |||
| 147 | + | if !strings.HasSuffix(logOf(rec.steps[2]), "old=v") { | |||
| 148 | + | t.Errorf("legacy output not visible: %s", logOf(rec.steps[2])) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestCommandFailureFailsTheStep(t *testing.T) { | |||
| 153 | + | needTools(t, "bash") | |||
| 154 | + | rec, res := runLocal(t, wfHead+" steps:\n - run: |\n echo '::set-env name=X::1'\n", "j", nil) | |||
| 155 | + | if res.Conclusion != ConclusionFailure { | |||
| 156 | + | t.Fatalf("%+v", res) | |||
| 157 | + | } | |||
| 158 | + | got := logOf(rec.steps[1]) | |||
| 159 | + | if !strings.Contains(got, "##[error]Unable to process command '::set-env name=X::1' successfully.") || !strings.Contains(got, "The `set-env` command is disabled.") { | |||
| 160 | + | t.Errorf("%s", got) | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func TestJobOutputs(t *testing.T) { | |||
| 165 | + | needTools(t, "bash") | |||
| 166 | + | rec, res := runLocal(t, `on: push | |||
| 167 | + | jobs: | |||
| 168 | + | j: | |||
| 169 | + | runs-on: x | |||
| 170 | + | outputs: | |||
| 171 | + | a: ${{ steps.one.outputs.v }} | |||
| 172 | + | empty: ${{ steps.one.outputs.none }} | |||
| 173 | + | lit: fixed | |||
| 174 | + | env: ${{ env.E }} | |||
| 175 | + | env: | |||
| 176 | + | E: from-env | |||
| 177 | + | steps: | |||
| 178 | + | - id: one | |||
| 179 | + | run: echo "v=1" >> "$GITHUB_OUTPUT" | |||
| 180 | + | - run: exit 1 | |||
| 181 | + | `, "j", nil) | |||
| 182 | + | if res.Conclusion != ConclusionFailure { | |||
| 183 | + | t.Fatalf("%+v", res) | |||
| 184 | + | } | |||
| 185 | + | // Outputs are collected even after a failure; empty ones are dropped. | |||
| 186 | + | if len(res.Outputs) != 3 || res.Outputs["a"] != "1" || res.Outputs["lit"] != "fixed" || res.Outputs["env"] != "from-env" { | |||
| 187 | + | t.Fatalf("%v", res.Outputs) | |||
| 188 | + | } | |||
| 189 | + | _ = rec | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func TestNotSupportedYet(t *testing.T) { | |||
| 193 | + | needTools(t, "bash") | |||
| 194 | + | rec, res := runLocal(t, wfHead+` steps: | |||
| 195 | + | - uses: actions/checkout@v4 | |||
| 196 | + | - name: after | |||
| 197 | + | if: always() | |||
| 198 | + | run: echo still-runs | |||
| 199 | + | - run: echo skipped | |||
| 200 | + | `, "j", nil) | |||
| 201 | + | if res.Conclusion != ConclusionFailure { | |||
| 202 | + | t.Fatalf("%+v", res) | |||
| 203 | + | } | |||
| 204 | + | u := rec.step(t, "Run actions/checkout@v4") | |||
| 205 | + | if u.Conclusion != ConclusionFailure || !strings.Contains(logOf(u), "##[error]not supported yet: uses (actions/checkout@v4)") { | |||
| 206 | + | t.Fatalf("%+v", u) | |||
| 207 | + | } | |||
| 208 | + | if rec.step(t, "after").Conclusion != ConclusionSuccess { | |||
| 209 | + | t.Error("always() steps still run after a failure") | |||
| 210 | + | } | |||
| 211 | + | for _, f := range []string{"container: alpine", "services:\n s:\n image: x"} { | |||
| 212 | + | rec2, res2 := runLocal(t, wfHead+" "+f+"\n steps:\n - run: echo hi\n", "j", nil) | |||
| 213 | + | if res2.Conclusion != ConclusionFailure || res2.Error == "" || !strings.Contains(logOf(rec2.steps[0]), "not supported yet") { | |||
| 214 | + | t.Errorf("%q: %+v %v", f, res2, rec2.names()) | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func TestInvalidWorkflowAndUnknownJob(t *testing.T) { | |||
| 220 | + | rec, res := runLocal(t, "on: push\njobs: [", "j", nil) | |||
| 221 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "The workflow is not valid") || len(rec.steps) != 1 || !rec.done { | |||
| 222 | + | t.Fatalf("%+v %v", res, rec.names()) | |||
| 223 | + | } | |||
| 224 | + | _, res = runLocal(t, wfHead+" steps: [{run: a}]\n", "nope", nil) | |||
| 225 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "no job 'nope'") { | |||
| 226 | + | t.Fatalf("%+v", res) | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func TestShellVariants(t *testing.T) { | |||
| 231 | + | needTools(t, "bash", "sh", "python3") | |||
| 232 | + | rec, res := runLocal(t, wfHead+` steps: | |||
| 233 | + | - shell: python3 {0} | |||
| 234 | + | run: print("py-ok") | |||
| 235 | + | - shell: bash {0} | |||
| 236 | + | run: echo "no-e $-" | |||
| 237 | + | - shell: sh | |||
| 238 | + | run: echo sh-ok | |||
| 239 | + | - shell: nonexistent-shell {0} | |||
| 240 | + | run: echo NO | |||
| 241 | + | `, "j", nil) | |||
| 242 | + | if res.Conclusion != ConclusionFailure { | |||
| 243 | + | t.Fatalf("%+v", res) | |||
| 244 | + | } | |||
| 245 | + | if !strings.HasSuffix(logOf(rec.steps[1]), "py-ok") || !strings.Contains(logOf(rec.steps[2]), "shell: ") { | |||
| 246 | + | t.Errorf("%s | %s", logOf(rec.steps[1]), logOf(rec.steps[2])) | |||
| 247 | + | } | |||
| 248 | + | last := rec.steps[4] | |||
| 249 | + | if last.Conclusion != ConclusionFailure || !strings.Contains(logOf(last), "##[error]nonexistent-shell: command not found") { | |||
| 250 | + | t.Errorf("%+v", last) | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func TestGithubPathAndEnvReachNextSteps(t *testing.T) { | |||
| 255 | + | needTools(t, "bash") | |||
| 256 | + | rec, _ := runLocal(t, wfHead+` steps: | |||
| 257 | + | - run: | | |||
| 258 | + | mkdir -p "$RUNNER_TEMP/bin1" "$RUNNER_TEMP/bin2" | |||
| 259 | + | printf '#!/bin/sh\necho tool-1\n' > "$RUNNER_TEMP/bin1/tool-ermite"; chmod +x "$RUNNER_TEMP/bin1/tool-ermite" | |||
| 260 | + | printf '#!/bin/sh\necho tool-2\n' > "$RUNNER_TEMP/bin2/tool-ermite"; chmod +x "$RUNNER_TEMP/bin2/tool-ermite" | |||
| 261 | + | echo "$RUNNER_TEMP/bin1" >> "$GITHUB_PATH" | |||
| 262 | + | echo "$RUNNER_TEMP/bin2" >> "$GITHUB_PATH" | |||
| 263 | + | echo "MULTI<<X" >> "$GITHUB_ENV"; echo "a" >> "$GITHUB_ENV"; echo "b" >> "$GITHUB_ENV"; echo "X" >> "$GITHUB_ENV" | |||
| 264 | + | - run: | | |||
| 265 | + | tool-ermite | |||
| 266 | + | printf 'MULTI=<%s>\n' "$MULTI" | |||
| 267 | + | echo "ci=$CI actions=$GITHUB_ACTIONS ws=${GITHUB_WORKSPACE##*/} cwd=${PWD##*/}" | |||
| 268 | + | `, "j", nil) | |||
| 269 | + | got := logOf(rec.steps[2]) | |||
| 270 | + | if !strings.HasSuffix(got, "tool-2\nMULTI=<a\nb>\nci=true actions=true ws=hello cwd=hello") { | |||
| 271 | + | t.Errorf("%s", got) | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // ---- cancellation and timeouts | |||
| 276 | + | ||||
| 277 | + | func TestCancelSendsSIGINTThenStopsTheJob(t *testing.T) { | |||
| 278 | + | needTools(t, "bash") | |||
| 279 | + | c := NewCanceller() | |||
| 280 | + | started := make(chan struct{}) | |||
| 281 | + | // The script prints "ready" after it installed its trap: the cancel can come | |||
| 282 | + | // at once. | |||
| 283 | + | go func() { | |||
| 284 | + | <-started | |||
| 285 | + | c.Cancel(CancelUser) | |||
| 286 | + | }() | |||
| 287 | + | rec := newRecorder() | |||
| 288 | + | rep := &signalReporter{recorder: rec, started: started, match: "ready"} | |||
| 289 | + | home := t.TempDir() | |||
| 290 | + | layout := NewLayout(home, "hello", home+"/tc") | |||
| 291 | + | res := Run(c, Config{ | |||
| 292 | + | WorkflowYAML: []byte(wfHead + ` steps: | |||
| 293 | + | - run: | | |||
| 294 | + | trap 'echo got-int; exit 130' INT | |||
| 295 | + | echo ready | |||
| 296 | + | while :; do sleep 0.05; done | |||
| 297 | + | - name: after | |||
| 298 | + | run: echo NO | |||
| 299 | + | - name: cleanup | |||
| 300 | + | if: always() | |||
| 301 | + | run: echo cleanup-ran | |||
| 302 | + | `), JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep, | |||
| 303 | + | BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, | |||
| 304 | + | TermAfter: 300 * time.Millisecond, KillAfter: 300 * time.Millisecond, | |||
| 305 | + | }) | |||
| 306 | + | if res.Conclusion != ConclusionCancelled || res.RunnerShutdown { | |||
| 307 | + | t.Fatalf("%+v", res) | |||
| 308 | + | } | |||
| 309 | + | run := rec.steps[1] | |||
| 310 | + | if run.Conclusion != ConclusionCancelled || !strings.Contains(logOf(run), "got-int") || !strings.Contains(logOf(run), "##[error]The operation was canceled.") { | |||
| 311 | + | t.Fatalf("%s %s", run.Conclusion, logOf(run)) | |||
| 312 | + | } | |||
| 313 | + | if rec.step(t, "after").Conclusion != ConclusionSkipped { | |||
| 314 | + | t.Error("after must be skipped") | |||
| 315 | + | } | |||
| 316 | + | if got := rec.step(t, "cleanup"); got.Conclusion != ConclusionSuccess || !strings.HasSuffix(logOf(got), "cleanup-ran") { | |||
| 317 | + | t.Errorf("always() step must run after a cancel: %+v", got) | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | // signalReporter closes started when a log line equals match. | |||
| 322 | + | type signalReporter struct { | |||
| 323 | + | *recorder | |||
| 324 | + | started chan struct{} | |||
| 325 | + | match string | |||
| 326 | + | once bool | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func (r *signalReporter) Log(n int, at time.Time, line string) { | |||
| 330 | + | r.recorder.Log(n, at, line) | |||
| 331 | + | if !r.once && line == r.match { | |||
| 332 | + | r.once = true | |||
| 333 | + | close(r.started) | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func TestCancelEscalatesToTermAndKill(t *testing.T) { | |||
| 338 | + | needTools(t, "bash") | |||
| 339 | + | c := NewCanceller() | |||
| 340 | + | var mu = make(chan struct{}, 8) | |||
| 341 | + | var got []syscall.Signal | |||
| 342 | + | started := make(chan struct{}) | |||
| 343 | + | rec := newRecorder() | |||
| 344 | + | rep := &signalReporter{recorder: rec, started: started, match: "ready"} | |||
| 345 | + | go func() { <-started; c.Cancel(CancelUser) }() | |||
| 346 | + | home := t.TempDir() | |||
| 347 | + | layout := NewLayout(home, "hello", home+"/tc") | |||
| 348 | + | t0 := time.Now() | |||
| 349 | + | var times []time.Duration | |||
| 350 | + | res := Run(c, Config{ | |||
| 351 | + | WorkflowYAML: []byte(wfHead + " steps:\n - run: |\n trap '' INT TERM\n echo ready\n while :; do sleep 0.05; done\n"), | |||
| 352 | + | JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep, | |||
| 353 | + | BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, | |||
| 354 | + | TermAfter: 250 * time.Millisecond, KillAfter: 250 * time.Millisecond, | |||
| 355 | + | Signal: func(pid int, sig syscall.Signal) { | |||
| 356 | + | got = append(got, sig) | |||
| 357 | + | times = append(times, time.Since(t0)) | |||
| 358 | + | _ = syscall.Kill(pid, sig) | |||
| 359 | + | mu <- struct{}{} | |||
| 360 | + | }, | |||
| 361 | + | }) | |||
| 362 | + | if res.Conclusion != ConclusionCancelled { | |||
| 363 | + | t.Fatalf("%+v", res) | |||
| 364 | + | } | |||
| 365 | + | if len(got) != 3 || got[0] != syscall.SIGINT || got[1] != syscall.SIGTERM || got[2] != syscall.SIGKILL { | |||
| 366 | + | t.Fatalf("signals %v", got) | |||
| 367 | + | } | |||
| 368 | + | if d := times[2] - times[0]; d < 450*time.Millisecond || d > 2*time.Second { | |||
| 369 | + | t.Errorf("kill came %v after the interrupt, want about 500ms", d) | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | func TestShutdownCancelSkipsRemainingSteps(t *testing.T) { | |||
| 374 | + | needTools(t, "bash") | |||
| 375 | + | c := NewCanceller() | |||
| 376 | + | started := make(chan struct{}) | |||
| 377 | + | rec := newRecorder() | |||
| 378 | + | rep := &signalReporter{recorder: rec, started: started, match: "ready"} | |||
| 379 | + | go func() { <-started; c.Cancel(CancelShutdown) }() | |||
| 380 | + | home := t.TempDir() | |||
| 381 | + | layout := NewLayout(home, "hello", home+"/tc") | |||
| 382 | + | res := Run(c, Config{ | |||
| 383 | + | WorkflowYAML: []byte(wfHead + " steps:\n - run: |\n echo ready\n sleep 30\n - if: always()\n run: echo NO\n"), | |||
| 384 | + | JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep, | |||
| 385 | + | BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, | |||
| 386 | + | }) | |||
| 387 | + | if res.Conclusion != ConclusionCancelled || !res.RunnerShutdown { | |||
| 388 | + | t.Fatalf("%+v", res) | |||
| 389 | + | } | |||
| 390 | + | // On runner shutdown conditions are not evaluated (StepsRunner.cs:205-208): | |||
| 391 | + | // even always() steps are skipped. | |||
| 392 | + | if got := rec.steps[2]; got.Conclusion != ConclusionSkipped { | |||
| 393 | + | t.Errorf("%+v", got) | |||
| 394 | + | } | |||
| 395 | + | } | |||
| 396 | + | ||||
| 397 | + | func TestJobTimeout(t *testing.T) { | |||
| 398 | + | needTools(t, "bash") | |||
| 399 | + | home := t.TempDir() | |||
| 400 | + | layout := NewLayout(home, "hello", home+"/tc") | |||
| 401 | + | rec := newRecorder() | |||
| 402 | + | cfg := Config{ | |||
| 403 | + | WorkflowYAML: []byte(wfHead + " steps:\n - run: sleep 30\n"), | |||
| 404 | + | JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rec, | |||
| 405 | + | BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, TermAfter: 100 * time.Millisecond, KillAfter: 100 * time.Millisecond, | |||
| 406 | + | } | |||
| 407 | + | c := NewCanceller() | |||
| 408 | + | // A one-minute job timeout cannot be waited for: fire it as the timer does. | |||
| 409 | + | time.AfterFunc(300*time.Millisecond, func() { c.Cancel(CancelTimeout) }) | |||
| 410 | + | res := Run(c, cfg) | |||
| 411 | + | if res.Conclusion != ConclusionTimedOut || !strings.Contains(res.Error, "maximum execution time of 360 minutes") { | |||
| 412 | + | t.Fatalf("%+v", res) | |||
| 413 | + | } | |||
| 414 | + | if rec.steps[1].Conclusion != ConclusionCancelled { | |||
| 415 | + | t.Errorf("%+v", rec.steps[1]) | |||
| 416 | + | } | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | func TestStartErrorsAndOrphans(t *testing.T) { | |||
| 420 | + | needTools(t, "bash") | |||
| 421 | + | rec, res := runLocal(t, wfHead+` steps: | |||
| 422 | + | - run: (sleep 60 &) ; echo started-background | |||
| 423 | + | `, "j", nil) | |||
| 424 | + | if res.Conclusion != ConclusionSuccess { | |||
| 425 | + | t.Fatalf("%+v", res) | |||
| 426 | + | } | |||
| 427 | + | last := logOf(rec.steps[len(rec.steps)-1]) | |||
| 428 | + | if !strings.Contains(last, "Cleaning up orphan processes") || !strings.Contains(last, "Terminate orphan process: pid (") || !strings.Contains(last, ") (sleep)") { | |||
| 429 | + | t.Errorf("orphan cleanup: %q", last) | |||
| 430 | + | } | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | func TestLineWriter(t *testing.T) { | |||
| 434 | + | var got []string | |||
| 435 | + | w := &lineWriter{fn: func(l string) { got = append(got, l) }} | |||
| 436 | + | for _, chunk := range []string{"a\nb\r", "\nc\rd", "\ne\xff", "f\n", "tail"} { | |||
| 437 | + | _, _ = w.Write([]byte(chunk)) | |||
| 438 | + | } | |||
| 439 | + | w.Flush() | |||
| 440 | + | want := []string{"a", "b", "c", "d", "e\ufffdf", "tail"} | |||
| 441 | + | if strings.Join(got, "|") != strings.Join(want, "|") { | |||
| 442 | + | t.Fatalf("%q", got) | |||
| 443 | + | } | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | func TestParseCommands(t *testing.T) { | |||
| 447 | + | reg := func(s string) bool { return s == "set-output" || s == "group" || s == "warning" } | |||
| 448 | + | c, ok := parseCommandV2("::set-output name=a%2Cb,x=y::v%0Aw%25", reg) | |||
| 449 | + | if !ok || c.Name != "set-output" || c.prop("name") != "a,b" || c.Data != "v\nw%" { | |||
| 450 | + | t.Fatalf("%+v %v", c, ok) | |||
| 451 | + | } | |||
| 452 | + | if c, ok = parseCommandV2(" ::group::title", reg); !ok || c.Data != "title" { | |||
| 453 | + | t.Fatalf("leading space: %+v", c) | |||
| 454 | + | } | |||
| 455 | + | if _, ok = parseCommandV2("::unknown::x", reg); ok { | |||
| 456 | + | t.Fatal("unregistered command") | |||
| 457 | + | } | |||
| 458 | + | if _, ok = parseCommandV2("x ::group::t", reg); ok { | |||
| 459 | + | t.Fatal("V2 must start the line") | |||
| 460 | + | } | |||
| 461 | + | c, ok = parseCommandV1("text ##[set-output name=n;k=v%3Bz]data%5D", reg) | |||
| 462 | + | if !ok || c.prop("name") != "n" || c.prop("k") != "v;z" || c.Data != "data]" { | |||
| 463 | + | t.Fatalf("v1: %+v %v", c, ok) | |||
| 464 | + | } | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | func TestStopCommands(t *testing.T) { | |||
| 468 | + | needTools(t, "bash") | |||
| 469 | + | rec, _ := runLocal(t, wfHead+` steps: | |||
| 470 | + | - run: | | |||
| 471 | + | echo "::stop-commands::tok12345" | |||
| 472 | + | echo "::warning::not-processed" | |||
| 473 | + | echo "::tok12345::" | |||
| 474 | + | echo "::warning::processed" | |||
| 475 | + | `, "j", nil) | |||
| 476 | + | got := logOf(rec.steps[1]) | |||
| 477 | + | if !strings.Contains(got, "::warning::not-processed") || !strings.Contains(got, "##[warning]processed") || !strings.Contains(got, "::stop-commands::tok12345") { | |||
| 478 | + | t.Errorf("%s", got) | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | func TestJobOutputsOverOneMiBFailTheJob(t *testing.T) { | |||
| 483 | + | needTools(t, "bash") | |||
| 484 | + | rec, res := runLocal(t, `on: push | |||
| 485 | + | jobs: | |||
| 486 | + | j: | |||
| 487 | + | runs-on: x | |||
| 488 | + | outputs: | |||
| 489 | + | big: ${{ steps.s.outputs.big }} | |||
| 490 | + | other: ${{ steps.s.outputs.big }} | |||
| 491 | + | steps: | |||
| 492 | + | - id: s | |||
| 493 | + | run: echo "big=$(head -c 600000 /dev/zero | tr '\0' x)" >> "$GITHUB_OUTPUT" | |||
| 494 | + | `, "j", nil) | |||
| 495 | + | if res.Conclusion != ConclusionFailure || len(res.Outputs) != 0 { | |||
| 496 | + | t.Fatalf("%+v", res.Conclusion) | |||
| 497 | + | } | |||
| 498 | + | last := rec.steps[len(rec.steps)-1] | |||
| 499 | + | if last.Name != CompleteJobName || last.Conclusion != ConclusionFailure || !strings.Contains(logOf(last), "##[error]The job outputs are larger than 1 MiB in total.") { | |||
| 500 | + | t.Fatalf("%s %s", last.Conclusion, logOf(last)) | |||
| 501 | + | } | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | // A step that plants a link where the executor writes event.json (before the | |||
| 505 | + | // directory exists, or between two steps) must not make it write elsewhere. | |||
| 506 | + | func TestEventJSONIsNeverWrittenThroughALink(t *testing.T) { | |||
| 507 | + | needTools(t, "bash") | |||
| 508 | + | victim := filepath.Join(t.TempDir(), "victim") | |||
| 509 | + | if err := os.WriteFile(victim, []byte("keep"), 0o600); err != nil { | |||
| 510 | + | t.Fatal(err) | |||
| 511 | + | } | |||
| 512 | + | rec, res := runLocal(t, wfHead+` steps: | |||
| 513 | + | - run: | | |||
| 514 | + | rm -f "$RUNNER_TEMP/_github_workflow/event.json" | |||
| 515 | + | ln -s "`+victim+`" "$RUNNER_TEMP/_github_workflow/event.json" | |||
| 516 | + | - run: cat "$GITHUB_EVENT_PATH" | |||
| 517 | + | `, "j", nil) | |||
| 518 | + | if res.Conclusion != ConclusionSuccess { | |||
| 519 | + | t.Fatalf("%+v %s", res, logOf(rec.steps[2])) | |||
| 520 | + | } | |||
| 521 | + | if b, _ := os.ReadFile(victim); string(b) != "keep" { | |||
| 522 | + | t.Fatalf("the target of the link was overwritten: %q", b) | |||
| 523 | + | } | |||
| 524 | + | if got := logOf(rec.steps[2]); !strings.Contains(got, `"ref": "refs/heads/scn/x/r1"`) { | |||
| 525 | + | t.Fatalf("the second step does not see a fresh event.json:\n%s", got) | |||
| 526 | + | } | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func TestLineWriterBoundsALineWithoutEnd(t *testing.T) { | |||
| 530 | + | var got []string | |||
| 531 | + | w := &lineWriter{fn: func(l string) { got = append(got, l) }} | |||
| 532 | + | // 2.5 MiB of two-byte characters without a line feed, in odd-sized writes | |||
| 533 | + | // that cut characters in two. | |||
| 534 | + | blob := []byte(strings.Repeat("é", (5<<20)/4)) | |||
| 535 | + | for i := 0; i < len(blob); i += 7777 { | |||
| 536 | + | _, _ = w.Write(blob[i:min(i+7777, len(blob))]) | |||
| 537 | + | } | |||
| 538 | + | w.Flush() | |||
| 539 | + | total := 0 | |||
| 540 | + | for _, l := range got { | |||
| 541 | + | if len(l) > maxLineBytes { | |||
| 542 | + | t.Fatalf("a line of %d bytes", len(l)) | |||
| 543 | + | } | |||
| 544 | + | if strings.ContainsRune(l, '\ufffd') { | |||
| 545 | + | t.Fatal("a character was cut in two") | |||
| 546 | + | } | |||
| 547 | + | total += len(l) | |||
| 548 | + | } | |||
| 549 | + | if len(got) < 3 || total != len(blob) { | |||
| 550 | + | t.Fatalf("%d lines, %d bytes of %d", len(got), total, len(blob)) | |||
| 551 | + | } | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | func TestEnvFlagReadsTheStepEnvironmentNotTheExecutors(t *testing.T) { | |||
| 555 | + | // ACTIONS_ALLOW_UNSECURE_COMMANDS is read from the environment the steps | |||
| 556 | + | // get (BaseEnv), not from the process of the executor. | |||
| 557 | + | t.Setenv("ACTIONS_ALLOW_UNSECURE_COMMANDS", "true") | |||
| 558 | + | needTools(t, "bash") | |||
| 559 | + | rec, res := runLocal(t, wfHead+" steps:\n - run: echo '::set-env name=X::1'\n", "j", nil) | |||
| 560 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[1]), "The `set-env` command is disabled.") { | |||
| 561 | + | t.Fatalf("%+v\n%s", res, logOf(rec.steps[1])) | |||
| 562 | + | } | |||
| 563 | + | rec, res = runLocal(t, wfHead+" steps:\n - run: echo '::set-env name=X::1'\n - run: echo \"X=$X\"\n", "j", func(c *Config) { | |||
| 564 | + | c.BaseEnv = append(c.BaseEnv, "ACTIONS_ALLOW_UNSECURE_COMMANDS=true") | |||
| 565 | + | }) | |||
| 566 | + | if res.Conclusion != ConclusionSuccess || !strings.HasSuffix(logOf(rec.steps[2]), "X=1") { | |||
| 567 | + | t.Fatalf("%+v\n%s", res, logOf(rec.steps[1])) | |||
| 568 | + | } | |||
| 569 | + | } | |||
| 570 | + | ||||
| 571 | + | // SIGINT goes to the process the step started, like the official runner | |||
| 572 | + | // (ProcessInvoker.SendSignal: kill(_proc.Id, SIGINT)), not to its group: bash | |||
| 573 | + | // is interrupted but the foreground sleep it waits for is not signalled by it. | |||
| 574 | + | func TestCancelSignalsTheShellNotTheGroup(t *testing.T) { | |||
| 575 | + | needTools(t, "bash") | |||
| 576 | + | c := NewCanceller() | |||
| 577 | + | started := make(chan struct{}) | |||
| 578 | + | rec := newRecorder() | |||
| 579 | + | rep := &signalReporter{recorder: rec, started: started, match: "ready"} | |||
| 580 | + | go func() { <-started; c.Cancel(CancelUser) }() | |||
| 581 | + | home := t.TempDir() | |||
| 582 | + | layout := NewLayout(home, "hello", home+"/tc") | |||
| 583 | + | marker := filepath.Join(home, "child-survived") | |||
| 584 | + | var sigs []syscall.Signal | |||
| 585 | + | res := Run(c, Config{ | |||
| 586 | + | WorkflowYAML: []byte(wfHead + " steps:\n - run: |\n trap 'echo got-int' INT\n echo ready\n sleep 1 && touch " + marker + "\n"), | |||
| 587 | + | JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep, | |||
| 588 | + | BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, | |||
| 589 | + | TermAfter: 5 * time.Second, KillAfter: 5 * time.Second, | |||
| 590 | + | Signal: func(pid int, sig syscall.Signal) { | |||
| 591 | + | sigs = append(sigs, sig) | |||
| 592 | + | if err := syscall.Kill(pid, sig); err != nil { | |||
| 593 | + | t.Error(err) | |||
| 594 | + | } | |||
| 595 | + | }, | |||
| 596 | + | }) | |||
| 597 | + | if res.Conclusion != ConclusionCancelled || len(sigs) != 1 || sigs[0] != syscall.SIGINT { | |||
| 598 | + | t.Fatalf("%+v signals %v", res, sigs) | |||
| 599 | + | } | |||
| 600 | + | // bash runs its trap after the foreground child ends: the sleep was not killed. | |||
| 601 | + | if _, err := os.Stat(marker); err != nil { | |||
| 602 | + | t.Fatalf("the foreground child was signalled with the shell: %v", err) | |||
| 603 | + | } | |||
| 604 | + | } | |||
| 605 | + | ||||
| 606 | + | // Deadline is what is left of the timeout when the host sent the job: the | |||
| 607 | + | // executor must not grant the whole timeout again. | |||
| 608 | + | func TestDeadlineOverridesTheTimeout(t *testing.T) { | |||
| 609 | + | needTools(t, "bash") | |||
| 610 | + | start := time.Now() | |||
| 611 | + | rec, res := runLocal(t, wfHead+" steps:\n - run: sleep 30\n", "j", func(c *Config) { | |||
| 612 | + | c.TimeoutMinutes = 60 | |||
| 613 | + | c.Deadline = 500 * time.Millisecond | |||
| 614 | + | }) | |||
| 615 | + | if res.Conclusion != ConclusionTimedOut || !strings.Contains(res.Error, "60 minutes") { | |||
| 616 | + | t.Fatalf("%+v", res) | |||
| 617 | + | } | |||
| 618 | + | if time.Since(start) > 10*time.Second || rec.steps[1].Conclusion != ConclusionCancelled { | |||
| 619 | + | t.Fatalf("took %v: %+v", time.Since(start), rec.steps[1]) | |||
| 620 | + | } | |||
| 621 | + | } | |||
A internal/executor/proc.go +413 -0
| @@ -0,0 +1,413 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "os" | |||
| 11 | + | "os/exec" | |||
| 12 | + | "sort" | |||
| 13 | + | "strconv" | |||
| 14 | + | "strings" | |||
| 15 | + | "sync" | |||
| 16 | + | "syscall" | |||
| 17 | + | "time" | |||
| 18 | + | "unicode/utf8" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // Cancellation timings of the official runner (Runner.Sdk/ProcessInvoker.cs:32): | |||
| 22 | + | // SIGINT at once, SIGTERM after 7.5 s, kill 2.5 s later. | |||
| 23 | + | const ( | |||
| 24 | + | DefaultTermAfter = 7500 * time.Millisecond | |||
| 25 | + | DefaultKillAfter = 2500 * time.Millisecond | |||
| 26 | + | // DefaultWaitDelay is how long the executor waits for the output pipes to | |||
| 27 | + | // close after the shell exited, before it gives up on background children | |||
| 28 | + | // that still hold them: ProcessInvoker.ProcessExitedHandler waits 5 s | |||
| 29 | + | // (ProcessInvoker.cs:491). | |||
| 30 | + | DefaultWaitDelay = 5 * time.Second | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | // lineWriter turns a byte stream into lines like StreamReader.ReadLine: "\n", | |||
| 34 | + | // "\r" and "\r\n" end a line, and invalid UTF-8 becomes U+FFFD. It calls fn | |||
| 35 | + | // for every complete line, in order, from the goroutine that writes. | |||
| 36 | + | type lineWriter struct { | |||
| 37 | + | fn func(line string) | |||
| 38 | + | buf []byte | |||
| 39 | + | pending bool // the last byte seen was "\r": a following "\n" is part of it | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // maxLineBytes bounds what a line may hold before it is emitted unfinished: a | |||
| 43 | + | // step that prints gigabytes without a line feed must not fill the memory of | |||
| 44 | + | // the executor. The official runner reads lines with StreamReader.ReadLine and | |||
| 45 | + | // has no such bound; a line cut here is sent as consecutive lines. | |||
| 46 | + | const maxLineBytes = 1 << 20 | |||
| 47 | + | ||||
| 48 | + | func (w *lineWriter) Write(p []byte) (int, error) { | |||
| 49 | + | n := len(p) | |||
| 50 | + | for len(p) > 0 { | |||
| 51 | + | if w.pending { | |||
| 52 | + | w.pending = false | |||
| 53 | + | if p[0] == '\n' { | |||
| 54 | + | p = p[1:] | |||
| 55 | + | continue | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | i := bytes.IndexAny(p, "\r\n") | |||
| 59 | + | if i < 0 { | |||
| 60 | + | for len(w.buf)+len(p) > maxLineBytes { | |||
| 61 | + | room := maxLineBytes - len(w.buf) | |||
| 62 | + | w.buf = append(w.buf, p[:room]...) | |||
| 63 | + | p = p[room:] | |||
| 64 | + | w.emitCut() | |||
| 65 | + | } | |||
| 66 | + | w.buf = append(w.buf, p...) | |||
| 67 | + | break | |||
| 68 | + | } | |||
| 69 | + | w.buf = append(w.buf, p[:i]...) | |||
| 70 | + | w.emit() | |||
| 71 | + | w.pending = p[i] == '\r' | |||
| 72 | + | p = p[i+1:] | |||
| 73 | + | } | |||
| 74 | + | return n, nil | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // emitCut emits the buffer without a line end, cutting before an incomplete | |||
| 78 | + | // UTF-8 character so that it is not replaced by U+FFFD. | |||
| 79 | + | func (w *lineWriter) emitCut() { | |||
| 80 | + | cut := len(w.buf) | |||
| 81 | + | for back := 1; back <= utf8.UTFMax && back <= len(w.buf); back++ { | |||
| 82 | + | if utf8.RuneStart(w.buf[len(w.buf)-back]) { | |||
| 83 | + | if !utf8.FullRune(w.buf[len(w.buf)-back:]) { | |||
| 84 | + | cut = len(w.buf) - back | |||
| 85 | + | } | |||
| 86 | + | break | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | rest := append([]byte(nil), w.buf[cut:]...) | |||
| 90 | + | w.buf = w.buf[:cut] | |||
| 91 | + | w.emit() | |||
| 92 | + | w.buf = append(w.buf, rest...) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func (w *lineWriter) emit() { | |||
| 96 | + | b := w.buf | |||
| 97 | + | if !utf8.Valid(b) { | |||
| 98 | + | b = bytes.ToValidUTF8(b, []byte("\ufffd")) | |||
| 99 | + | } | |||
| 100 | + | line := string(b) | |||
| 101 | + | w.buf = w.buf[:0] | |||
| 102 | + | w.fn(line) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Flush emits the last line when the stream ended without a terminator. | |||
| 106 | + | func (w *lineWriter) Flush() { | |||
| 107 | + | if len(w.buf) > 0 { | |||
| 108 | + | w.emit() | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // dispatchBatchDelay is how long the dispatcher lets lines pile up after the | |||
| 113 | + | // first one before it hands a batch over. The official runner hands over | |||
| 114 | + | // whatever its reader threads queued when its loop wakes up (a thread-pool | |||
| 115 | + | // wake-up, about a millisecond); Go wakes in microseconds, so without this | |||
| 116 | + | // pause the interleaving of a fast script's stdout and stderr would differ | |||
| 117 | + | // from the official runner's more often. | |||
| 118 | + | const dispatchBatchDelay = 2 * time.Millisecond | |||
| 119 | + | ||||
| 120 | + | // dispatcher reproduces how ProcessInvoker delivers output: stdout and stderr | |||
| 121 | + | // are read by two readers into two queues, and a loop that wakes up on a | |||
| 122 | + | // signal hands over all queued stderr lines first and then all queued stdout | |||
| 123 | + | // lines (ProcessInvoker.ProcessOutput, ProcessInvoker.cs:392-436). Within a | |||
| 124 | + | // stream the order is kept; between the streams a batch puts stderr first, | |||
| 125 | + | // which is visible with `bash -x` (the corpus recording of shell-custom). | |||
| 126 | + | type dispatcher struct { | |||
| 127 | + | fn func(string) | |||
| 128 | + | mu sync.Mutex | |||
| 129 | + | outQ []string | |||
| 130 | + | errQ []string | |||
| 131 | + | signal chan struct{} | |||
| 132 | + | closed chan struct{} | |||
| 133 | + | finished chan struct{} | |||
| 134 | + | out, err *lineWriter | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func newDispatcher(fn func(string)) *dispatcher { | |||
| 138 | + | d := &dispatcher{fn: fn, signal: make(chan struct{}, 1), closed: make(chan struct{}), finished: make(chan struct{})} | |||
| 139 | + | push := func(q *[]string) func(string) { | |||
| 140 | + | return func(line string) { | |||
| 141 | + | d.mu.Lock() | |||
| 142 | + | *q = append(*q, line) | |||
| 143 | + | d.mu.Unlock() | |||
| 144 | + | select { | |||
| 145 | + | case d.signal <- struct{}{}: | |||
| 146 | + | default: | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | d.out = &lineWriter{fn: push(&d.outQ)} | |||
| 151 | + | d.err = &lineWriter{fn: push(&d.errQ)} | |||
| 152 | + | go d.loop() | |||
| 153 | + | return d | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (d *dispatcher) drain() { | |||
| 157 | + | d.mu.Lock() | |||
| 158 | + | errs, outs := d.errQ, d.outQ | |||
| 159 | + | d.errQ, d.outQ = nil, nil | |||
| 160 | + | d.mu.Unlock() | |||
| 161 | + | for _, l := range errs { | |||
| 162 | + | d.fn(l) | |||
| 163 | + | } | |||
| 164 | + | for _, l := range outs { | |||
| 165 | + | d.fn(l) | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func (d *dispatcher) loop() { | |||
| 170 | + | defer close(d.finished) | |||
| 171 | + | for { | |||
| 172 | + | select { | |||
| 173 | + | case <-d.signal: | |||
| 174 | + | time.Sleep(dispatchBatchDelay) | |||
| 175 | + | d.drain() | |||
| 176 | + | case <-d.closed: | |||
| 177 | + | d.drain() | |||
| 178 | + | return | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // Close flushes the readers and delivers what is left. Call it once the | |||
| 184 | + | // process and its pipes are done. | |||
| 185 | + | func (d *dispatcher) Close() { | |||
| 186 | + | d.err.Flush() | |||
| 187 | + | d.out.Flush() | |||
| 188 | + | close(d.closed) | |||
| 189 | + | <-d.finished | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // procSpec describes one process to run. | |||
| 193 | + | type procSpec struct { | |||
| 194 | + | Argv []string | |||
| 195 | + | Dir string | |||
| 196 | + | Env []string | |||
| 197 | + | // Credential, when set, is the user the process runs as. | |||
| 198 | + | Credential *syscall.Credential | |||
| 199 | + | // OnLine receives the lines of stdout and stderr, one call at a time, in | |||
| 200 | + | // the order of dispatcher. | |||
| 201 | + | OnLine func(line string) | |||
| 202 | + | // Cancel starts the SIGINT, SIGTERM, SIGKILL escalation when closed. | |||
| 203 | + | Cancel <-chan struct{} | |||
| 204 | + | ||||
| 205 | + | TermAfter, KillAfter, WaitDelay time.Duration | |||
| 206 | + | // Signal delivers a signal to the process the step started (the shell); | |||
| 207 | + | // tests replace it. | |||
| 208 | + | Signal func(pid int, sig syscall.Signal) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // procResult is how a process ended. | |||
| 212 | + | type procResult struct { | |||
| 213 | + | ExitCode int | |||
| 214 | + | Cancelled bool // Cancel was closed while the process ran | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // runProcess starts the process in its own process group, streams its output | |||
| 218 | + | // and waits. It is ProcessInvoker.ExecuteAsync for one process: stdin is | |||
| 219 | + | // closed, and a closed Cancel starts the escalation. After the shell exits the | |||
| 220 | + | // group is not touched (background children survive, like on a hosted runner | |||
| 221 | + | // until the orphan cleanup of "Complete job"). | |||
| 222 | + | func runProcess(spec procSpec) (procResult, error) { | |||
| 223 | + | if spec.TermAfter == 0 { | |||
| 224 | + | spec.TermAfter = DefaultTermAfter | |||
| 225 | + | } | |||
| 226 | + | if spec.KillAfter == 0 { | |||
| 227 | + | spec.KillAfter = DefaultKillAfter | |||
| 228 | + | } | |||
| 229 | + | if spec.WaitDelay == 0 { | |||
| 230 | + | spec.WaitDelay = DefaultWaitDelay | |||
| 231 | + | } | |||
| 232 | + | if spec.Signal == nil { | |||
| 233 | + | // Like the official runner, which signals the process it started and | |||
| 234 | + | // not its group: ProcessInvoker.SendSignal calls kill(_proc.Id, signal) | |||
| 235 | + | // for SIGINT and SIGTERM (Runner.Sdk/ProcessInvoker.cs:829-833), and the | |||
| 236 | + | // last resort is Process.Kill() of that process alone (:855-869, which | |||
| 237 | + | // is named "KillProcessTree" but does not kill the tree). What the shell | |||
| 238 | + | // started is left to the shell: bash forwards nothing to a foreground | |||
| 239 | + | // child it waits for, and the children that outlive the step are | |||
| 240 | + | // killed by "Complete job" (the orphan cleanup, JobExtension.cs:891-939). | |||
| 241 | + | // The step is still started in its own process group, so that a signal | |||
| 242 | + | // sent to the executor's group (init forwarding a SIGTERM) does not reach | |||
| 243 | + | // it before the executor decides. | |||
| 244 | + | spec.Signal = func(pid int, sig syscall.Signal) { _ = syscall.Kill(pid, sig) } | |||
| 245 | + | } | |||
| 246 | + | cmd := exec.Command(spec.Argv[0], spec.Argv[1:]...) | |||
| 247 | + | cmd.Dir = spec.Dir | |||
| 248 | + | cmd.Env = spec.Env | |||
| 249 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Credential: spec.Credential} | |||
| 250 | + | disp := newDispatcher(spec.OnLine) | |||
| 251 | + | cmd.Stdout, cmd.Stderr = disp.out, disp.err | |||
| 252 | + | cmd.WaitDelay = spec.WaitDelay | |||
| 253 | + | if err := cmd.Start(); err != nil { | |||
| 254 | + | return procResult{}, err | |||
| 255 | + | } | |||
| 256 | + | pid := cmd.Process.Pid | |||
| 257 | + | setOOMScoreAdj(pid) | |||
| 258 | + | ||||
| 259 | + | var mu sync.Mutex | |||
| 260 | + | done := false | |||
| 261 | + | cancelled := false | |||
| 262 | + | var timers []*time.Timer | |||
| 263 | + | stopWatch := make(chan struct{}) | |||
| 264 | + | watchDone := make(chan struct{}) | |||
| 265 | + | go func() { | |||
| 266 | + | defer close(watchDone) | |||
| 267 | + | select { | |||
| 268 | + | case <-spec.Cancel: | |||
| 269 | + | case <-stopWatch: | |||
| 270 | + | return | |||
| 271 | + | } | |||
| 272 | + | mu.Lock() | |||
| 273 | + | defer mu.Unlock() | |||
| 274 | + | if done { | |||
| 275 | + | return | |||
| 276 | + | } | |||
| 277 | + | cancelled = true | |||
| 278 | + | spec.Signal(pid, syscall.SIGINT) | |||
| 279 | + | send := func(sig syscall.Signal) func() { | |||
| 280 | + | return func() { | |||
| 281 | + | mu.Lock() | |||
| 282 | + | defer mu.Unlock() | |||
| 283 | + | if !done { | |||
| 284 | + | spec.Signal(pid, sig) | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | timers = append(timers, | |||
| 289 | + | time.AfterFunc(spec.TermAfter, send(syscall.SIGTERM)), | |||
| 290 | + | time.AfterFunc(spec.TermAfter+spec.KillAfter, send(syscall.SIGKILL))) | |||
| 291 | + | }() | |||
| 292 | + | ||||
| 293 | + | err := cmd.Wait() | |||
| 294 | + | mu.Lock() | |||
| 295 | + | done = true | |||
| 296 | + | for _, t := range timers { | |||
| 297 | + | t.Stop() | |||
| 298 | + | } | |||
| 299 | + | was := cancelled | |||
| 300 | + | mu.Unlock() | |||
| 301 | + | close(stopWatch) | |||
| 302 | + | <-watchDone | |||
| 303 | + | disp.Close() | |||
| 304 | + | ||||
| 305 | + | res := procResult{Cancelled: was} | |||
| 306 | + | var ee *exec.ExitError | |||
| 307 | + | switch { | |||
| 308 | + | case err == nil, errors.Is(err, exec.ErrWaitDelay): | |||
| 309 | + | case errors.As(err, &ee): | |||
| 310 | + | res.ExitCode = ee.ExitCode() | |||
| 311 | + | if res.ExitCode < 0 { // killed by a signal: 128+signal, as .NET reports it | |||
| 312 | + | if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() { | |||
| 313 | + | res.ExitCode = 128 + int(ws.Signal()) | |||
| 314 | + | } else { | |||
| 315 | + | res.ExitCode = 1 | |||
| 316 | + | } | |||
| 317 | + | } | |||
| 318 | + | default: | |||
| 319 | + | return res, err | |||
| 320 | + | } | |||
| 321 | + | return res, nil | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | // setOOMScoreAdj makes the step process a likelier OOM victim than the | |||
| 325 | + | // executor (ProcessInvoker.DecreaseProcessPriority, ProcessInvoker.cs:575). | |||
| 326 | + | func setOOMScoreAdj(pid int) { | |||
| 327 | + | _ = os.WriteFile(fmt.Sprintf("/proc/%d/oom_score_adj", pid), []byte("500"), 0o644) | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | // startError turns the error of a failed start into the text of .NET's | |||
| 331 | + | // Win32Exception, which the official runner logs (the corpus recording | |||
| 332 | + | // "working directory that does not exist" has it). | |||
| 333 | + | func startError(shellPath, dir string, err error) string { | |||
| 334 | + | if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOENT) { | |||
| 335 | + | return fmt.Sprintf("An error occurred trying to start process '%s' with working directory '%s'. No such file or directory", shellPath, dir) | |||
| 336 | + | } | |||
| 337 | + | if errors.Is(err, syscall.ENOTDIR) { | |||
| 338 | + | return fmt.Sprintf("An error occurred trying to start process '%s' with working directory '%s'. Not a directory", shellPath, dir) | |||
| 339 | + | } | |||
| 340 | + | if errors.Is(err, os.ErrPermission) { | |||
| 341 | + | return fmt.Sprintf("An error occurred trying to start process '%s' with working directory '%s'. Permission denied", shellPath, dir) | |||
| 342 | + | } | |||
| 343 | + | return fmt.Sprintf("An error occurred trying to start process '%s' with working directory '%s'. %v", shellPath, dir, err) | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | // ProcessTrackingID is the variable that marks every process of a job so that | |||
| 347 | + | // "Complete job" can find the orphans (Constants.ProcessTrackingId). | |||
| 348 | + | const ProcessTrackingID = "RUNNER_TRACKING_ID" | |||
| 349 | + | ||||
| 350 | + | // procEntry is a process seen in /proc. | |||
| 351 | + | type procEntry struct { | |||
| 352 | + | PID int | |||
| 353 | + | Name string // comm, as .NET's ProcessName | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func (p procEntry) key() string { return strconv.Itoa(p.PID) + "_" + p.Name } | |||
| 357 | + | ||||
| 358 | + | // snapshotProcesses lists the processes (JobExtension.SnapshotProcesses). | |||
| 359 | + | func snapshotProcesses() []procEntry { | |||
| 360 | + | ents, err := os.ReadDir("/proc") | |||
| 361 | + | if err != nil { | |||
| 362 | + | return nil | |||
| 363 | + | } | |||
| 364 | + | var out []procEntry | |||
| 365 | + | for _, e := range ents { | |||
| 366 | + | pid, err := strconv.Atoi(e.Name()) | |||
| 367 | + | if err != nil { | |||
| 368 | + | continue | |||
| 369 | + | } | |||
| 370 | + | comm, err := os.ReadFile("/proc/" + e.Name() + "/comm") | |||
| 371 | + | if err != nil { | |||
| 372 | + | continue | |||
| 373 | + | } | |||
| 374 | + | out = append(out, procEntry{pid, strings.TrimSuffix(string(comm), "\n")}) | |||
| 375 | + | } | |||
| 376 | + | sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID }) | |||
| 377 | + | return out | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | // processTrackingValue reads RUNNER_TRACKING_ID from the environment of a | |||
| 381 | + | // process; "" when it cannot be read. | |||
| 382 | + | func processTrackingValue(pid int) string { | |||
| 383 | + | b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/environ") | |||
| 384 | + | if err != nil { | |||
| 385 | + | return "" | |||
| 386 | + | } | |||
| 387 | + | prefix := []byte(ProcessTrackingID + "=") | |||
| 388 | + | for _, kv := range bytes.Split(b, []byte{0}) { | |||
| 389 | + | if bytes.HasPrefix(kv, prefix) { | |||
| 390 | + | return string(kv[len(prefix):]) | |||
| 391 | + | } | |||
| 392 | + | } | |||
| 393 | + | return "" | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | // killOrphans kills the processes that carry the tracking id and were not | |||
| 397 | + | // there before the job (JobExtension.FinalizeJob, JobExtension.cs:891-939). | |||
| 398 | + | // logf receives the "Terminate orphan process" lines. | |||
| 399 | + | func killOrphans(id string, before map[string]bool, logf func(string)) { | |||
| 400 | + | self := os.Getpid() | |||
| 401 | + | for _, p := range snapshotProcesses() { | |||
| 402 | + | if p.PID == self || before[p.key()] { | |||
| 403 | + | continue | |||
| 404 | + | } | |||
| 405 | + | if !strings.EqualFold(processTrackingValue(p.PID), id) { | |||
| 406 | + | continue | |||
| 407 | + | } | |||
| 408 | + | logf(fmt.Sprintf("Terminate orphan process: pid (%d) (%s)", p.PID, p.Name)) | |||
| 409 | + | _ = syscall.Kill(p.PID, syscall.SIGKILL) | |||
| 410 | + | } | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | var _ io.Writer = (*lineWriter)(nil) | |||
A internal/executor/report.go +50 -0
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import "time" | |||
| 6 | + | ||||
| 7 | + | // Conclusion is how a step or a job ended, with the names of the protocol | |||
| 8 | + | // (CiConclusion in ermite-api). | |||
| 9 | + | type Conclusion string | |||
| 10 | + | ||||
| 11 | + | // Conclusions. | |||
| 12 | + | const ( | |||
| 13 | + | ConclusionSuccess Conclusion = "success" | |||
| 14 | + | ConclusionFailure Conclusion = "failure" | |||
| 15 | + | ConclusionCancelled Conclusion = "cancelled" | |||
| 16 | + | ConclusionSkipped Conclusion = "skipped" | |||
| 17 | + | ConclusionTimedOut Conclusion = "timed_out" // jobs only | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // Reporter receives what happens in a job. The executor calls it from several | |||
| 21 | + | // goroutines but never concurrently: calls are serialised. | |||
| 22 | + | // | |||
| 23 | + | // Steps are numbered like GitHub's log: "Set up job" is 1, the steps of the | |||
| 24 | + | // workflow follow, and "Complete job" is last. Every log line belongs to the | |||
| 25 | + | // step that is running. | |||
| 26 | + | type Reporter interface { | |||
| 27 | + | // StepStarted announces a step that begins to run, with its display name. | |||
| 28 | + | // A step skipped by its condition gets StepStarted and StepCompleted too. | |||
| 29 | + | StepStarted(number int, name string, at time.Time) | |||
| 30 | + | // Log is one line of the job log, without its line terminator. | |||
| 31 | + | Log(number int, at time.Time, line string) | |||
| 32 | + | // StepCompleted ends a step. | |||
| 33 | + | StepCompleted(number int, name string, conclusion Conclusion, at time.Time) | |||
| 34 | + | // JobCompleted is the last call: the result of the job. | |||
| 35 | + | JobCompleted(Result) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Result is the outcome of a job. | |||
| 39 | + | type Result struct { | |||
| 40 | + | Conclusion Conclusion | |||
| 41 | + | // Outputs are the job outputs, evaluated at the end of the job. Empty values | |||
| 42 | + | // are left out, like the official runner does. | |||
| 43 | + | Outputs map[string]string | |||
| 44 | + | // Error is a job-level error (a timeout, a workflow that cannot be run); | |||
| 45 | + | // step errors are in the log. | |||
| 46 | + | Error string | |||
| 47 | + | // RunnerShutdown is true when the job was cut short because the runner was | |||
| 48 | + | // told to stop (see ErrShutdown). | |||
| 49 | + | RunnerShutdown bool | |||
| 50 | + | } | |||
A internal/executor/shell.go +317 -0
| @@ -0,0 +1,317 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | ||||
| 11 | + | "github.com/google/uuid" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Shells and script files of `run:` steps: a port of ScriptHandlerHelpers.cs | |||
| 15 | + | // and of the parts of ScriptHandler.cs that build the command line | |||
| 16 | + | // (Runner.Worker/Handlers). Only the Linux behaviour is ported. | |||
| 17 | + | ||||
| 18 | + | // defaultArguments is ScriptHandlerHelpers._defaultArguments | |||
| 19 | + | // (ScriptHandlerHelpers.cs:13). Keys are compared case-insensitively. | |||
| 20 | + | var defaultArguments = map[string]string{ | |||
| 21 | + | "cmd": `/D /E:ON /V:OFF /S /C "CALL "{0}""`, | |||
| 22 | + | "pwsh": `-command ". '{0}'"`, | |||
| 23 | + | "powershell": `-command ". '{0}'"`, | |||
| 24 | + | "bash": "--noprofile --norc -e -o pipefail {0}", | |||
| 25 | + | "sh": "-e {0}", | |||
| 26 | + | "python": "{0}", | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // scriptExtensions is ScriptHandlerHelpers._extensions (:23). | |||
| 30 | + | var scriptExtensions = map[string]string{ | |||
| 31 | + | "cmd": ".cmd", | |||
| 32 | + | "pwsh": ".ps1", | |||
| 33 | + | "powershell": ".ps1", | |||
| 34 | + | "bash": ".sh", | |||
| 35 | + | "sh": ".sh", | |||
| 36 | + | "python": ".py", | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // ScriptArgumentsFormat is GetScriptArgumentsFormat (:33): the argument format | |||
| 40 | + | // of a built-in shell, "" for any other command. | |||
| 41 | + | func ScriptArgumentsFormat(shellType string) string { | |||
| 42 | + | return defaultArguments[strings.ToLower(shellType)] | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // ScriptFileExtension is GetScriptFileExtension (:42): ".sh", ".py", ... or "". | |||
| 46 | + | func ScriptFileExtension(shellType string) string { | |||
| 47 | + | return scriptExtensions[strings.ToLower(shellType)] | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // FixUpScriptContents is ScriptHandlerHelpers.FixUpScriptContents (:51). The | |||
| 51 | + | // switch is case-sensitive, unlike the two lookups above. | |||
| 52 | + | func FixUpScriptContents(shellType, contents string) string { | |||
| 53 | + | switch shellType { | |||
| 54 | + | case "cmd": | |||
| 55 | + | // Use @echo off rather than /Q: with /Q, echo can't be turned on. | |||
| 56 | + | return "@echo off\n" + contents | |||
| 57 | + | case "powershell", "pwsh": | |||
| 58 | + | return "$ErrorActionPreference = 'stop'\n" + contents + "\n" + | |||
| 59 | + | `if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }` | |||
| 60 | + | } | |||
| 61 | + | return contents | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // ParseShellOption is ParseShellOptionString (:70): "COMMAND [..ARGS]" split at | |||
| 65 | + | // the first space. | |||
| 66 | + | func ParseShellOption(shell string) (command, args string) { | |||
| 67 | + | command, args, _ = strings.Cut(shell, " ") | |||
| 68 | + | return command, args | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // ErrInvalidShell is the error of a shell whose argument format has no {0} | |||
| 72 | + | // (ScriptHandler.cs:245). | |||
| 73 | + | var ErrInvalidShell error = msgError("Invalid shell option. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{0}'") | |||
| 74 | + | ||||
| 75 | + | // Lookup finds executables like WhichUtil.Which (Runner.Sdk/Util/WhichUtil.cs): | |||
| 76 | + | // in PATH with the directories added by GITHUB_PATH in front. | |||
| 77 | + | type Lookup struct { | |||
| 78 | + | // Path is the PATH of the executor process (not of the step). | |||
| 79 | + | Path string | |||
| 80 | + | // Prepend is Global.PrependPath in the order of the AddPath calls. | |||
| 81 | + | Prepend []string | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // NotFoundError is the FileNotFoundException of Which(require: true). | |||
| 85 | + | type NotFoundError struct{ Command string } | |||
| 86 | + | ||||
| 87 | + | func (e *NotFoundError) Error() string { return e.Command + ": command not found" } | |||
| 88 | + | ||||
| 89 | + | // Which returns the path of command, or "" when it is not found. | |||
| 90 | + | func (l Lookup) Which(command string) string { | |||
| 91 | + | if filepath.IsAbs(command) { | |||
| 92 | + | if isFile(command) { | |||
| 93 | + | return command | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | path := PrependedPath(l.Prepend, l.Path) | |||
| 97 | + | for _, seg := range strings.Split(path, ":") { | |||
| 98 | + | if seg == "" { | |||
| 99 | + | continue | |||
| 100 | + | } | |||
| 101 | + | seg = os.ExpandEnv(seg) | |||
| 102 | + | // Directory.EnumerateFiles(seg, command): a plain file name in the directory. | |||
| 103 | + | if strings.ContainsRune(command, '/') { | |||
| 104 | + | continue | |||
| 105 | + | } | |||
| 106 | + | p := filepath.Join(seg, command) | |||
| 107 | + | if isFile(p) { | |||
| 108 | + | return p | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | return "" | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // isFile reports whether p is a file, or a symbolic link whose target exists | |||
| 115 | + | // (WhichUtil.IsPathValid). | |||
| 116 | + | func isFile(p string) bool { | |||
| 117 | + | st, err := os.Stat(p) | |||
| 118 | + | return err == nil && !st.IsDir() | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // Require is Which(command, require: true). | |||
| 122 | + | func (l Lookup) Require(command string) (string, error) { | |||
| 123 | + | if p := l.Which(command); p != "" { | |||
| 124 | + | return p, nil | |||
| 125 | + | } | |||
| 126 | + | return "", &NotFoundError{command} | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // ShellPlan is how a step's script is run: the shell chosen by the step, the | |||
| 130 | + | // job defaults, or the runner's default. | |||
| 131 | + | type ShellPlan struct { | |||
| 132 | + | // Command is the shell command: "sh" for the default shell (whose path is | |||
| 133 | + | // bash when found), else the first word of the shell option. | |||
| 134 | + | Command string | |||
| 135 | + | // CommandPath is the executable that is started, and shown on the | |||
| 136 | + | // "shell:" line of the log. | |||
| 137 | + | CommandPath string | |||
| 138 | + | // ArgFormat is the argument format with {0} for the script path. | |||
| 139 | + | ArgFormat string | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // PlanShell chooses the shell like ScriptHandler.RunAsync (:170-234) and | |||
| 143 | + | // PrintActionDetails (:65-117). shell is the step's `shell` or, when empty, | |||
| 144 | + | // defaults.run.shell (the caller does that merge, :171-180). | |||
| 145 | + | // | |||
| 146 | + | // With no shell: bash if it is in PATH, else sh, with the arguments of "sh" | |||
| 147 | + | // (-e {0}). "sh" is only the name used for the arguments and the extension. | |||
| 148 | + | func PlanShell(shell string, l Lookup) (ShellPlan, error) { | |||
| 149 | + | if shell == "" { | |||
| 150 | + | p, err := l.lookupBashOrSh() | |||
| 151 | + | if err != nil { | |||
| 152 | + | return ShellPlan{}, err | |||
| 153 | + | } | |||
| 154 | + | return checkFormat(ShellPlan{Command: "sh", CommandPath: p, ArgFormat: ScriptArgumentsFormat("sh")}) | |||
| 155 | + | } | |||
| 156 | + | cmd, args := ParseShellOption(shell) | |||
| 157 | + | p, err := l.Require(cmd) | |||
| 158 | + | if err != nil { | |||
| 159 | + | return ShellPlan{}, err | |||
| 160 | + | } | |||
| 161 | + | format := strings.TrimLeft(args, " \t\n\v\f\r") | |||
| 162 | + | if format == "" { | |||
| 163 | + | format = ScriptArgumentsFormat(cmd) | |||
| 164 | + | } | |||
| 165 | + | return checkFormat(ShellPlan{Command: cmd, CommandPath: p, ArgFormat: format}) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func (l Lookup) lookupBashOrSh() (string, error) { | |||
| 169 | + | if p := l.Which("bash"); p != "" { | |||
| 170 | + | return p, nil | |||
| 171 | + | } | |||
| 172 | + | return l.Require("sh") | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func checkFormat(p ShellPlan) (ShellPlan, error) { | |||
| 176 | + | if p.ArgFormat == "" || !strings.Contains(p.ArgFormat, "{0}") { | |||
| 177 | + | return p, ErrInvalidShell | |||
| 178 | + | } | |||
| 179 | + | return p, nil | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // ShellLine is the text after "shell: " in the step header (:121-126). | |||
| 183 | + | func (p ShellPlan) ShellLine() string { return p.CommandPath + " " + p.ArgFormat } | |||
| 184 | + | ||||
| 185 | + | // Extension is the extension of the script file, from the shell command. | |||
| 186 | + | func (p ShellPlan) Extension() string { return ScriptFileExtension(p.Command) } | |||
| 187 | + | ||||
| 188 | + | // FormatArguments is string.Format(argFormat, path) of .NET with one argument: | |||
| 189 | + | // {0} is the path, {{ and }} are braces; anything else is a format error. The | |||
| 190 | + | // script path has its double quotes escaped first (ScriptHandler.cs:252). | |||
| 191 | + | func FormatArguments(format, scriptPath string) (string, error) { | |||
| 192 | + | path := strings.ReplaceAll(scriptPath, `"`, `\"`) | |||
| 193 | + | var b strings.Builder | |||
| 194 | + | for i := 0; i < len(format); i++ { | |||
| 195 | + | switch c := format[i]; { | |||
| 196 | + | case c == '{' && i+1 < len(format) && format[i+1] == '{': | |||
| 197 | + | b.WriteByte('{') | |||
| 198 | + | i++ | |||
| 199 | + | case c == '}' && i+1 < len(format) && format[i+1] == '}': | |||
| 200 | + | b.WriteByte('}') | |||
| 201 | + | i++ | |||
| 202 | + | case c == '{': | |||
| 203 | + | end := strings.IndexByte(format[i:], '}') | |||
| 204 | + | if end < 0 { | |||
| 205 | + | return "", ghErrorf("Input string was not in a correct format.") | |||
| 206 | + | } | |||
| 207 | + | spec := format[i+1 : i+end] | |||
| 208 | + | index, _, _ := strings.Cut(spec, ",") | |||
| 209 | + | index, _, _ = strings.Cut(index, ":") | |||
| 210 | + | if strings.TrimSpace(index) != "0" { | |||
| 211 | + | return "", ghErrorf("Index (zero based) must be greater than or equal to zero and less than the size of the argument list.") | |||
| 212 | + | } | |||
| 213 | + | b.WriteString(path) | |||
| 214 | + | i += end | |||
| 215 | + | case c == '}': | |||
| 216 | + | return "", ghErrorf("Input string was not in a correct format.") | |||
| 217 | + | default: | |||
| 218 | + | b.WriteByte(c) | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | return b.String(), nil | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // ParseArguments splits an argument string into arguments the way .NET does | |||
| 225 | + | // for Process.Start on Unix (Process.Unix.cs ParseArgumentsIntoList): spaces | |||
| 226 | + | // and tabs separate; double quotes group; backslashes only count before a | |||
| 227 | + | // quote. | |||
| 228 | + | func ParseArguments(s string) []string { | |||
| 229 | + | var out []string | |||
| 230 | + | for i := 0; i < len(s); i++ { | |||
| 231 | + | for i < len(s) && (s[i] == ' ' || s[i] == '\t') { | |||
| 232 | + | i++ | |||
| 233 | + | } | |||
| 234 | + | if i == len(s) { | |||
| 235 | + | break | |||
| 236 | + | } | |||
| 237 | + | var cur strings.Builder | |||
| 238 | + | inQuotes := false | |||
| 239 | + | for i < len(s) { | |||
| 240 | + | n := 0 | |||
| 241 | + | for i < len(s) && s[i] == '\\' { | |||
| 242 | + | i++ | |||
| 243 | + | n++ | |||
| 244 | + | } | |||
| 245 | + | if n > 0 { | |||
| 246 | + | if i >= len(s) || s[i] != '"' { | |||
| 247 | + | cur.WriteString(strings.Repeat(`\`, n)) | |||
| 248 | + | } else { | |||
| 249 | + | cur.WriteString(strings.Repeat(`\`, n/2)) | |||
| 250 | + | if n%2 != 0 { | |||
| 251 | + | cur.WriteByte('"') | |||
| 252 | + | i++ | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | continue | |||
| 256 | + | } | |||
| 257 | + | c := s[i] | |||
| 258 | + | if c == '"' { | |||
| 259 | + | if inQuotes && i < len(s)-1 && s[i+1] == '"' { | |||
| 260 | + | cur.WriteByte('"') | |||
| 261 | + | i++ | |||
| 262 | + | } else { | |||
| 263 | + | inQuotes = !inQuotes | |||
| 264 | + | } | |||
| 265 | + | i++ | |||
| 266 | + | continue | |||
| 267 | + | } | |||
| 268 | + | if (c == ' ' || c == '\t') && !inQuotes { | |||
| 269 | + | break | |||
| 270 | + | } | |||
| 271 | + | cur.WriteByte(c) | |||
| 272 | + | i++ | |||
| 273 | + | } | |||
| 274 | + | out = append(out, cur.String()) | |||
| 275 | + | } | |||
| 276 | + | return out | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | // Script is a script written to the temp directory, ready to run. | |||
| 280 | + | type Script struct { | |||
| 281 | + | // Path is the script file. | |||
| 282 | + | Path string | |||
| 283 | + | // Argv is the command: the shell and its arguments with the script path. | |||
| 284 | + | Argv []string | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | // WriteScript writes the script of a step to the directory of root (RUNNER_TEMP) | |||
| 288 | + | // under a random name with the extension of the shell, and builds the command | |||
| 289 | + | // line (ScriptHandler.RunAsync :247-285). The file has no BOM and is not | |||
| 290 | + | // executable: the shell reads it. The directory belongs to the user of the steps, | |||
| 291 | + | // so the file is created through root, exclusively and without following links | |||
| 292 | + | // (see createExcl). | |||
| 293 | + | func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) { | |||
| 294 | + | name := uuid.NewString() + p.Extension() | |||
| 295 | + | path := filepath.Join(root.Name(), name) | |||
| 296 | + | args, err := FormatArguments(p.ArgFormat, path) | |||
| 297 | + | if err != nil { | |||
| 298 | + | return Script{}, err | |||
| 299 | + | } | |||
| 300 | + | contents = FixUpScriptContents(p.Command, contents) | |||
| 301 | + | if err := createExcl(root, name, []byte(contents), 0o644, nil); err != nil { | |||
| 302 | + | return Script{}, fmt.Errorf("write script: %w", err) | |||
| 303 | + | } | |||
| 304 | + | return Script{Path: path, Argv: append([]string{p.CommandPath}, ParseArguments(args)...)}, nil | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | // ResolveWorkingDirectory is Path.Combine(workspace, workingDirectory ?? "") | |||
| 308 | + | // (ScriptHandler.cs:168): an absolute directory replaces the workspace. | |||
| 309 | + | func ResolveWorkingDirectory(workspace, dir string) string { | |||
| 310 | + | if filepath.IsAbs(dir) { | |||
| 311 | + | return dir | |||
| 312 | + | } | |||
| 313 | + | if dir == "" { | |||
| 314 | + | return workspace | |||
| 315 | + | } | |||
| 316 | + | return strings.TrimRight(workspace, "/") + "/" + dir | |||
| 317 | + | } | |||
A internal/executor/shell_test.go +281 -0
| @@ -0,0 +1,281 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "reflect" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func fakeBin(t *testing.T, names ...string) string { | |||
| 14 | + | t.Helper() | |||
| 15 | + | dir := t.TempDir() | |||
| 16 | + | for _, n := range names { | |||
| 17 | + | if err := os.WriteFile(filepath.Join(dir, n), []byte("#!/bin/sh\n"), 0o755); err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | return dir | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestBuiltinFormatsAndExtensions(t *testing.T) { | |||
| 25 | + | // ScriptHandlerHelpers.cs _defaultArguments and _extensions (Linux rows). | |||
| 26 | + | for shell, want := range map[string][2]string{ | |||
| 27 | + | "bash": {"--noprofile --norc -e -o pipefail {0}", ".sh"}, | |||
| 28 | + | "sh": {"-e {0}", ".sh"}, | |||
| 29 | + | "python": {"{0}", ".py"}, | |||
| 30 | + | "pwsh": {`-command ". '{0}'"`, ".ps1"}, | |||
| 31 | + | "powershell": {`-command ". '{0}'"`, ".ps1"}, | |||
| 32 | + | "cmd": {`/D /E:ON /V:OFF /S /C "CALL "{0}""`, ".cmd"}, | |||
| 33 | + | "BASH": {"--noprofile --norc -e -o pipefail {0}", ".sh"}, // case-insensitive tables | |||
| 34 | + | "python3": {"", ""}, | |||
| 35 | + | "ruby": {"", ""}, | |||
| 36 | + | } { | |||
| 37 | + | if got := ScriptArgumentsFormat(shell); got != want[0] { | |||
| 38 | + | t.Errorf("format(%s) = %q", shell, got) | |||
| 39 | + | } | |||
| 40 | + | if got := ScriptFileExtension(shell); got != want[1] { | |||
| 41 | + | t.Errorf("extension(%s) = %q", shell, got) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestFixUpScriptContents(t *testing.T) { | |||
| 47 | + | if got := FixUpScriptContents("cmd", "echo hi"); got != "@echo off\necho hi" { | |||
| 48 | + | t.Fatal(got) | |||
| 49 | + | } | |||
| 50 | + | want := "$ErrorActionPreference = 'stop'\nGet-Date\nif ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) { exit $LASTEXITCODE }" | |||
| 51 | + | for _, s := range []string{"pwsh", "powershell"} { | |||
| 52 | + | if got := FixUpScriptContents(s, "Get-Date"); got != want { | |||
| 53 | + | t.Fatalf("%s: %q", s, got) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | // The switch is case-sensitive: PWSH is left alone. | |||
| 57 | + | for _, s := range []string{"bash", "sh", "python", "PWSH", "Cmd"} { | |||
| 58 | + | if got := FixUpScriptContents(s, "x"); got != "x" { | |||
| 59 | + | t.Fatalf("%s: %q", s, got) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestParseShellOption(t *testing.T) { | |||
| 65 | + | for in, want := range map[string][2]string{ | |||
| 66 | + | "bash": {"bash", ""}, | |||
| 67 | + | "bash -x {0}": {"bash", "-x {0}"}, | |||
| 68 | + | "python3 {0}": {"python3", "{0}"}, | |||
| 69 | + | "perl -e {0}": {"perl", " -e {0}"}, // Split(" ", 2) keeps the second space | |||
| 70 | + | "": {"", ""}, | |||
| 71 | + | "node -e \"x y\" {0}": {"node", "-e \"x y\" {0}"}, | |||
| 72 | + | } { | |||
| 73 | + | c, a := ParseShellOption(in) | |||
| 74 | + | if c != want[0] || a != want[1] { | |||
| 75 | + | t.Errorf("%q: %q %q", in, c, a) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestPlanShell(t *testing.T) { | |||
| 81 | + | both := fakeBin(t, "bash", "sh", "python", "python3", "perl") | |||
| 82 | + | onlySh := fakeBin(t, "sh") | |||
| 83 | + | l := Lookup{Path: both} | |||
| 84 | + | tests := []struct { | |||
| 85 | + | name string | |||
| 86 | + | shell string | |||
| 87 | + | l Lookup | |||
| 88 | + | want ShellPlan | |||
| 89 | + | err string | |||
| 90 | + | }{ | |||
| 91 | + | {"default uses bash with the arguments of sh", "", l, ShellPlan{"sh", filepath.Join(both, "bash"), "-e {0}"}, ""}, | |||
| 92 | + | {"default falls back to sh", "", Lookup{Path: onlySh}, ShellPlan{"sh", filepath.Join(onlySh, "sh"), "-e {0}"}, ""}, | |||
| 93 | + | {"bash", "bash", l, ShellPlan{"bash", filepath.Join(both, "bash"), "--noprofile --norc -e -o pipefail {0}"}, ""}, | |||
| 94 | + | {"sh", "sh", l, ShellPlan{"sh", filepath.Join(both, "sh"), "-e {0}"}, ""}, | |||
| 95 | + | {"python", "python", l, ShellPlan{"python", filepath.Join(both, "python"), "{0}"}, ""}, | |||
| 96 | + | {"custom with args", "python3 {0}", l, ShellPlan{"python3", filepath.Join(both, "python3"), "{0}"}, ""}, | |||
| 97 | + | {"custom args are left-trimmed", "perl -w {0}", l, ShellPlan{"perl", filepath.Join(both, "perl"), "-w {0}"}, ""}, | |||
| 98 | + | {"bash with explicit args", "bash -x {0}", l, ShellPlan{"bash", filepath.Join(both, "bash"), "-x {0}"}, ""}, | |||
| 99 | + | {"unknown command", "nope {0}", l, ShellPlan{}, "nope: command not found"}, | |||
| 100 | + | {"pwsh not installed", "pwsh", l, ShellPlan{}, "pwsh: command not found"}, | |||
| 101 | + | {"no sh at all", "", Lookup{Path: t.TempDir()}, ShellPlan{}, "sh: command not found"}, | |||
| 102 | + | {"custom without {0}", "perl -w", l, ShellPlan{}, "Invalid shell option"}, | |||
| 103 | + | {"no format for an unknown shell", "perl", l, ShellPlan{}, "Invalid shell option"}, | |||
| 104 | + | } | |||
| 105 | + | for _, tc := range tests { | |||
| 106 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 107 | + | got, err := PlanShell(tc.shell, tc.l) | |||
| 108 | + | if tc.err != "" { | |||
| 109 | + | if err == nil || !strings.Contains(err.Error(), tc.err) { | |||
| 110 | + | t.Fatalf("err = %v, want %q", err, tc.err) | |||
| 111 | + | } | |||
| 112 | + | return | |||
| 113 | + | } | |||
| 114 | + | if err != nil || got != tc.want { | |||
| 115 | + | t.Fatalf("got %+v, %v; want %+v", got, err, tc.want) | |||
| 116 | + | } | |||
| 117 | + | }) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func TestPlanShellUsesPrependedPath(t *testing.T) { | |||
| 122 | + | sys, extra := fakeBin(t, "sh"), fakeBin(t, "bash") | |||
| 123 | + | p, err := PlanShell("", Lookup{Path: sys, Prepend: []string{extra}}) | |||
| 124 | + | if err != nil || p.CommandPath != filepath.Join(extra, "bash") { | |||
| 125 | + | t.Fatalf("%+v %v", p, err) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestShellLine(t *testing.T) { | |||
| 130 | + | p := ShellPlan{"sh", "/usr/bin/bash", "-e {0}"} | |||
| 131 | + | if p.ShellLine() != "/usr/bin/bash -e {0}" || p.Extension() != ".sh" { | |||
| 132 | + | t.Fatal(p.ShellLine(), p.Extension()) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestFormatArguments(t *testing.T) { | |||
| 137 | + | for _, tc := range []struct{ format, path, want, err string }{ | |||
| 138 | + | {"-e {0}", "/t/a.sh", "-e /t/a.sh", ""}, | |||
| 139 | + | {"{0}", "/t/a b.sh", "/t/a b.sh", ""}, | |||
| 140 | + | {"-c '{0}' {0}", "/t/x", "-c '/t/x' /t/x", ""}, | |||
| 141 | + | {"{{0}} {0}", "/t/x", "{0} /t/x", ""}, | |||
| 142 | + | {"x }}", "/t/x", "x }", ""}, | |||
| 143 | + | {"{0}", `/t/a"b`, `/t/a\"b`, ""}, // double quotes in the path are escaped | |||
| 144 | + | {"{0:D}", "/t/x", "/t/x", ""}, | |||
| 145 | + | {"{1}", "/t/x", "", "Index"}, | |||
| 146 | + | {"{0", "/t/x", "", "not in a correct format"}, | |||
| 147 | + | {"a } b", "/t/x", "", "not in a correct format"}, | |||
| 148 | + | } { | |||
| 149 | + | got, err := FormatArguments(tc.format, tc.path) | |||
| 150 | + | if tc.err != "" { | |||
| 151 | + | if err == nil || !strings.Contains(err.Error(), tc.err) { | |||
| 152 | + | t.Errorf("%q: err = %v", tc.format, err) | |||
| 153 | + | } | |||
| 154 | + | continue | |||
| 155 | + | } | |||
| 156 | + | if err != nil || got != tc.want { | |||
| 157 | + | t.Errorf("%q: %q %v", tc.format, got, err) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestParseArguments(t *testing.T) { | |||
| 163 | + | for in, want := range map[string][]string{ | |||
| 164 | + | "": nil, | |||
| 165 | + | " ": nil, | |||
| 166 | + | "-e /t/a.sh": {"-e", "/t/a.sh"}, | |||
| 167 | + | "--noprofile --norc -e -o pipefail /t/a.sh": {"--noprofile", "--norc", "-e", "-o", "pipefail", "/t/a.sh"}, | |||
| 168 | + | `-c "a b" c`: {"-c", "a b", "c"}, | |||
| 169 | + | `a\"b`: {`a"b`}, | |||
| 170 | + | `a\\"b c"`: {`a\b c`}, | |||
| 171 | + | `a\\b`: {`a\\b`}, | |||
| 172 | + | `"a""b"`: {`a"b`}, | |||
| 173 | + | "a\tb": {"a", "b"}, | |||
| 174 | + | `-command ". '/t/x.ps1'"`: {"-command", ". '/t/x.ps1'"}, | |||
| 175 | + | `""`: {""}, | |||
| 176 | + | } { | |||
| 177 | + | if got := ParseArguments(in); !reflect.DeepEqual(got, want) { | |||
| 178 | + | t.Errorf("%q: %q, want %q", in, got, want) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestWriteScript(t *testing.T) { | |||
| 184 | + | dir := t.TempDir() | |||
| 185 | + | root, err := os.OpenRoot(dir) | |||
| 186 | + | if err != nil { | |||
| 187 | + | t.Fatal(err) | |||
| 188 | + | } | |||
| 189 | + | defer func() { _ = root.Close() }() | |||
| 190 | + | bin := fakeBin(t, "bash", "sh", "python") | |||
| 191 | + | l := Lookup{Path: bin} | |||
| 192 | + | for _, tc := range []struct { | |||
| 193 | + | shell, ext string | |||
| 194 | + | args []string | |||
| 195 | + | }{ | |||
| 196 | + | {"", ".sh", []string{"-e"}}, | |||
| 197 | + | {"bash", ".sh", []string{"--noprofile", "--norc", "-e", "-o", "pipefail"}}, | |||
| 198 | + | {"python", ".py", nil}, | |||
| 199 | + | } { | |||
| 200 | + | plan, err := PlanShell(tc.shell, l) | |||
| 201 | + | if err != nil { | |||
| 202 | + | t.Fatal(err) | |||
| 203 | + | } | |||
| 204 | + | s, err := WriteScript(root, plan, "echo hi\n") | |||
| 205 | + | if err != nil { | |||
| 206 | + | t.Fatal(err) | |||
| 207 | + | } | |||
| 208 | + | if filepath.Dir(s.Path) != dir || filepath.Ext(s.Path) != tc.ext { | |||
| 209 | + | t.Fatalf("%s: path %s", tc.shell, s.Path) | |||
| 210 | + | } | |||
| 211 | + | want := append(append([]string{plan.CommandPath}, tc.args...), s.Path) | |||
| 212 | + | if !reflect.DeepEqual(s.Argv, want) { | |||
| 213 | + | t.Fatalf("%s: argv %q, want %q", tc.shell, s.Argv, want) | |||
| 214 | + | } | |||
| 215 | + | b, _ := os.ReadFile(s.Path) | |||
| 216 | + | if string(b) != "echo hi\n" { | |||
| 217 | + | t.Fatalf("contents %q", b) | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | // A custom shell without an extension in the table: no extension. | |||
| 221 | + | plan, _ := PlanShell("python {0}", l) | |||
| 222 | + | if s, _ := WriteScript(root, plan, "x"); filepath.Ext(s.Path) != ".py" { | |||
| 223 | + | t.Fatalf("python {0}: %s", s.Path) | |||
| 224 | + | } | |||
| 225 | + | bin2 := fakeBin(t, "perl") | |||
| 226 | + | plan, _ = PlanShell("perl {0}", Lookup{Path: bin2}) | |||
| 227 | + | if s, _ := WriteScript(root, plan, "x"); filepath.Ext(s.Path) != "" { | |||
| 228 | + | t.Fatalf("perl: %s", s.Path) | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func TestResolveWorkingDirectory(t *testing.T) { | |||
| 233 | + | for _, tc := range [][3]string{ | |||
| 234 | + | {"/w/r/r", "", "/w/r/r"}, | |||
| 235 | + | {"/w/r/r", "sub", "/w/r/r/sub"}, | |||
| 236 | + | {"/w/r/r", "a/b", "/w/r/r/a/b"}, | |||
| 237 | + | {"/w/r/r", "/tmp", "/tmp"}, | |||
| 238 | + | {"/w/r/r", "..", "/w/r/r/.."}, | |||
| 239 | + | } { | |||
| 240 | + | if got := ResolveWorkingDirectory(tc[0], tc[1]); got != tc[2] { | |||
| 241 | + | t.Errorf("%v: %q", tc, got) | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // The script goes through the os.Root of RUNNER_TEMP: a directory swapped for a | |||
| 247 | + | // link to elsewhere after the root was opened does not redirect it (GO-002). | |||
| 248 | + | func TestWriteScriptStaysInTheOpenedDirectory(t *testing.T) { | |||
| 249 | + | parent := t.TempDir() | |||
| 250 | + | dir := filepath.Join(parent, "tmp") | |||
| 251 | + | if err := os.Mkdir(dir, 0o755); err != nil { | |||
| 252 | + | t.Fatal(err) | |||
| 253 | + | } | |||
| 254 | + | root, err := os.OpenRoot(dir) | |||
| 255 | + | if err != nil { | |||
| 256 | + | t.Fatal(err) | |||
| 257 | + | } | |||
| 258 | + | defer func() { _ = root.Close() }() | |||
| 259 | + | outside := filepath.Join(parent, "outside") | |||
| 260 | + | if err := os.Mkdir(outside, 0o755); err != nil { | |||
| 261 | + | t.Fatal(err) | |||
| 262 | + | } | |||
| 263 | + | // The step swaps the directory for a link to another one. | |||
| 264 | + | if err := os.Rename(dir, dir+".moved"); err != nil { | |||
| 265 | + | t.Fatal(err) | |||
| 266 | + | } | |||
| 267 | + | if err := os.Symlink(outside, dir); err != nil { | |||
| 268 | + | t.Fatal(err) | |||
| 269 | + | } | |||
| 270 | + | bin := fakeBin(t, "bash", "sh") | |||
| 271 | + | plan, _ := PlanShell("", Lookup{Path: bin}) | |||
| 272 | + | if _, err := WriteScript(root, plan, "echo hi\n"); err != nil { | |||
| 273 | + | t.Fatal(err) | |||
| 274 | + | } | |||
| 275 | + | if ents, _ := os.ReadDir(outside); len(ents) != 0 { | |||
| 276 | + | t.Fatalf("the script was written through the swapped link: %v", ents) | |||
| 277 | + | } | |||
| 278 | + | if ents, _ := os.ReadDir(dir + ".moved"); len(ents) != 1 { | |||
| 279 | + | t.Fatalf("the script is not in the directory that root was opened on: %v", ents) | |||
| 280 | + | } | |||
| 281 | + | } | |||
A internal/executor/step.go +275 -0
| @@ -0,0 +1,275 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite-actions/template" | |||
| 12 | + | "bnema.dev/ermite-actions/workflow" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // ---------------------------------------------------------------- steps | |||
| 16 | + | ||||
| 17 | + | // stepRun is the state of one step being run; it implements Sink and | |||
| 18 | + | // commandHost. | |||
| 19 | + | type stepRun struct { | |||
| 20 | + | j *job | |||
| 21 | + | num int | |||
| 22 | + | st *workflow.Step | |||
| 23 | + | env *ordered // the step's environment: job env + step env | |||
| 24 | + | cmdResult *stepResult | |||
| 25 | + | outcome *stepResult | |||
| 26 | + | cmds *commandManager | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (s *stepRun) Out(text string) { s.j.out(s.num, text) } | |||
| 30 | + | func (s *stepRun) Tagged(tag, msg string) { s.j.tagged(s.num, tag, msg) } | |||
| 31 | + | func (s *stepRun) Debug(msg string) { s.j.debug(s.num, msg) } | |||
| 32 | + | func (s *stepRun) Error(msg string) { s.j.errorf(s.num, "%s", msg) } | |||
| 33 | + | func (s *stepRun) CommandFailed() { f := resFailed; s.cmdResult = &f } | |||
| 34 | + | func (s *stepRun) SetOutput(name, value string) { | |||
| 35 | + | s.j.mu.Lock() | |||
| 36 | + | s.j.state.SetOutput(s.st.ID, name, value) | |||
| 37 | + | s.j.mu.Unlock() | |||
| 38 | + | } | |||
| 39 | + | func (s *stepRun) SaveState(name, value string) {} | |||
| 40 | + | func (s *stepRun) SetEnv(name, value string) { | |||
| 41 | + | s.j.mu.Lock() | |||
| 42 | + | s.j.state.SetEnv(name, value) | |||
| 43 | + | s.j.mu.Unlock() | |||
| 44 | + | } | |||
| 45 | + | func (s *stepRun) AddPath(p string) { | |||
| 46 | + | s.j.mu.Lock() | |||
| 47 | + | s.j.state.AddPath(p) | |||
| 48 | + | s.j.mu.Unlock() | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // EnvFlag reads a boolean variable of the env context, then of the process | |||
| 52 | + | // (ActionCommandManager.cs:236-246). | |||
| 53 | + | func (s *stepRun) EnvFlag(name string) bool { | |||
| 54 | + | v := strings.ToLower(envValue(s.j.baseEnv, name)) | |||
| 55 | + | if v != "true" { | |||
| 56 | + | if e, ok := s.env.Get(name); ok { | |||
| 57 | + | v = strings.ToLower(e) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | return v == "true" | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func firstLine(contents string) string { | |||
| 64 | + | l := strings.TrimLeft(contents, " \t\r\n") | |||
| 65 | + | if i := strings.IndexAny(l, "\r\n"); i >= 0 { | |||
| 66 | + | l = l[:i] | |||
| 67 | + | } | |||
| 68 | + | return l | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // displayName is the name shown for the step (ActionRunner.GenerateDisplayName, | |||
| 72 | + | // ActionRunner.cs:363): the evaluated name, or "Run " and the first line of | |||
| 73 | + | // the script or the action reference. | |||
| 74 | + | func (j *job) displayName(st *workflow.Step, env *ordered) string { | |||
| 75 | + | tok := st.Name | |||
| 76 | + | prefix := "" | |||
| 77 | + | switch { | |||
| 78 | + | case tok != nil: | |||
| 79 | + | case st.Run != nil: | |||
| 80 | + | tok, prefix = st.Run, "Run " | |||
| 81 | + | case st.Uses != nil: | |||
| 82 | + | tok, prefix = st.Uses, "Run " | |||
| 83 | + | default: | |||
| 84 | + | return "" | |||
| 85 | + | } | |||
| 86 | + | name, err := j.evalString(tok, env) | |||
| 87 | + | if err != nil { | |||
| 88 | + | name = tokenSource(tok) | |||
| 89 | + | } | |||
| 90 | + | return formatStepName(prefix, name) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func tokenSource(t template.Token) string { | |||
| 94 | + | if be, ok := t.(*template.BasicExpression); ok && be.Source != "" { | |||
| 95 | + | return be.Source | |||
| 96 | + | } | |||
| 97 | + | return t.String() | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // formatStepName is ActionRunner.FormatStepName (ActionRunner.cs:447). | |||
| 101 | + | func formatStepName(prefix, name string) string { | |||
| 102 | + | if name == "" { | |||
| 103 | + | return "" | |||
| 104 | + | } | |||
| 105 | + | return prefix + firstLine(name) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (j *job) statusFuncs() workflow.StatusFuncs { | |||
| 109 | + | st := j.state.Status() | |||
| 110 | + | return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"} | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // markCancelled records the cancellation of the job: job result Canceled and | |||
| 114 | + | // job.status cancelled (StepsRunner.cs:152-155 and :190-196). | |||
| 115 | + | func (j *job) markCancelledLocked() { | |||
| 116 | + | if j.cancelled { | |||
| 117 | + | return | |||
| 118 | + | } | |||
| 119 | + | j.cancelled = true | |||
| 120 | + | c := resCanceled | |||
| 121 | + | j.result = &c | |||
| 122 | + | j.state.SetStatus("cancelled") | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (j *job) runStep(num int, st *workflow.Step) { | |||
| 126 | + | rep := j.cfg.Reporter | |||
| 127 | + | s := &stepRun{j: j, num: num, st: st} | |||
| 128 | + | s.cmds = newCommandManager(s, j.cfg.Debug) | |||
| 129 | + | ||||
| 130 | + | // Environment of the step (StepsRunner.cs:108-139): job env, then the step's | |||
| 131 | + | // own, evaluated against the job env. | |||
| 132 | + | j.mu.Lock() | |||
| 133 | + | jobEnv := j.state.JobEnv() | |||
| 134 | + | j.mu.Unlock() | |||
| 135 | + | s.env = jobEnv.Clone() | |||
| 136 | + | pairs, envErr := j.evalMap(st.Env, jobEnv) | |||
| 137 | + | if envErr == nil { | |||
| 138 | + | for _, p := range pairs { | |||
| 139 | + | s.env.Set(p.Key, p.Value) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | name := j.displayName(st, s.env) | |||
| 143 | + | rep.StepStarted(num, name, j.now()) | |||
| 144 | + | ||||
| 145 | + | complete := func(r stepResult) { | |||
| 146 | + | res := r | |||
| 147 | + | j.mu.Lock() | |||
| 148 | + | // Update the steps context (ExecutionContext.UpdateGlobalStepsContext). | |||
| 149 | + | outcome := res | |||
| 150 | + | if s.outcome != nil { | |||
| 151 | + | outcome = *s.outcome | |||
| 152 | + | } | |||
| 153 | + | j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion())) | |||
| 154 | + | if res == resFailed { | |||
| 155 | + | m := merge(j.result, resFailed) | |||
| 156 | + | j.result = &m | |||
| 157 | + | j.state.SetStatus(statusName(m)) | |||
| 158 | + | } | |||
| 159 | + | j.mu.Unlock() | |||
| 160 | + | rep.StepCompleted(num, name, res.conclusion(), j.now()) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | if envErr != nil { | |||
| 164 | + | j.errorf(num, "%s", envErr.Error()) | |||
| 165 | + | complete(resFailed) | |||
| 166 | + | return | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // Cancellation seen before the step starts (StepsRunner.cs:188-197). | |||
| 170 | + | cancelledAtStart := false | |||
| 171 | + | select { | |||
| 172 | + | case <-j.cancel.Done(): | |||
| 173 | + | cancelledAtStart = true | |||
| 174 | + | j.mu.Lock() | |||
| 175 | + | j.markCancelledLocked() | |||
| 176 | + | j.mu.Unlock() | |||
| 177 | + | default: | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // Condition (StepsRunner.cs:200-261). On runner shutdown conditions are not | |||
| 181 | + | // evaluated and the step is skipped. | |||
| 182 | + | cond := func() (bool, error) { | |||
| 183 | + | j.mu.Lock() | |||
| 184 | + | defer j.mu.Unlock() | |||
| 185 | + | return st.EvaluateIf(context.Background(), j.evalEnv(s.env), j.statusFuncs()) | |||
| 186 | + | } | |||
| 187 | + | if cancelledAtStart && j.cancel.Reason() == CancelShutdown { | |||
| 188 | + | complete(resSkipped) | |||
| 189 | + | return | |||
| 190 | + | } | |||
| 191 | + | ok, err := cond() | |||
| 192 | + | switch { | |||
| 193 | + | case err != nil: | |||
| 194 | + | j.errorf(num, "%s", err.Error()) | |||
| 195 | + | complete(resFailed) | |||
| 196 | + | return | |||
| 197 | + | case !ok: | |||
| 198 | + | complete(resSkipped) | |||
| 199 | + | return | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes | |||
| 203 | + | // cancelled and the step is stopped unless its condition still holds | |||
| 204 | + | // (always()). | |||
| 205 | + | stepCancel := make(chan struct{}) | |||
| 206 | + | stopWatch := make(chan struct{}) | |||
| 207 | + | var watch sync.WaitGroup | |||
| 208 | + | if !cancelledAtStart { | |||
| 209 | + | watch.Add(1) | |||
| 210 | + | go func() { | |||
| 211 | + | defer watch.Done() | |||
| 212 | + | select { | |||
| 213 | + | case <-j.cancel.Done(): | |||
| 214 | + | case <-stopWatch: | |||
| 215 | + | return | |||
| 216 | + | } | |||
| 217 | + | j.mu.Lock() | |||
| 218 | + | j.markCancelledLocked() | |||
| 219 | + | j.mu.Unlock() | |||
| 220 | + | keep := false | |||
| 221 | + | if j.cancel.Reason() != CancelShutdown { | |||
| 222 | + | keep, _ = cond() | |||
| 223 | + | } | |||
| 224 | + | if !keep { | |||
| 225 | + | close(stepCancel) | |||
| 226 | + | } | |||
| 227 | + | }() | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | r := j.execute(s, stepCancel) | |||
| 231 | + | close(stopWatch) | |||
| 232 | + | watch.Wait() | |||
| 233 | + | ||||
| 234 | + | // Merge the result of the commands (StepsRunner.cs:347-351). | |||
| 235 | + | if s.cmdResult != nil { | |||
| 236 | + | m := merge(&r, *s.cmdResult) | |||
| 237 | + | r = m | |||
| 238 | + | } | |||
| 239 | + | // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422). | |||
| 240 | + | if r == resFailed && st.ContinueOnError != nil { | |||
| 241 | + | coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env)) | |||
| 242 | + | if cerr != nil { | |||
| 243 | + | j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.") | |||
| 244 | + | j.errorf(num, "%s", cerr.Error()) | |||
| 245 | + | } | |||
| 246 | + | if coe && cerr == nil { | |||
| 247 | + | o := resFailed | |||
| 248 | + | s.outcome = &o | |||
| 249 | + | r = resSucceeded | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | complete(r) | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // statusName is the job.status for a result (TaskResult.ToActionResult). | |||
| 256 | + | func statusName(r stepResult) string { | |||
| 257 | + | switch r { | |||
| 258 | + | case resFailed: | |||
| 259 | + | return "failure" | |||
| 260 | + | case resCanceled: | |||
| 261 | + | return "cancelled" | |||
| 262 | + | } | |||
| 263 | + | return "success" | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // notSupported is the failure of a step that uses a feature of a later phase. | |||
| 267 | + | func notSupported(st *workflow.Step) string { | |||
| 268 | + | switch { | |||
| 269 | + | case st.Uses != nil: | |||
| 270 | + | return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value) | |||
| 271 | + | case st.TimeoutMinutes != nil: | |||
| 272 | + | return "not supported yet: timeout-minutes" | |||
| 273 | + | } | |||
| 274 | + | return "" | |||
| 275 | + | } | |||
M internal/guest/exec.go +54 -21
| @@ -38,6 +38,13 @@ type ExecConfig struct { | |||||
| 38 | Version string | 38 | Version string | ||
| 39 | // Shell runs the script as `Shell -c script`; default /bin/sh. | 39 | // Shell runs the script as `Shell -c script`; default /bin/sh. | ||
| 40 | Shell string | 40 | Shell string | ||
| 41 | + | // StepUser is the account the steps of a job run as when the executor is | |||
| 42 | + | // root; default "runner". | |||
| 43 | + | StepUser string | |||
| 44 | + | // AllowRootSteps lets the steps run as root when StepUser does not exist. | |||
| 45 | + | // For test images only; set by ERMITE_RUNNER_ALLOW_ROOT_STEPS=1 in the | |||
| 46 | + | // executor's environment. | |||
| 47 | + | AllowRootSteps bool | |||
| 41 | // ConnectTimeout bounds the connection attempts (default 30 s). | 48 | // ConnectTimeout bounds the connection attempts (default 30 s). | ||
| 42 | ConnectTimeout time.Duration | 49 | ConnectTimeout time.Duration | ||
| 43 | // RunTimeout bounds the wait for the host's Run frame (default 60 s). | 50 | // RunTimeout bounds the wait for the host's Run frame (default 60 s). | ||
| @@ -57,6 +64,9 @@ func (c *ExecConfig) defaults() { | |||||
| 57 | if c.Shell == "" { | 64 | if c.Shell == "" { | ||
| 58 | c.Shell = "/bin/sh" | 65 | c.Shell = "/bin/sh" | ||
| 59 | } | 66 | } | ||
| 67 | + | if c.StepUser == "" { | |||
| 68 | + | c.StepUser = "runner" | |||
| 69 | + | } | |||
| 60 | if c.ConnectTimeout == 0 { | 70 | if c.ConnectTimeout == 0 { | ||
| 61 | c.ConnectTimeout = 30 * time.Second | 71 | c.ConnectTimeout = 30 * time.Second | ||
| 62 | } | 72 | } | ||
| @@ -77,8 +87,9 @@ func (c *ExecConfig) defaults() { | |||||
| 77 | } | 87 | } | ||
| 78 | } | 88 | } | ||
| 79 | 89 | ||||
| 80 | - | // Exec is `ermite-runner exec`, the CI-1 stub executor: it connects back to the | 90 | + | // Exec is `ermite-runner exec`: it connects back to the host and receives one |
| 81 | - | // host, receives one Run, runs the script with the shell, streams stdout and | 91 | + | // Run. A Run with a job is executed by package executor (see runJob); a Run |
| 92 | + | // without one is the stub of sandbox-check, which runs the script with the shell, streams stdout and | |||
| 82 | // stderr as Output frames, and ends with an Exit frame. It returns the exit code | 93 | // stderr as Output frames, and ends with an Exit frame. It returns the exit code | ||
| 83 | // it reported. A Cancel frame, or the end of ctx, stops the script's process | 94 | // it reported. A Cancel frame, or the end of ctx, stops the script's process | ||
| 84 | // group with escalating signals (see ExecConfig). | 95 | // group with escalating signals (see ExecConfig). | ||
| @@ -179,29 +190,47 @@ func serve(ctx context.Context, rw io.ReadWriteCloser, cfg ExecConfig) (int, err | |||||
| 179 | }}}); err != nil { | 190 | }}}); err != nil { | ||
| 180 | return 1, fmt.Errorf("send hello: %w", err) | 191 | return 1, fmt.Errorf("send hello: %w", err) | ||
| 181 | } | 192 | } | ||
| 193 | + | run, err := waitForRun(ctx, conn, cfg.RunTimeout) | |||
| 194 | + | if err != nil { | |||
| 195 | + | return 1, err | |||
| 196 | + | } | |||
| 197 | + | if run.GetJob() != nil { | |||
| 198 | + | return sendExit(conn, runJob(ctx, conn, cfg, run.GetJob()), nil) | |||
| 199 | + | } | |||
| 200 | + | return runStub(ctx, conn, cfg, run) | |||
| 201 | + | } | |||
| 182 | 202 | ||||
| 183 | - | // Wait for Run, bounded: a host that never answers must not pin the sandbox. | 203 | + | // waitForRun waits for the Run frame of the host, bounded: a host that never |
| 204 | + | // answers must not pin the sandbox. | |||
| 205 | + | func waitForRun(ctx context.Context, conn *execstream.Conn, timeout time.Duration) (*execpb.Run, error) { | |||
| 184 | type recv struct { | 206 | type recv struct { | ||
| 185 | f *execpb.Frame | 207 | f *execpb.Frame | ||
| 186 | err error | 208 | err error | ||
| 187 | } | 209 | } | ||
| 188 | first := make(chan recv, 1) | 210 | first := make(chan recv, 1) | ||
| 189 | go func() { f, err := conn.Recv(); first <- recv{f, err} }() | 211 | go func() { f, err := conn.Recv(); first <- recv{f, err} }() | ||
| 190 | - | var run *execpb.Run | 212 | + | timer := time.NewTimer(timeout) |
| 213 | + | defer timer.Stop() | |||
| 191 | select { | 214 | select { | ||
| 192 | case r := <-first: | 215 | case r := <-first: | ||
| 193 | if r.err != nil { | 216 | if r.err != nil { | ||
| 194 | - | return 1, fmt.Errorf("waiting for run: %w", r.err) | 217 | + | return nil, fmt.Errorf("waiting for run: %w", r.err) |
| 195 | } | 218 | } | ||
| 196 | - | if run = r.f.GetRun(); run == nil { | 219 | + | run := r.f.GetRun() |
| 197 | - | return 1, errors.New("first frame from host is not run") | 220 | + | if run == nil { |
| 221 | + | return nil, errors.New("first frame from host is not run") | |||
| 198 | } | 222 | } | ||
| 199 | - | case <-time.After(cfg.RunTimeout): | 223 | + | return run, nil |
| 200 | - | return 1, errors.New("timed out waiting for the host's run frame") | 224 | + | case <-timer.C: |
| 225 | + | return nil, errors.New("timed out waiting for the host's run frame") | |||
| 201 | case <-ctx.Done(): | 226 | case <-ctx.Done(): | ||
| 202 | - | return 1, ctx.Err() | 227 | + | return nil, ctx.Err() |
| 203 | } | 228 | } | ||
| 229 | + | } | |||
| 204 | 230 | ||||
| 231 | + | // runStub is the stub executor of sandbox-check: it runs the script of a Run | |||
| 232 | + | // without a job with the shell and streams its output. | |||
| 233 | + | func runStub(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, run *execpb.Run) (int, error) { | |||
| 205 | script := run.GetScript() | 234 | script := run.GetScript() | ||
| 206 | if script == "" { | 235 | if script == "" { | ||
| 207 | script = DefaultScript | 236 | script = DefaultScript | ||
| @@ -242,26 +271,30 @@ func serve(ctx context.Context, rw io.ReadWriteCloser, cfg ExecConfig) (int, err | |||||
| 242 | 271 | ||||
| 243 | waitErr := cmd.Wait() | 272 | waitErr := cmd.Wait() | ||
| 244 | esc.finish() | 273 | esc.finish() | ||
| 274 | + | code, err := exitCode(waitErr) | |||
| 275 | + | return sendExit(conn, code, err) | |||
| 276 | + | } | |||
| 245 | 277 | ||||
| 246 | - | code := 0 | 278 | + | // exitCode is the exit code to report for the error of cmd.Wait: 0 for a clean |
| 279 | + | // exit (also when a background child kept the pipes open past WaitDelay: the | |||
| 280 | + | // group was killed by esc.finish), the code of the process (128+signal when it | |||
| 281 | + | // was killed, like a shell), or 1 with the error for anything else. | |||
| 282 | + | func exitCode(waitErr error) (int, error) { | |||
| 247 | var ee *exec.ExitError | 283 | var ee *exec.ExitError | ||
| 248 | switch { | 284 | switch { | ||
| 249 | case waitErr == nil, errors.Is(waitErr, exec.ErrWaitDelay): | 285 | case waitErr == nil, errors.Is(waitErr, exec.ErrWaitDelay): | ||
| 250 | - | // ErrWaitDelay: the shell exited cleanly but a background child kept the | 286 | + | return 0, nil |
| 251 | - | // pipes open; the group was killed above. | |||
| 252 | case errors.As(waitErr, &ee): | 287 | case errors.As(waitErr, &ee): | ||
| 253 | - | code = ee.ExitCode() | 288 | + | code := ee.ExitCode() |
| 254 | - | if code < 0 { // killed by a signal: 128+signal like a shell | 289 | + | if code < 0 { |
| 255 | if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() { | 290 | if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() { | ||
| 256 | - | code = 128 + int(ws.Signal()) | 291 | + | return 128 + int(ws.Signal()), nil |
| 257 | - | } else { | |||
| 258 | - | code = 1 | |||
| 259 | } | 292 | } | ||
| 293 | + | return 1, nil | |||
| 260 | } | 294 | } | ||
| 261 | - | default: | 295 | + | return code, nil |
| 262 | - | return sendExit(conn, 1, waitErr) | |||
| 263 | } | 296 | } | ||
| 264 | - | return sendExit(conn, code, nil) | 297 | + | return 1, waitErr |
| 265 | } | 298 | } | ||
| 266 | 299 | ||||
| 267 | func sendExit(c *execstream.Conn, code int, cause error) (int, error) { | 300 | func sendExit(c *execstream.Conn, code int, cause error) (int, error) { | ||
A internal/guest/jobexec.go +226 -0
| @@ -0,0 +1,226 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package guest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "os/user" | |||
| 10 | + | "strconv" | |||
| 11 | + | "strings" | |||
| 12 | + | "syscall" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite-runner/internal/execstream" | |||
| 16 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 17 | + | "bnema.dev/ermite-runner/internal/executor" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // frameReporter is the executor.Reporter that writes frames on the stream. | |||
| 21 | + | type frameReporter struct { | |||
| 22 | + | conn *execstream.Conn | |||
| 23 | + | // err keeps the first send error; the job goes on without a host. | |||
| 24 | + | failed chan struct{} | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func ts(t time.Time) int64 { return t.UnixNano() } | |||
| 28 | + | ||||
| 29 | + | func conclusionProto(c executor.Conclusion) execpb.StepConclusion { | |||
| 30 | + | switch c { | |||
| 31 | + | case executor.ConclusionSuccess: | |||
| 32 | + | return execpb.StepConclusion_STEP_CONCLUSION_SUCCESS | |||
| 33 | + | case executor.ConclusionFailure: | |||
| 34 | + | return execpb.StepConclusion_STEP_CONCLUSION_FAILURE | |||
| 35 | + | case executor.ConclusionCancelled: | |||
| 36 | + | return execpb.StepConclusion_STEP_CONCLUSION_CANCELLED | |||
| 37 | + | case executor.ConclusionSkipped: | |||
| 38 | + | return execpb.StepConclusion_STEP_CONCLUSION_SKIPPED | |||
| 39 | + | case executor.ConclusionTimedOut: | |||
| 40 | + | return execpb.StepConclusion_STEP_CONCLUSION_TIMED_OUT | |||
| 41 | + | } | |||
| 42 | + | return execpb.StepConclusion_STEP_CONCLUSION_UNSPECIFIED | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (r *frameReporter) send(f *execpb.Frame) { | |||
| 46 | + | if err := r.conn.Send(f); err != nil { | |||
| 47 | + | select { | |||
| 48 | + | case <-r.failed: | |||
| 49 | + | default: | |||
| 50 | + | close(r.failed) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (r *frameReporter) StepStarted(n int, name string, at time.Time) { | |||
| 56 | + | r.send(&execpb.Frame{Body: &execpb.Frame_StepStarted{StepStarted: &execpb.StepStarted{Number: int32(n), Name: name, UnixNano: ts(at)}}}) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (r *frameReporter) Log(n int, at time.Time, line string) { | |||
| 60 | + | for i, piece := range execstream.SplitLine(line, execstream.MaxLogLine) { | |||
| 61 | + | r.send(&execpb.Frame{Body: &execpb.Frame_Log{Log: &execpb.Log{Step: int32(n), UnixNano: ts(at), Text: piece, Continued: i > 0}}}) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (r *frameReporter) StepCompleted(n int, name string, c executor.Conclusion, at time.Time) { | |||
| 66 | + | r.send(&execpb.Frame{Body: &execpb.Frame_StepCompleted{StepCompleted: &execpb.StepCompleted{Number: int32(n), Name: name, Conclusion: conclusionProto(c), UnixNano: ts(at)}}}) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (r *frameReporter) JobCompleted(res executor.Result) { | |||
| 70 | + | r.send(&execpb.Frame{Body: &execpb.Frame_JobResult{JobResult: &execpb.JobResult{ | |||
| 71 | + | Conclusion: conclusionProto(res.Conclusion), Outputs: res.Outputs, Error: res.Error, RunnerShutdown: res.RunnerShutdown, | |||
| 72 | + | }}}) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // StepUser is the account the steps run as: uid, gid and supplementary groups, | |||
| 76 | + | // like the hosted runners' "runner" user. Nil credential means the steps run as | |||
| 77 | + | // the executor itself. | |||
| 78 | + | type StepUser struct { | |||
| 79 | + | Credential *syscall.Credential | |||
| 80 | + | Name, Home string | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // LookupStepUser finds the user the steps run as. When the executor is not | |||
| 84 | + | // root it cannot switch user: the steps run as the current user (a development | |||
| 85 | + | // or test setup, never the sandbox of a runner, whose init is root). When it is | |||
| 86 | + | // root and the user does not exist, the lookup fails: running a job's code as | |||
| 87 | + | // root of the sandbox, with the docker daemon and the whole image at hand, | |||
| 88 | + | // because an image lacks the account would hide a broken image. A test image | |||
| 89 | + | // opts in with allowRoot. | |||
| 90 | + | func LookupStepUser(name string, allowRoot bool) (StepUser, error) { | |||
| 91 | + | return lookupStepUser(os.Geteuid(), user.Lookup, name, allowRoot) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func lookupStepUser(euid int, lookup func(string) (*user.User, error), name string, allowRoot bool) (StepUser, error) { | |||
| 95 | + | if euid != 0 || name == "" { | |||
| 96 | + | u := StepUser{Home: os.Getenv("HOME")} | |||
| 97 | + | if cu, err := user.Current(); err == nil { | |||
| 98 | + | u.Name = cu.Username | |||
| 99 | + | if u.Home == "" { | |||
| 100 | + | u.Home = cu.HomeDir | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | return u, nil | |||
| 104 | + | } | |||
| 105 | + | u, err := lookup(name) | |||
| 106 | + | if err != nil { | |||
| 107 | + | if allowRoot { | |||
| 108 | + | return StepUser{Name: "root", Home: "/root"}, nil | |||
| 109 | + | } | |||
| 110 | + | return StepUser{}, fmt.Errorf("the image has no user %q to run the steps as (%v): refusing to run them as root", name, err) | |||
| 111 | + | } | |||
| 112 | + | uid, _ := strconv.ParseUint(u.Uid, 10, 32) | |||
| 113 | + | gid, _ := strconv.ParseUint(u.Gid, 10, 32) | |||
| 114 | + | cred := &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)} | |||
| 115 | + | if ids, err := u.GroupIds(); err == nil { | |||
| 116 | + | for _, g := range ids { | |||
| 117 | + | if n, err := strconv.ParseUint(g, 10, 32); err == nil { | |||
| 118 | + | cred.Groups = append(cred.Groups, uint32(n)) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | return StepUser{Credential: cred, Name: u.Username, Home: u.HomeDir}, nil | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // stepEnv is the environment the steps inherit: the executor's, without the | |||
| 126 | + | // variables of the stream, with HOME, USER and LOGNAME of the step user. | |||
| 127 | + | func stepEnv(environ []string, u StepUser) []string { | |||
| 128 | + | var out []string | |||
| 129 | + | for _, kv := range environ { | |||
| 130 | + | k, _, _ := strings.Cut(kv, "=") | |||
| 131 | + | switch { | |||
| 132 | + | case strings.HasPrefix(k, "ERMITE_"), k == "RUNNER_TOOL_CACHE", k == "HOME", k == "USER", k == "LOGNAME": | |||
| 133 | + | continue | |||
| 134 | + | } | |||
| 135 | + | out = append(out, kv) | |||
| 136 | + | } | |||
| 137 | + | if u.Home != "" { | |||
| 138 | + | out = append(out, "HOME="+u.Home) | |||
| 139 | + | } | |||
| 140 | + | if u.Name != "" { | |||
| 141 | + | out = append(out, "USER="+u.Name, "LOGNAME="+u.Name) | |||
| 142 | + | } | |||
| 143 | + | return out | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func cancelKind(k execpb.CancelKind) executor.CancelReason { | |||
| 147 | + | switch k { | |||
| 148 | + | case execpb.CancelKind_CANCEL_KIND_TIMEOUT: | |||
| 149 | + | return executor.CancelTimeout | |||
| 150 | + | case execpb.CancelKind_CANCEL_KIND_SHUTDOWN: | |||
| 151 | + | return executor.CancelShutdown | |||
| 152 | + | } | |||
| 153 | + | return executor.CancelUser | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // runJob runs the job of a Run frame with the executor and reports on conn. | |||
| 157 | + | // It returns the exit code to report. | |||
| 158 | + | func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *execpb.JobSpec) int { | |||
| 159 | + | su, err := LookupStepUser(cfg.StepUser, cfg.AllowRootSteps) | |||
| 160 | + | if err != nil { | |||
| 161 | + | // Reported like any failure of "Set up job": the job fails, with the reason | |||
| 162 | + | // in its log, and nothing of the workflow runs. | |||
| 163 | + | return failSetup(conn, err.Error()) | |||
| 164 | + | } | |||
| 165 | + | canceller := executor.NewCanceller() | |||
| 166 | + | rep := &frameReporter{conn: conn, failed: make(chan struct{})} | |||
| 167 | + | ||||
| 168 | + | // Frames from the host while the job runs: a Cancel stops the job; a lost | |||
| 169 | + | // host does too, like a runner shutdown. The reader ends with the stream: the | |||
| 170 | + | // caller closes it after runJob, and the goroutine that reports a failed | |||
| 171 | + | // send ends with the job (done). | |||
| 172 | + | done := make(chan struct{}) | |||
| 173 | + | defer close(done) | |||
| 174 | + | go func() { | |||
| 175 | + | for { | |||
| 176 | + | f, err := conn.Recv() | |||
| 177 | + | if err != nil { | |||
| 178 | + | canceller.Cancel(executor.CancelShutdown) | |||
| 179 | + | return | |||
| 180 | + | } | |||
| 181 | + | if c := f.GetCancel(); c != nil { | |||
| 182 | + | canceller.Cancel(cancelKind(c.GetKind())) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | }() | |||
| 186 | + | // The executor's own context (SIGTERM forwarded by init: the sandbox is | |||
| 187 | + | // being stopped) is a shutdown. | |||
| 188 | + | stop := context.AfterFunc(ctx, func() { canceller.Cancel(executor.CancelShutdown) }) | |||
| 189 | + | defer stop() | |||
| 190 | + | go func() { | |||
| 191 | + | select { | |||
| 192 | + | case <-rep.failed: | |||
| 193 | + | canceller.Cancel(executor.CancelShutdown) | |||
| 194 | + | case <-canceller.Done(): | |||
| 195 | + | case <-done: | |||
| 196 | + | } | |||
| 197 | + | }() | |||
| 198 | + | ||||
| 199 | + | env := spec.GetRunnerEnvironment() | |||
| 200 | + | res := executor.Run(canceller, executor.Config{ | |||
| 201 | + | WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(), | |||
| 202 | + | ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(), | |||
| 203 | + | TimeoutMinutes: int(spec.GetTimeoutMinutes()), Deadline: time.Duration(spec.GetDeadlineMs()) * time.Millisecond, | |||
| 204 | + | Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env}, | |||
| 205 | + | RunnerVersion: spec.GetRunnerVersion(), | |||
| 206 | + | Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential, | |||
| 207 | + | Debug: spec.GetDebug(), Reporter: rep, | |||
| 208 | + | TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay, | |||
| 209 | + | }) | |||
| 210 | + | if res.Conclusion == executor.ConclusionSuccess { | |||
| 211 | + | return 0 | |||
| 212 | + | } | |||
| 213 | + | return 1 | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // failSetup ends a job that cannot start, like a failed "Set up job": step 1 | |||
| 217 | + | // starts, logs the reason as an error, and fails; the job result is a failure. | |||
| 218 | + | func failSetup(conn *execstream.Conn, msg string) int { | |||
| 219 | + | rep := &frameReporter{conn: conn, failed: make(chan struct{})} | |||
| 220 | + | now := time.Now() | |||
| 221 | + | rep.StepStarted(1, executor.SetUpJobName, now) | |||
| 222 | + | rep.Log(1, now, "##[error]"+msg) | |||
| 223 | + | rep.StepCompleted(1, executor.SetUpJobName, executor.ConclusionFailure, time.Now()) | |||
| 224 | + | rep.JobCompleted(executor.Result{Conclusion: executor.ConclusionFailure, Error: msg}) | |||
| 225 | + | return 1 | |||
| 226 | + | } | |||
A internal/guest/jobexec_test.go +259 -0
| @@ -0,0 +1,259 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package guest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "net" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "os/user" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "strings" | |||
| 12 | + | "sync" | |||
| 13 | + | "testing" | |||
| 14 | + | "time" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite-runner/internal/execstream" | |||
| 17 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // runJobSession starts Exec on a unix socket and drives it with a job. | |||
| 21 | + | func runJobSession(t *testing.T, yaml string, cancelOn string, tweak func(*ExecConfig)) (steps []string, logs []*execpb.Log, res *execpb.JobResult, exit *execpb.Exit) { | |||
| 22 | + | t.Helper() | |||
| 23 | + | sock := filepath.Join(t.TempDir(), "s") | |||
| 24 | + | ln, err := net.Listen("unix", sock) | |||
| 25 | + | if err != nil { | |||
| 26 | + | t.Skip(err) | |||
| 27 | + | } | |||
| 28 | + | defer func() { _ = ln.Close() }() | |||
| 29 | + | home := t.TempDir() | |||
| 30 | + | t.Setenv("HOME", home) | |||
| 31 | + | t.Setenv("RUNNER_TOOL_CACHE", filepath.Join(home, "toolcache")) | |||
| 32 | + | t.Setenv("ERMITE_CONNECT", "unix:"+sock) | |||
| 33 | + | done := make(chan struct{}) | |||
| 34 | + | go func() { | |||
| 35 | + | defer close(done) | |||
| 36 | + | cfg := ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", RunTimeout: 5 * time.Second, | |||
| 37 | + | TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 300 * time.Millisecond} | |||
| 38 | + | if tweak != nil { | |||
| 39 | + | tweak(&cfg) | |||
| 40 | + | } | |||
| 41 | + | _, _ = Exec(context.Background(), cfg) | |||
| 42 | + | }() | |||
| 43 | + | conn, err := ln.Accept() | |||
| 44 | + | if err != nil { | |||
| 45 | + | t.Fatal(err) | |||
| 46 | + | } | |||
| 47 | + | defer func() { _ = conn.Close() }() | |||
| 48 | + | // With cancelOn, the job is cancelled when it logs that line (the script | |||
| 49 | + | // prints it once it is ready to be interrupted): no timer to guess. | |||
| 50 | + | var cancel chan *execpb.Cancel | |||
| 51 | + | if cancelOn != "" { | |||
| 52 | + | cancel = make(chan *execpb.Cancel, 1) | |||
| 53 | + | } | |||
| 54 | + | var cancelOnce sync.Once | |||
| 55 | + | var mu sync.Mutex | |||
| 56 | + | _, exit, err = execstream.DriveJob(conn, &execpb.Run{Job: &execpb.JobSpec{ | |||
| 57 | + | WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello", RunnerName: "r", RunnerVersion: "v", | |||
| 58 | + | Contexts: `{"github":{"repository":"octo/hello","event_name":"push","job":"j"}}`, Matrix: "{}", | |||
| 59 | + | }}, cancel, execstream.JobHandler{ | |||
| 60 | + | StepStarted: func(s *execpb.StepStarted) { | |||
| 61 | + | mu.Lock() | |||
| 62 | + | steps = append(steps, s.Name) | |||
| 63 | + | mu.Unlock() | |||
| 64 | + | }, | |||
| 65 | + | Log: func(l *execpb.Log) { | |||
| 66 | + | logs = append(logs, l) | |||
| 67 | + | if cancelOn != "" && l.Text == cancelOn { | |||
| 68 | + | cancelOnce.Do(func() { cancel <- &execpb.Cancel{Reason: "test", Kind: execpb.CancelKind_CANCEL_KIND_USER} }) | |||
| 69 | + | } | |||
| 70 | + | }, | |||
| 71 | + | JobResult: func(r *execpb.JobResult) { res = r }, | |||
| 72 | + | }) | |||
| 73 | + | if err != nil { | |||
| 74 | + | t.Fatal(err) | |||
| 75 | + | } | |||
| 76 | + | <-done | |||
| 77 | + | return | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func requireBash(t *testing.T) { | |||
| 81 | + | t.Helper() | |||
| 82 | + | if _, err := lookPath("bash"); err != nil { | |||
| 83 | + | t.Skip("bash not installed") | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestExecRunsAJob(t *testing.T) { | |||
| 88 | + | requireBash(t) | |||
| 89 | + | steps, logs, res, exit := runJobSession(t, "on: push\njobs:\n j:\n runs-on: x\n outputs:\n o: ${{ steps.s.outputs.v }}\n steps:\n - id: s\n run: |\n echo hello-from-job\n echo v=42 >> \"$GITHUB_OUTPUT\"\n", "", nil) | |||
| 90 | + | if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS || res.Outputs["o"] != "42" || exit.Code != 0 { | |||
| 91 | + | t.Fatalf("res=%v exit=%v", res, exit) | |||
| 92 | + | } | |||
| 93 | + | if strings.Join(steps, "|") != "Set up job|Run echo hello-from-job|Complete job" { | |||
| 94 | + | t.Fatalf("steps %v", steps) | |||
| 95 | + | } | |||
| 96 | + | found := false | |||
| 97 | + | for _, l := range logs { | |||
| 98 | + | if l.Text == "hello-from-job" && l.Step == 2 && l.UnixNano > 0 && !l.Continued { | |||
| 99 | + | found = true | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | if !found { | |||
| 103 | + | t.Fatalf("log line missing: %d lines", len(logs)) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestExecJobFailureExitsNonZero(t *testing.T) { | |||
| 108 | + | requireBash(t) | |||
| 109 | + | _, _, res, exit := runJobSession(t, "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: exit 4\n", "", nil) | |||
| 110 | + | if res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_FAILURE || exit.Code == 0 { | |||
| 111 | + | t.Fatalf("res=%v exit=%v", res, exit) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestExecJobLongLinesAreContinued(t *testing.T) { | |||
| 116 | + | requireBash(t) | |||
| 117 | + | _, logs, res, _ := runJobSession(t, "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: head -c 150000 /dev/zero | tr '\\0' y; echo\n", "", nil) | |||
| 118 | + | if res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS { | |||
| 119 | + | t.Fatalf("%v", res) | |||
| 120 | + | } | |||
| 121 | + | var total int | |||
| 122 | + | var pieces int | |||
| 123 | + | for _, l := range logs { | |||
| 124 | + | if strings.HasPrefix(l.Text, "yyyy") { | |||
| 125 | + | pieces++ | |||
| 126 | + | total += len(l.Text) | |||
| 127 | + | if len(l.Text) > execstream.MaxLogLine || (pieces > 1) != l.Continued { | |||
| 128 | + | t.Fatalf("piece %d: len %d continued %v", pieces, len(l.Text), l.Continued) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | if pieces != 3 || total != 150000 { | |||
| 133 | + | t.Fatalf("pieces=%d total=%d", pieces, total) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestExecJobCancel(t *testing.T) { | |||
| 138 | + | requireBash(t) | |||
| 139 | + | start := time.Now() | |||
| 140 | + | _, _, res, _ := runJobSession(t, "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: |\n echo ready\n sleep 30\n", "ready", nil) | |||
| 141 | + | if res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_CANCELLED || res.RunnerShutdown { | |||
| 142 | + | t.Fatalf("%v", res) | |||
| 143 | + | } | |||
| 144 | + | if time.Since(start) > 8*time.Second { | |||
| 145 | + | t.Fatal("cancel too slow") | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestExecJobHostGoneIsAShutdown(t *testing.T) { | |||
| 150 | + | requireBash(t) | |||
| 151 | + | sock := filepath.Join(t.TempDir(), "s") | |||
| 152 | + | ln, err := net.Listen("unix", sock) | |||
| 153 | + | if err != nil { | |||
| 154 | + | t.Skip(err) | |||
| 155 | + | } | |||
| 156 | + | defer func() { _ = ln.Close() }() | |||
| 157 | + | t.Setenv("HOME", t.TempDir()) | |||
| 158 | + | t.Setenv("RUNNER_TOOL_CACHE", filepath.Join(t.TempDir(), "toolcache")) | |||
| 159 | + | done := make(chan int, 1) | |||
| 160 | + | go func() { | |||
| 161 | + | c, _ := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, RunTimeout: 5 * time.Second, | |||
| 162 | + | TermAfter: 100 * time.Millisecond, KillAfter: 100 * time.Millisecond, WaitDelay: 200 * time.Millisecond}) | |||
| 163 | + | done <- c | |||
| 164 | + | }() | |||
| 165 | + | conn, _ := ln.Accept() | |||
| 166 | + | c := execstream.NewConn(conn) | |||
| 167 | + | if _, err := c.Recv(); err != nil { | |||
| 168 | + | t.Fatal(err) | |||
| 169 | + | } | |||
| 170 | + | _ = c.Send(&execpb.Frame{Body: &execpb.Frame_Run{Run: &execpb.Run{Job: &execpb.JobSpec{ | |||
| 171 | + | WorkflowYaml: "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: echo running; sleep 60\n", JobKey: "j", Repo: "hello", | |||
| 172 | + | Contexts: `{"github":{"repository":"o/hello"}}`, | |||
| 173 | + | }}}}) | |||
| 174 | + | // Close the stream once the step is running (its first line arrived). | |||
| 175 | + | for { | |||
| 176 | + | f, err := c.Recv() | |||
| 177 | + | if err != nil { | |||
| 178 | + | t.Fatal(err) | |||
| 179 | + | } | |||
| 180 | + | if l := f.GetLog(); l != nil && l.Text == "running" { | |||
| 181 | + | break | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | _ = conn.Close() | |||
| 185 | + | select { | |||
| 186 | + | case <-done: | |||
| 187 | + | case <-time.After(10 * time.Second): | |||
| 188 | + | t.Fatal("the executor kept running without a host") | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func TestStepEnv(t *testing.T) { | |||
| 193 | + | got := stepEnv([]string{"PATH=/bin", "HOME=/root", "ERMITE_CONNECT=x", "USER=root", "RUNNER_TOOL_CACHE=/x", "FOO=1"}, StepUser{Name: "runner", Home: "/home/runner"}) | |||
| 194 | + | want := "PATH=/bin FOO=1 HOME=/home/runner USER=runner LOGNAME=runner" | |||
| 195 | + | if strings.Join(got, " ") != want { | |||
| 196 | + | t.Fatalf("%v", got) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func lookPath(name string) (string, error) { return exec.LookPath(name) } | |||
| 201 | + | ||||
| 202 | + | func TestMissingStepUserFailsTheJobUnlessTestOptionSet(t *testing.T) { | |||
| 203 | + | none := func(string) (*user.User, error) { return nil, user.UnknownUserError("runner") } | |||
| 204 | + | // As root with no such user: refuse. | |||
| 205 | + | if _, err := lookupStepUser(0, none, "runner", false); err == nil || !strings.Contains(err.Error(), `no user "runner"`) || !strings.Contains(err.Error(), "refusing to run them as root") { | |||
| 206 | + | t.Fatalf("%v", err) | |||
| 207 | + | } | |||
| 208 | + | // The test option allows root. | |||
| 209 | + | if su, err := lookupStepUser(0, none, "runner", true); err != nil || su.Name != "root" || su.Credential != nil { | |||
| 210 | + | t.Fatalf("%+v %v", su, err) | |||
| 211 | + | } | |||
| 212 | + | // Not root: the steps run as the current user, whatever the name. | |||
| 213 | + | if su, err := lookupStepUser(1000, none, "runner", false); err != nil || su.Credential != nil { | |||
| 214 | + | t.Fatalf("%+v %v", su, err) | |||
| 215 | + | } | |||
| 216 | + | // As root with the user: switch to it. | |||
| 217 | + | found := func(string) (*user.User, error) { | |||
| 218 | + | return &user.User{Uid: "1001", Gid: "1001", Username: "runner", HomeDir: "/home/runner"}, nil | |||
| 219 | + | } | |||
| 220 | + | su, err := lookupStepUser(0, found, "runner", false) | |||
| 221 | + | if err != nil || su.Credential == nil || su.Credential.Uid != 1001 || su.Credential.Gid != 1001 || su.Home != "/home/runner" { | |||
| 222 | + | t.Fatalf("%+v %v", su, err) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // The failure is reported like a failed "Set up job". | |||
| 227 | + | func TestFailSetupReportsAFailedFirstStep(t *testing.T) { | |||
| 228 | + | host, guest := net.Pipe() | |||
| 229 | + | got := make(chan []string, 1) | |||
| 230 | + | go func() { | |||
| 231 | + | var frames []string | |||
| 232 | + | c := execstream.NewConn(host) | |||
| 233 | + | for { | |||
| 234 | + | f, err := c.Recv() | |||
| 235 | + | if err != nil { | |||
| 236 | + | got <- frames | |||
| 237 | + | return | |||
| 238 | + | } | |||
| 239 | + | switch b := f.Body.(type) { | |||
| 240 | + | case *execpb.Frame_StepStarted: | |||
| 241 | + | frames = append(frames, "start:"+b.StepStarted.Name) | |||
| 242 | + | case *execpb.Frame_Log: | |||
| 243 | + | frames = append(frames, "log:"+b.Log.Text) | |||
| 244 | + | case *execpb.Frame_StepCompleted: | |||
| 245 | + | frames = append(frames, "done:"+b.StepCompleted.Conclusion.String()) | |||
| 246 | + | case *execpb.Frame_JobResult: | |||
| 247 | + | frames = append(frames, "result:"+b.JobResult.Conclusion.String()) | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | }() | |||
| 251 | + | if code := failSetup(execstream.NewConn(guest), "no user"); code != 1 { | |||
| 252 | + | t.Fatal(code) | |||
| 253 | + | } | |||
| 254 | + | _ = guest.Close() | |||
| 255 | + | want := "start:Set up job|log:##[error]no user|done:STEP_CONCLUSION_FAILURE|result:STEP_CONCLUSION_FAILURE" | |||
| 256 | + | if g := strings.Join(<-got, "|"); g != want { | |||
| 257 | + | t.Fatalf("%s", g) | |||
| 258 | + | } | |||
| 259 | + | } | |||
A internal/host/config.go +220 -0
| @@ -0,0 +1,220 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "net/http" | |||
| 11 | + | "os" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "regexp" | |||
| 14 | + | "strings" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // Environment variables of the host process. The sandbox settings | |||
| 20 | + | // (ERMITE_RUNNER_ISOLATION, _CAPACITY, _IMAGE, _JOB_*) are read by package | |||
| 21 | + | // sandbox. | |||
| 22 | + | const ( | |||
| 23 | + | EnvURL = "ERMITE_RUNNER_URL" | |||
| 24 | + | EnvName = "ERMITE_RUNNER_NAME" | |||
| 25 | + | EnvLabels = "ERMITE_RUNNER_LABELS" | |||
| 26 | + | EnvRegisterToken = "ERMITE_RUNNER_REGISTER_TOKEN" | |||
| 27 | + | // EnvInsecureHTTP allows a plain http server URL that is not loopback. The | |||
| 28 | + | // runner token and the job tokens then travel in clear: tests only. | |||
| 29 | + | EnvInsecureHTTP = "ERMITE_RUNNER_INSECURE_HTTP" | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | // Config is the host part of the runner configuration. | |||
| 33 | + | type Config struct { | |||
| 34 | + | // URL is the base URL of the ermite server. | |||
| 35 | + | URL string | |||
| 36 | + | // Name is the runner name: 1 to 64 letters, digits, ".", "_" and "-". | |||
| 37 | + | Name string | |||
| 38 | + | // Labels are the extra labels of the runner. | |||
| 39 | + | Labels []string | |||
| 40 | + | // StateDir holds the runner token (mode 0600). | |||
| 41 | + | StateDir string | |||
| 42 | + | // Capacity is the number of jobs in parallel. | |||
| 43 | + | Capacity int | |||
| 44 | + | // Version is the version of the runner binary. | |||
| 45 | + | Version string | |||
| 46 | + | // InsecureHTTP allows a non-loopback http URL (ERMITE_RUNNER_INSECURE_HTTP=1). | |||
| 47 | + | InsecureHTTP bool | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Client returns the client for a token, honouring InsecureHTTP. | |||
| 51 | + | func (c Config) Client(url, token string) ermiteconnect.RunnerServiceClient { | |||
| 52 | + | if c.InsecureHTTP { | |||
| 53 | + | return NewInsecureClient(url, token, http.DefaultTransport) | |||
| 54 | + | } | |||
| 55 | + | return NewClient(url, token, http.DefaultTransport) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var nameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`) | |||
| 59 | + | ||||
| 60 | + | // LoadConfig reads the host settings. hostname is the default runner name. | |||
| 61 | + | // stateDir and capacity come from the sandbox configuration, so that both | |||
| 62 | + | // agree. | |||
| 63 | + | func LoadConfig(getenv func(string) string, hostname, stateDir string, capacity int, version string) (Config, error) { | |||
| 64 | + | c := Config{URL: strings.TrimSpace(getenv(EnvURL)), Name: strings.TrimSpace(getenv(EnvName)), StateDir: stateDir, Capacity: capacity, Version: version} | |||
| 65 | + | if c.Name == "" { | |||
| 66 | + | c.Name = sanitizeName(hostname) | |||
| 67 | + | } | |||
| 68 | + | switch strings.ToLower(strings.TrimSpace(getenv(EnvInsecureHTTP))) { | |||
| 69 | + | case "", "0", "false", "no": | |||
| 70 | + | case "1", "true", "yes": | |||
| 71 | + | c.InsecureHTTP = true | |||
| 72 | + | default: | |||
| 73 | + | return c, fmt.Errorf("%s: %q is not 0 or 1", EnvInsecureHTTP, getenv(EnvInsecureHTTP)) | |||
| 74 | + | } | |||
| 75 | + | if c.URL != "" { | |||
| 76 | + | if err := CheckURL(c.URL, c.InsecureHTTP); err != nil { | |||
| 77 | + | return c, fmt.Errorf("%s: %w", EnvURL, err) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | if !nameRe.MatchString(c.Name) { | |||
| 81 | + | return c, fmt.Errorf("%s: %q is not a valid runner name (1 to 64 letters, digits, '.', '_' and '-')", EnvName, c.Name) | |||
| 82 | + | } | |||
| 83 | + | seen := map[string]bool{} | |||
| 84 | + | for _, l := range strings.Split(getenv(EnvLabels), ",") { | |||
| 85 | + | l = strings.TrimSpace(l) | |||
| 86 | + | if l == "" { | |||
| 87 | + | continue | |||
| 88 | + | } | |||
| 89 | + | if !nameRe.MatchString(l) { | |||
| 90 | + | return c, fmt.Errorf("%s: %q is not a valid label (1 to 64 letters, digits, '.', '_' and '-')", EnvLabels, l) | |||
| 91 | + | } | |||
| 92 | + | if k := strings.ToLower(l); !seen[k] { | |||
| 93 | + | seen[k] = true | |||
| 94 | + | c.Labels = append(c.Labels, l) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | return c, nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func sanitizeName(s string) string { | |||
| 101 | + | b := []byte(s) | |||
| 102 | + | for i, ch := range b { | |||
| 103 | + | if ok := ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '.' || ch == '_' || ch == '-'; !ok { | |||
| 104 | + | b[i] = '-' | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | if len(b) > 64 { | |||
| 108 | + | b = b[:64] | |||
| 109 | + | } | |||
| 110 | + | if len(b) == 0 { | |||
| 111 | + | return "runner" | |||
| 112 | + | } | |||
| 113 | + | return string(b) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // State is what register stores: the runner token and who it belongs to. The | |||
| 117 | + | // file is mode 0600 and the token is never logged. | |||
| 118 | + | type State struct { | |||
| 119 | + | URL string `json:"url"` | |||
| 120 | + | RunnerID string `json:"runner_id"` | |||
| 121 | + | Name string `json:"name"` | |||
| 122 | + | RunnerToken string `json:"runner_token"` | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // StateFile is the path of the state file in dir. | |||
| 126 | + | func StateFile(dir string) string { return filepath.Join(dir, "runner.json") } | |||
| 127 | + | ||||
| 128 | + | // ErrNotRegistered is returned by LoadState when no runner is registered. | |||
| 129 | + | var ErrNotRegistered = errors.New("this runner is not registered: run `ermite-runner register` first") | |||
| 130 | + | ||||
| 131 | + | // SaveState writes the state atomically with mode 0600. | |||
| 132 | + | func SaveState(dir string, s State) error { | |||
| 133 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 134 | + | return err | |||
| 135 | + | } | |||
| 136 | + | // MkdirAll leaves an existing directory as it is; the directory holds the | |||
| 137 | + | // runner token, so it is made private (the VM images live in it too, and | |||
| 138 | + | // the runner runs as root: nobody else needs to enter). | |||
| 139 | + | if err := os.Chmod(dir, 0o700); err != nil { | |||
| 140 | + | return fmt.Errorf("make the state directory private: %w", err) | |||
| 141 | + | } | |||
| 142 | + | b, err := json.MarshalIndent(s, "", " ") | |||
| 143 | + | if err != nil { | |||
| 144 | + | return err | |||
| 145 | + | } | |||
| 146 | + | tmp, err := os.CreateTemp(dir, ".runner-*.json") | |||
| 147 | + | if err != nil { | |||
| 148 | + | return err | |||
| 149 | + | } | |||
| 150 | + | defer func() { _ = os.Remove(tmp.Name()) }() | |||
| 151 | + | if err := tmp.Chmod(0o600); err != nil { | |||
| 152 | + | _ = tmp.Close() | |||
| 153 | + | return err | |||
| 154 | + | } | |||
| 155 | + | if _, err := tmp.Write(append(b, '\n')); err != nil { | |||
| 156 | + | _ = tmp.Close() | |||
| 157 | + | return err | |||
| 158 | + | } | |||
| 159 | + | if err := tmp.Sync(); err != nil { | |||
| 160 | + | _ = tmp.Close() | |||
| 161 | + | return err | |||
| 162 | + | } | |||
| 163 | + | if err := tmp.Close(); err != nil { | |||
| 164 | + | return err | |||
| 165 | + | } | |||
| 166 | + | return os.Rename(tmp.Name(), StateFile(dir)) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // LoadState reads the state; it refuses a file readable by others, because the | |||
| 170 | + | // runner token is a secret. | |||
| 171 | + | func LoadState(dir string) (State, error) { | |||
| 172 | + | p := StateFile(dir) | |||
| 173 | + | st, err := os.Stat(p) | |||
| 174 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 175 | + | return State{}, ErrNotRegistered | |||
| 176 | + | } | |||
| 177 | + | if err != nil { | |||
| 178 | + | return State{}, err | |||
| 179 | + | } | |||
| 180 | + | if st.Mode().Perm()&0o077 != 0 { | |||
| 181 | + | return State{}, fmt.Errorf("%s is accessible by other users (mode %o): chmod 600 it", p, st.Mode().Perm()) | |||
| 182 | + | } | |||
| 183 | + | b, err := os.ReadFile(p) | |||
| 184 | + | if err != nil { | |||
| 185 | + | return State{}, err | |||
| 186 | + | } | |||
| 187 | + | var s State | |||
| 188 | + | if err := json.Unmarshal(b, &s); err != nil { | |||
| 189 | + | return State{}, fmt.Errorf("%s: %w", p, err) | |||
| 190 | + | } | |||
| 191 | + | if s.RunnerToken == "" { | |||
| 192 | + | return State{}, fmt.Errorf("%s has no runner token", p) | |||
| 193 | + | } | |||
| 194 | + | return s, nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // ResolveRegisterToken returns the token for `register`, from the first source | |||
| 198 | + | // that has one: standard input (--token-stdin, read to the end and trimmed), | |||
| 199 | + | // the environment (ERMITE_RUNNER_REGISTER_TOKEN), then the --token flag. The | |||
| 200 | + | // flag is last because the arguments of a process are visible to every user of | |||
| 201 | + | // the host (ps, /proc/<pid>/cmdline); the other two are not. | |||
| 202 | + | func ResolveRegisterToken(flagValue string, fromStdin bool, stdin io.Reader, getenv func(string) string) (string, error) { | |||
| 203 | + | if fromStdin { | |||
| 204 | + | b, err := io.ReadAll(io.LimitReader(stdin, 4096)) | |||
| 205 | + | if err != nil { | |||
| 206 | + | return "", fmt.Errorf("reading the token from standard input: %w", err) | |||
| 207 | + | } | |||
| 208 | + | if t := strings.TrimSpace(string(b)); t != "" { | |||
| 209 | + | return t, nil | |||
| 210 | + | } | |||
| 211 | + | return "", errors.New("standard input has no token") | |||
| 212 | + | } | |||
| 213 | + | if t := strings.TrimSpace(getenv(EnvRegisterToken)); t != "" { | |||
| 214 | + | return t, nil | |||
| 215 | + | } | |||
| 216 | + | if t := strings.TrimSpace(flagValue); t != "" { | |||
| 217 | + | return t, nil | |||
| 218 | + | } | |||
| 219 | + | return "", fmt.Errorf("give the token in %s or on standard input (--token-stdin); --token works too but is visible in the process list", EnvRegisterToken) | |||
| 220 | + | } | |||
A internal/host/fake_test.go +562 -0
| @@ -0,0 +1,562 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "io" | |||
| 8 | + | "net" | |||
| 9 | + | "net/http" | |||
| 10 | + | "net/http/httptest" | |||
| 11 | + | "strings" | |||
| 12 | + | "sync" | |||
| 13 | + | "testing" | |||
| 14 | + | "time" | |||
| 15 | + | ||||
| 16 | + | "connectrpc.com/connect" | |||
| 17 | + | "github.com/bnema/zerowrap" | |||
| 18 | + | ||||
| 19 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 20 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 21 | + | ||||
| 22 | + | "bnema.dev/ermite-runner/internal/execstream" | |||
| 23 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 24 | + | "bnema.dev/ermite-runner/internal/host/hosttest" | |||
| 25 | + | "bnema.dev/ermite-runner/internal/sandbox" | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | // fakeServer is an in-memory RunnerService with the contract's rules for | |||
| 29 | + | // AppendLog (offsets), tokens and the end of a job. Hooks let a test inject | |||
| 30 | + | // failures. | |||
| 31 | + | type fakeServer struct { | |||
| 32 | + | ermiteconnect.UnimplementedRunnerServiceHandler | |||
| 33 | + | ||||
| 34 | + | mu sync.Mutex | |||
| 35 | + | runnerTok string | |||
| 36 | + | jobs []*ermite.Job // queued, handed out in order | |||
| 37 | + | assigned map[string]*ermite.Job | |||
| 38 | + | polls []string // poll ids seen | |||
| 39 | + | pollHook func(n int, req *ermite.FetchJobRequest) (*ermite.FetchJobResponse, error) | |||
| 40 | + | pollCalls int | |||
| 41 | + | retryAfter time.Duration | |||
| 42 | + | ||||
| 43 | + | beats []time.Time | |||
| 44 | + | beatHook func(n int) error | |||
| 45 | + | registers []*ermite.RegisterRunnerRequest | |||
| 46 | + | ||||
| 47 | + | store *hosttest.LogStore | |||
| 48 | + | violations []string | |||
| 49 | + | lines []string // log text, one per line (mirror of store) | |||
| 50 | + | cont []bool | |||
| 51 | + | steps map[int32]*ermite.StepUpdate | |||
| 52 | + | appendReq []*ermite.AppendLogRequest | |||
| 53 | + | appendHook func(n int, req *ermite.AppendLogRequest) (applyThenFail bool, err error) | |||
| 54 | + | appendN int | |||
| 55 | + | ||||
| 56 | + | completes []*ermite.CompleteJobRequest | |||
| 57 | + | completeHook func(n int) (applyThenFail bool, err error) | |||
| 58 | + | completeN int | |||
| 59 | + | ended *ermite.JobEnded // when set, job calls answer JobEnded | |||
| 60 | + | completed map[string]bool | |||
| 61 | + | ||||
| 62 | + | watchMsgs chan *ermite.WatchJobResponse | |||
| 63 | + | watches int | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func newFakeServer() *fakeServer { | |||
| 67 | + | s := &fakeServer{ | |||
| 68 | + | runnerTok: "ermiterun_tok", assigned: map[string]*ermite.Job{}, completed: map[string]bool{}, steps: map[int32]*ermite.StepUpdate{}, | |||
| 69 | + | watchMsgs: make(chan *ermite.WatchJobResponse, 16), | |||
| 70 | + | } | |||
| 71 | + | s.store = &hosttest.LogStore{OnViolation: func(m string) { s.violations = append(s.violations, m) }} | |||
| 72 | + | return s | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func endedErr(je *ermite.JobEnded) error { | |||
| 76 | + | e := connect.NewError(connect.CodeFailedPrecondition, io.EOF) | |||
| 77 | + | if d, err := connect.NewErrorDetail(je); err == nil { | |||
| 78 | + | e.AddDetail(d) | |||
| 79 | + | } | |||
| 80 | + | return e | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (s *fakeServer) checkRunner(h http.Header) error { | |||
| 84 | + | if h.Get("Authorization") != "Bearer "+s.runnerTok { | |||
| 85 | + | return connect.NewError(connect.CodeUnauthenticated, io.EOF) | |||
| 86 | + | } | |||
| 87 | + | return nil | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (s *fakeServer) checkJob(h http.Header, id string) (*ermite.Job, error) { | |||
| 91 | + | tok := strings.TrimPrefix(h.Get("Authorization"), "Bearer ") | |||
| 92 | + | for _, j := range s.assigned { | |||
| 93 | + | if j.JobToken == tok { | |||
| 94 | + | if j.JobId != id { | |||
| 95 | + | return nil, connect.NewError(connect.CodePermissionDenied, io.EOF) | |||
| 96 | + | } | |||
| 97 | + | return j, nil | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | return nil, connect.NewError(connect.CodeUnauthenticated, io.EOF) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func (s *fakeServer) RegisterRunner(_ context.Context, r *connect.Request[ermite.RegisterRunnerRequest]) (*connect.Response[ermite.RegisterRunnerResponse], error) { | |||
| 104 | + | s.mu.Lock() | |||
| 105 | + | defer s.mu.Unlock() | |||
| 106 | + | s.registers = append(s.registers, r.Msg) | |||
| 107 | + | if r.Header().Get("Authorization") != "Bearer reg-token" { | |||
| 108 | + | return nil, connect.NewError(connect.CodePermissionDenied, io.EOF) | |||
| 109 | + | } | |||
| 110 | + | return connect.NewResponse(&ermite.RegisterRunnerResponse{RunnerId: "rid1", RunnerToken: "ermiterun_new"}), nil | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (s *fakeServer) FetchJob(_ context.Context, r *connect.Request[ermite.FetchJobRequest]) (*connect.Response[ermite.FetchJobResponse], error) { | |||
| 114 | + | s.mu.Lock() | |||
| 115 | + | defer s.mu.Unlock() | |||
| 116 | + | if err := s.checkRunner(r.Header()); err != nil { | |||
| 117 | + | return nil, err | |||
| 118 | + | } | |||
| 119 | + | s.pollCalls++ | |||
| 120 | + | s.polls = append(s.polls, r.Msg.PollId) | |||
| 121 | + | if s.pollHook != nil { | |||
| 122 | + | if resp, err := s.pollHook(s.pollCalls, r.Msg); resp != nil || err != nil { | |||
| 123 | + | if resp != nil && resp.Job != nil { | |||
| 124 | + | s.assigned[resp.Job.JobId] = resp.Job | |||
| 125 | + | } | |||
| 126 | + | return connect.NewResponse(resp), err | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | if len(s.jobs) == 0 { | |||
| 130 | + | resp := &ermite.FetchJobResponse{} | |||
| 131 | + | if s.retryAfter > 0 { | |||
| 132 | + | resp.RetryAfter = durationpbOf(s.retryAfter) | |||
| 133 | + | } | |||
| 134 | + | return connect.NewResponse(resp), nil | |||
| 135 | + | } | |||
| 136 | + | j := s.jobs[0] | |||
| 137 | + | s.jobs = s.jobs[1:] | |||
| 138 | + | s.assigned[j.JobId] = j | |||
| 139 | + | return connect.NewResponse(&ermite.FetchJobResponse{Job: j}), nil | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func (s *fakeServer) Heartbeat(_ context.Context, r *connect.Request[ermite.HeartbeatRequest]) (*connect.Response[ermite.HeartbeatResponse], error) { | |||
| 143 | + | s.mu.Lock() | |||
| 144 | + | defer s.mu.Unlock() | |||
| 145 | + | if _, err := s.checkJob(r.Header(), r.Msg.JobId); err != nil { | |||
| 146 | + | return nil, err | |||
| 147 | + | } | |||
| 148 | + | if s.ended != nil { | |||
| 149 | + | return nil, endedErr(s.ended) | |||
| 150 | + | } | |||
| 151 | + | s.beats = append(s.beats, time.Now()) | |||
| 152 | + | if s.beatHook != nil { | |||
| 153 | + | if err := s.beatHook(len(s.beats)); err != nil { | |||
| 154 | + | return nil, err | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | return connect.NewResponse(&ermite.HeartbeatResponse{}), nil | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (s *fakeServer) UpdateJob(_ context.Context, r *connect.Request[ermite.UpdateJobRequest]) (*connect.Response[ermite.UpdateJobResponse], error) { | |||
| 161 | + | s.mu.Lock() | |||
| 162 | + | defer s.mu.Unlock() | |||
| 163 | + | if _, err := s.checkJob(r.Header(), r.Msg.JobId); err != nil { | |||
| 164 | + | return nil, err | |||
| 165 | + | } | |||
| 166 | + | if s.ended != nil || s.completed[r.Msg.JobId] { | |||
| 167 | + | je := s.ended | |||
| 168 | + | if je == nil { | |||
| 169 | + | je = &ermite.JobEnded{Conclusion: ermite.CiConclusion_CI_CONCLUSION_SUCCESS} | |||
| 170 | + | } | |||
| 171 | + | return nil, endedErr(je) | |||
| 172 | + | } | |||
| 173 | + | for _, u := range r.Msg.Steps { | |||
| 174 | + | s.steps[u.Number] = u | |||
| 175 | + | } | |||
| 176 | + | return connect.NewResponse(&ermite.UpdateJobResponse{}), nil | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func (s *fakeServer) AppendLog(_ context.Context, r *connect.Request[ermite.AppendLogRequest]) (*connect.Response[ermite.AppendLogResponse], error) { | |||
| 180 | + | s.mu.Lock() | |||
| 181 | + | defer s.mu.Unlock() | |||
| 182 | + | if _, err := s.checkJob(r.Header(), r.Msg.JobId); err != nil { | |||
| 183 | + | return nil, err | |||
| 184 | + | } | |||
| 185 | + | if s.ended != nil || s.completed[r.Msg.JobId] { | |||
| 186 | + | je := s.ended | |||
| 187 | + | if je == nil { | |||
| 188 | + | je = &ermite.JobEnded{Conclusion: ermite.CiConclusion_CI_CONCLUSION_SUCCESS} | |||
| 189 | + | } | |||
| 190 | + | return nil, endedErr(je) | |||
| 191 | + | } | |||
| 192 | + | s.appendN++ | |||
| 193 | + | s.appendReq = append(s.appendReq, r.Msg) | |||
| 194 | + | // The rules of runner.proto are written once, in hosttest.LogStore. | |||
| 195 | + | var failAfter error | |||
| 196 | + | if s.appendHook != nil { | |||
| 197 | + | thenFail, err := s.appendHook(s.appendN, r.Msg) | |||
| 198 | + | if err != nil && !thenFail { | |||
| 199 | + | return nil, err | |||
| 200 | + | } | |||
| 201 | + | failAfter = err | |||
| 202 | + | } | |||
| 203 | + | next, err := s.store.Apply(r.Msg) | |||
| 204 | + | if err != nil { | |||
| 205 | + | return nil, err | |||
| 206 | + | } | |||
| 207 | + | s.lines, s.cont = s.lines[:0], s.cont[:0] | |||
| 208 | + | for _, l := range s.store.Lines() { | |||
| 209 | + | s.lines = append(s.lines, l.Text) | |||
| 210 | + | s.cont = append(s.cont, l.Continued) | |||
| 211 | + | } | |||
| 212 | + | if failAfter != nil { | |||
| 213 | + | return nil, failAfter | |||
| 214 | + | } | |||
| 215 | + | return connect.NewResponse(&ermite.AppendLogResponse{NextOffset: next}), nil | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func (s *fakeServer) CompleteJob(_ context.Context, r *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) { | |||
| 219 | + | s.mu.Lock() | |||
| 220 | + | defer s.mu.Unlock() | |||
| 221 | + | if _, err := s.checkJob(r.Header(), r.Msg.JobId); err != nil { | |||
| 222 | + | return nil, err | |||
| 223 | + | } | |||
| 224 | + | s.completeN++ | |||
| 225 | + | s.completes = append(s.completes, r.Msg) | |||
| 226 | + | if s.completeHook != nil { | |||
| 227 | + | thenFail, err := s.completeHook(s.completeN) | |||
| 228 | + | if err != nil { | |||
| 229 | + | if thenFail { | |||
| 230 | + | s.completed[r.Msg.JobId] = true | |||
| 231 | + | } | |||
| 232 | + | return nil, err | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | if s.completed[r.Msg.JobId] || s.ended != nil { | |||
| 236 | + | c := ermite.CiConclusion_CI_CONCLUSION_FAILURE | |||
| 237 | + | if s.ended != nil { | |||
| 238 | + | c = s.ended.Conclusion | |||
| 239 | + | } | |||
| 240 | + | return connect.NewResponse(&ermite.CompleteJobResponse{Conclusion: c, Applied: false}), nil | |||
| 241 | + | } | |||
| 242 | + | s.completed[r.Msg.JobId] = true | |||
| 243 | + | return connect.NewResponse(&ermite.CompleteJobResponse{Conclusion: r.Msg.Conclusion, Applied: true}), nil | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func (s *fakeServer) WatchJob(ctx context.Context, r *connect.Request[ermite.WatchJobRequest], st *connect.ServerStream[ermite.WatchJobResponse]) error { | |||
| 247 | + | s.mu.Lock() | |||
| 248 | + | _, err := s.checkJob(r.Header(), r.Msg.JobId) | |||
| 249 | + | s.watches++ | |||
| 250 | + | ended := s.ended | |||
| 251 | + | s.mu.Unlock() | |||
| 252 | + | if err != nil { | |||
| 253 | + | return err | |||
| 254 | + | } | |||
| 255 | + | if ended != nil { | |||
| 256 | + | return endedErr(ended) | |||
| 257 | + | } | |||
| 258 | + | for { | |||
| 259 | + | select { | |||
| 260 | + | case m := <-s.watchMsgs: | |||
| 261 | + | if err := st.Send(m); err != nil { | |||
| 262 | + | return err | |||
| 263 | + | } | |||
| 264 | + | if m.GetEnded() != nil { | |||
| 265 | + | return nil | |||
| 266 | + | } | |||
| 267 | + | case <-ctx.Done(): | |||
| 268 | + | return ctx.Err() | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | // start serves the handler over HTTP/1.1 on a local port (Connect streams server messages over it) and returns its URL. | |||
| 274 | + | func (s *fakeServer) start(t *testing.T) string { | |||
| 275 | + | t.Helper() | |||
| 276 | + | _, h := ermiteconnect.NewRunnerServiceHandler(s) | |||
| 277 | + | srv := httptest.NewServer(h) | |||
| 278 | + | t.Cleanup(srv.Close) | |||
| 279 | + | return srv.URL | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | func (s *fakeServer) snapshotLines() []string { | |||
| 283 | + | s.mu.Lock() | |||
| 284 | + | defer s.mu.Unlock() | |||
| 285 | + | return append([]string(nil), s.lines...) | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | // ---- fake sandbox: the "executor" is a function on the other end of a pipe | |||
| 289 | + | ||||
| 290 | + | type script func(t *testing.T, c *execstream.Conn, run *execpb.Run, cancels <-chan *execpb.Cancel) | |||
| 291 | + | ||||
| 292 | + | type fakeDriver struct { | |||
| 293 | + | mu sync.Mutex | |||
| 294 | + | script script | |||
| 295 | + | t *testing.T | |||
| 296 | + | news int | |||
| 297 | + | stops int | |||
| 298 | + | started int | |||
| 299 | + | reaps int | |||
| 300 | + | reapErr error | |||
| 301 | + | startErr error | |||
| 302 | + | stopErr error | |||
| 303 | + | capacity int | |||
| 304 | + | live int | |||
| 305 | + | blockStart chan struct{} // when set, Start waits on it | |||
| 306 | + | specs []sandbox.JobSpec // what each Start was asked for | |||
| 307 | + | iso sandbox.Isolation | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func (d *fakeDriver) Mode() sandbox.Mode { return sandbox.Container } | |||
| 311 | + | func (d *fakeDriver) Isolation() sandbox.Isolation { return d.iso } | |||
| 312 | + | func (d *fakeDriver) Close() error { return nil } | |||
| 313 | + | func (d *fakeDriver) Reap(context.Context) error { | |||
| 314 | + | d.mu.Lock() | |||
| 315 | + | defer d.mu.Unlock() | |||
| 316 | + | d.reaps++ | |||
| 317 | + | return d.reapErr | |||
| 318 | + | } | |||
| 319 | + | func (d *fakeDriver) New() (sandbox.Sandbox, error) { | |||
| 320 | + | d.mu.Lock() | |||
| 321 | + | defer d.mu.Unlock() | |||
| 322 | + | if d.capacity > 0 && d.live >= d.capacity { | |||
| 323 | + | return nil, sandbox.ErrCapacity | |||
| 324 | + | } | |||
| 325 | + | d.news++ | |||
| 326 | + | d.live++ | |||
| 327 | + | return &fakeSandbox{d: d}, nil | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | type fakeSandbox struct { | |||
| 331 | + | d *fakeDriver | |||
| 332 | + | mu sync.Mutex | |||
| 333 | + | conn net.Conn | |||
| 334 | + | stoped bool | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func (s *fakeSandbox) ID() string { return "er00000000" } | |||
| 338 | + | func (s *fakeSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Conn, error) { | |||
| 339 | + | d := s.d | |||
| 340 | + | d.mu.Lock() | |||
| 341 | + | d.specs = append(d.specs, spec) | |||
| 342 | + | d.mu.Unlock() | |||
| 343 | + | if d.blockStart != nil { | |||
| 344 | + | select { | |||
| 345 | + | case <-d.blockStart: | |||
| 346 | + | case <-ctx.Done(): | |||
| 347 | + | return nil, ctx.Err() | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | d.mu.Lock() | |||
| 351 | + | if d.startErr != nil { | |||
| 352 | + | err := d.startErr | |||
| 353 | + | d.mu.Unlock() | |||
| 354 | + | return nil, err | |||
| 355 | + | } | |||
| 356 | + | d.started++ | |||
| 357 | + | sc := d.script | |||
| 358 | + | d.mu.Unlock() | |||
| 359 | + | host, guest := net.Pipe() | |||
| 360 | + | s.mu.Lock() | |||
| 361 | + | s.conn = guest | |||
| 362 | + | s.mu.Unlock() | |||
| 363 | + | go func() { | |||
| 364 | + | defer func() { _ = guest.Close() }() | |||
| 365 | + | c := execstream.NewConn(guest) | |||
| 366 | + | _ = c.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{Version: "test", Sandbox: "container"}}}) | |||
| 367 | + | f, err := c.Recv() | |||
| 368 | + | if err != nil || f.GetRun() == nil { | |||
| 369 | + | return | |||
| 370 | + | } | |||
| 371 | + | cancels := make(chan *execpb.Cancel, 4) | |||
| 372 | + | go func() { | |||
| 373 | + | for { | |||
| 374 | + | f, err := c.Recv() | |||
| 375 | + | if err != nil { | |||
| 376 | + | close(cancels) | |||
| 377 | + | return | |||
| 378 | + | } | |||
| 379 | + | if cn := f.GetCancel(); cn != nil { | |||
| 380 | + | cancels <- cn | |||
| 381 | + | } | |||
| 382 | + | } | |||
| 383 | + | }() | |||
| 384 | + | sc(d.t, c, f.GetRun(), cancels) | |||
| 385 | + | }() | |||
| 386 | + | return host, nil | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | func (s *fakeSandbox) Stop(context.Context) error { | |||
| 390 | + | s.mu.Lock() | |||
| 391 | + | if s.conn != nil { | |||
| 392 | + | _ = s.conn.Close() | |||
| 393 | + | } | |||
| 394 | + | first := !s.stoped | |||
| 395 | + | s.stoped = true | |||
| 396 | + | s.mu.Unlock() | |||
| 397 | + | if first { | |||
| 398 | + | s.d.mu.Lock() | |||
| 399 | + | s.d.stops++ | |||
| 400 | + | s.d.live-- | |||
| 401 | + | err := s.d.stopErr | |||
| 402 | + | s.d.mu.Unlock() | |||
| 403 | + | return err | |||
| 404 | + | } | |||
| 405 | + | return nil | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | // ---- script helpers | |||
| 409 | + | ||||
| 410 | + | func send(c *execstream.Conn, b *execpb.Frame) { _ = c.Send(b) } | |||
| 411 | + | ||||
| 412 | + | func stepStart(c *execstream.Conn, n int, name string) { | |||
| 413 | + | send(c, &execpb.Frame{Body: &execpb.Frame_StepStarted{StepStarted: &execpb.StepStarted{Number: int32(n), Name: name, UnixNano: time.Now().UnixNano()}}}) | |||
| 414 | + | } | |||
| 415 | + | func stepDone(c *execstream.Conn, n int, name string, concl execpb.StepConclusion) { | |||
| 416 | + | send(c, &execpb.Frame{Body: &execpb.Frame_StepCompleted{StepCompleted: &execpb.StepCompleted{Number: int32(n), Name: name, Conclusion: concl, UnixNano: time.Now().UnixNano()}}}) | |||
| 417 | + | } | |||
| 418 | + | func logLineFrame(c *execstream.Conn, n int, text string, continued bool) { | |||
| 419 | + | send(c, &execpb.Frame{Body: &execpb.Frame_Log{Log: &execpb.Log{Step: int32(n), UnixNano: time.Now().UnixNano(), Text: text, Continued: continued}}}) | |||
| 420 | + | } | |||
| 421 | + | func jobDone(c *execstream.Conn, concl execpb.StepConclusion, outputs map[string]string, shutdown bool) { | |||
| 422 | + | send(c, &execpb.Frame{Body: &execpb.Frame_JobResult{JobResult: &execpb.JobResult{Conclusion: concl, Outputs: outputs, RunnerShutdown: shutdown}}}) | |||
| 423 | + | send(c, &execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{}}}) | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | const ( | |||
| 427 | + | cOK = execpb.StepConclusion_STEP_CONCLUSION_SUCCESS | |||
| 428 | + | cFail = execpb.StepConclusion_STEP_CONCLUSION_FAILURE | |||
| 429 | + | cCancel = execpb.StepConclusion_STEP_CONCLUSION_CANCELLED | |||
| 430 | + | ) | |||
| 431 | + | ||||
| 432 | + | // simpleScript runs a one-step job that logs the given lines. | |||
| 433 | + | func simpleScript(lines ...string) script { | |||
| 434 | + | return func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 435 | + | stepStart(c, 1, "Set up job") | |||
| 436 | + | stepDone(c, 1, "Set up job", cOK) | |||
| 437 | + | stepStart(c, 2, "Run it") | |||
| 438 | + | for _, l := range lines { | |||
| 439 | + | logLineFrame(c, 2, l, false) | |||
| 440 | + | } | |||
| 441 | + | stepDone(c, 2, "Run it", cOK) | |||
| 442 | + | stepStart(c, 3, "Complete job") | |||
| 443 | + | stepDone(c, 3, "Complete job", cOK) | |||
| 444 | + | jobDone(c, cOK, map[string]string{"out": "v"}, false) | |||
| 445 | + | } | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | // ---- harness | |||
| 449 | + | ||||
| 450 | + | func testJob(id string) *ermite.Job { | |||
| 451 | + | return &ermite.Job{ | |||
| 452 | + | JobId: id, RunId: 7, Attempt: 1, JobToken: "ermitejob_" + id, WorkflowPath: ".github/workflows/ci.yml", | |||
| 453 | + | WorkflowYaml: "on: push\njobs:\n j:\n runs-on: x\n steps: [{run: echo}]\n", JobKey: "j", Matrix: "{}", | |||
| 454 | + | Contexts: `{"github":{"repository":"o/r"}}`, Repo: "r", TimeoutMinutes: 5, | |||
| 455 | + | } | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | func fastTiming() Timing { | |||
| 459 | + | return Timing{ | |||
| 460 | + | PollTimeout: 5 * time.Second, HeartbeatEvery: 100 * time.Millisecond, HeartbeatRetry: 30 * time.Millisecond, | |||
| 461 | + | CallTimeout: 5 * time.Second, BackoffMin: 10 * time.Millisecond, BackoffMax: 40 * time.Millisecond, | |||
| 462 | + | LogFlush: 5 * time.Millisecond, WatchRetry: 20 * time.Millisecond, ShutdownGrace: 2 * time.Second, | |||
| 463 | + | CompleteDeadline: 10 * time.Second, NoJobPause: 10 * time.Millisecond, | |||
| 464 | + | } | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | type harness struct { | |||
| 468 | + | srv *fakeServer | |||
| 469 | + | url string | |||
| 470 | + | driver *fakeDriver | |||
| 471 | + | runner *Runner | |||
| 472 | + | logs *logBuf | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | // logBuf collects what the runner logs (zerowrap, console format without | |||
| 476 | + | // colors), so that tests can look at it. | |||
| 477 | + | type logBuf struct { | |||
| 478 | + | mu sync.Mutex | |||
| 479 | + | b strings.Builder | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | func (l *logBuf) Write(p []byte) (int, error) { | |||
| 483 | + | l.mu.Lock() | |||
| 484 | + | defer l.mu.Unlock() | |||
| 485 | + | return l.b.Write(p) | |||
| 486 | + | } | |||
| 487 | + | ||||
| 488 | + | func (l *logBuf) String() string { l.mu.Lock(); defer l.mu.Unlock(); return l.b.String() } | |||
| 489 | + | ||||
| 490 | + | // ctx returns a context with a logger that writes to the buffer (JSON, one | |||
| 491 | + | // event per line, debug level). | |||
| 492 | + | func (l *logBuf) ctx() context.Context { | |||
| 493 | + | return zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: l})) | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | func newHarness(t *testing.T, sc script) *harness { | |||
| 497 | + | t.Helper() | |||
| 498 | + | srv := newFakeServer() | |||
| 499 | + | url := srv.start(t) | |||
| 500 | + | drv := &fakeDriver{script: sc, t: t, iso: sandbox.IsolationVM} | |||
| 501 | + | lb := &logBuf{} | |||
| 502 | + | h := &harness{srv: srv, url: url, driver: drv, logs: lb} | |||
| 503 | + | t.Cleanup(func() { | |||
| 504 | + | srv.mu.Lock() | |||
| 505 | + | defer srv.mu.Unlock() | |||
| 506 | + | if len(srv.violations) > 0 { | |||
| 507 | + | t.Errorf("the host broke the contract of AppendLog: %q", srv.violations) | |||
| 508 | + | } | |||
| 509 | + | }) | |||
| 510 | + | h.runner = &Runner{ | |||
| 511 | + | Cfg: Config{URL: url, Name: "r1", Capacity: 1, Version: "test"}, | |||
| 512 | + | Driver: drv, | |||
| 513 | + | Poll: NewClient(url, "ermiterun_tok", http.DefaultTransport), | |||
| 514 | + | Job: func(tok string) ermiteconnect.RunnerServiceClient { return NewClient(url, tok, http.DefaultTransport) }, | |||
| 515 | + | Timing: fastTiming(), | |||
| 516 | + | } | |||
| 517 | + | return h | |||
| 518 | + | } | |||
| 519 | + | ||||
| 520 | + | // run starts the loop; stop cancels it and waits for Run to return. | |||
| 521 | + | func (h *harness) run(t *testing.T) (stop func() error) { | |||
| 522 | + | t.Helper() | |||
| 523 | + | ctx, cancel := context.WithCancel(h.logs.ctx()) | |||
| 524 | + | done := make(chan error, 1) | |||
| 525 | + | go func() { done <- h.runner.Run(ctx) }() | |||
| 526 | + | var once sync.Once | |||
| 527 | + | var res error | |||
| 528 | + | stop = func() error { | |||
| 529 | + | once.Do(func() { | |||
| 530 | + | cancel() | |||
| 531 | + | select { | |||
| 532 | + | case res = <-done: | |||
| 533 | + | case <-time.After(20 * time.Second): | |||
| 534 | + | t.Error("Run did not return after shutdown") | |||
| 535 | + | } | |||
| 536 | + | }) | |||
| 537 | + | return res | |||
| 538 | + | } | |||
| 539 | + | t.Cleanup(func() { _ = stop() }) | |||
| 540 | + | return stop | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | func eventually(t *testing.T, what string, cond func() bool) { | |||
| 544 | + | t.Helper() | |||
| 545 | + | deadline := time.Now().Add(15 * time.Second) | |||
| 546 | + | for time.Now().Before(deadline) { | |||
| 547 | + | if cond() { | |||
| 548 | + | return | |||
| 549 | + | } | |||
| 550 | + | time.Sleep(10 * time.Millisecond) | |||
| 551 | + | } | |||
| 552 | + | t.Fatalf("timed out waiting for %s", what) | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | func (s *fakeServer) completeCount() int { s.mu.Lock(); defer s.mu.Unlock(); return len(s.completes) } | |||
| 556 | + | ||||
| 557 | + | // Result returns what a job reported, like hosttest.Server.Result. | |||
| 558 | + | func (s *fakeServer) Result(jobID string) hosttest.Result { | |||
| 559 | + | s.mu.Lock() | |||
| 560 | + | defer s.mu.Unlock() | |||
| 561 | + | return hosttest.Result{Steps: s.steps} | |||
| 562 | + | } | |||
A internal/host/hosttest/hosttest.go +229 -0
| @@ -0,0 +1,229 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | // Package hosttest is an in-memory RunnerService for tests that run the real | |||
| 4 | + | // host loop against a real sandbox. It enforces the rules of runner.proto that | |||
| 5 | + | // matter to a client: tokens per kind and per job, the offsets of AppendLog, | |||
| 6 | + | // the limits of a request, and the end of a job after CompleteJob. | |||
| 7 | + | package hosttest | |||
| 8 | + | ||||
| 9 | + | import ( | |||
| 10 | + | "context" | |||
| 11 | + | "io" | |||
| 12 | + | "net/http" | |||
| 13 | + | "net/http/httptest" | |||
| 14 | + | "strings" | |||
| 15 | + | "sync" | |||
| 16 | + | ||||
| 17 | + | "connectrpc.com/connect" | |||
| 18 | + | ||||
| 19 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 20 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // RunnerToken is the runner token the server accepts. | |||
| 24 | + | const RunnerToken = "ermiterun_test" | |||
| 25 | + | ||||
| 26 | + | // Line is one stored log line. | |||
| 27 | + | type Line struct { | |||
| 28 | + | Step int32 | |||
| 29 | + | Text string | |||
| 30 | + | Continued bool | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // Result is what a job reported. | |||
| 34 | + | type Result struct { | |||
| 35 | + | Complete *ermite.CompleteJobRequest | |||
| 36 | + | Lines []Line | |||
| 37 | + | Steps map[int32]*ermite.StepUpdate | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Server is the fake. Create it with New; URL is where to point the runner. | |||
| 41 | + | type Server struct { | |||
| 42 | + | ermiteconnect.UnimplementedRunnerServiceHandler | |||
| 43 | + | ||||
| 44 | + | URL string | |||
| 45 | + | srv *httptest.Server | |||
| 46 | + | ||||
| 47 | + | mu sync.Mutex | |||
| 48 | + | queue []*ermite.Job | |||
| 49 | + | jobs map[string]*jobState | |||
| 50 | + | assigned map[string]*ermite.Job // by job token | |||
| 51 | + | // Errors lists contract violations seen (a gap in AppendLog, a request over | |||
| 52 | + | // the limits, a call after the end): a test fails when it is not empty. | |||
| 53 | + | Errors []string | |||
| 54 | + | done chan string | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | type jobState struct { | |||
| 58 | + | log *LogStore | |||
| 59 | + | steps map[int32]*ermite.StepUpdate | |||
| 60 | + | complete *ermite.CompleteJobRequest | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // New starts the server; Close stops it. | |||
| 64 | + | func New() *Server { | |||
| 65 | + | s := &Server{jobs: map[string]*jobState{}, assigned: map[string]*ermite.Job{}, done: make(chan string, 256)} | |||
| 66 | + | _, h := ermiteconnect.NewRunnerServiceHandler(s) | |||
| 67 | + | s.srv = httptest.NewServer(h) | |||
| 68 | + | s.URL = s.srv.URL | |||
| 69 | + | return s | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Close stops the server. | |||
| 73 | + | func (s *Server) Close() { s.srv.Close() } | |||
| 74 | + | ||||
| 75 | + | // Queue adds jobs to hand out, in order. | |||
| 76 | + | func (s *Server) Queue(jobs ...*ermite.Job) { | |||
| 77 | + | s.mu.Lock() | |||
| 78 | + | defer s.mu.Unlock() | |||
| 79 | + | s.queue = append(s.queue, jobs...) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Violations returns the contract violations seen so far. | |||
| 83 | + | func (s *Server) Violations() []string { | |||
| 84 | + | s.mu.Lock() | |||
| 85 | + | defer s.mu.Unlock() | |||
| 86 | + | return append([]string(nil), s.Errors...) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Done receives the id of each job when its CompleteJob has been applied. | |||
| 90 | + | func (s *Server) Done() <-chan string { return s.done } | |||
| 91 | + | ||||
| 92 | + | // Result returns what a job reported so far. | |||
| 93 | + | func (s *Server) Result(jobID string) Result { | |||
| 94 | + | s.mu.Lock() | |||
| 95 | + | defer s.mu.Unlock() | |||
| 96 | + | st := s.jobs[jobID] | |||
| 97 | + | if st == nil { | |||
| 98 | + | return Result{} | |||
| 99 | + | } | |||
| 100 | + | steps := map[int32]*ermite.StepUpdate{} | |||
| 101 | + | for k, v := range st.steps { | |||
| 102 | + | steps[k] = v | |||
| 103 | + | } | |||
| 104 | + | return Result{Complete: st.complete, Lines: st.log.Lines(), Steps: steps} | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (s *Server) violation(msg string) { s.Errors = append(s.Errors, msg) } // called with s.mu held | |||
| 108 | + | ||||
| 109 | + | func unauth() error { return connect.NewError(connect.CodeUnauthenticated, io.EOF) } | |||
| 110 | + | ||||
| 111 | + | func (s *Server) job(h http.Header, id string) (*jobState, error) { | |||
| 112 | + | j := s.assigned[strings.TrimPrefix(h.Get("Authorization"), "Bearer ")] | |||
| 113 | + | if j == nil { | |||
| 114 | + | return nil, unauth() | |||
| 115 | + | } | |||
| 116 | + | if j.JobId != id { | |||
| 117 | + | return nil, connect.NewError(connect.CodePermissionDenied, io.EOF) | |||
| 118 | + | } | |||
| 119 | + | return s.jobs[id], nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func ended(c ermite.CiConclusion) error { | |||
| 123 | + | e := connect.NewError(connect.CodeFailedPrecondition, io.EOF) | |||
| 124 | + | if d, err := connect.NewErrorDetail(&ermite.JobEnded{Conclusion: c}); err == nil { | |||
| 125 | + | e.AddDetail(d) | |||
| 126 | + | } | |||
| 127 | + | return e | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // FetchJob hands out the queued jobs. | |||
| 131 | + | func (s *Server) FetchJob(_ context.Context, r *connect.Request[ermite.FetchJobRequest]) (*connect.Response[ermite.FetchJobResponse], error) { | |||
| 132 | + | s.mu.Lock() | |||
| 133 | + | defer s.mu.Unlock() | |||
| 134 | + | if r.Header().Get("Authorization") != "Bearer "+RunnerToken { | |||
| 135 | + | return nil, unauth() | |||
| 136 | + | } | |||
| 137 | + | if len(s.queue) == 0 { | |||
| 138 | + | return connect.NewResponse(&ermite.FetchJobResponse{}), nil | |||
| 139 | + | } | |||
| 140 | + | j := s.queue[0] | |||
| 141 | + | s.queue = s.queue[1:] | |||
| 142 | + | s.assigned[j.JobToken] = j | |||
| 143 | + | s.jobs[j.JobId] = &jobState{log: &LogStore{OnViolation: s.violation}, steps: map[int32]*ermite.StepUpdate{}} | |||
| 144 | + | return connect.NewResponse(&ermite.FetchJobResponse{Job: j}), nil | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // Heartbeat accepts heartbeats of running jobs. | |||
| 148 | + | func (s *Server) Heartbeat(_ context.Context, r *connect.Request[ermite.HeartbeatRequest]) (*connect.Response[ermite.HeartbeatResponse], error) { | |||
| 149 | + | s.mu.Lock() | |||
| 150 | + | defer s.mu.Unlock() | |||
| 151 | + | st, err := s.job(r.Header(), r.Msg.JobId) | |||
| 152 | + | if err != nil { | |||
| 153 | + | return nil, err | |||
| 154 | + | } | |||
| 155 | + | if st.complete != nil { | |||
| 156 | + | return nil, ended(st.complete.Conclusion) | |||
| 157 | + | } | |||
| 158 | + | return connect.NewResponse(&ermite.HeartbeatResponse{}), nil | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // UpdateJob stores step snapshots. | |||
| 162 | + | func (s *Server) UpdateJob(_ context.Context, r *connect.Request[ermite.UpdateJobRequest]) (*connect.Response[ermite.UpdateJobResponse], error) { | |||
| 163 | + | s.mu.Lock() | |||
| 164 | + | defer s.mu.Unlock() | |||
| 165 | + | st, err := s.job(r.Header(), r.Msg.JobId) | |||
| 166 | + | if err != nil { | |||
| 167 | + | return nil, err | |||
| 168 | + | } | |||
| 169 | + | if st.complete != nil { | |||
| 170 | + | return nil, ended(st.complete.Conclusion) | |||
| 171 | + | } | |||
| 172 | + | for _, u := range r.Msg.Steps { | |||
| 173 | + | if old := st.steps[u.Number]; old != nil && old.State == ermite.StepState_STEP_STATE_COMPLETED { | |||
| 174 | + | s.violation("UpdateJob: a completed step changed") | |||
| 175 | + | } | |||
| 176 | + | st.steps[u.Number] = u | |||
| 177 | + | } | |||
| 178 | + | return connect.NewResponse(&ermite.UpdateJobResponse{}), nil | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // AppendLog applies the rules of runner.proto through a LogStore. | |||
| 182 | + | func (s *Server) AppendLog(_ context.Context, r *connect.Request[ermite.AppendLogRequest]) (*connect.Response[ermite.AppendLogResponse], error) { | |||
| 183 | + | s.mu.Lock() | |||
| 184 | + | defer s.mu.Unlock() | |||
| 185 | + | st, err := s.job(r.Header(), r.Msg.JobId) | |||
| 186 | + | if err != nil { | |||
| 187 | + | return nil, err | |||
| 188 | + | } | |||
| 189 | + | if st.complete != nil { | |||
| 190 | + | s.violation("AppendLog after CompleteJob") | |||
| 191 | + | return nil, ended(st.complete.Conclusion) | |||
| 192 | + | } | |||
| 193 | + | next, err := st.log.Apply(r.Msg) | |||
| 194 | + | if err != nil { | |||
| 195 | + | return nil, err | |||
| 196 | + | } | |||
| 197 | + | return connect.NewResponse(&ermite.AppendLogResponse{NextOffset: next}), nil | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // CompleteJob ends a job, idempotently. | |||
| 201 | + | func (s *Server) CompleteJob(_ context.Context, r *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) { | |||
| 202 | + | s.mu.Lock() | |||
| 203 | + | defer s.mu.Unlock() | |||
| 204 | + | st, err := s.job(r.Header(), r.Msg.JobId) | |||
| 205 | + | if err != nil { | |||
| 206 | + | return nil, err | |||
| 207 | + | } | |||
| 208 | + | if st.complete != nil { | |||
| 209 | + | return connect.NewResponse(&ermite.CompleteJobResponse{Conclusion: st.complete.Conclusion}), nil | |||
| 210 | + | } | |||
| 211 | + | st.complete = r.Msg | |||
| 212 | + | for _, u := range r.Msg.Steps { | |||
| 213 | + | st.steps[u.Number] = u | |||
| 214 | + | } | |||
| 215 | + | s.done <- r.Msg.JobId | |||
| 216 | + | return connect.NewResponse(&ermite.CompleteJobResponse{Conclusion: r.Msg.Conclusion, Applied: true}), nil | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // WatchJob keeps the stream open until the job ends or the client leaves. | |||
| 220 | + | func (s *Server) WatchJob(ctx context.Context, r *connect.Request[ermite.WatchJobRequest], _ *connect.ServerStream[ermite.WatchJobResponse]) error { | |||
| 221 | + | s.mu.Lock() | |||
| 222 | + | _, err := s.job(r.Header(), r.Msg.JobId) | |||
| 223 | + | s.mu.Unlock() | |||
| 224 | + | if err != nil { | |||
| 225 | + | return err | |||
| 226 | + | } | |||
| 227 | + | <-ctx.Done() | |||
| 228 | + | return ctx.Err() | |||
| 229 | + | } | |||
A internal/host/hosttest/logstore.go +89 -0
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package hosttest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "io" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | ||||
| 10 | + | "connectrpc.com/connect" | |||
| 11 | + | ||||
| 12 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // LogStore is the log of one job as the server of runner.proto keeps it, with | |||
| 16 | + | // the rules of AppendLog (the one place where they are written down for tests): | |||
| 17 | + | // | |||
| 18 | + | // - a request has 1 to 1,000 lines, at most 64 KiB of text in total, no line | |||
| 19 | + | // feed in a line, a time on every line, and a continued first line only if | |||
| 20 | + | // it follows a line; | |||
| 21 | + | // - offset is the number of lines the server holds: equal appends, below skips | |||
| 22 | + | // the first (count - offset) lines of the request (a retry), above is | |||
| 23 | + | // out_of_range; | |||
| 24 | + | // - the step of a request is at least the step of the last stored line | |||
| 25 | + | // (failed_precondition otherwise; lines skipped as already stored are not | |||
| 26 | + | // checked). | |||
| 27 | + | // | |||
| 28 | + | // A request that breaks a rule the client must never break (the first group) | |||
| 29 | + | // is reported through OnViolation as well as answered with the error. | |||
| 30 | + | type LogStore struct { | |||
| 31 | + | // OnViolation, when set, receives the description of each client violation. | |||
| 32 | + | OnViolation func(string) | |||
| 33 | + | ||||
| 34 | + | mu sync.Mutex | |||
| 35 | + | lines []Line | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Lines returns a copy of the stored lines. | |||
| 39 | + | func (s *LogStore) Lines() []Line { | |||
| 40 | + | s.mu.Lock() | |||
| 41 | + | defer s.mu.Unlock() | |||
| 42 | + | return append([]Line(nil), s.lines...) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // Apply applies a request and returns the number of lines held afterwards. | |||
| 46 | + | func (s *LogStore) Apply(r *ermite.AppendLogRequest) (int64, error) { | |||
| 47 | + | s.mu.Lock() | |||
| 48 | + | defer s.mu.Unlock() | |||
| 49 | + | bad := func(code connect.Code, why string) (int64, error) { | |||
| 50 | + | if s.OnViolation != nil { | |||
| 51 | + | s.OnViolation("AppendLog: " + why) | |||
| 52 | + | } | |||
| 53 | + | return 0, connect.NewError(code, io.EOF) | |||
| 54 | + | } | |||
| 55 | + | if r.Step < 1 { | |||
| 56 | + | return bad(connect.CodeInvalidArgument, "step below 1") | |||
| 57 | + | } | |||
| 58 | + | n, bytes := len(r.Lines), 0 | |||
| 59 | + | if n == 0 || n > 1000 { | |||
| 60 | + | return bad(connect.CodeInvalidArgument, "bad number of lines") | |||
| 61 | + | } | |||
| 62 | + | have := int64(len(s.lines)) | |||
| 63 | + | for i, l := range r.Lines { | |||
| 64 | + | bytes += len(l.Text) | |||
| 65 | + | if strings.Contains(l.Text, "\n") || l.Time == nil { | |||
| 66 | + | return bad(connect.CodeInvalidArgument, "invalid line") | |||
| 67 | + | } | |||
| 68 | + | if l.Continued && i == 0 && have == 0 && r.Offset == 0 { | |||
| 69 | + | return bad(connect.CodeInvalidArgument, "the first line of the job is continued") | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | if bytes > 64*1024 { | |||
| 73 | + | return bad(connect.CodeInvalidArgument, "more than 64 KiB") | |||
| 74 | + | } | |||
| 75 | + | if r.Offset > have { | |||
| 76 | + | return bad(connect.CodeOutOfRange, "a gap in the offsets") | |||
| 77 | + | } | |||
| 78 | + | skip := have - r.Offset | |||
| 79 | + | if skip < int64(n) && have > 0 && r.Step < s.lines[have-1].Step { | |||
| 80 | + | return bad(connect.CodeFailedPrecondition, "a step went back") | |||
| 81 | + | } | |||
| 82 | + | for i, l := range r.Lines { | |||
| 83 | + | if int64(i) < skip { | |||
| 84 | + | continue | |||
| 85 | + | } | |||
| 86 | + | s.lines = append(s.lines, Line{Step: r.Step, Text: l.Text, Continued: l.Continued}) | |||
| 87 | + | } | |||
| 88 | + | return int64(len(s.lines)), nil | |||
| 89 | + | } | |||
A internal/host/job.go +888 -0
| @@ -0,0 +1,888 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "sort" | |||
| 10 | + | "strings" | |||
| 11 | + | "sync" | |||
| 12 | + | "time" | |||
| 13 | + | "unicode/utf8" | |||
| 14 | + | ||||
| 15 | + | "connectrpc.com/connect" | |||
| 16 | + | "github.com/bnema/zerowrap" | |||
| 17 | + | "google.golang.org/protobuf/proto" | |||
| 18 | + | "google.golang.org/protobuf/types/known/timestamppb" | |||
| 19 | + | ||||
| 20 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 21 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 22 | + | ||||
| 23 | + | "bnema.dev/ermite-runner/internal/execstream" | |||
| 24 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 25 | + | "bnema.dev/ermite-runner/internal/sandbox" | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | // jobClient is what a job needs from the RunnerService client (job token). | |||
| 29 | + | type jobClient interface { | |||
| 30 | + | callClient | |||
| 31 | + | Heartbeat(context.Context, *connect.Request[ermite.HeartbeatRequest]) (*connect.Response[ermite.HeartbeatResponse], error) | |||
| 32 | + | WatchJob(context.Context, *connect.Request[ermite.WatchJobRequest]) (*connect.ServerStreamForClient[ermite.WatchJobResponse], error) | |||
| 33 | + | CompleteJob(context.Context, *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | var _ jobClient = ermiteconnect.RunnerServiceClient(nil) | |||
| 37 | + | ||||
| 38 | + | // jobRun is the host's side of one job: the assignment, the sandbox, and the | |||
| 39 | + | // calls to ermite with the job token. | |||
| 40 | + | type jobRun struct { | |||
| 41 | + | r *Runner | |||
| 42 | + | job *ermite.Job | |||
| 43 | + | client jobClient | |||
| 44 | + | t Timing | |||
| 45 | + | log zerowrap.Logger // with job_id, run_id and attempt | |||
| 46 | + | ||||
| 47 | + | // ctx lives until the job is finished or abandoned. Shutdown does not end | |||
| 48 | + | // it: a shutdown is a cancellation that the job reports. | |||
| 49 | + | ctx context.Context | |||
| 50 | + | cancel context.CancelCauseFunc | |||
| 51 | + | ||||
| 52 | + | // receivedAt is when the host got the job: the job timeout counts from it | |||
| 53 | + | // (runner.proto: Job.timeout_minutes). | |||
| 54 | + | receivedAt time.Time | |||
| 55 | + | ||||
| 56 | + | cancelCh chan *execpb.Cancel // the first value is sent to the executor | |||
| 57 | + | cancelMu sync.Mutex | |||
| 58 | + | cancelAt string // reason of the first cancel request, "" if none | |||
| 59 | + | cancelKind execpb.CancelKind // kind of the first cancel request | |||
| 60 | + | stopping bool // the runner is shutting down | |||
| 61 | + | ||||
| 62 | + | mu sync.Mutex | |||
| 63 | + | steps map[int32]*ermite.StepUpdate | |||
| 64 | + | // What the guest has told us, for the validation of the next frame: the | |||
| 65 | + | // guest is untrusted code, its step numbers and lines are checked before | |||
| 66 | + | // they reach the server (see violate). | |||
| 67 | + | maxStarted int32 | |||
| 68 | + | lastLogStep int32 | |||
| 69 | + | violation string | |||
| 70 | + | // hard stops the sandbox at once; set before the session starts. | |||
| 71 | + | hard func() | |||
| 72 | + | result *execpb.JobResult | |||
| 73 | + | outbox *outbox | |||
| 74 | + | endedBy error // the server ended the job, or the assignment is gone | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | var errAbandoned = errors.New("the server ended the job or revoked the assignment") | |||
| 78 | + | ||||
| 79 | + | func (j *jobRun) abandon(err error) { | |||
| 80 | + | j.mu.Lock() | |||
| 81 | + | if j.endedBy == nil { | |||
| 82 | + | j.endedBy = err | |||
| 83 | + | } | |||
| 84 | + | j.mu.Unlock() | |||
| 85 | + | j.cancel(errAbandoned) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (j *jobRun) abandoned() error { | |||
| 89 | + | j.mu.Lock() | |||
| 90 | + | defer j.mu.Unlock() | |||
| 91 | + | return j.endedBy | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // requestCancel asks the executor to stop; only the first request counts. | |||
| 95 | + | func (j *jobRun) requestCancel(reason string, kind execpb.CancelKind) { | |||
| 96 | + | j.cancelMu.Lock() | |||
| 97 | + | defer j.cancelMu.Unlock() | |||
| 98 | + | if j.cancelAt != "" { | |||
| 99 | + | return | |||
| 100 | + | } | |||
| 101 | + | j.cancelAt = reason | |||
| 102 | + | j.cancelKind = kind | |||
| 103 | + | j.cancelCh <- &execpb.Cancel{Reason: reason, Kind: kind} | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // firstCancelKind is the kind of the first cancel request. | |||
| 107 | + | func (j *jobRun) firstCancelKind() execpb.CancelKind { | |||
| 108 | + | j.cancelMu.Lock() | |||
| 109 | + | defer j.cancelMu.Unlock() | |||
| 110 | + | return j.cancelKind | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (j *jobRun) cancelRequested() bool { | |||
| 114 | + | j.cancelMu.Lock() | |||
| 115 | + | defer j.cancelMu.Unlock() | |||
| 116 | + | return j.cancelAt != "" | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // heartbeat sends the acknowledgement. It retries every HeartbeatRetry until | |||
| 120 | + | // it succeeds, so that the 30 s of the server are used well; it returns false | |||
| 121 | + | // when the assignment is gone. | |||
| 122 | + | func (j *jobRun) ack(ctx context.Context) bool { | |||
| 123 | + | for { | |||
| 124 | + | err := j.beat(ctx) | |||
| 125 | + | if err == nil { | |||
| 126 | + | return true | |||
| 127 | + | } | |||
| 128 | + | if ctx.Err() != nil { | |||
| 129 | + | return false | |||
| 130 | + | } | |||
| 131 | + | switch classify(err) { | |||
| 132 | + | case kindEnded, kindAbandoned: | |||
| 133 | + | j.abandon(err) | |||
| 134 | + | return false | |||
| 135 | + | } | |||
| 136 | + | j.log.Warn().Err(err).Msg("first heartbeat failed, retrying") | |||
| 137 | + | if !sleep(ctx, j.t.HeartbeatRetry) { | |||
| 138 | + | return false | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func (j *jobRun) beat(ctx context.Context) error { | |||
| 144 | + | cctx, cancel := context.WithTimeout(ctx, j.t.CallTimeout) | |||
| 145 | + | defer cancel() | |||
| 146 | + | _, err := j.client.Heartbeat(cctx, connect.NewRequest(&ermite.HeartbeatRequest{JobId: j.job.JobId})) | |||
| 147 | + | return err | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // heartbeats keeps the job alive: every HeartbeatEvery, and after a failure | |||
| 151 | + | // every HeartbeatRetry until one succeeds (runner.proto: Heartbeat). | |||
| 152 | + | func (j *jobRun) heartbeats(ctx context.Context) { | |||
| 153 | + | wait := j.t.HeartbeatEvery | |||
| 154 | + | for { | |||
| 155 | + | if !sleep(ctx, wait) { | |||
| 156 | + | return | |||
| 157 | + | } | |||
| 158 | + | if err := j.beat(ctx); err != nil { | |||
| 159 | + | if ctx.Err() != nil { | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 162 | + | switch classify(err) { | |||
| 163 | + | case kindEnded, kindAbandoned: | |||
| 164 | + | j.abandon(err) | |||
| 165 | + | return | |||
| 166 | + | } | |||
| 167 | + | j.log.Warn().Err(err).Dur("retry_in", j.t.HeartbeatRetry).Msg("heartbeat failed") | |||
| 168 | + | wait = j.t.HeartbeatRetry | |||
| 169 | + | continue | |||
| 170 | + | } | |||
| 171 | + | wait = j.t.HeartbeatEvery | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // watch follows WatchJob: a cancel request stops the job, an Ended message or | |||
| 176 | + | // the end-of-job error abandons it. The stream is reopened after any other | |||
| 177 | + | // error and after a clean close without Ended. | |||
| 178 | + | func (j *jobRun) watch(ctx context.Context) { | |||
| 179 | + | for ctx.Err() == nil { | |||
| 180 | + | stream, err := j.client.WatchJob(ctx, connect.NewRequest(&ermite.WatchJobRequest{JobId: j.job.JobId})) | |||
| 181 | + | if err == nil { | |||
| 182 | + | for stream.Receive() { | |||
| 183 | + | switch m := stream.Msg().Msg.(type) { | |||
| 184 | + | case *ermite.WatchJobResponse_Cancel: | |||
| 185 | + | j.requestCancel(cancelText(m.Cancel), kindOf(m.Cancel.GetReason())) | |||
| 186 | + | case *ermite.WatchJobResponse_Ended: | |||
| 187 | + | _ = stream.Close() | |||
| 188 | + | j.abandon(fmt.Errorf("job ended: %s", m.Ended.GetConclusion())) | |||
| 189 | + | return | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | err = stream.Err() | |||
| 193 | + | _ = stream.Close() | |||
| 194 | + | } | |||
| 195 | + | if ctx.Err() != nil { | |||
| 196 | + | return | |||
| 197 | + | } | |||
| 198 | + | if err != nil { | |||
| 199 | + | switch classify(err) { | |||
| 200 | + | case kindEnded, kindAbandoned: | |||
| 201 | + | j.abandon(err) | |||
| 202 | + | return | |||
| 203 | + | } | |||
| 204 | + | j.log.Warn().Err(err).Msg("WatchJob failed, reopening") | |||
| 205 | + | } | |||
| 206 | + | if !sleep(ctx, j.t.WatchRetry) { | |||
| 207 | + | return | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func cancelText(c *ermite.CancelRequest) string { | |||
| 213 | + | if c.GetMessage() != "" { | |||
| 214 | + | return c.GetMessage() | |||
| 215 | + | } | |||
| 216 | + | return c.GetReason().String() | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func kindOf(r ermite.CancelReason) execpb.CancelKind { | |||
| 220 | + | if r == ermite.CancelReason_CANCEL_REASON_TIMEOUT { | |||
| 221 | + | return execpb.CancelKind_CANCEL_KIND_TIMEOUT | |||
| 222 | + | } | |||
| 223 | + | // User, fail-fast, and any reason this runner does not know: cancelled. | |||
| 224 | + | return execpb.CancelKind_CANCEL_KIND_USER | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // ---- steps | |||
| 228 | + | ||||
| 229 | + | // Limits on what the guest may send, which keep the memory of the host bounded | |||
| 230 | + | // whatever the guest does: runner.proto allows 1,000 steps per job, names of | |||
| 231 | + | // 1,024 characters, and lines of 64 KiB. | |||
| 232 | + | const ( | |||
| 233 | + | maxSteps = 1000 | |||
| 234 | + | maxNameLen = 1024 | |||
| 235 | + | maxLineText = 64 * 1024 | |||
| 236 | + | ) | |||
| 237 | + | ||||
| 238 | + | // violate fails the job because the guest broke the protocol: it writes the | |||
| 239 | + | // reason in the log, asks the executor to stop and destroys the sandbox. Only | |||
| 240 | + | // the first violation counts; the frames that follow are dropped. | |||
| 241 | + | func (j *jobRun) violate(format string, a ...any) { | |||
| 242 | + | j.mu.Lock() | |||
| 243 | + | if j.violation != "" { | |||
| 244 | + | j.mu.Unlock() | |||
| 245 | + | return | |||
| 246 | + | } | |||
| 247 | + | j.violation = fmt.Sprintf(format, a...) | |||
| 248 | + | msg := j.violation | |||
| 249 | + | j.mu.Unlock() | |||
| 250 | + | j.log.Error().Str("violation", msg).Msg("the executor broke the protocol") | |||
| 251 | + | j.sandboxError("The runner stopped the job: the executor broke the protocol: " + msg) | |||
| 252 | + | if j.hard != nil { | |||
| 253 | + | j.hard() | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func (j *jobRun) violated() bool { | |||
| 258 | + | j.mu.Lock() | |||
| 259 | + | defer j.mu.Unlock() | |||
| 260 | + | return j.violation != "" | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func (j *jobRun) stepStarted(s *execpb.StepStarted) { | |||
| 264 | + | j.mu.Lock() | |||
| 265 | + | switch { | |||
| 266 | + | case j.violation != "": | |||
| 267 | + | j.mu.Unlock() | |||
| 268 | + | return | |||
| 269 | + | case s.Number < 1 || s.Number > maxSteps: | |||
| 270 | + | j.mu.Unlock() | |||
| 271 | + | j.violate("step number %d is outside 1..%d", s.Number, maxSteps) | |||
| 272 | + | return | |||
| 273 | + | case s.Number <= j.maxStarted: | |||
| 274 | + | j.mu.Unlock() | |||
| 275 | + | j.violate("step %d started after step %d", s.Number, j.maxStarted) | |||
| 276 | + | return | |||
| 277 | + | } | |||
| 278 | + | j.maxStarted = s.Number | |||
| 279 | + | u := &ermite.StepUpdate{Number: s.Number, Name: cutName(s.Name), State: ermite.StepState_STEP_STATE_IN_PROGRESS, StartedAt: tsOf(s.UnixNano)} | |||
| 280 | + | j.steps[s.Number] = u | |||
| 281 | + | j.mu.Unlock() | |||
| 282 | + | j.outbox.Step(u) | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | func (j *jobRun) stepCompleted(s *execpb.StepCompleted) { | |||
| 286 | + | j.mu.Lock() | |||
| 287 | + | if j.violation != "" { | |||
| 288 | + | j.mu.Unlock() | |||
| 289 | + | return | |||
| 290 | + | } | |||
| 291 | + | old := j.steps[s.Number] | |||
| 292 | + | switch { | |||
| 293 | + | case old == nil: | |||
| 294 | + | j.mu.Unlock() | |||
| 295 | + | j.violate("step %d completed without having started", s.Number) | |||
| 296 | + | return | |||
| 297 | + | case old.State == ermite.StepState_STEP_STATE_COMPLETED: | |||
| 298 | + | j.mu.Unlock() | |||
| 299 | + | j.violate("step %d completed twice", s.Number) | |||
| 300 | + | return | |||
| 301 | + | } | |||
| 302 | + | u := cloneStep(old) | |||
| 303 | + | if s.Name != "" { | |||
| 304 | + | u.Name = cutName(s.Name) | |||
| 305 | + | } | |||
| 306 | + | u.State = ermite.StepState_STEP_STATE_COMPLETED | |||
| 307 | + | u.Conclusion = conclusionOf(s.Conclusion) | |||
| 308 | + | if u.Conclusion == ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED || u.Conclusion == ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT { | |||
| 309 | + | u.Conclusion = ermite.CiConclusion_CI_CONCLUSION_FAILURE | |||
| 310 | + | } | |||
| 311 | + | u.CompletedAt = tsOf(s.UnixNano) | |||
| 312 | + | j.steps[s.Number] = u | |||
| 313 | + | j.mu.Unlock() | |||
| 314 | + | j.outbox.Step(u) | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // logLine checks and queues a line of the log. The step must be one that was | |||
| 318 | + | // started and not completed, and not lower than the step of the previous line | |||
| 319 | + | // (the server refuses a log that goes back); the text is made valid for the | |||
| 320 | + | // server: UTF-8, no line feed (a line feed in a line makes more lines). | |||
| 321 | + | func (j *jobRun) logLine(l *execpb.Log) { | |||
| 322 | + | j.mu.Lock() | |||
| 323 | + | if j.violation != "" { | |||
| 324 | + | j.mu.Unlock() | |||
| 325 | + | return | |||
| 326 | + | } | |||
| 327 | + | st := j.steps[l.Step] | |||
| 328 | + | switch { | |||
| 329 | + | case l.Step < 1 || l.Step > maxSteps || st == nil: | |||
| 330 | + | j.mu.Unlock() | |||
| 331 | + | j.violate("a log line for step %d, which has not started", l.Step) | |||
| 332 | + | return | |||
| 333 | + | case st.State == ermite.StepState_STEP_STATE_COMPLETED: | |||
| 334 | + | j.mu.Unlock() | |||
| 335 | + | j.violate("a log line for step %d, which is completed", l.Step) | |||
| 336 | + | return | |||
| 337 | + | case l.Step < j.lastLogStep: | |||
| 338 | + | j.mu.Unlock() | |||
| 339 | + | j.violate("a log line for step %d after one for step %d", l.Step, j.lastLogStep) | |||
| 340 | + | return | |||
| 341 | + | case len(l.Text) > maxLineText: | |||
| 342 | + | j.mu.Unlock() | |||
| 343 | + | j.violate("a log line of %d bytes (the limit is %d)", len(l.Text), maxLineText) | |||
| 344 | + | return | |||
| 345 | + | } | |||
| 346 | + | j.lastLogStep = l.Step | |||
| 347 | + | j.mu.Unlock() | |||
| 348 | + | text := l.Text | |||
| 349 | + | if !utf8.ValidString(text) { | |||
| 350 | + | text = strings.ToValidUTF8(text, "\ufffd") | |||
| 351 | + | } | |||
| 352 | + | for i, part := range strings.Split(text, "\n") { | |||
| 353 | + | j.outbox.Log(logLine{step: l.Step, unixNano: l.UnixNano, text: part, continued: l.Continued && i == 0}) | |||
| 354 | + | } | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | // cutName bounds a step name to what the server keeps, on a character boundary. | |||
| 358 | + | func cutName(s string) string { | |||
| 359 | + | if len(s) <= maxNameLen { | |||
| 360 | + | return strings.ToValidUTF8(s, "\ufffd") | |||
| 361 | + | } | |||
| 362 | + | s = s[:maxNameLen] | |||
| 363 | + | for !utf8.ValidString(s) && len(s) > 0 { | |||
| 364 | + | s = s[:len(s)-1] | |||
| 365 | + | } | |||
| 366 | + | return s | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | func cloneStep(u *ermite.StepUpdate) *ermite.StepUpdate { | |||
| 370 | + | return proto.Clone(u).(*ermite.StepUpdate) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | func tsOf(unixNano int64) *timestamppb.Timestamp { | |||
| 374 | + | if unixNano == 0 { | |||
| 375 | + | return timestamppb.Now() | |||
| 376 | + | } | |||
| 377 | + | return timestamppb.New(time.Unix(0, unixNano)) | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | func conclusionOf(c execpb.StepConclusion) ermite.CiConclusion { | |||
| 381 | + | switch c { | |||
| 382 | + | case execpb.StepConclusion_STEP_CONCLUSION_SUCCESS: | |||
| 383 | + | return ermite.CiConclusion_CI_CONCLUSION_SUCCESS | |||
| 384 | + | case execpb.StepConclusion_STEP_CONCLUSION_FAILURE: | |||
| 385 | + | return ermite.CiConclusion_CI_CONCLUSION_FAILURE | |||
| 386 | + | case execpb.StepConclusion_STEP_CONCLUSION_CANCELLED: | |||
| 387 | + | return ermite.CiConclusion_CI_CONCLUSION_CANCELLED | |||
| 388 | + | case execpb.StepConclusion_STEP_CONCLUSION_SKIPPED: | |||
| 389 | + | return ermite.CiConclusion_CI_CONCLUSION_SKIPPED | |||
| 390 | + | case execpb.StepConclusion_STEP_CONCLUSION_TIMED_OUT: | |||
| 391 | + | return ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT | |||
| 392 | + | } | |||
| 393 | + | return ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | // finalSteps closes the steps that never completed (the executor died or was | |||
| 397 | + | // stopped) with the conclusion of the job, and returns all snapshots in order. | |||
| 398 | + | func (j *jobRun) finalSteps(jobConclusion ermite.CiConclusion) []*ermite.StepUpdate { | |||
| 399 | + | j.mu.Lock() | |||
| 400 | + | defer j.mu.Unlock() | |||
| 401 | + | end := ermite.CiConclusion_CI_CONCLUSION_FAILURE | |||
| 402 | + | if jobConclusion == ermite.CiConclusion_CI_CONCLUSION_CANCELLED || jobConclusion == ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT { | |||
| 403 | + | end = ermite.CiConclusion_CI_CONCLUSION_CANCELLED | |||
| 404 | + | } | |||
| 405 | + | var out []*ermite.StepUpdate | |||
| 406 | + | for _, u := range j.steps { | |||
| 407 | + | if u.State != ermite.StepState_STEP_STATE_COMPLETED { | |||
| 408 | + | u = cloneStep(u) | |||
| 409 | + | u.State, u.Conclusion, u.CompletedAt = ermite.StepState_STEP_STATE_COMPLETED, end, timestamppb.Now() | |||
| 410 | + | } | |||
| 411 | + | out = append(out, u) | |||
| 412 | + | } | |||
| 413 | + | sort.Slice(out, func(a, b int) bool { return out[a].Number < out[b].Number }) | |||
| 414 | + | return out | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | // ---- the job | |||
| 418 | + | ||||
| 419 | + | // run runs the whole job: acknowledgement, sandbox, executor session, final | |||
| 420 | + | // report. It returns when everything is over and the sandbox is gone. | |||
| 421 | + | func (j *jobRun) run(rt *Runner, shutdown <-chan struct{}) { | |||
| 422 | + | defer j.cancel(nil) | |||
| 423 | + | ctx := j.ctx | |||
| 424 | + | ||||
| 425 | + | if !j.acknowledge(ctx, shutdown) { | |||
| 426 | + | return | |||
| 427 | + | } | |||
| 428 | + | var bg sync.WaitGroup | |||
| 429 | + | bg.Add(2) | |||
| 430 | + | go func() { defer bg.Done(); j.heartbeats(ctx) }() | |||
| 431 | + | go func() { defer bg.Done(); j.watch(ctx) }() | |||
| 432 | + | // The context is cancelled first, so that the heartbeat and watch loops end. | |||
| 433 | + | defer func() { j.cancel(nil); bg.Wait() }() | |||
| 434 | + | ||||
| 435 | + | j.outbox = newOutbox(j.client, j.job.JobId, j.t, func(err error) { j.abandon(err) }, j.log) | |||
| 436 | + | j.outbox.start(ctx) | |||
| 437 | + | ||||
| 438 | + | stopHard := j.startStopTimers(ctx, shutdown) | |||
| 439 | + | out := j.execute(ctx, rt, stopHard) | |||
| 440 | + | j.report(ctx, out, shutdown) | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | // acknowledge sends the first heartbeat, before anything else. A shutdown ends | |||
| 444 | + | // the wait: the job is not started and not acknowledged, and the server gives it | |||
| 445 | + | // to another runner when no heartbeat comes within 30 s (runner.proto, FetchJob: | |||
| 446 | + | // this is not a failure of the job). It reports whether the job goes on. | |||
| 447 | + | func (j *jobRun) acknowledge(ctx context.Context, shutdown <-chan struct{}) bool { | |||
| 448 | + | ackCtx, stopAck := context.WithCancel(ctx) | |||
| 449 | + | defer stopAck() | |||
| 450 | + | go func() { | |||
| 451 | + | select { | |||
| 452 | + | case <-shutdown: | |||
| 453 | + | stopAck() | |||
| 454 | + | case <-ackCtx.Done(): | |||
| 455 | + | } | |||
| 456 | + | }() | |||
| 457 | + | if j.ack(ackCtx) { | |||
| 458 | + | return true | |||
| 459 | + | } | |||
| 460 | + | select { | |||
| 461 | + | case <-shutdown: | |||
| 462 | + | j.log.Warn().Msg("the runner is shutting down before the job was acknowledged: the server will give it to another runner") | |||
| 463 | + | default: | |||
| 464 | + | j.log.Warn().Msg("the assignment was lost before the job started") | |||
| 465 | + | } | |||
| 466 | + | return false | |||
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | // startStopTimers arms the two reasons to stop the job from the host, each a | |||
| 470 | + | // cancellation followed by a hard stop of the sandbox (the returned channel is | |||
| 471 | + | // closed) if the executor does not answer: a shutdown of the runner | |||
| 472 | + | // (ShutdownGrace), and the job timeout, which the executor enforces itself but | |||
| 473 | + | // a stuck executor would not (TimeoutGrace after the deadline: the escalation | |||
| 474 | + | // SIGINT, SIGTERM, SIGKILL takes 10 s). The goroutines end with ctx. | |||
| 475 | + | func (j *jobRun) startStopTimers(ctx context.Context, shutdown <-chan struct{}) <-chan struct{} { | |||
| 476 | + | stopHard := make(chan struct{}) | |||
| 477 | + | var hardOnce sync.Once | |||
| 478 | + | hard := func() { hardOnce.Do(func() { close(stopHard) }) } | |||
| 479 | + | j.hard = hard | |||
| 480 | + | // after waits grace, then stops the sandbox; it returns early with ctx. | |||
| 481 | + | after := func(grace time.Duration) { | |||
| 482 | + | timer := time.NewTimer(grace) | |||
| 483 | + | defer timer.Stop() | |||
| 484 | + | select { | |||
| 485 | + | case <-timer.C: | |||
| 486 | + | hard() | |||
| 487 | + | case <-ctx.Done(): | |||
| 488 | + | } | |||
| 489 | + | } | |||
| 490 | + | go func() { | |||
| 491 | + | select { | |||
| 492 | + | case <-shutdown: | |||
| 493 | + | case <-ctx.Done(): | |||
| 494 | + | return | |||
| 495 | + | } | |||
| 496 | + | j.cancelMu.Lock() | |||
| 497 | + | j.stopping = true | |||
| 498 | + | j.cancelMu.Unlock() | |||
| 499 | + | j.requestCancel("the runner is shutting down", execpb.CancelKind_CANCEL_KIND_SHUTDOWN) | |||
| 500 | + | after(j.t.ShutdownGrace) | |||
| 501 | + | }() | |||
| 502 | + | go func() { | |||
| 503 | + | wait := time.NewTimer(time.Until(j.deadline())) | |||
| 504 | + | defer wait.Stop() | |||
| 505 | + | select { | |||
| 506 | + | case <-wait.C: | |||
| 507 | + | case <-ctx.Done(): | |||
| 508 | + | return | |||
| 509 | + | } | |||
| 510 | + | j.log.Warn().Int("timeout_minutes", j.timeoutMinutes()).Msg("the job timeout has passed") | |||
| 511 | + | j.requestCancel("the job timeout has passed", execpb.CancelKind_CANCEL_KIND_TIMEOUT) | |||
| 512 | + | after(j.t.TimeoutGrace) | |||
| 513 | + | }() | |||
| 514 | + | return stopHard | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | // report ends the job towards the server: the log is flushed, then CompleteJob. | |||
| 518 | + | // Nothing is sent to a job that the server has ended. | |||
| 519 | + | func (j *jobRun) report(ctx context.Context, out outcome, shutdown <-chan struct{}) { | |||
| 520 | + | if err := j.abandoned(); err != nil { | |||
| 521 | + | j.log.Warn().Err(err).Msg("the server ended the job: no result is reported") | |||
| 522 | + | return | |||
| 523 | + | } | |||
| 524 | + | // The report is made even when the runner is shutting down: it uses its own | |||
| 525 | + | // deadline. | |||
| 526 | + | budget := j.t.CompleteDeadline | |||
| 527 | + | select { | |||
| 528 | + | case <-shutdown: | |||
| 529 | + | // The service manager waits only so long (see Timing): once the runner's | |||
| 530 | + | // shutdown channel is closed, whatever the job's outcome, the report gets | |||
| 531 | + | // what is left of the budget. The channel is read here, not the flag set | |||
| 532 | + | // by the stop timers, which may not have run yet. | |||
| 533 | + | budget = min(budget, j.t.ShutdownReport) | |||
| 534 | + | default: | |||
| 535 | + | } | |||
| 536 | + | rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), budget) | |||
| 537 | + | defer cancel() | |||
| 538 | + | // Output limits are checked before the log is flushed: a job whose outputs | |||
| 539 | + | // the server would refuse fails without outputs, with a line in the log | |||
| 540 | + | // (runner.proto: CompleteJobRequest.outputs), and lines sent after | |||
| 541 | + | // CompleteJob are rejected. | |||
| 542 | + | if msg := checkOutputs(out.outputs); msg != "" { | |||
| 543 | + | j.sandboxError(msg) | |||
| 544 | + | out = failure() | |||
| 545 | + | } | |||
| 546 | + | j.outbox.Close(rctx) | |||
| 547 | + | if err := j.abandoned(); err != nil { | |||
| 548 | + | return | |||
| 549 | + | } | |||
| 550 | + | j.complete(rctx, out.conclusion, out.outputs, out.shutdown) | |||
| 551 | + | } | |||
| 552 | + | ||||
| 553 | + | // outcome is how a job ended, as far as the host can tell. | |||
| 554 | + | type outcome struct { | |||
| 555 | + | conclusion ermite.CiConclusion | |||
| 556 | + | outputs map[string]string | |||
| 557 | + | // shutdown is true when the runner's shutdown cut the job short. | |||
| 558 | + | shutdown bool | |||
| 559 | + | } | |||
| 560 | + | ||||
| 561 | + | func failure() outcome { return outcome{conclusion: ermite.CiConclusion_CI_CONCLUSION_FAILURE} } | |||
| 562 | + | ||||
| 563 | + | // execute starts the sandbox, runs the job in it and returns how it ended. | |||
| 564 | + | func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct{}) outcome { | |||
| 565 | + | sb, err := rt.Driver.New() | |||
| 566 | + | if err != nil { | |||
| 567 | + | j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err)) | |||
| 568 | + | return failure() | |||
| 569 | + | } | |||
| 570 | + | // Stop runs to completion even when the job context is done. | |||
| 571 | + | defer j.stopSandbox(ctx, rt, sb) | |||
| 572 | + | ||||
| 573 | + | conn, err := j.startSandbox(ctx, rt, sb, stopHard) | |||
| 574 | + | if err != nil { | |||
| 575 | + | if ctx.Err() != nil || j.cancelRequested() { | |||
| 576 | + | return outcome{conclusion: ermite.CiConclusion_CI_CONCLUSION_CANCELLED, shutdown: j.isStopping()} | |||
| 577 | + | } | |||
| 578 | + | j.failEarly(fmt.Sprintf("The sandbox did not start: %v", err)) | |||
| 579 | + | return failure() | |||
| 580 | + | } | |||
| 581 | + | defer func() { _ = conn.Close() }() | |||
| 582 | + | ||||
| 583 | + | // Hard stops (grace elapsed, or the job was abandoned) close the stream. The | |||
| 584 | + | // goroutine ends when this function returns, whatever happens to ctx. | |||
| 585 | + | sessionDone := make(chan struct{}) | |||
| 586 | + | defer close(sessionDone) | |||
| 587 | + | go func() { | |||
| 588 | + | select { | |||
| 589 | + | case <-stopHard: | |||
| 590 | + | _ = conn.Close() | |||
| 591 | + | case <-ctx.Done(): | |||
| 592 | + | _ = conn.Close() | |||
| 593 | + | case <-sessionDone: | |||
| 594 | + | } | |||
| 595 | + | }() | |||
| 596 | + | ||||
| 597 | + | exit, err := j.drive(conn, rt) | |||
| 598 | + | return j.outcomeOf(ctx, exit, err) | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | // stopSandbox destroys the sandbox; a cleanup that left something behind stops | |||
| 602 | + | // the runner from taking jobs until it is reaped. | |||
| 603 | + | func (j *jobRun) stopSandbox(ctx context.Context, rt *Runner, sb sandbox.Sandbox) { | |||
| 604 | + | sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), j.t.StopDeadline) | |||
| 605 | + | defer cancel() | |||
| 606 | + | if err := sb.Stop(sctx); err != nil { | |||
| 607 | + | j.log.Error().Err(err).Msg("stopping the sandbox") | |||
| 608 | + | if errors.Is(err, sandbox.ErrLeaked) { | |||
| 609 | + | rt.markLeaked() | |||
| 610 | + | } | |||
| 611 | + | } | |||
| 612 | + | } | |||
| 613 | + | ||||
| 614 | + | // startSandbox starts the sandbox; the start is bounded by the job context and | |||
| 615 | + | // by the hard stop. | |||
| 616 | + | func (j *jobRun) startSandbox(ctx context.Context, rt *Runner, sb sandbox.Sandbox, stopHard <-chan struct{}) (sandbox.Conn, error) { | |||
| 617 | + | startCtx, cancelStart := context.WithCancel(ctx) | |||
| 618 | + | defer cancelStart() | |||
| 619 | + | stopWatch := make(chan struct{}) | |||
| 620 | + | defer close(stopWatch) | |||
| 621 | + | go func() { | |||
| 622 | + | select { | |||
| 623 | + | case <-stopHard: | |||
| 624 | + | cancelStart() | |||
| 625 | + | case <-stopWatch: | |||
| 626 | + | } | |||
| 627 | + | }() | |||
| 628 | + | return sb.Start(startCtx, rt.spec(j.job)) | |||
| 629 | + | } | |||
| 630 | + | ||||
| 631 | + | // drive sends the job to the executor and handles its frames until it exits. | |||
| 632 | + | func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) { | |||
| 633 | + | run := &execpb.Run{Job: &execpb.JobSpec{ | |||
| 634 | + | WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey, | |||
| 635 | + | Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()), | |||
| 636 | + | DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()), | |||
| 637 | + | RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: "self-hosted", | |||
| 638 | + | }} | |||
| 639 | + | _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{ | |||
| 640 | + | StepStarted: j.stepStarted, StepCompleted: j.stepCompleted, | |||
| 641 | + | Log: j.logLine, | |||
| 642 | + | JobResult: func(r *execpb.JobResult) { j.mu.Lock(); j.result = r; j.mu.Unlock() }, | |||
| 643 | + | }) | |||
| 644 | + | return exit, err | |||
| 645 | + | } | |||
| 646 | + | ||||
| 647 | + | // outcomeOf decides how the job ended from what the executor reported and what | |||
| 648 | + | // the host asked of it. | |||
| 649 | + | func (j *jobRun) outcomeOf(ctx context.Context, exit *execpb.Exit, driveErr error) outcome { | |||
| 650 | + | j.mu.Lock() | |||
| 651 | + | res := j.result | |||
| 652 | + | j.mu.Unlock() | |||
| 653 | + | ||||
| 654 | + | switch { | |||
| 655 | + | case j.violated(): | |||
| 656 | + | // The step numbers or lines of the executor were wrong: whatever it | |||
| 657 | + | // reported is not trusted, the job fails (the reason is in the log). | |||
| 658 | + | return failure() | |||
| 659 | + | case res != nil: | |||
| 660 | + | c := conclusionOf(res.Conclusion) | |||
| 661 | + | if c == ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED || c == ermite.CiConclusion_CI_CONCLUSION_SKIPPED { | |||
| 662 | + | c = ermite.CiConclusion_CI_CONCLUSION_FAILURE | |||
| 663 | + | } | |||
| 664 | + | if res.Error != "" { | |||
| 665 | + | j.log.Info().Str("error", res.Error).Msg("the job reported an error") | |||
| 666 | + | } | |||
| 667 | + | return outcome{conclusion: c, outputs: res.Outputs, shutdown: res.RunnerShutdown && c == ermite.CiConclusion_CI_CONCLUSION_CANCELLED} | |||
| 668 | + | case j.cancelRequested() || ctx.Err() != nil: | |||
| 669 | + | // The executor did not report: it was stopped. A timeout wins over a | |||
| 670 | + | // shutdown or a user cancel that came later, as on the server (the first | |||
| 671 | + | // reason decides, and the timeout is the first when it fired first). | |||
| 672 | + | switch { | |||
| 673 | + | case j.firstCancelKind() == execpb.CancelKind_CANCEL_KIND_TIMEOUT: | |||
| 674 | + | return outcome{conclusion: ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT} | |||
| 675 | + | case j.isStopping(): | |||
| 676 | + | return outcome{conclusion: ermite.CiConclusion_CI_CONCLUSION_CANCELLED, shutdown: true} | |||
| 677 | + | } | |||
| 678 | + | return outcome{conclusion: ermite.CiConclusion_CI_CONCLUSION_CANCELLED} | |||
| 679 | + | } | |||
| 680 | + | why := "the executor ended without a result" | |||
| 681 | + | if driveErr != nil { | |||
| 682 | + | why = driveErr.Error() | |||
| 683 | + | } else if exit != nil && exit.Error != "" { | |||
| 684 | + | why = exit.Error | |||
| 685 | + | } | |||
| 686 | + | j.sandboxError(fmt.Sprintf("The job's sandbox ended unexpectedly: %s", why)) | |||
| 687 | + | return failure() | |||
| 688 | + | } | |||
| 689 | + | ||||
| 690 | + | // timeoutMinutes is the job timeout: at least 1, 360 when the job has none | |||
| 691 | + | // (runner.proto: Job.timeout_minutes). | |||
| 692 | + | func (j *jobRun) timeoutMinutes() int { | |||
| 693 | + | if m := int(j.job.TimeoutMinutes); m > 0 { | |||
| 694 | + | return m | |||
| 695 | + | } | |||
| 696 | + | return 360 | |||
| 697 | + | } | |||
| 698 | + | ||||
| 699 | + | // deadline is when the job times out, counted from its receipt. | |||
| 700 | + | func (j *jobRun) deadline() time.Time { | |||
| 701 | + | return j.receivedAt.Add(time.Duration(j.timeoutMinutes()) * time.Minute) | |||
| 702 | + | } | |||
| 703 | + | ||||
| 704 | + | func (j *jobRun) isStopping() bool { | |||
| 705 | + | j.cancelMu.Lock() | |||
| 706 | + | defer j.cancelMu.Unlock() | |||
| 707 | + | return j.stopping | |||
| 708 | + | } | |||
| 709 | + | ||||
| 710 | + | // failEarly reports a job that could not start: step 1 fails with the reason | |||
| 711 | + | // in its log. | |||
| 712 | + | func (j *jobRun) failEarly(msg string) { | |||
| 713 | + | now := time.Now() | |||
| 714 | + | j.stepStarted(&execpb.StepStarted{Number: 1, Name: "Set up job", UnixNano: now.UnixNano()}) | |||
| 715 | + | j.outbox.Log(logLine{step: 1, unixNano: now.UnixNano(), text: "##[error]" + msg}) | |||
| 716 | + | j.stepCompleted(&execpb.StepCompleted{Number: 1, Name: "Set up job", Conclusion: execpb.StepConclusion_STEP_CONCLUSION_FAILURE, UnixNano: time.Now().UnixNano()}) | |||
| 717 | + | } | |||
| 718 | + | ||||
| 719 | + | // sandboxError writes the reason into the log of the step that was running. | |||
| 720 | + | func (j *jobRun) sandboxError(msg string) { | |||
| 721 | + | j.mu.Lock() | |||
| 722 | + | var last int32 = 1 | |||
| 723 | + | for n := range j.steps { | |||
| 724 | + | last = max(last, n) | |||
| 725 | + | } | |||
| 726 | + | j.mu.Unlock() | |||
| 727 | + | j.outbox.Log(logLine{step: last, unixNano: time.Now().UnixNano(), text: "##[error]" + msg}) | |||
| 728 | + | } | |||
| 729 | + | ||||
| 730 | + | // complete reports the end of the job. CompleteJob is idempotent, so a lost | |||
| 731 | + | // response is retried until it is answered (applied or not). | |||
| 732 | + | func (j *jobRun) complete(ctx context.Context, conclusion ermite.CiConclusion, outputs map[string]string, shutdown bool) { | |||
| 733 | + | req := &ermite.CompleteJobRequest{ | |||
| 734 | + | JobId: j.job.JobId, Conclusion: conclusion, Outputs: outputs, | |||
| 735 | + | RunnerShutdown: shutdown && conclusion == ermite.CiConclusion_CI_CONCLUSION_CANCELLED, | |||
| 736 | + | Steps: j.finalSteps(conclusion), | |||
| 737 | + | } | |||
| 738 | + | err := j.sendComplete(ctx, req, conclusion) | |||
| 739 | + | if err == nil || ctx.Err() != nil || classify(err) != kindPermanent { | |||
| 740 | + | return | |||
| 741 | + | } | |||
| 742 | + | // The server refused the result itself (invalid_argument, or | |||
| 743 | + | // failed_precondition that is not the end of the job). CompleteJob is | |||
| 744 | + | // "applied whole or not at all", so the job is still open: say why in the | |||
| 745 | + | // log, then report a plain failure without outputs and with steps that are | |||
| 746 | + | // valid by construction. Without this the job would stay running until the | |||
| 747 | + | // heartbeat deadline of the server and then be reported as a lost runner. | |||
| 748 | + | j.errorLog(ctx, fmt.Sprintf("The runner could not report the result of the job: %s. The job is reported as failed.", connect.CodeOf(err))) | |||
| 749 | + | j.log.Error().Err(err).Msg("CompleteJob refused: retrying as a failure without outputs") | |||
| 750 | + | req = &ermite.CompleteJobRequest{ | |||
| 751 | + | JobId: j.job.JobId, Conclusion: ermite.CiConclusion_CI_CONCLUSION_FAILURE, | |||
| 752 | + | Steps: j.rebuiltSteps(), | |||
| 753 | + | } | |||
| 754 | + | if err := j.sendComplete(ctx, req, req.Conclusion); err != nil && ctx.Err() == nil { | |||
| 755 | + | j.log.Error().Err(err).Msg("CompleteJob refused again, giving up") | |||
| 756 | + | } | |||
| 757 | + | } | |||
| 758 | + | ||||
| 759 | + | // sendComplete sends the request until the server answers: success, the end of | |||
| 760 | + | // the job, a lost assignment, or a refusal (returned). | |||
| 761 | + | func (j *jobRun) sendComplete(ctx context.Context, req *ermite.CompleteJobRequest, conclusion ermite.CiConclusion) error { | |||
| 762 | + | b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax} | |||
| 763 | + | for { | |||
| 764 | + | cctx, cancel := context.WithTimeout(ctx, j.t.CallTimeout) | |||
| 765 | + | resp, err := j.client.CompleteJob(cctx, connect.NewRequest(req)) | |||
| 766 | + | cancel() | |||
| 767 | + | if err == nil { | |||
| 768 | + | if resp.Msg.Conclusion != conclusion { | |||
| 769 | + | j.log.Warn().Str("reported", conclusion.String()).Str("stored", resp.Msg.Conclusion.String()).Msg("the server holds another conclusion") | |||
| 770 | + | } | |||
| 771 | + | return nil | |||
| 772 | + | } | |||
| 773 | + | if ctx.Err() != nil { | |||
| 774 | + | j.log.Error().Err(ctx.Err()).Msg("could not report the result") | |||
| 775 | + | return err | |||
| 776 | + | } | |||
| 777 | + | switch classify(err) { | |||
| 778 | + | case kindEnded, kindAbandoned: | |||
| 779 | + | return nil | |||
| 780 | + | case kindPermanent: | |||
| 781 | + | return err | |||
| 782 | + | } | |||
| 783 | + | j.log.Warn().Err(err).Msg("CompleteJob failed, retrying") | |||
| 784 | + | if !sleep(ctx, b.next()) { | |||
| 785 | + | return err | |||
| 786 | + | } | |||
| 787 | + | } | |||
| 788 | + | } | |||
| 789 | + | ||||
| 790 | + | // errorLog appends a line to the log after the outbox is closed. | |||
| 791 | + | func (j *jobRun) errorLog(ctx context.Context, msg string) { | |||
| 792 | + | j.mu.Lock() | |||
| 793 | + | var last int32 = 1 | |||
| 794 | + | for n := range j.steps { | |||
| 795 | + | last = max(last, n) | |||
| 796 | + | } | |||
| 797 | + | j.mu.Unlock() | |||
| 798 | + | j.outbox.AppendNow(ctx, logLine{step: last, unixNano: time.Now().UnixNano(), text: "##[error]" + msg}) | |||
| 799 | + | } | |||
| 800 | + | ||||
| 801 | + | // rebuiltSteps returns every known step as a valid, completed snapshot: number | |||
| 802 | + | // in 1..maxSteps, a name of at most 1,024 bytes, a conclusion the server | |||
| 803 | + | // accepts (success, failure, cancelled, skipped), both times set, and no step | |||
| 804 | + | // that finished before it began. A step that was running is failed. | |||
| 805 | + | func (j *jobRun) rebuiltSteps() []*ermite.StepUpdate { | |||
| 806 | + | j.mu.Lock() | |||
| 807 | + | defer j.mu.Unlock() | |||
| 808 | + | now := timestamppb.Now() | |||
| 809 | + | var out []*ermite.StepUpdate | |||
| 810 | + | for n, u := range j.steps { | |||
| 811 | + | if n < 1 || n > maxSteps { | |||
| 812 | + | continue | |||
| 813 | + | } | |||
| 814 | + | c := proto.Clone(u).(*ermite.StepUpdate) | |||
| 815 | + | c.Name = cutName(c.Name) | |||
| 816 | + | if c.StartedAt == nil { | |||
| 817 | + | c.StartedAt = now | |||
| 818 | + | } | |||
| 819 | + | if c.State != ermite.StepState_STEP_STATE_COMPLETED { | |||
| 820 | + | c.Conclusion = ermite.CiConclusion_CI_CONCLUSION_FAILURE | |||
| 821 | + | c.CompletedAt = now | |||
| 822 | + | } | |||
| 823 | + | switch c.Conclusion { | |||
| 824 | + | case ermite.CiConclusion_CI_CONCLUSION_SUCCESS, ermite.CiConclusion_CI_CONCLUSION_FAILURE, | |||
| 825 | + | ermite.CiConclusion_CI_CONCLUSION_CANCELLED, ermite.CiConclusion_CI_CONCLUSION_SKIPPED: | |||
| 826 | + | default: | |||
| 827 | + | c.Conclusion = ermite.CiConclusion_CI_CONCLUSION_FAILURE | |||
| 828 | + | } | |||
| 829 | + | c.State = ermite.StepState_STEP_STATE_COMPLETED | |||
| 830 | + | if c.CompletedAt == nil || c.CompletedAt.AsTime().Before(c.StartedAt.AsTime()) { | |||
| 831 | + | c.CompletedAt = c.StartedAt | |||
| 832 | + | } | |||
| 833 | + | out = append(out, c) | |||
| 834 | + | } | |||
| 835 | + | sort.Slice(out, func(a, b int) bool { return out[a].Number < out[b].Number }) | |||
| 836 | + | return out | |||
| 837 | + | } | |||
| 838 | + | ||||
| 839 | + | // Limits of CompleteJobRequest.outputs. | |||
| 840 | + | const ( | |||
| 841 | + | maxOutputBytes = 1 << 20 | |||
| 842 | + | maxOutputName = 255 | |||
| 843 | + | ) | |||
| 844 | + | ||||
| 845 | + | // checkOutputs returns a message when the server would refuse the outputs: a | |||
| 846 | + | // name of 1 to 255 letters, digits, "_" and "-" starting with a letter or "_", | |||
| 847 | + | // no two names equal ignoring case, each value and the sum of names and values | |||
| 848 | + | // at most 1 MiB. | |||
| 849 | + | func checkOutputs(outputs map[string]string) string { | |||
| 850 | + | total := 0 | |||
| 851 | + | seen := map[string]string{} | |||
| 852 | + | names := make([]string, 0, len(outputs)) | |||
| 853 | + | for n := range outputs { | |||
| 854 | + | names = append(names, n) | |||
| 855 | + | } | |||
| 856 | + | sort.Strings(names) | |||
| 857 | + | for _, n := range names { | |||
| 858 | + | v := outputs[n] | |||
| 859 | + | if !validOutputName(n) { | |||
| 860 | + | return fmt.Sprintf("Job output name %q is not valid: use 1 to 255 letters, digits, '_' and '-', starting with a letter or '_'.", n) | |||
| 861 | + | } | |||
| 862 | + | if prev, dup := seen[strings.ToLower(n)]; dup { | |||
| 863 | + | return fmt.Sprintf("Job outputs %q and %q differ only by case.", prev, n) | |||
| 864 | + | } | |||
| 865 | + | seen[strings.ToLower(n)] = n | |||
| 866 | + | if len(v) > maxOutputBytes { | |||
| 867 | + | return fmt.Sprintf("Job output %q is larger than 1 MiB.", n) | |||
| 868 | + | } | |||
| 869 | + | total += len(n) + len(v) | |||
| 870 | + | } | |||
| 871 | + | if total > maxOutputBytes { | |||
| 872 | + | return "The job outputs are larger than 1 MiB in total." | |||
| 873 | + | } | |||
| 874 | + | return "" | |||
| 875 | + | } | |||
| 876 | + | ||||
| 877 | + | func validOutputName(n string) bool { | |||
| 878 | + | if n == "" || len(n) > maxOutputName { | |||
| 879 | + | return false | |||
| 880 | + | } | |||
| 881 | + | for i, r := range n { | |||
| 882 | + | letter := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r == '_' | |||
| 883 | + | if ok := letter || (i > 0 && (r >= '0' && r <= '9' || r == '-')); !ok { | |||
| 884 | + | return false | |||
| 885 | + | } | |||
| 886 | + | } | |||
| 887 | + | return true | |||
| 888 | + | } | |||
A internal/host/outbox.go +325 -0
| @@ -0,0 +1,325 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "sync" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "connectrpc.com/connect" | |||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | "google.golang.org/protobuf/types/known/timestamppb" | |||
| 13 | + | ||||
| 14 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Limits of AppendLog (runner.proto): at most 1,000 lines and 64 KiB of text | |||
| 18 | + | // per request, all in the same step. | |||
| 19 | + | const ( | |||
| 20 | + | maxBatchLines = 1000 | |||
| 21 | + | maxBatchBytes = 64 * 1024 | |||
| 22 | + | // maxQueuedBytes is the log kept in memory while the server cannot be | |||
| 23 | + | // reached; beyond it the executor is slowed down (the stream is not read) | |||
| 24 | + | // rather than the host's memory filled. | |||
| 25 | + | maxQueuedBytes = 16 << 20 | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | type logLine struct { | |||
| 29 | + | step int32 | |||
| 30 | + | unixNano int64 | |||
| 31 | + | text string | |||
| 32 | + | continued bool | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // event is something to send, in the order it happened: a log line, or a | |||
| 36 | + | // snapshot of a step (UpdateJob). | |||
| 37 | + | type event struct { | |||
| 38 | + | line *logLine | |||
| 39 | + | step *ermite.StepUpdate | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // outbox sends logs and step updates of one job to ermite, in order, one call | |||
| 43 | + | // at a time. Log lines are numbered from 0 across the whole job; the number of | |||
| 44 | + | // the next line is the offset of the next AppendLog, so that a retry after a | |||
| 45 | + | // lost response sends the same lines at the same offset (the server skips what | |||
| 46 | + | // it already holds). | |||
| 47 | + | type outbox struct { | |||
| 48 | + | client callClient | |||
| 49 | + | jobID string | |||
| 50 | + | t Timing | |||
| 51 | + | // onEnded is called once when the server says the job is over (JobEnded) | |||
| 52 | + | // or the assignment is gone; the outbox then drops everything. | |||
| 53 | + | onEnded func(error) | |||
| 54 | + | log zerowrap.Logger | |||
| 55 | + | ||||
| 56 | + | mu sync.Mutex | |||
| 57 | + | cond *sync.Cond | |||
| 58 | + | events []event | |||
| 59 | + | queued int // bytes of log text in events | |||
| 60 | + | closing bool | |||
| 61 | + | dead bool | |||
| 62 | + | done chan struct{} | |||
| 63 | + | ||||
| 64 | + | offset int64 // lines the server holds, as far as we know | |||
| 65 | + | // offsetMaybeStale is set when a send was cut short by its context while | |||
| 66 | + | // the call may have been applied by the server with the response lost: the | |||
| 67 | + | // offset is then possibly behind the server's count. | |||
| 68 | + | offsetMaybeStale bool | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // callClient is the part of the RunnerService client the job uses. | |||
| 72 | + | type callClient interface { | |||
| 73 | + | UpdateJob(context.Context, *connect.Request[ermite.UpdateJobRequest]) (*connect.Response[ermite.UpdateJobResponse], error) | |||
| 74 | + | AppendLog(context.Context, *connect.Request[ermite.AppendLogRequest]) (*connect.Response[ermite.AppendLogResponse], error) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func newOutbox(c callClient, jobID string, t Timing, onEnded func(error), log zerowrap.Logger) *outbox { | |||
| 78 | + | o := &outbox{client: c, jobID: jobID, t: t, onEnded: onEnded, log: log, done: make(chan struct{})} | |||
| 79 | + | o.cond = sync.NewCond(&o.mu) | |||
| 80 | + | return o | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // start runs the sender until Close or until ctx ends. | |||
| 84 | + | func (o *outbox) start(ctx context.Context) { | |||
| 85 | + | go func() { | |||
| 86 | + | defer close(o.done) | |||
| 87 | + | o.loop(ctx) | |||
| 88 | + | }() | |||
| 89 | + | // A cancelled context must wake the loop and the producers. | |||
| 90 | + | go func() { | |||
| 91 | + | select { | |||
| 92 | + | case <-ctx.Done(): | |||
| 93 | + | o.kill() | |||
| 94 | + | case <-o.done: | |||
| 95 | + | } | |||
| 96 | + | }() | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (o *outbox) kill() { | |||
| 100 | + | o.mu.Lock() | |||
| 101 | + | o.dead = true | |||
| 102 | + | o.events = nil | |||
| 103 | + | o.queued = 0 | |||
| 104 | + | o.mu.Unlock() | |||
| 105 | + | o.cond.Broadcast() | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // Log queues a line of the job log. It blocks while too much is queued. | |||
| 109 | + | func (o *outbox) Log(l logLine) { | |||
| 110 | + | o.mu.Lock() | |||
| 111 | + | for o.queued > maxQueuedBytes && !o.dead && !o.closing { | |||
| 112 | + | o.cond.Wait() | |||
| 113 | + | } | |||
| 114 | + | if o.dead { | |||
| 115 | + | o.mu.Unlock() | |||
| 116 | + | return | |||
| 117 | + | } | |||
| 118 | + | o.events = append(o.events, event{line: &l}) | |||
| 119 | + | o.queued += len(l.text) + 32 | |||
| 120 | + | o.mu.Unlock() | |||
| 121 | + | o.cond.Broadcast() | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Step queues a step snapshot. | |||
| 125 | + | func (o *outbox) Step(s *ermite.StepUpdate) { | |||
| 126 | + | o.mu.Lock() | |||
| 127 | + | if o.dead { | |||
| 128 | + | o.mu.Unlock() | |||
| 129 | + | return | |||
| 130 | + | } | |||
| 131 | + | o.events = append(o.events, event{step: s}) | |||
| 132 | + | o.mu.Unlock() | |||
| 133 | + | o.cond.Broadcast() | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // Close sends what is queued, then stops the sender. It waits at most until | |||
| 137 | + | // ctx ends. | |||
| 138 | + | func (o *outbox) Close(ctx context.Context) { | |||
| 139 | + | o.mu.Lock() | |||
| 140 | + | o.closing = true | |||
| 141 | + | o.mu.Unlock() | |||
| 142 | + | o.cond.Broadcast() | |||
| 143 | + | select { | |||
| 144 | + | case <-o.done: | |||
| 145 | + | case <-ctx.Done(): | |||
| 146 | + | o.kill() | |||
| 147 | + | <-o.done | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // Lines returns how many lines the server holds (as far as we know). | |||
| 152 | + | func (o *outbox) Lines() int64 { | |||
| 153 | + | o.mu.Lock() | |||
| 154 | + | defer o.mu.Unlock() | |||
| 155 | + | return o.offset | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // next waits for a batch of events. It returns nil when the outbox is done. | |||
| 159 | + | func (o *outbox) next(ctx context.Context) []event { | |||
| 160 | + | o.mu.Lock() | |||
| 161 | + | defer o.mu.Unlock() | |||
| 162 | + | for len(o.events) == 0 { | |||
| 163 | + | if o.dead || o.closing { | |||
| 164 | + | return nil | |||
| 165 | + | } | |||
| 166 | + | o.cond.Wait() | |||
| 167 | + | } | |||
| 168 | + | // Let a burst of lines fill the batch, unless it is full, a step event is | |||
| 169 | + | // at the front, or we are closing. | |||
| 170 | + | if o.events[0].line != nil && !o.closing && o.queued < maxBatchBytes && len(o.events) < maxBatchLines { | |||
| 171 | + | o.mu.Unlock() | |||
| 172 | + | sleep(ctx, o.t.LogFlush) | |||
| 173 | + | o.mu.Lock() | |||
| 174 | + | if o.dead { | |||
| 175 | + | return nil | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | if len(o.events) == 0 { | |||
| 179 | + | return nil | |||
| 180 | + | } | |||
| 181 | + | first := o.events[0] | |||
| 182 | + | if first.step != nil { | |||
| 183 | + | o.events = o.events[1:] | |||
| 184 | + | return []event{first} | |||
| 185 | + | } | |||
| 186 | + | // A run of lines of the same step, within the limits. | |||
| 187 | + | n, bytes := 0, 0 | |||
| 188 | + | step := first.line.step | |||
| 189 | + | for _, e := range o.events { | |||
| 190 | + | if e.line == nil || e.line.step != step || n == maxBatchLines || (n > 0 && bytes+len(e.line.text) > maxBatchBytes) { | |||
| 191 | + | break | |||
| 192 | + | } | |||
| 193 | + | n++ | |||
| 194 | + | bytes += len(e.line.text) | |||
| 195 | + | } | |||
| 196 | + | batch := append([]event(nil), o.events[:n]...) | |||
| 197 | + | o.events = o.events[n:] | |||
| 198 | + | for _, e := range batch { | |||
| 199 | + | o.queued -= len(e.line.text) + 32 | |||
| 200 | + | } | |||
| 201 | + | o.cond.Broadcast() // producers waiting for room | |||
| 202 | + | return batch | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func (o *outbox) loop(ctx context.Context) { | |||
| 206 | + | for { | |||
| 207 | + | batch := o.next(ctx) | |||
| 208 | + | if batch == nil { | |||
| 209 | + | return | |||
| 210 | + | } | |||
| 211 | + | if batch[0].step != nil { | |||
| 212 | + | o.sendStep(ctx, batch[0].step) | |||
| 213 | + | } else { | |||
| 214 | + | o.sendLines(ctx, batch) | |||
| 215 | + | } | |||
| 216 | + | o.mu.Lock() | |||
| 217 | + | dead := o.dead | |||
| 218 | + | o.mu.Unlock() | |||
| 219 | + | if dead { | |||
| 220 | + | return | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // retry runs call until it succeeds, the server gives a verdict, or ctx ends. | |||
| 226 | + | // It returns nil on success. | |||
| 227 | + | func (o *outbox) retry(ctx context.Context, what string, call func(context.Context) error) error { | |||
| 228 | + | b := backoff{min: o.t.BackoffMin, max: o.t.BackoffMax} | |||
| 229 | + | for { | |||
| 230 | + | cctx, cancel := context.WithTimeout(ctx, o.t.CallTimeout) | |||
| 231 | + | err := call(cctx) | |||
| 232 | + | cancel() | |||
| 233 | + | if err == nil { | |||
| 234 | + | return nil | |||
| 235 | + | } | |||
| 236 | + | if ctx.Err() != nil { | |||
| 237 | + | return ctx.Err() | |||
| 238 | + | } | |||
| 239 | + | switch classify(err) { | |||
| 240 | + | case kindEnded, kindAbandoned: | |||
| 241 | + | o.onEnded(err) | |||
| 242 | + | o.kill() | |||
| 243 | + | return err | |||
| 244 | + | case kindPermanent: | |||
| 245 | + | return err | |||
| 246 | + | } | |||
| 247 | + | o.log.Warn().Err(err).Str("call", what).Msg("call failed, retrying") | |||
| 248 | + | if !sleep(ctx, b.next()) { | |||
| 249 | + | return ctx.Err() | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (o *outbox) sendStep(ctx context.Context, s *ermite.StepUpdate) { | |||
| 255 | + | err := o.retry(ctx, "UpdateJob", func(c context.Context) error { | |||
| 256 | + | _, err := o.client.UpdateJob(c, connect.NewRequest(&ermite.UpdateJobRequest{JobId: o.jobID, Steps: []*ermite.StepUpdate{s}})) | |||
| 257 | + | return err | |||
| 258 | + | }) | |||
| 259 | + | if err != nil && ctx.Err() == nil && classify(err) == kindPermanent { | |||
| 260 | + | o.log.Error().Err(err).Int32("step", s.Number).Msg("UpdateJob refused") | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func (o *outbox) sendLines(ctx context.Context, batch []event) { | |||
| 265 | + | req := &ermite.AppendLogRequest{JobId: o.jobID, Step: batch[0].line.step} | |||
| 266 | + | for _, e := range batch { | |||
| 267 | + | req.Lines = append(req.Lines, &ermite.LogLine{Time: timestamppb.New(time.Unix(0, e.line.unixNano)), Text: e.line.text, Continued: e.line.continued}) | |||
| 268 | + | } | |||
| 269 | + | // The offset is fixed before the first attempt: every retry sends the same | |||
| 270 | + | // lines at the same offset. | |||
| 271 | + | req.Offset = o.offset | |||
| 272 | + | var next int64 | |||
| 273 | + | err := o.retry(ctx, "AppendLog", func(c context.Context) error { | |||
| 274 | + | resp, err := o.client.AppendLog(c, connect.NewRequest(req)) | |||
| 275 | + | if err != nil { | |||
| 276 | + | return err | |||
| 277 | + | } | |||
| 278 | + | next = resp.Msg.NextOffset | |||
| 279 | + | return nil | |||
| 280 | + | }) | |||
| 281 | + | switch { | |||
| 282 | + | case err == nil: | |||
| 283 | + | o.mu.Lock() | |||
| 284 | + | o.offsetMaybeStale = false | |||
| 285 | + | // The server says how many lines it holds: that is the truth. A | |||
| 286 | + | // difference means lines were stored that we did not count (a retry | |||
| 287 | + | // whose answer we lost) or dropped; continue from the server's count, | |||
| 288 | + | // so that the next offset is never a gap (out_of_range) and never a | |||
| 289 | + | // repeat of lines that would be skipped. | |||
| 290 | + | if want := req.Offset + int64(len(req.Lines)); next != want { | |||
| 291 | + | o.log.Warn().Int64("server_lines", next).Int64("expected", want).Msg("AppendLog: the server holds another number of lines, continuing from its count") | |||
| 292 | + | } | |||
| 293 | + | o.offset = next | |||
| 294 | + | o.mu.Unlock() | |||
| 295 | + | case ctx.Err() != nil: | |||
| 296 | + | // Cut short in the middle of a call or of its retries: the server may | |||
| 297 | + | // hold these lines without our having seen the answer. | |||
| 298 | + | o.mu.Lock() | |||
| 299 | + | o.offsetMaybeStale = true | |||
| 300 | + | o.mu.Unlock() | |||
| 301 | + | case classify(err) == kindPermanent: | |||
| 302 | + | // The server refused these lines; they are dropped and the offset stays | |||
| 303 | + | // where the server is. | |||
| 304 | + | o.log.Error().Err(err).Int("lines", len(req.Lines)).Int32("step", req.Step).Msg("AppendLog refused, the lines are dropped") | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // AppendNow sends one line synchronously, after Close: the sender has stopped, | |||
| 309 | + | // so nothing else uses the offset. It is for the last words of a job (an error | |||
| 310 | + | // that explains why the result could not be reported). | |||
| 311 | + | // | |||
| 312 | + | // If Close gave up in the middle of a send, the offset may be behind the | |||
| 313 | + | // server's: the line is sent anyway (the server answers with its own count, and | |||
| 314 | + | // an offset that is too low makes it skip the line as a repeat or refuse it), | |||
| 315 | + | // and the warning says why it may be missing. | |||
| 316 | + | func (o *outbox) AppendNow(ctx context.Context, l logLine) { | |||
| 317 | + | o.mu.Lock() | |||
| 318 | + | stale := o.offsetMaybeStale | |||
| 319 | + | offset := o.offset | |||
| 320 | + | o.mu.Unlock() | |||
| 321 | + | if stale { | |||
| 322 | + | o.log.Warn().Int64("offset", offset).Msg("the log offset may be stale: Close was cut short in the middle of a send, so the last line may be skipped or refused by the server") | |||
| 323 | + | } | |||
| 324 | + | o.sendLines(ctx, []event{{line: &l}}) | |||
| 325 | + | } | |||
A internal/host/outbox_test.go +45 -0
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // AppendNow says so when an earlier send was cut short and the offset may be | |||
| 15 | + | // behind the server's (GO-005), and stays silent otherwise. | |||
| 16 | + | func TestAppendNowWarnsOfAStaleOffset(t *testing.T) { | |||
| 17 | + | const warning = "log offset may be stale" | |||
| 18 | + | logs := &logBuf{} | |||
| 19 | + | log := zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: logs}) | |||
| 20 | + | tm := Timing{CallTimeout: time.Hour, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond} | |||
| 21 | + | cl := &stubClient{hang: true} | |||
| 22 | + | o := newOutbox(cl, "j1", tm, func(error) {}, log) | |||
| 23 | + | ||||
| 24 | + | // A send that Close cut short: the server may have applied it. | |||
| 25 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |||
| 26 | + | defer cancel() | |||
| 27 | + | o.sendLines(ctx, []event{{line: &logLine{step: 1, text: "a"}}}) | |||
| 28 | + | ||||
| 29 | + | cl.mu.Lock() | |||
| 30 | + | cl.hang = false | |||
| 31 | + | cl.mu.Unlock() | |||
| 32 | + | o.AppendNow(context.Background(), logLine{step: 1, text: "b"}) | |||
| 33 | + | if !strings.Contains(logs.String(), warning) { | |||
| 34 | + | t.Fatalf("no warning about the offset: %q", logs.String()) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // The send went through: the offset is the server's again. | |||
| 38 | + | logs.mu.Lock() | |||
| 39 | + | logs.b.Reset() | |||
| 40 | + | logs.mu.Unlock() | |||
| 41 | + | o.AppendNow(context.Background(), logLine{step: 1, text: "c"}) | |||
| 42 | + | if strings.Contains(logs.String(), warning) { | |||
| 43 | + | t.Fatalf("a warning with a fresh offset: %q", logs.String()) | |||
| 44 | + | } | |||
| 45 | + | } | |||
A internal/host/rpc.go +258 -0
| @@ -0,0 +1,258 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | // Package host is the host side of ermite-runner: it registers the runner with | |||
| 4 | + | // ermite, fetches jobs, runs each one in a sandbox through the executor, and | |||
| 5 | + | // reports logs, steps and the result with the job token. The sandbox never sees | |||
| 6 | + | // a token. | |||
| 7 | + | package host | |||
| 8 | + | ||||
| 9 | + | import ( | |||
| 10 | + | "context" | |||
| 11 | + | "errors" | |||
| 12 | + | "fmt" | |||
| 13 | + | "net/http" | |||
| 14 | + | "net/netip" | |||
| 15 | + | "net/url" | |||
| 16 | + | "strings" | |||
| 17 | + | "time" | |||
| 18 | + | ||||
| 19 | + | "connectrpc.com/connect" | |||
| 20 | + | ||||
| 21 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 22 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // bearer adds "Authorization: Bearer <token>" to every request. The token is | |||
| 26 | + | // never logged. It refuses to send the token over a connection that is not | |||
| 27 | + | // protected: plain http is only for loopback, unless AllowInsecure is set. | |||
| 28 | + | type bearer struct { | |||
| 29 | + | token string | |||
| 30 | + | base http.RoundTripper | |||
| 31 | + | allowInsecure bool | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // ErrInsecureURL is returned for a server URL that is not https and not | |||
| 35 | + | // loopback: the runner token and the job tokens would travel in clear. | |||
| 36 | + | var ErrInsecureURL = errors.New("the server URL must be https (plain http is only accepted for localhost; ERMITE_RUNNER_INSECURE_HTTP=1 allows it, for tests, and sends every token in clear)") | |||
| 37 | + | ||||
| 38 | + | // CheckURL validates the URL of the server: http or https, a host, no | |||
| 39 | + | // credentials, and https unless the host is loopback or allowInsecure is set. | |||
| 40 | + | func CheckURL(raw string, allowInsecure bool) error { | |||
| 41 | + | u, err := url.Parse(raw) | |||
| 42 | + | if err != nil || u.Host == "" { | |||
| 43 | + | return fmt.Errorf("%q is not a valid URL", raw) | |||
| 44 | + | } | |||
| 45 | + | if u.User != nil { | |||
| 46 | + | return errors.New("the server URL must not contain credentials") | |||
| 47 | + | } | |||
| 48 | + | switch u.Scheme { | |||
| 49 | + | case "https": | |||
| 50 | + | return nil | |||
| 51 | + | case "http": | |||
| 52 | + | if allowInsecure || isLoopback(u.Hostname()) { | |||
| 53 | + | return nil | |||
| 54 | + | } | |||
| 55 | + | return ErrInsecureURL | |||
| 56 | + | } | |||
| 57 | + | return fmt.Errorf("%q: the scheme must be https", raw) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func isLoopback(host string) bool { | |||
| 61 | + | // Host names are not case sensitive (LOCALHOST, Localhost). | |||
| 62 | + | if strings.EqualFold(host, "localhost") { | |||
| 63 | + | return true | |||
| 64 | + | } | |||
| 65 | + | ip, err := netip.ParseAddr(host) | |||
| 66 | + | return err == nil && ip.IsLoopback() | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (b bearer) RoundTrip(r *http.Request) (*http.Response, error) { | |||
| 70 | + | if err := CheckURL(r.URL.String(), b.allowInsecure); err != nil { | |||
| 71 | + | return nil, err | |||
| 72 | + | } | |||
| 73 | + | r = r.Clone(r.Context()) | |||
| 74 | + | if b.token != "" { | |||
| 75 | + | r.Header.Set("Authorization", "Bearer "+b.token) | |||
| 76 | + | } | |||
| 77 | + | base := b.base | |||
| 78 | + | if base == nil { | |||
| 79 | + | base = http.DefaultTransport | |||
| 80 | + | } | |||
| 81 | + | return base.RoundTrip(r) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // NewClient returns a RunnerService client that authenticates with token. The | |||
| 85 | + | // HTTP client has no overall timeout: calls carry their own context deadline | |||
| 86 | + | // (WatchJob is a long stream). It never follows a redirect: a response from the | |||
| 87 | + | // server that points elsewhere is returned as is, so that the token is not | |||
| 88 | + | // carried to another host (Go would keep the Authorization header on a redirect | |||
| 89 | + | // to the same host and drop it for others, but the client has no business | |||
| 90 | + | // following any). | |||
| 91 | + | func NewClient(baseURL, token string, base http.RoundTripper) ermiteconnect.RunnerServiceClient { | |||
| 92 | + | return newClient(baseURL, token, base, false) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // NewInsecureClient is NewClient for a server reached over plain http that is | |||
| 96 | + | // not loopback. Only for tests and trusted networks. | |||
| 97 | + | func NewInsecureClient(baseURL, token string, base http.RoundTripper) ermiteconnect.RunnerServiceClient { | |||
| 98 | + | return newClient(baseURL, token, base, true) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func newClient(baseURL, token string, base http.RoundTripper, insecure bool) ermiteconnect.RunnerServiceClient { | |||
| 102 | + | hc := &http.Client{ | |||
| 103 | + | Transport: bearer{token: token, base: base, allowInsecure: insecure}, | |||
| 104 | + | CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, | |||
| 105 | + | } | |||
| 106 | + | return ermiteconnect.NewRunnerServiceClient(hc, strings.TrimRight(baseURL, "/")) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // ClientFactory builds a client for a token; tests replace it. | |||
| 110 | + | type ClientFactory func(token string) ermiteconnect.RunnerServiceClient | |||
| 111 | + | ||||
| 112 | + | // jobEnded extracts the JobEnded detail of a failed_precondition error. | |||
| 113 | + | func jobEnded(err error) (*ermite.JobEnded, bool) { | |||
| 114 | + | var ce *connect.Error | |||
| 115 | + | if !errors.As(err, &ce) || ce.Code() != connect.CodeFailedPrecondition { | |||
| 116 | + | return nil, false | |||
| 117 | + | } | |||
| 118 | + | for _, d := range ce.Details() { | |||
| 119 | + | m, derr := d.Value() | |||
| 120 | + | if derr != nil { | |||
| 121 | + | continue | |||
| 122 | + | } | |||
| 123 | + | if je, ok := m.(*ermite.JobEnded); ok { | |||
| 124 | + | return je, true | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | return nil, false | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // kind classifies the error of a job call. | |||
| 131 | + | type kind int | |||
| 132 | + | ||||
| 133 | + | const ( | |||
| 134 | + | kindRetry kind = iota // network error, unavailable, internal...: try again | |||
| 135 | + | kindEnded // failed_precondition with JobEnded: stop, send nothing more | |||
| 136 | + | kindAbandoned // unauthenticated: the assignment is gone, stop at once | |||
| 137 | + | kindPermanent // the request itself is wrong: do not repeat it | |||
| 138 | + | ) | |||
| 139 | + | ||||
| 140 | + | func classify(err error) kind { | |||
| 141 | + | if err == nil { | |||
| 142 | + | return kindRetry | |||
| 143 | + | } | |||
| 144 | + | if _, ok := jobEnded(err); ok { | |||
| 145 | + | return kindEnded | |||
| 146 | + | } | |||
| 147 | + | switch connect.CodeOf(err) { | |||
| 148 | + | case connect.CodeUnauthenticated: | |||
| 149 | + | return kindAbandoned | |||
| 150 | + | case connect.CodeInvalidArgument, connect.CodeFailedPrecondition, connect.CodeOutOfRange, | |||
| 151 | + | connect.CodePermissionDenied, connect.CodeNotFound, connect.CodeUnimplemented, connect.CodeAlreadyExists: | |||
| 152 | + | return kindPermanent | |||
| 153 | + | } | |||
| 154 | + | // Canceled by our own context is handled by the callers through ctx.Err(). | |||
| 155 | + | return kindRetry | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // Timing holds the delays of the host loop. Zero fields take the defaults, | |||
| 159 | + | // which are the protocol's. | |||
| 160 | + | type Timing struct { | |||
| 161 | + | // PollTimeout bounds one FetchJob call (the server holds it up to 50 s). | |||
| 162 | + | PollTimeout time.Duration | |||
| 163 | + | // HeartbeatEvery is the heartbeat period (30 s); HeartbeatRetry the delay | |||
| 164 | + | // after a failed heartbeat (5 s). | |||
| 165 | + | HeartbeatEvery, HeartbeatRetry time.Duration | |||
| 166 | + | // CallTimeout bounds one unary job call. | |||
| 167 | + | CallTimeout time.Duration | |||
| 168 | + | // BackoffMin and BackoffMax bound the retry delay of calls that fail | |||
| 169 | + | // without a verdict. | |||
| 170 | + | BackoffMin, BackoffMax time.Duration | |||
| 171 | + | // LogFlush is how long log lines are held to fill a batch. | |||
| 172 | + | LogFlush time.Duration | |||
| 173 | + | // WatchRetry is the delay before WatchJob is reopened. | |||
| 174 | + | WatchRetry time.Duration | |||
| 175 | + | // TimeoutGrace is how long the executor gets after the job timeout to stop | |||
| 176 | + | // its steps (SIGINT, then SIGTERM after 7.5 s, then SIGKILL 2.5 s later) and | |||
| 177 | + | // report, before the sandbox is destroyed. | |||
| 178 | + | TimeoutGrace time.Duration | |||
| 179 | + | // ShutdownGrace is how long jobs get to stop after a shutdown signal before | |||
| 180 | + | // their sandbox is destroyed. The executor needs 10 s for its escalation | |||
| 181 | + | // (SIGINT, SIGTERM after 7.5 s, SIGKILL 2.5 s later) and some for the | |||
| 182 | + | // post-steps and the report to the host. | |||
| 183 | + | ShutdownGrace time.Duration | |||
| 184 | + | // StopDeadline bounds the destruction of a sandbox. | |||
| 185 | + | StopDeadline time.Duration | |||
| 186 | + | // ShutdownReport bounds the last flush of the log and CompleteJob of a job | |||
| 187 | + | // that a shutdown cut short. | |||
| 188 | + | ShutdownReport time.Duration | |||
| 189 | + | // CompleteDeadline bounds the flush of logs and CompleteJob at the end of | |||
| 190 | + | // a job. | |||
| 191 | + | CompleteDeadline time.Duration | |||
| 192 | + | // NoJobPause is the pause after a poll without a job and without | |||
| 193 | + | // retry_after. | |||
| 194 | + | NoJobPause time.Duration | |||
| 195 | + | // ReapRetry is the delay between attempts to reap after a leaked sandbox. | |||
| 196 | + | ReapRetry time.Duration | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func (t Timing) withDefaults() Timing { | |||
| 200 | + | def := func(d *time.Duration, v time.Duration) { | |||
| 201 | + | if *d == 0 { | |||
| 202 | + | *d = v | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | def(&t.PollTimeout, 60*time.Second) | |||
| 206 | + | def(&t.HeartbeatEvery, 30*time.Second) | |||
| 207 | + | def(&t.HeartbeatRetry, 5*time.Second) | |||
| 208 | + | def(&t.CallTimeout, 30*time.Second) | |||
| 209 | + | def(&t.BackoffMin, time.Second) | |||
| 210 | + | def(&t.BackoffMax, 30*time.Second) | |||
| 211 | + | def(&t.LogFlush, 250*time.Millisecond) | |||
| 212 | + | def(&t.WatchRetry, time.Second) | |||
| 213 | + | def(&t.TimeoutGrace, 30*time.Second) | |||
| 214 | + | def(&t.ShutdownGrace, 30*time.Second) | |||
| 215 | + | def(&t.StopDeadline, 30*time.Second) | |||
| 216 | + | def(&t.ShutdownReport, 30*time.Second) | |||
| 217 | + | def(&t.CompleteDeadline, 5*time.Minute) | |||
| 218 | + | def(&t.NoJobPause, 0) | |||
| 219 | + | def(&t.ReapRetry, 5*time.Second) | |||
| 220 | + | return t | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // ShutdownBudget is the longest a graceful shutdown takes with these timings: | |||
| 224 | + | // the grace for the executor, the destruction of the sandbox, and the report. | |||
| 225 | + | // The jobs shut down in parallel, so it does not depend on their number. The | |||
| 226 | + | // service manager must wait at least this long (contrib/ermite-runner.service | |||
| 227 | + | // has TimeoutStopSec=120 for the defaults, 90 s). | |||
| 228 | + | func (t Timing) ShutdownBudget() time.Duration { | |||
| 229 | + | t = t.withDefaults() | |||
| 230 | + | return t.ShutdownGrace + t.StopDeadline + t.ShutdownReport | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // sleep waits d or until ctx ends; it reports whether the full delay passed. | |||
| 234 | + | func sleep(ctx context.Context, d time.Duration) bool { | |||
| 235 | + | if d <= 0 { | |||
| 236 | + | return ctx.Err() == nil | |||
| 237 | + | } | |||
| 238 | + | t := time.NewTimer(d) | |||
| 239 | + | defer t.Stop() | |||
| 240 | + | select { | |||
| 241 | + | case <-t.C: | |||
| 242 | + | return true | |||
| 243 | + | case <-ctx.Done(): | |||
| 244 | + | return false | |||
| 245 | + | } | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // backoff doubles from min to max. | |||
| 249 | + | type backoff struct{ min, max, cur time.Duration } | |||
| 250 | + | ||||
| 251 | + | func (b *backoff) next() time.Duration { | |||
| 252 | + | if b.cur == 0 { | |||
| 253 | + | b.cur = b.min | |||
| 254 | + | } else if b.cur *= 2; b.cur > b.max { | |||
| 255 | + | b.cur = b.max | |||
| 256 | + | } | |||
| 257 | + | return b.cur | |||
| 258 | + | } | |||
A internal/host/rpc_test.go +136 -0
| @@ -0,0 +1,136 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync/atomic" | |||
| 11 | + | "testing" | |||
| 12 | + | ||||
| 13 | + | "connectrpc.com/connect" | |||
| 14 | + | ||||
| 15 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestCheckURL(t *testing.T) { | |||
| 19 | + | for _, tc := range []struct { | |||
| 20 | + | url string | |||
| 21 | + | insecure bool | |||
| 22 | + | want string // "" = accepted | |||
| 23 | + | }{ | |||
| 24 | + | {"https://ermite.example.org", false, ""}, | |||
| 25 | + | {"https://ermite.example.org:8443/base", false, ""}, | |||
| 26 | + | {"http://localhost:8080", false, ""}, | |||
| 27 | + | {"http://LOCALHOST:8080", false, ""}, | |||
| 28 | + | {"http://127.0.0.1:8080", false, ""}, | |||
| 29 | + | {"http://[::1]:8080", false, ""}, | |||
| 30 | + | {"http://ermite.example.org", false, "must be https"}, | |||
| 31 | + | {"http://10.0.0.5:8080", false, "must be https"}, | |||
| 32 | + | {"http://localhost.evil.example", false, "must be https"}, | |||
| 33 | + | {"http://127.0.0.1.evil.example", false, "must be https"}, | |||
| 34 | + | {"http://ermite.example.org", true, ""}, | |||
| 35 | + | {"ftp://ermite.example.org", false, "scheme must be https"}, | |||
| 36 | + | {"ermite.example.org", false, "not a valid URL"}, | |||
| 37 | + | {"https://user:pw@ermite.example.org", false, "credentials"}, | |||
| 38 | + | {"", false, "not a valid URL"}, | |||
| 39 | + | } { | |||
| 40 | + | err := CheckURL(tc.url, tc.insecure) | |||
| 41 | + | if tc.want == "" { | |||
| 42 | + | if err != nil { | |||
| 43 | + | t.Errorf("%q: %v", tc.url, err) | |||
| 44 | + | } | |||
| 45 | + | continue | |||
| 46 | + | } | |||
| 47 | + | if err == nil || !strings.Contains(err.Error(), tc.want) { | |||
| 48 | + | t.Errorf("%q (insecure=%v): %v, want %q", tc.url, tc.insecure, err, tc.want) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // The client does not follow a redirect: the token must not reach the target, | |||
| 54 | + | // whoever it is. | |||
| 55 | + | func TestClientDoesNotFollowRedirects(t *testing.T) { | |||
| 56 | + | var hits atomic.Int32 | |||
| 57 | + | var leaked atomic.Value | |||
| 58 | + | target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 59 | + | hits.Add(1) | |||
| 60 | + | leaked.Store(r.Header.Get("Authorization")) | |||
| 61 | + | w.WriteHeader(200) | |||
| 62 | + | })) | |||
| 63 | + | defer target.Close() | |||
| 64 | + | redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 65 | + | http.Redirect(w, r, target.URL+"/stolen", http.StatusTemporaryRedirect) | |||
| 66 | + | })) | |||
| 67 | + | defer redirector.Close() | |||
| 68 | + | ||||
| 69 | + | c := NewClient(redirector.URL, "ermiterun_secret", nil) | |||
| 70 | + | _, err := c.FetchJob(context.Background(), connect.NewRequest(&ermite.FetchJobRequest{})) | |||
| 71 | + | if err == nil { | |||
| 72 | + | t.Fatal("a redirect answered a call") | |||
| 73 | + | } | |||
| 74 | + | if hits.Load() != 0 { | |||
| 75 | + | t.Fatalf("the redirect was followed (%d hits), the token was sent to the target: %v", hits.Load(), leaked.Load()) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // The token is not sent over plain http to a host that is not loopback. | |||
| 80 | + | func TestClientRefusesToSendTheTokenInClear(t *testing.T) { | |||
| 81 | + | var hits atomic.Int32 | |||
| 82 | + | rt := roundTripFunc(func(*http.Request) (*http.Response, error) { hits.Add(1); return nil, nil }) | |||
| 83 | + | c := NewClient("http://ermite.example.org", "ermiterun_secret", rt) | |||
| 84 | + | _, err := c.FetchJob(context.Background(), connect.NewRequest(&ermite.FetchJobRequest{})) | |||
| 85 | + | if err == nil || !strings.Contains(err.Error(), "must be https") || hits.Load() != 0 { | |||
| 86 | + | t.Fatalf("hits=%d err=%v", hits.Load(), err) | |||
| 87 | + | } | |||
| 88 | + | // The explicit flag allows it. | |||
| 89 | + | c = NewInsecureClient("http://ermite.example.org", "ermiterun_secret", rt) | |||
| 90 | + | _, _ = c.FetchJob(context.Background(), connect.NewRequest(&ermite.FetchJobRequest{})) | |||
| 91 | + | if hits.Load() != 1 { | |||
| 92 | + | t.Fatal("the insecure client did not send the request") | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | type roundTripFunc func(*http.Request) (*http.Response, error) | |||
| 97 | + | ||||
| 98 | + | func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } | |||
| 99 | + | ||||
| 100 | + | func TestLoadConfigChecksTheURL(t *testing.T) { | |||
| 101 | + | env := map[string]string{EnvURL: "http://ermite.example.org"} | |||
| 102 | + | get := func(k string) string { return env[k] } | |||
| 103 | + | if _, err := LoadConfig(get, "h", "/s", 1, ""); err == nil || !strings.Contains(err.Error(), EnvURL) { | |||
| 104 | + | t.Fatalf("%v", err) | |||
| 105 | + | } | |||
| 106 | + | env[EnvInsecureHTTP] = "1" | |||
| 107 | + | c, err := LoadConfig(get, "h", "/s", 1, "") | |||
| 108 | + | if err != nil || !c.InsecureHTTP { | |||
| 109 | + | t.Fatalf("%+v %v", c, err) | |||
| 110 | + | } | |||
| 111 | + | env[EnvInsecureHTTP] = "maybe" | |||
| 112 | + | if _, err := LoadConfig(get, "h", "/s", 1, ""); err == nil { | |||
| 113 | + | t.Fatal("a bad value was accepted") | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestResolveRegisterToken(t *testing.T) { | |||
| 118 | + | env := map[string]string{} | |||
| 119 | + | get := func(k string) string { return env[k] } | |||
| 120 | + | if _, err := ResolveRegisterToken("", false, strings.NewReader(""), get); err == nil || !strings.Contains(err.Error(), EnvRegisterToken) { | |||
| 121 | + | t.Fatalf("%v", err) | |||
| 122 | + | } | |||
| 123 | + | if tok, _ := ResolveRegisterToken("from-flag", false, nil, get); tok != "from-flag" { | |||
| 124 | + | t.Fatal(tok) | |||
| 125 | + | } | |||
| 126 | + | env[EnvRegisterToken] = " from-env \n" | |||
| 127 | + | if tok, _ := ResolveRegisterToken("from-flag", false, nil, get); tok != "from-env" { | |||
| 128 | + | t.Fatalf("the environment must win over the flag: %q", tok) | |||
| 129 | + | } | |||
| 130 | + | if tok, _ := ResolveRegisterToken("from-flag", true, strings.NewReader("from-stdin\n"), get); tok != "from-stdin" { | |||
| 131 | + | t.Fatalf("standard input must win: %q", tok) | |||
| 132 | + | } | |||
| 133 | + | if _, err := ResolveRegisterToken("x", true, strings.NewReader(" \n"), get); err == nil { | |||
| 134 | + | t.Fatal("an empty standard input was accepted") | |||
| 135 | + | } | |||
| 136 | + | } | |||
A internal/host/runner.go +240 -0
| @@ -0,0 +1,240 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "crypto/rand" | |||
| 8 | + | "encoding/base64" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "runtime" | |||
| 12 | + | "sync" | |||
| 13 | + | "sync/atomic" | |||
| 14 | + | "time" | |||
| 15 | + | ||||
| 16 | + | "connectrpc.com/connect" | |||
| 17 | + | "github.com/bnema/zerowrap" | |||
| 18 | + | ||||
| 19 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 20 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 21 | + | ||||
| 22 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 23 | + | "bnema.dev/ermite-runner/internal/sandbox" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // Runner fetches jobs from ermite and runs each one in its own sandbox. | |||
| 27 | + | type Runner struct { | |||
| 28 | + | Cfg Config | |||
| 29 | + | Driver sandbox.Driver | |||
| 30 | + | // Poll is the client that carries the runner token (FetchJob). | |||
| 31 | + | Poll ermiteconnect.RunnerServiceClient | |||
| 32 | + | // Job builds the client of a job from its job token. | |||
| 33 | + | Job func(jobToken string) ermiteconnect.RunnerServiceClient | |||
| 34 | + | Timing Timing | |||
| 35 | + | ||||
| 36 | + | // now returns the current time; tests replace it. | |||
| 37 | + | now func() time.Time | |||
| 38 | + | ||||
| 39 | + | leaked atomic.Bool | |||
| 40 | + | active sync.WaitGroup | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (r *Runner) clock() time.Time { | |||
| 44 | + | if r.now != nil { | |||
| 45 | + | return r.now() | |||
| 46 | + | } | |||
| 47 | + | return time.Now() | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // spec is the sandbox request of a job. It names the job and leaves CPUs, | |||
| 51 | + | // memory and disk at zero: the driver fills them from its configuration | |||
| 52 | + | // (sandbox.Config.Resolve in Start), that is from ERMITE_RUNNER_JOB_CPUS, | |||
| 53 | + | // _JOB_MEMORY and _JOB_DISK, so that the limits are one setting of the runner | |||
| 54 | + | // and not something a job or this package can change. | |||
| 55 | + | func (r *Runner) spec(j *ermite.Job) sandbox.JobSpec { | |||
| 56 | + | return sandbox.JobSpec{JobID: j.JobId} | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // markLeaked records that a sandbox could not be cleaned up: no new job is | |||
| 60 | + | // taken until the running ones are over and Driver.Reap has succeeded. | |||
| 61 | + | func (r *Runner) markLeaked() { r.leaked.Store(true) } | |||
| 62 | + | ||||
| 63 | + | // ErrRemoved is returned by Run when ermite no longer knows the runner token: | |||
| 64 | + | // the runner was removed or replaced. | |||
| 65 | + | var ErrRemoved = errors.New("ermite rejected the runner token: the runner was removed or replaced; register it again") | |||
| 66 | + | ||||
| 67 | + | // newPollID is 16 random bytes in base64url: 22 characters of the set the | |||
| 68 | + | // protocol allows. | |||
| 69 | + | func newPollID() string { | |||
| 70 | + | var b [16]byte | |||
| 71 | + | _, _ = rand.Read(b[:]) | |||
| 72 | + | return base64.RawURLEncoding.EncodeToString(b[:]) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Run is the main loop. It returns nil after a graceful shutdown (ctx ended and | |||
| 76 | + | // every running job reported), or an error when the runner cannot go on. | |||
| 77 | + | // | |||
| 78 | + | // Capacity: a poll is made only when a slot is free. A lost response is retried | |||
| 79 | + | // with the same poll_id, so that the server hands back the same assignment. | |||
| 80 | + | func (r *Runner) Run(ctx context.Context) error { | |||
| 81 | + | log := zerowrap.FromCtx(ctx) | |||
| 82 | + | t := r.Timing.withDefaults() | |||
| 83 | + | shutdown := make(chan struct{}) | |||
| 84 | + | slots := make(chan struct{}, max(1, r.Cfg.Capacity)) | |||
| 85 | + | defer func() { | |||
| 86 | + | // Graceful shutdown: running jobs are cancelled with runner_shutdown and | |||
| 87 | + | // get ShutdownGrace to stop; Run returns once they have reported. | |||
| 88 | + | close(shutdown) | |||
| 89 | + | r.active.Wait() | |||
| 90 | + | }() | |||
| 91 | + | ||||
| 92 | + | if err := r.Driver.Reap(ctx); err != nil && !errors.Is(err, sandbox.ErrBusy) { | |||
| 93 | + | log.Error().Err(err).Msg("reaping what a previous run left") | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | var pollID string | |||
| 97 | + | bo := backoff{min: t.BackoffMin, max: t.BackoffMax} | |||
| 98 | + | for ctx.Err() == nil { | |||
| 99 | + | // Wait for a free slot, and for the end of a leak. | |||
| 100 | + | select { | |||
| 101 | + | case slots <- struct{}{}: | |||
| 102 | + | case <-ctx.Done(): | |||
| 103 | + | return nil | |||
| 104 | + | } | |||
| 105 | + | if r.leaked.Load() { | |||
| 106 | + | <-slots | |||
| 107 | + | if !r.recover(ctx, log, slots, t.ReapRetry) { | |||
| 108 | + | return nil | |||
| 109 | + | } | |||
| 110 | + | continue | |||
| 111 | + | } | |||
| 112 | + | if pollID == "" { | |||
| 113 | + | pollID = newPollID() | |||
| 114 | + | } | |||
| 115 | + | pctx, cancel := context.WithTimeout(ctx, t.PollTimeout) | |||
| 116 | + | resp, err := r.Poll.FetchJob(pctx, connect.NewRequest(&ermite.FetchJobRequest{PollId: pollID})) | |||
| 117 | + | cancel() | |||
| 118 | + | if err != nil { | |||
| 119 | + | <-slots | |||
| 120 | + | if ctx.Err() != nil { | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | if connect.CodeOf(err) == connect.CodeUnauthenticated { | |||
| 124 | + | return ErrRemoved | |||
| 125 | + | } | |||
| 126 | + | if connect.CodeOf(err) == connect.CodeInvalidArgument { | |||
| 127 | + | pollID = "" // our id was refused: a new one | |||
| 128 | + | } | |||
| 129 | + | d := bo.next() | |||
| 130 | + | log.Warn().Err(err).Dur("retry_in", d).Msg("FetchJob failed") | |||
| 131 | + | if !sleep(ctx, d) { | |||
| 132 | + | return nil | |||
| 133 | + | } | |||
| 134 | + | continue | |||
| 135 | + | } | |||
| 136 | + | bo.cur = 0 | |||
| 137 | + | pollID = "" // answered: the next poll is a new one | |||
| 138 | + | if resp.Msg.Job == nil { | |||
| 139 | + | <-slots | |||
| 140 | + | d := t.NoJobPause | |||
| 141 | + | if ra := resp.Msg.RetryAfter; ra != nil && ra.AsDuration() > d { | |||
| 142 | + | d = ra.AsDuration() | |||
| 143 | + | } | |||
| 144 | + | if !sleep(ctx, d) { | |||
| 145 | + | return nil | |||
| 146 | + | } | |||
| 147 | + | continue | |||
| 148 | + | } | |||
| 149 | + | r.startJob(ctx, log, resp.Msg.Job, t, shutdown, slots) | |||
| 150 | + | if ra := resp.Msg.RetryAfter; ra != nil && ra.AsDuration() > 0 { | |||
| 151 | + | if !sleep(ctx, ra.AsDuration()) { | |||
| 152 | + | return nil | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | return nil | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // recover waits until no job runs, then reaps. It returns false when ctx ends. | |||
| 160 | + | func (r *Runner) recover(ctx context.Context, log zerowrap.Logger, slots chan struct{}, retry time.Duration) bool { | |||
| 161 | + | log.Error().Msg("a sandbox could not be cleaned up: no new job until the running ones end and the leftovers are reaped") | |||
| 162 | + | for ctx.Err() == nil { | |||
| 163 | + | if len(slots) == 0 { | |||
| 164 | + | if err := r.Driver.Reap(ctx); err == nil { | |||
| 165 | + | r.leaked.Store(false) | |||
| 166 | + | log.Info().Msg("reaped: taking jobs again") | |||
| 167 | + | return true | |||
| 168 | + | } else if !errors.Is(err, sandbox.ErrBusy) { | |||
| 169 | + | log.Error().Err(err).Msg("reaping failed, retrying") | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | if !sleep(ctx, retry) { | |||
| 173 | + | return false | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | return false | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func (r *Runner) startJob(ctx context.Context, log zerowrap.Logger, job *ermite.Job, t Timing, shutdown <-chan struct{}, slots chan struct{}) { | |||
| 180 | + | jctx, cancel := context.WithCancelCause(context.WithoutCancel(ctx)) | |||
| 181 | + | jr := &jobRun{ | |||
| 182 | + | r: r, job: job, client: r.Job(job.JobToken), t: t, ctx: jctx, cancel: cancel, | |||
| 183 | + | log: zerowrap.Logger{Logger: log.With().Str("job_id", job.JobId).Int64("run_id", job.RunId).Int32("attempt", job.Attempt).Logger()}, | |||
| 184 | + | receivedAt: r.clock(), cancelCh: make(chan *execpb.Cancel, 1), steps: map[int32]*ermite.StepUpdate{}, | |||
| 185 | + | } | |||
| 186 | + | jr.log.Info().Str("job_key", job.JobKey).Msg("job assigned") | |||
| 187 | + | r.active.Add(1) | |||
| 188 | + | go func() { | |||
| 189 | + | defer r.active.Done() | |||
| 190 | + | defer func() { <-slots }() | |||
| 191 | + | jr.run(r, shutdown) | |||
| 192 | + | jr.log.Info().Msg("job finished") | |||
| 193 | + | }() | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // ---- registration | |||
| 197 | + | ||||
| 198 | + | // Isolation maps the driver's isolation to the protocol's. | |||
| 199 | + | func Isolation(i sandbox.Isolation) ermite.RunnerIsolation { | |||
| 200 | + | switch i { | |||
| 201 | + | case sandbox.IsolationVM: | |||
| 202 | + | return ermite.RunnerIsolation_RUNNER_ISOLATION_VM | |||
| 203 | + | case sandbox.IsolationContainerSysbox: | |||
| 204 | + | return ermite.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_SYSBOX | |||
| 205 | + | case sandbox.IsolationContainerPrivileged: | |||
| 206 | + | return ermite.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_PRIVILEGED | |||
| 207 | + | } | |||
| 208 | + | return ermite.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // GoArch is the architecture in GitHub's names. | |||
| 212 | + | func GoArch() string { | |||
| 213 | + | if runtime.GOARCH == "arm64" { | |||
| 214 | + | return "arm64" | |||
| 215 | + | } | |||
| 216 | + | return "x64" | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // Register registers the runner with ermite using a token that has the | |||
| 220 | + | // runner:register scope, and stores the runner token (mode 0600) in the state | |||
| 221 | + | // directory. replace removes a runner of the same name first. | |||
| 222 | + | func Register(ctx context.Context, cfg Config, client ermiteconnect.RunnerServiceClient, isolation sandbox.Isolation, replace bool) (State, error) { | |||
| 223 | + | iso := Isolation(isolation) | |||
| 224 | + | if iso == ermite.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED { | |||
| 225 | + | return State{}, fmt.Errorf("unknown isolation %q", isolation) | |||
| 226 | + | } | |||
| 227 | + | rctx, cancel := context.WithTimeout(ctx, 30*time.Second) | |||
| 228 | + | defer cancel() | |||
| 229 | + | resp, err := client.RegisterRunner(rctx, connect.NewRequest(&ermite.RegisterRunnerRequest{ | |||
| 230 | + | Name: cfg.Name, Labels: cfg.Labels, Version: cfg.Version, Isolation: iso, Arch: GoArch(), Os: "linux", Replace: replace, | |||
| 231 | + | })) | |||
| 232 | + | if err != nil { | |||
| 233 | + | return State{}, fmt.Errorf("register: %w", err) | |||
| 234 | + | } | |||
| 235 | + | st := State{URL: cfg.URL, RunnerID: resp.Msg.RunnerId, Name: cfg.Name, RunnerToken: resp.Msg.RunnerToken} | |||
| 236 | + | if err := SaveState(cfg.StateDir, st); err != nil { | |||
| 237 | + | return State{}, fmt.Errorf("the runner is registered but its token could not be stored: %w", err) | |||
| 238 | + | } | |||
| 239 | + | return st, nil | |||
| 240 | + | } | |||
A internal/host/runner_test.go +1316 -0
| @@ -0,0 +1,1316 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "errors" | |||
| 9 | + | "io" | |||
| 10 | + | "net/http" | |||
| 11 | + | "os" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "regexp" | |||
| 14 | + | "strconv" | |||
| 15 | + | "strings" | |||
| 16 | + | "testing" | |||
| 17 | + | "time" | |||
| 18 | + | "unicode/utf8" | |||
| 19 | + | ||||
| 20 | + | "google.golang.org/protobuf/types/known/timestamppb" | |||
| 21 | + | ||||
| 22 | + | "connectrpc.com/connect" | |||
| 23 | + | ||||
| 24 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 25 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 26 | + | ||||
| 27 | + | "bnema.dev/ermite-runner/internal/execstream" | |||
| 28 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 29 | + | "bnema.dev/ermite-runner/internal/sandbox" | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | func TestJobRunsAndReports(t *testing.T) { | |||
| 33 | + | h := newHarness(t, simpleScript("hello", "world")) | |||
| 34 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 35 | + | stop := h.run(t) | |||
| 36 | + | ||||
| 37 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 38 | + | if err := stop(); err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | s := h.srv | |||
| 42 | + | s.mu.Lock() | |||
| 43 | + | defer s.mu.Unlock() | |||
| 44 | + | c := s.completes[0] | |||
| 45 | + | if c.Conclusion != ermite.CiConclusion_CI_CONCLUSION_SUCCESS || c.Outputs["out"] != "v" || c.RunnerShutdown { | |||
| 46 | + | t.Fatalf("%+v", c) | |||
| 47 | + | } | |||
| 48 | + | if len(s.beats) == 0 { | |||
| 49 | + | t.Error("no heartbeat: the first one is the acknowledgement") | |||
| 50 | + | } | |||
| 51 | + | if strings.Join(s.lines, "|") != "hello|world" { | |||
| 52 | + | t.Errorf("lines %q", s.lines) | |||
| 53 | + | } | |||
| 54 | + | for n, name := range map[int32]string{1: "Set up job", 2: "Run it", 3: "Complete job"} { | |||
| 55 | + | u := s.steps[n] | |||
| 56 | + | if u == nil || u.Name != name || u.State != ermite.StepState_STEP_STATE_COMPLETED || u.Conclusion != ermite.CiConclusion_CI_CONCLUSION_SUCCESS || u.StartedAt == nil || u.CompletedAt == nil { | |||
| 57 | + | t.Errorf("step %d: %v", n, u) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | if len(c.Steps) != 3 { | |||
| 61 | + | t.Errorf("CompleteJob carries %d final steps", len(c.Steps)) | |||
| 62 | + | } | |||
| 63 | + | if h.driver.stops != 1 || h.driver.reaps < 1 { | |||
| 64 | + | t.Errorf("stops=%d reaps=%d: the sandbox must be stopped and the start must reap", h.driver.stops, h.driver.reaps) | |||
| 65 | + | } | |||
| 66 | + | if strings.Contains(h.logs.String(), "ermitejob_") || strings.Contains(h.logs.String(), "ermiterun_") { | |||
| 67 | + | t.Errorf("a token reached the log: %s", h.logs.String()) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestTheJobTokenAndTheRunnerTokenStayApart(t *testing.T) { | |||
| 72 | + | h := newHarness(t, simpleScript("x")) | |||
| 73 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 74 | + | var sawJobSpec *execpb.JobSpec | |||
| 75 | + | inner := h.driver.script | |||
| 76 | + | h.driver.script = func(t *testing.T, c *execstream.Conn, run *execpb.Run, cn <-chan *execpb.Cancel) { | |||
| 77 | + | sawJobSpec = run.Job | |||
| 78 | + | inner(t, c, run, cn) | |||
| 79 | + | } | |||
| 80 | + | h.run(t) | |||
| 81 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 82 | + | // The sandbox gets the workflow and contexts, never a token. | |||
| 83 | + | if sawJobSpec == nil || sawJobSpec.JobKey != "j" || sawJobSpec.RunnerName != "r1" || sawJobSpec.RunnerEnvironment != "self-hosted" || sawJobSpec.TimeoutMinutes != 5 { | |||
| 84 | + | t.Fatalf("%v", sawJobSpec) | |||
| 85 | + | } | |||
| 86 | + | if strings.Contains(sawJobSpec.String(), "ermitejob_") || strings.Contains(sawJobSpec.String(), "ermiterun_") { | |||
| 87 | + | t.Fatal("a token reached the sandbox") | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestPollRetryUsesTheSamePollIDAfterAnError(t *testing.T) { | |||
| 92 | + | h := newHarness(t, simpleScript("x")) | |||
| 93 | + | h.srv.pollHook = func(n int, req *ermite.FetchJobRequest) (*ermite.FetchJobResponse, error) { | |||
| 94 | + | if n <= 2 { // the response is lost twice | |||
| 95 | + | return nil, connect.NewError(connect.CodeUnavailable, io.EOF) | |||
| 96 | + | } | |||
| 97 | + | if n == 3 { | |||
| 98 | + | return &ermite.FetchJobResponse{Job: testJob("j1")}, nil | |||
| 99 | + | } | |||
| 100 | + | return nil, nil | |||
| 101 | + | } | |||
| 102 | + | h.run(t) | |||
| 103 | + | eventually(t, "CompleteJob and the next poll", func() bool { | |||
| 104 | + | h.srv.mu.Lock() | |||
| 105 | + | defer h.srv.mu.Unlock() | |||
| 106 | + | return len(h.srv.completes) == 1 && len(h.srv.polls) >= 4 | |||
| 107 | + | }) | |||
| 108 | + | h.srv.mu.Lock() | |||
| 109 | + | defer h.srv.mu.Unlock() | |||
| 110 | + | if len(h.srv.polls) < 4 { | |||
| 111 | + | t.Fatalf("polls %v", h.srv.polls) | |||
| 112 | + | } | |||
| 113 | + | if h.srv.polls[0] == "" || h.srv.polls[0] != h.srv.polls[1] || h.srv.polls[1] != h.srv.polls[2] { | |||
| 114 | + | t.Errorf("a retried poll must keep its poll_id: %v", h.srv.polls) | |||
| 115 | + | } | |||
| 116 | + | if h.srv.polls[3] == h.srv.polls[2] { | |||
| 117 | + | t.Errorf("an answered poll is followed by a new id: %v", h.srv.polls) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func TestPollRespectsRetryAfter(t *testing.T) { | |||
| 122 | + | h := newHarness(t, simpleScript("x")) | |||
| 123 | + | h.srv.retryAfter = 300 * time.Millisecond | |||
| 124 | + | h.run(t) | |||
| 125 | + | time.Sleep(1000 * time.Millisecond) | |||
| 126 | + | h.srv.mu.Lock() | |||
| 127 | + | n := h.srv.pollCalls | |||
| 128 | + | h.srv.mu.Unlock() | |||
| 129 | + | if n < 2 || n > 5 { | |||
| 130 | + | t.Fatalf("%d polls in 1 s with retry_after=300ms", n) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestPollRemovedRunnerStops(t *testing.T) { | |||
| 135 | + | h := newHarness(t, simpleScript("x")) | |||
| 136 | + | h.runner.Poll = NewClient(h.url, "ermiterun_wrong", http.DefaultTransport) | |||
| 137 | + | err := h.runner.Run(h.logs.ctx()) | |||
| 138 | + | if !errors.Is(err, ErrRemoved) { | |||
| 139 | + | t.Fatalf("%v", err) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestNoPollWithoutFreeCapacity(t *testing.T) { | |||
| 144 | + | release := make(chan struct{}) | |||
| 145 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 146 | + | stepStart(c, 1, "Set up job") | |||
| 147 | + | <-release | |||
| 148 | + | stepDone(c, 1, "Set up job", cOK) | |||
| 149 | + | jobDone(c, cOK, nil, false) | |||
| 150 | + | }) | |||
| 151 | + | h.srv.jobs = []*ermite.Job{testJob("j1"), testJob("j2")} | |||
| 152 | + | h.run(t) | |||
| 153 | + | eventually(t, "first job started", func() bool { h.driver.mu.Lock(); defer h.driver.mu.Unlock(); return h.driver.started == 1 }) | |||
| 154 | + | time.Sleep(300 * time.Millisecond) | |||
| 155 | + | h.srv.mu.Lock() | |||
| 156 | + | calls := h.srv.pollCalls | |||
| 157 | + | h.srv.mu.Unlock() | |||
| 158 | + | if calls != 1 { | |||
| 159 | + | t.Fatalf("polled %d times with the only slot busy", calls) | |||
| 160 | + | } | |||
| 161 | + | h.driver.mu.Lock() | |||
| 162 | + | if h.driver.started != 1 { | |||
| 163 | + | t.Errorf("started=%d", h.driver.started) | |||
| 164 | + | } | |||
| 165 | + | h.driver.mu.Unlock() | |||
| 166 | + | close(release) | |||
| 167 | + | eventually(t, "both jobs done", func() bool { return h.srv.completeCount() == 2 }) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestCapacityTwoRunsTwoJobsAtOnce(t *testing.T) { | |||
| 171 | + | release := make(chan struct{}) | |||
| 172 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 173 | + | stepStart(c, 1, "Set up job") | |||
| 174 | + | <-release | |||
| 175 | + | stepDone(c, 1, "Set up job", cOK) | |||
| 176 | + | jobDone(c, cOK, nil, false) | |||
| 177 | + | }) | |||
| 178 | + | h.runner.Cfg.Capacity = 2 | |||
| 179 | + | h.srv.jobs = []*ermite.Job{testJob("j1"), testJob("j2"), testJob("j3")} | |||
| 180 | + | h.run(t) | |||
| 181 | + | eventually(t, "two sandboxes", func() bool { h.driver.mu.Lock(); defer h.driver.mu.Unlock(); return h.driver.started == 2 }) | |||
| 182 | + | time.Sleep(200 * time.Millisecond) | |||
| 183 | + | h.driver.mu.Lock() | |||
| 184 | + | if h.driver.started != 2 { | |||
| 185 | + | t.Errorf("started=%d with capacity 2", h.driver.started) | |||
| 186 | + | } | |||
| 187 | + | h.driver.mu.Unlock() | |||
| 188 | + | close(release) | |||
| 189 | + | eventually(t, "three jobs done", func() bool { return h.srv.completeCount() == 3 }) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func TestFirstHeartbeatRetriesEvery5sStyle(t *testing.T) { | |||
| 193 | + | h := newHarness(t, simpleScript("x")) | |||
| 194 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 195 | + | h.srv.beatHook = func(n int) error { | |||
| 196 | + | if n <= 2 { | |||
| 197 | + | return connect.NewError(connect.CodeUnavailable, io.EOF) | |||
| 198 | + | } | |||
| 199 | + | return nil | |||
| 200 | + | } | |||
| 201 | + | h.run(t) | |||
| 202 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 203 | + | h.srv.mu.Lock() | |||
| 204 | + | defer h.srv.mu.Unlock() | |||
| 205 | + | if len(h.srv.beats) < 3 { | |||
| 206 | + | t.Fatalf("beats %d", len(h.srv.beats)) | |||
| 207 | + | } | |||
| 208 | + | // The job starts only after the acknowledgement: no log before the third beat. | |||
| 209 | + | if gap := h.srv.beats[1].Sub(h.srv.beats[0]); gap > 90*time.Millisecond { | |||
| 210 | + | t.Errorf("retry after %v, want about HeartbeatRetry (30ms), not the 100ms period", gap) | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func TestHeartbeatTicksAndRetriesQuickly(t *testing.T) { | |||
| 215 | + | release := make(chan struct{}) | |||
| 216 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 217 | + | stepStart(c, 1, "Set up job") | |||
| 218 | + | <-release | |||
| 219 | + | stepDone(c, 1, "Set up job", cOK) | |||
| 220 | + | jobDone(c, cOK, nil, false) | |||
| 221 | + | }) | |||
| 222 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 223 | + | h.srv.beatHook = func(n int) error { | |||
| 224 | + | if n == 3 { // one failure in the middle of the job | |||
| 225 | + | return connect.NewError(connect.CodeUnavailable, io.EOF) | |||
| 226 | + | } | |||
| 227 | + | return nil | |||
| 228 | + | } | |||
| 229 | + | h.run(t) | |||
| 230 | + | eventually(t, "five heartbeats", func() bool { h.srv.mu.Lock(); defer h.srv.mu.Unlock(); return len(h.srv.beats) >= 5 }) | |||
| 231 | + | h.srv.mu.Lock() | |||
| 232 | + | b := append([]time.Time(nil), h.srv.beats...) | |||
| 233 | + | h.srv.mu.Unlock() | |||
| 234 | + | if d := b[2].Sub(b[1]); d < 80*time.Millisecond { | |||
| 235 | + | t.Errorf("period %v, want about 100ms", d) | |||
| 236 | + | } | |||
| 237 | + | if d := b[3].Sub(b[2]); d > 80*time.Millisecond { | |||
| 238 | + | t.Errorf("the retry after a failure came after %v, want about 30ms", d) | |||
| 239 | + | } | |||
| 240 | + | close(release) | |||
| 241 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func TestCancelFromWatchJobReachesTheExecutor(t *testing.T) { | |||
| 245 | + | got := make(chan *execpb.Cancel, 1) | |||
| 246 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, cancels <-chan *execpb.Cancel) { | |||
| 247 | + | stepStart(c, 1, "Set up job") | |||
| 248 | + | stepStart(c, 2, "Run sleep") | |||
| 249 | + | logLineFrame(c, 2, "working", false) | |||
| 250 | + | cn := <-cancels | |||
| 251 | + | got <- cn | |||
| 252 | + | stepDone(c, 2, "Run sleep", cCancel) | |||
| 253 | + | jobDone(c, cCancel, nil, false) | |||
| 254 | + | }) | |||
| 255 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 256 | + | h.run(t) | |||
| 257 | + | eventually(t, "the job to run", func() bool { return len(h.srv.snapshotLines()) == 1 }) | |||
| 258 | + | h.srv.watchMsgs <- &ermite.WatchJobResponse{Msg: &ermite.WatchJobResponse_Keepalive{Keepalive: &ermite.Keepalive{}}} | |||
| 259 | + | h.srv.watchMsgs <- &ermite.WatchJobResponse{Msg: &ermite.WatchJobResponse_Cancel{Cancel: &ermite.CancelRequest{Reason: ermite.CancelReason_CANCEL_REASON_USER, Message: "Canceled by the owner"}}} | |||
| 260 | + | select { | |||
| 261 | + | case cn := <-got: | |||
| 262 | + | if cn.Reason != "Canceled by the owner" || cn.Kind != execpb.CancelKind_CANCEL_KIND_USER { | |||
| 263 | + | t.Fatalf("%v", cn) | |||
| 264 | + | } | |||
| 265 | + | case <-time.After(5 * time.Second): | |||
| 266 | + | t.Fatal("the cancel did not reach the executor") | |||
| 267 | + | } | |||
| 268 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 269 | + | c := h.srv.completes[0] | |||
| 270 | + | if c.Conclusion != ermite.CiConclusion_CI_CONCLUSION_CANCELLED || c.RunnerShutdown { | |||
| 271 | + | t.Fatalf("%v", c) | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func TestTimeoutCancelKind(t *testing.T) { | |||
| 276 | + | got := make(chan *execpb.Cancel, 1) | |||
| 277 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, cancels <-chan *execpb.Cancel) { | |||
| 278 | + | stepStart(c, 1, "Set up job") | |||
| 279 | + | logLineFrame(c, 1, "x", false) | |||
| 280 | + | got <- <-cancels | |||
| 281 | + | jobDone(c, execpb.StepConclusion_STEP_CONCLUSION_TIMED_OUT, nil, false) | |||
| 282 | + | }) | |||
| 283 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 284 | + | h.run(t) | |||
| 285 | + | eventually(t, "log", func() bool { return len(h.srv.snapshotLines()) == 1 }) | |||
| 286 | + | h.srv.watchMsgs <- &ermite.WatchJobResponse{Msg: &ermite.WatchJobResponse_Cancel{Cancel: &ermite.CancelRequest{Reason: ermite.CancelReason_CANCEL_REASON_TIMEOUT}}} | |||
| 287 | + | if cn := <-got; cn.Kind != execpb.CancelKind_CANCEL_KIND_TIMEOUT { | |||
| 288 | + | t.Fatalf("%v", cn) | |||
| 289 | + | } | |||
| 290 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 291 | + | if c := h.srv.completes[0]; c.Conclusion != ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT { | |||
| 292 | + | t.Fatalf("%v", c.Conclusion) | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func TestWatchJobIsReopenedAfterAnError(t *testing.T) { | |||
| 297 | + | release := make(chan struct{}) | |||
| 298 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, cancels <-chan *execpb.Cancel) { | |||
| 299 | + | stepStart(c, 1, "Set up job") | |||
| 300 | + | <-release | |||
| 301 | + | jobDone(c, cOK, nil, false) | |||
| 302 | + | }) | |||
| 303 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 304 | + | h.run(t) | |||
| 305 | + | eventually(t, "the first watch", func() bool { h.srv.mu.Lock(); defer h.srv.mu.Unlock(); return h.srv.watches >= 1 }) | |||
| 306 | + | // A message that ends the stream cleanly without Ended: the runner reopens. | |||
| 307 | + | // The fake closes the stream only on Ended or context end, so cut the | |||
| 308 | + | // transport instead: close idle connections of the test server. | |||
| 309 | + | http.DefaultTransport.(*http.Transport).CloseIdleConnections() | |||
| 310 | + | close(release) | |||
| 311 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func TestLogBatchesRespectTheLimitsAndKeepOrder(t *testing.T) { | |||
| 315 | + | var lines []string | |||
| 316 | + | big := strings.Repeat("x", 1000) | |||
| 317 | + | for i := 0; i < 2500; i++ { | |||
| 318 | + | lines = append(lines, big[:1+i%1000]) | |||
| 319 | + | } | |||
| 320 | + | h := newHarness(t, simpleScript(lines...)) | |||
| 321 | + | h.runner.Timing.LogFlush = 50 * time.Millisecond | |||
| 322 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 323 | + | h.run(t) | |||
| 324 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 325 | + | h.srv.mu.Lock() | |||
| 326 | + | defer h.srv.mu.Unlock() | |||
| 327 | + | if len(h.srv.lines) != 2500 { | |||
| 328 | + | t.Fatalf("the server holds %d lines", len(h.srv.lines)) | |||
| 329 | + | } | |||
| 330 | + | for i, l := range h.srv.lines { | |||
| 331 | + | if len(l) != 1+(i)%1000 { | |||
| 332 | + | t.Fatalf("line %d out of order or wrong", i) | |||
| 333 | + | } | |||
| 334 | + | } | |||
| 335 | + | var next int64 | |||
| 336 | + | for _, r := range h.srv.appendReq { | |||
| 337 | + | if len(r.Lines) > 1000 { | |||
| 338 | + | t.Fatalf("batch of %d lines", len(r.Lines)) | |||
| 339 | + | } | |||
| 340 | + | n := 0 | |||
| 341 | + | for _, l := range r.Lines { | |||
| 342 | + | n += len(l.Text) | |||
| 343 | + | } | |||
| 344 | + | if n > 64*1024 { | |||
| 345 | + | t.Fatalf("batch of %d bytes", n) | |||
| 346 | + | } | |||
| 347 | + | if r.Offset != next { | |||
| 348 | + | t.Fatalf("offset %d, want %d", r.Offset, next) | |||
| 349 | + | } | |||
| 350 | + | next += int64(len(r.Lines)) | |||
| 351 | + | } | |||
| 352 | + | if next != 2500 { | |||
| 353 | + | t.Fatalf("offsets end at %d", next) | |||
| 354 | + | } | |||
| 355 | + | // Batches never mix steps. | |||
| 356 | + | for _, r := range h.srv.appendReq { | |||
| 357 | + | if r.Step < 1 { | |||
| 358 | + | t.Fatalf("step %d", r.Step) | |||
| 359 | + | } | |||
| 360 | + | } | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | func TestLogStepsAreSeparateRequests(t *testing.T) { | |||
| 364 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 365 | + | for n := 1; n <= 3; n++ { | |||
| 366 | + | stepStart(c, n, "s") | |||
| 367 | + | logLineFrame(c, n, "a", false) | |||
| 368 | + | logLineFrame(c, n, "b", false) | |||
| 369 | + | stepDone(c, n, "s", cOK) | |||
| 370 | + | } | |||
| 371 | + | jobDone(c, cOK, nil, false) | |||
| 372 | + | }) | |||
| 373 | + | h.runner.Timing.LogFlush = 50 * time.Millisecond | |||
| 374 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 375 | + | h.run(t) | |||
| 376 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 377 | + | h.srv.mu.Lock() | |||
| 378 | + | defer h.srv.mu.Unlock() | |||
| 379 | + | if len(h.srv.appendReq) != 3 { | |||
| 380 | + | t.Fatalf("%d requests, want one per step", len(h.srv.appendReq)) | |||
| 381 | + | } | |||
| 382 | + | for i, r := range h.srv.appendReq { | |||
| 383 | + | if r.Step != int32(i+1) || r.Offset != int64(2*i) || len(r.Lines) != 2 { | |||
| 384 | + | t.Errorf("request %d: step %d offset %d lines %d", i, r.Step, r.Offset, len(r.Lines)) | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | func TestContinuedLinesAreForwarded(t *testing.T) { | |||
| 390 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 391 | + | stepStart(c, 1, "s") | |||
| 392 | + | logLineFrame(c, 1, "first", false) | |||
| 393 | + | logLineFrame(c, 1, "piece1", false) | |||
| 394 | + | logLineFrame(c, 1, "piece2", true) | |||
| 395 | + | stepDone(c, 1, "s", cOK) | |||
| 396 | + | jobDone(c, cOK, nil, false) | |||
| 397 | + | }) | |||
| 398 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 399 | + | h.run(t) | |||
| 400 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 401 | + | h.srv.mu.Lock() | |||
| 402 | + | defer h.srv.mu.Unlock() | |||
| 403 | + | if len(h.srv.cont) != 3 || h.srv.cont[0] || h.srv.cont[1] || !h.srv.cont[2] { | |||
| 404 | + | t.Fatalf("%v", h.srv.cont) | |||
| 405 | + | } | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | func TestAppendLogRetriesAreIdempotent(t *testing.T) { | |||
| 409 | + | h := newHarness(t, simpleScript("l1", "l2", "l3")) | |||
| 410 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 411 | + | // The first AppendLog is applied but its response is lost; the second fails | |||
| 412 | + | // before being applied. The runner resends the same lines at the same offset. | |||
| 413 | + | h.srv.appendHook = func(n int, _ *ermite.AppendLogRequest) (bool, error) { | |||
| 414 | + | switch n { | |||
| 415 | + | case 1: | |||
| 416 | + | return true, connect.NewError(connect.CodeUnavailable, io.EOF) | |||
| 417 | + | case 2: | |||
| 418 | + | return false, connect.NewError(connect.CodeInternal, io.EOF) | |||
| 419 | + | } | |||
| 420 | + | return false, nil | |||
| 421 | + | } | |||
| 422 | + | h.run(t) | |||
| 423 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 424 | + | h.srv.mu.Lock() | |||
| 425 | + | defer h.srv.mu.Unlock() | |||
| 426 | + | if strings.Join(h.srv.lines, "|") != "l1|l2|l3" { | |||
| 427 | + | t.Fatalf("lines %q: a retry must neither lose nor duplicate", h.srv.lines) | |||
| 428 | + | } | |||
| 429 | + | if h.srv.appendN < 3 { | |||
| 430 | + | t.Fatalf("only %d calls", h.srv.appendN) | |||
| 431 | + | } | |||
| 432 | + | first := h.srv.appendReq[0] | |||
| 433 | + | for _, r := range h.srv.appendReq[1:3] { | |||
| 434 | + | if r.Offset != first.Offset || len(r.Lines) != len(first.Lines) { | |||
| 435 | + | t.Errorf("a retry changed the request: offset %d/%d lines %d/%d", r.Offset, first.Offset, len(r.Lines), len(first.Lines)) | |||
| 436 | + | } | |||
| 437 | + | } | |||
| 438 | + | } | |||
| 439 | + | ||||
| 440 | + | func TestStepUpdatesAreRetried(t *testing.T) { | |||
| 441 | + | h := newHarness(t, simpleScript("x")) | |||
| 442 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 443 | + | h.run(t) | |||
| 444 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 445 | + | // UpdateJob snapshots carry the state at each step boundary. | |||
| 446 | + | h.srv.mu.Lock() | |||
| 447 | + | defer h.srv.mu.Unlock() | |||
| 448 | + | if u := h.srv.steps[2]; u == nil || u.State != ermite.StepState_STEP_STATE_COMPLETED { | |||
| 449 | + | t.Fatalf("%v", u) | |||
| 450 | + | } | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | func TestCompleteJobIsRetriedAndIdempotent(t *testing.T) { | |||
| 454 | + | h := newHarness(t, simpleScript("x")) | |||
| 455 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 456 | + | h.srv.completeHook = func(n int) (bool, error) { | |||
| 457 | + | switch n { | |||
| 458 | + | case 1: // applied, response lost | |||
| 459 | + | return true, connect.NewError(connect.CodeUnavailable, io.EOF) | |||
| 460 | + | case 2: // not even reached | |||
| 461 | + | return false, connect.NewError(connect.CodeInternal, io.EOF) | |||
| 462 | + | } | |||
| 463 | + | return false, nil | |||
| 464 | + | } | |||
| 465 | + | h.run(t) | |||
| 466 | + | eventually(t, "three CompleteJob calls", func() bool { return h.srv.completeCount() == 3 }) | |||
| 467 | + | time.Sleep(100 * time.Millisecond) | |||
| 468 | + | if n := h.srv.completeCount(); n != 3 { | |||
| 469 | + | t.Fatalf("%d calls", n) | |||
| 470 | + | } | |||
| 471 | + | h.srv.mu.Lock() | |||
| 472 | + | defer h.srv.mu.Unlock() | |||
| 473 | + | a, b := h.srv.completes[0], h.srv.completes[2] | |||
| 474 | + | if a.Conclusion != b.Conclusion || a.Outputs["out"] != b.Outputs["out"] || len(a.Steps) != len(b.Steps) { | |||
| 475 | + | t.Error("a retry must repeat the same result") | |||
| 476 | + | } | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | func TestJobEndedStopsEverything(t *testing.T) { | |||
| 480 | + | release := make(chan struct{}) | |||
| 481 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, cancels <-chan *execpb.Cancel) { | |||
| 482 | + | stepStart(c, 1, "Set up job") | |||
| 483 | + | logLineFrame(c, 1, "before", false) | |||
| 484 | + | <-release | |||
| 485 | + | logLineFrame(c, 1, "after", false) | |||
| 486 | + | stepDone(c, 1, "Set up job", cOK) | |||
| 487 | + | jobDone(c, cOK, nil, false) | |||
| 488 | + | }) | |||
| 489 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 490 | + | h.run(t) | |||
| 491 | + | eventually(t, "first line", func() bool { return len(h.srv.snapshotLines()) == 1 }) | |||
| 492 | + | // The server ends the job (lost runner, log limit, ...): every call now says so. | |||
| 493 | + | h.srv.mu.Lock() | |||
| 494 | + | h.srv.ended = &ermite.JobEnded{Conclusion: ermite.CiConclusion_CI_CONCLUSION_CANCELLED, Error: "ended by the server"} | |||
| 495 | + | h.srv.mu.Unlock() | |||
| 496 | + | h.srv.watchMsgs <- &ermite.WatchJobResponse{Msg: &ermite.WatchJobResponse_Ended{Ended: &ermite.JobEnded{Conclusion: ermite.CiConclusion_CI_CONCLUSION_CANCELLED}}} | |||
| 497 | + | eventually(t, "the sandbox to be stopped", func() bool { h.driver.mu.Lock(); defer h.driver.mu.Unlock(); return h.driver.stops == 1 }) | |||
| 498 | + | close(release) | |||
| 499 | + | time.Sleep(300 * time.Millisecond) | |||
| 500 | + | if n := h.srv.completeCount(); n != 0 { | |||
| 501 | + | t.Fatalf("CompleteJob was called %d times for a job the server ended", n) | |||
| 502 | + | } | |||
| 503 | + | if got := h.srv.snapshotLines(); len(got) != 1 { | |||
| 504 | + | t.Fatalf("lines after the end: %q", got) | |||
| 505 | + | } | |||
| 506 | + | } | |||
| 507 | + | ||||
| 508 | + | func TestJobEndedFromAppendLogAbandonsTheJob(t *testing.T) { | |||
| 509 | + | release := make(chan struct{}) | |||
| 510 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 511 | + | stepStart(c, 1, "Set up job") | |||
| 512 | + | logLineFrame(c, 1, "a", false) | |||
| 513 | + | <-release | |||
| 514 | + | jobDone(c, cOK, nil, false) | |||
| 515 | + | }) | |||
| 516 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 517 | + | h.srv.appendHook = func(n int, _ *ermite.AppendLogRequest) (bool, error) { | |||
| 518 | + | return false, endedErr(&ermite.JobEnded{Conclusion: ermite.CiConclusion_CI_CONCLUSION_FAILURE, Error: "log limit reached"}) | |||
| 519 | + | } | |||
| 520 | + | h.run(t) | |||
| 521 | + | eventually(t, "the sandbox to be stopped", func() bool { h.driver.mu.Lock(); defer h.driver.mu.Unlock(); return h.driver.stops == 1 }) | |||
| 522 | + | close(release) | |||
| 523 | + | time.Sleep(200 * time.Millisecond) | |||
| 524 | + | if n := h.srv.completeCount(); n != 0 { | |||
| 525 | + | t.Fatalf("CompleteJob was called %d times", n) | |||
| 526 | + | } | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func TestUnauthenticatedJobCallAbandonsAtOnce(t *testing.T) { | |||
| 530 | + | release := make(chan struct{}) | |||
| 531 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 532 | + | stepStart(c, 1, "Set up job") | |||
| 533 | + | <-release | |||
| 534 | + | jobDone(c, cOK, nil, false) | |||
| 535 | + | }) | |||
| 536 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 537 | + | h.srv.beatHook = func(n int) error { | |||
| 538 | + | if n >= 2 { // the assignment was replaced | |||
| 539 | + | return connect.NewError(connect.CodeUnauthenticated, io.EOF) | |||
| 540 | + | } | |||
| 541 | + | return nil | |||
| 542 | + | } | |||
| 543 | + | h.run(t) | |||
| 544 | + | eventually(t, "the sandbox to be stopped", func() bool { h.driver.mu.Lock(); defer h.driver.mu.Unlock(); return h.driver.stops == 1 }) | |||
| 545 | + | close(release) | |||
| 546 | + | time.Sleep(200 * time.Millisecond) | |||
| 547 | + | if n := h.srv.completeCount(); n != 0 { | |||
| 548 | + | t.Fatalf("CompleteJob was called %d times", n) | |||
| 549 | + | } | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | func TestGracefulShutdownReportsRunnerShutdown(t *testing.T) { | |||
| 553 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, cancels <-chan *execpb.Cancel) { | |||
| 554 | + | stepStart(c, 1, "Set up job") | |||
| 555 | + | stepStart(c, 2, "Run sleep") | |||
| 556 | + | logLineFrame(c, 2, "working", false) | |||
| 557 | + | cn := <-cancels | |||
| 558 | + | if cn.Kind != execpb.CancelKind_CANCEL_KIND_SHUTDOWN { | |||
| 559 | + | t.Errorf("kind %v", cn.Kind) | |||
| 560 | + | } | |||
| 561 | + | stepDone(c, 2, "Run sleep", cCancel) | |||
| 562 | + | jobDone(c, cCancel, nil, true) | |||
| 563 | + | }) | |||
| 564 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 565 | + | stop := h.run(t) | |||
| 566 | + | eventually(t, "the job to run", func() bool { return len(h.srv.snapshotLines()) == 1 }) | |||
| 567 | + | if err := stop(); err != nil { | |||
| 568 | + | t.Fatal(err) | |||
| 569 | + | } | |||
| 570 | + | h.srv.mu.Lock() | |||
| 571 | + | defer h.srv.mu.Unlock() | |||
| 572 | + | if len(h.srv.completes) != 1 { | |||
| 573 | + | t.Fatalf("%d CompleteJob calls", len(h.srv.completes)) | |||
| 574 | + | } | |||
| 575 | + | if c := h.srv.completes[0]; c.Conclusion != ermite.CiConclusion_CI_CONCLUSION_CANCELLED || !c.RunnerShutdown { | |||
| 576 | + | t.Fatalf("%+v", c) | |||
| 577 | + | } | |||
| 578 | + | if h.driver.stops != 1 { | |||
| 579 | + | t.Errorf("stops=%d", h.driver.stops) | |||
| 580 | + | } | |||
| 581 | + | } | |||
| 582 | + | ||||
| 583 | + | func TestShutdownHardStopsAnExecutorThatIgnoresIt(t *testing.T) { | |||
| 584 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, cancels <-chan *execpb.Cancel) { | |||
| 585 | + | stepStart(c, 1, "Set up job") | |||
| 586 | + | stepStart(c, 2, "Run hang") | |||
| 587 | + | logLineFrame(c, 2, "hung", false) | |||
| 588 | + | <-cancels // received, ignored: keep running until the stream is cut | |||
| 589 | + | select {} | |||
| 590 | + | }) | |||
| 591 | + | h.runner.Timing.ShutdownGrace = 300 * time.Millisecond | |||
| 592 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 593 | + | stop := h.run(t) | |||
| 594 | + | eventually(t, "the job to run", func() bool { return len(h.srv.snapshotLines()) == 1 }) | |||
| 595 | + | start := time.Now() | |||
| 596 | + | _ = stop() | |||
| 597 | + | if time.Since(start) > 5*time.Second { | |||
| 598 | + | t.Fatal("shutdown waited for an executor that does not answer") | |||
| 599 | + | } | |||
| 600 | + | h.srv.mu.Lock() | |||
| 601 | + | defer h.srv.mu.Unlock() | |||
| 602 | + | if len(h.srv.completes) != 1 || h.srv.completes[0].Conclusion != ermite.CiConclusion_CI_CONCLUSION_CANCELLED || !h.srv.completes[0].RunnerShutdown { | |||
| 603 | + | t.Fatalf("%+v", h.srv.completes) | |||
| 604 | + | } | |||
| 605 | + | // Steps that never completed are closed by the runner. | |||
| 606 | + | for _, u := range h.srv.completes[0].Steps { | |||
| 607 | + | if u.State != ermite.StepState_STEP_STATE_COMPLETED || u.Conclusion != ermite.CiConclusion_CI_CONCLUSION_CANCELLED { | |||
| 608 | + | t.Errorf("step %d: %v/%v", u.Number, u.State, u.Conclusion) | |||
| 609 | + | } | |||
| 610 | + | } | |||
| 611 | + | } | |||
| 612 | + | ||||
| 613 | + | func TestExecutorDyingFailsTheJob(t *testing.T) { | |||
| 614 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 615 | + | stepStart(c, 1, "Set up job") | |||
| 616 | + | logLineFrame(c, 1, "partial", false) | |||
| 617 | + | // the stream closes without a result | |||
| 618 | + | }) | |||
| 619 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 620 | + | h.run(t) | |||
| 621 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 622 | + | h.srv.mu.Lock() | |||
| 623 | + | defer h.srv.mu.Unlock() | |||
| 624 | + | c := h.srv.completes[0] | |||
| 625 | + | if c.Conclusion != ermite.CiConclusion_CI_CONCLUSION_FAILURE || c.RunnerShutdown { | |||
| 626 | + | t.Fatalf("%+v", c) | |||
| 627 | + | } | |||
| 628 | + | if len(h.srv.lines) != 2 || !strings.HasPrefix(h.srv.lines[1], "##[error]The job's sandbox ended unexpectedly") { | |||
| 629 | + | t.Fatalf("lines %q", h.srv.lines) | |||
| 630 | + | } | |||
| 631 | + | if len(c.Steps) != 1 || c.Steps[0].State != ermite.StepState_STEP_STATE_COMPLETED || c.Steps[0].Conclusion != ermite.CiConclusion_CI_CONCLUSION_FAILURE { | |||
| 632 | + | t.Fatalf("%v", c.Steps) | |||
| 633 | + | } | |||
| 634 | + | } | |||
| 635 | + | ||||
| 636 | + | func TestSandboxStartFailureFailsTheJobInStepOne(t *testing.T) { | |||
| 637 | + | h := newHarness(t, simpleScript("x")) | |||
| 638 | + | h.driver.startErr = errors.New("no kvm today") | |||
| 639 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 640 | + | h.run(t) | |||
| 641 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 642 | + | h.srv.mu.Lock() | |||
| 643 | + | defer h.srv.mu.Unlock() | |||
| 644 | + | if c := h.srv.completes[0]; c.Conclusion != ermite.CiConclusion_CI_CONCLUSION_FAILURE { | |||
| 645 | + | t.Fatalf("%v", c.Conclusion) | |||
| 646 | + | } | |||
| 647 | + | if len(h.srv.lines) != 1 || !strings.Contains(h.srv.lines[0], "The sandbox did not start: no kvm today") { | |||
| 648 | + | t.Fatalf("%q", h.srv.lines) | |||
| 649 | + | } | |||
| 650 | + | if h.driver.stops != 1 { | |||
| 651 | + | t.Errorf("a sandbox that failed to start is still stopped: stops=%d", h.driver.stops) | |||
| 652 | + | } | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | func TestOutputsOverTheLimitFailTheJob(t *testing.T) { | |||
| 656 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 657 | + | stepStart(c, 1, "Set up job") | |||
| 658 | + | stepDone(c, 1, "Set up job", cOK) | |||
| 659 | + | // Names that ermite would refuse: the job fails without outputs. | |||
| 660 | + | jobDone(c, cOK, map[string]string{"1bad": "x", "fine": "y"}, false) | |||
| 661 | + | }) | |||
| 662 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 663 | + | h.run(t) | |||
| 664 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 665 | + | h.srv.mu.Lock() | |||
| 666 | + | defer h.srv.mu.Unlock() | |||
| 667 | + | c := h.srv.completes[0] | |||
| 668 | + | if c.Conclusion != ermite.CiConclusion_CI_CONCLUSION_FAILURE || len(c.Outputs) != 0 { | |||
| 669 | + | t.Fatalf("%v %v", c.Conclusion, len(c.Outputs)) | |||
| 670 | + | } | |||
| 671 | + | if len(h.srv.lines) != 1 || !strings.Contains(h.srv.lines[0], "is not valid") { | |||
| 672 | + | t.Fatalf("%q", h.srv.lines) | |||
| 673 | + | } | |||
| 674 | + | } | |||
| 675 | + | ||||
| 676 | + | func TestCheckOutputs(t *testing.T) { | |||
| 677 | + | for _, tc := range []struct { | |||
| 678 | + | in map[string]string | |||
| 679 | + | want string | |||
| 680 | + | }{ | |||
| 681 | + | {nil, ""}, | |||
| 682 | + | {map[string]string{"a-b_c1": "v", "_x": ""}, ""}, | |||
| 683 | + | {map[string]string{"1a": "v"}, "not valid"}, | |||
| 684 | + | {map[string]string{"a b": "v"}, "not valid"}, | |||
| 685 | + | {map[string]string{"": "v"}, "not valid"}, | |||
| 686 | + | {map[string]string{strings.Repeat("a", 256): "v"}, "not valid"}, | |||
| 687 | + | {map[string]string{"Foo": "1", "foo": "2"}, "differ only by case"}, | |||
| 688 | + | {map[string]string{"a": strings.Repeat("x", 1<<20+1)}, "larger than 1 MiB"}, | |||
| 689 | + | {map[string]string{"a": strings.Repeat("x", 600000), "b": strings.Repeat("x", 600000)}, "in total"}, | |||
| 690 | + | } { | |||
| 691 | + | got := checkOutputs(tc.in) | |||
| 692 | + | if (tc.want == "") != (got == "") || !strings.Contains(got, tc.want) { | |||
| 693 | + | t.Errorf("%v: %q, want %q", tc.in, got, tc.want) | |||
| 694 | + | } | |||
| 695 | + | } | |||
| 696 | + | } | |||
| 697 | + | ||||
| 698 | + | func TestLeakedSandboxStopsTakingJobsUntilReaped(t *testing.T) { | |||
| 699 | + | h := newHarness(t, simpleScript("x")) | |||
| 700 | + | h.driver.stopErr = sandbox.ErrLeaked | |||
| 701 | + | h.driver.reapErr = errors.New("still leaked") | |||
| 702 | + | h.runner.Timing.ReapRetry = 50 * time.Millisecond | |||
| 703 | + | h.srv.jobs = []*ermite.Job{testJob("j1"), testJob("j2")} | |||
| 704 | + | h.run(t) | |||
| 705 | + | eventually(t, "first job done", func() bool { return h.srv.completeCount() == 1 }) | |||
| 706 | + | time.Sleep(400 * time.Millisecond) | |||
| 707 | + | if h.srv.completeCount() != 1 { | |||
| 708 | + | t.Fatal("a second job started while a sandbox is leaked and cannot be reaped") | |||
| 709 | + | } | |||
| 710 | + | // Once the reap works, jobs are taken again. | |||
| 711 | + | h.driver.mu.Lock() | |||
| 712 | + | h.driver.reapErr = nil | |||
| 713 | + | h.driver.stopErr = nil | |||
| 714 | + | h.driver.mu.Unlock() | |||
| 715 | + | eventually(t, "the second job", func() bool { return h.srv.completeCount() == 2 }) | |||
| 716 | + | } | |||
| 717 | + | ||||
| 718 | + | func TestReapAtStartAndIsolation(t *testing.T) { | |||
| 719 | + | h := newHarness(t, simpleScript("x")) | |||
| 720 | + | h.run(t) | |||
| 721 | + | eventually(t, "reap", func() bool { h.driver.mu.Lock(); defer h.driver.mu.Unlock(); return h.driver.reaps >= 1 }) | |||
| 722 | + | for in, want := range map[sandbox.Isolation]ermite.RunnerIsolation{ | |||
| 723 | + | sandbox.IsolationVM: ermite.RunnerIsolation_RUNNER_ISOLATION_VM, | |||
| 724 | + | sandbox.IsolationContainerSysbox: ermite.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_SYSBOX, | |||
| 725 | + | sandbox.IsolationContainerPrivileged: ermite.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_PRIVILEGED, | |||
| 726 | + | "other": ermite.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED, | |||
| 727 | + | } { | |||
| 728 | + | if got := Isolation(in); got != want { | |||
| 729 | + | t.Errorf("%s: %v", in, got) | |||
| 730 | + | } | |||
| 731 | + | } | |||
| 732 | + | } | |||
| 733 | + | ||||
| 734 | + | func TestRegisterStoresTheToken(t *testing.T) { | |||
| 735 | + | srv := newFakeServer() | |||
| 736 | + | url := srv.start(t) | |||
| 737 | + | dir := t.TempDir() + "/state" | |||
| 738 | + | cfg := Config{URL: url, Name: "r1", Labels: []string{"ubuntu-latest"}, StateDir: dir, Version: "v1"} | |||
| 739 | + | st, err := Register(context.Background(), cfg, NewClient(url, "reg-token", http.DefaultTransport), sandbox.IsolationContainerSysbox, true) | |||
| 740 | + | if err != nil { | |||
| 741 | + | t.Fatal(err) | |||
| 742 | + | } | |||
| 743 | + | if st.RunnerToken != "ermiterun_new" || st.RunnerID != "rid1" { | |||
| 744 | + | t.Fatalf("%+v", st) | |||
| 745 | + | } | |||
| 746 | + | req := srv.registers[0] | |||
| 747 | + | if req.Name != "r1" || req.Isolation != ermite.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_SYSBOX || req.Os != "linux" || (req.Arch != "x64" && req.Arch != "arm64") || !req.Replace || req.Version != "v1" || len(req.Labels) != 1 { | |||
| 748 | + | t.Fatalf("%v", req) | |||
| 749 | + | } | |||
| 750 | + | fi, err := os.Stat(StateFile(dir)) | |||
| 751 | + | if err != nil || fi.Mode().Perm() != 0o600 { | |||
| 752 | + | t.Fatalf("%v %v", fi, err) | |||
| 753 | + | } | |||
| 754 | + | got, err := LoadState(dir) | |||
| 755 | + | if err != nil || got != st { | |||
| 756 | + | t.Fatalf("%+v %v", got, err) | |||
| 757 | + | } | |||
| 758 | + | // Anyone can read a state file with a loose mode: refuse it. | |||
| 759 | + | if err := os.Chmod(StateFile(dir), 0o644); err != nil { | |||
| 760 | + | t.Fatal(err) | |||
| 761 | + | } | |||
| 762 | + | if _, err := LoadState(dir); err == nil || !strings.Contains(err.Error(), "accessible by other users") { | |||
| 763 | + | t.Fatalf("%v", err) | |||
| 764 | + | } | |||
| 765 | + | if _, err := LoadState(t.TempDir()); !errors.Is(err, ErrNotRegistered) { | |||
| 766 | + | t.Fatalf("%v", err) | |||
| 767 | + | } | |||
| 768 | + | // A refused registration stores nothing. | |||
| 769 | + | dir2 := t.TempDir() | |||
| 770 | + | cfg.StateDir = dir2 | |||
| 771 | + | if _, err := Register(context.Background(), cfg, NewClient(url, "bad", http.DefaultTransport), sandbox.IsolationVM, false); err == nil { | |||
| 772 | + | t.Fatal("registration with a bad token succeeded") | |||
| 773 | + | } | |||
| 774 | + | if _, err := os.Stat(StateFile(dir2)); err == nil { | |||
| 775 | + | t.Fatal("a state file was written") | |||
| 776 | + | } | |||
| 777 | + | } | |||
| 778 | + | ||||
| 779 | + | func TestLoadConfig(t *testing.T) { | |||
| 780 | + | env := map[string]string{EnvURL: " https://ermite.example ", EnvLabels: "ubuntu-latest, Ubuntu-24.04 ,ubuntu-latest,"} | |||
| 781 | + | c, err := LoadConfig(func(k string) string { return env[k] }, "my host.local", "/state", 2, "v1") | |||
| 782 | + | if err != nil { | |||
| 783 | + | t.Fatal(err) | |||
| 784 | + | } | |||
| 785 | + | if c.URL != "https://ermite.example" || c.Name != "my-host.local" || strings.Join(c.Labels, ",") != "ubuntu-latest,Ubuntu-24.04" || c.Capacity != 2 { | |||
| 786 | + | t.Fatalf("%+v", c) | |||
| 787 | + | } | |||
| 788 | + | env[EnvName] = "bad name" | |||
| 789 | + | if _, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err == nil { | |||
| 790 | + | t.Error("an invalid name was accepted") | |||
| 791 | + | } | |||
| 792 | + | env[EnvName] = "ok" | |||
| 793 | + | env[EnvLabels] = "good,bad label" | |||
| 794 | + | if _, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err == nil { | |||
| 795 | + | t.Error("an invalid label was accepted") | |||
| 796 | + | } | |||
| 797 | + | } | |||
| 798 | + | ||||
| 799 | + | var _ = ermiteconnect.RunnerServiceClient(nil) | |||
| 800 | + | ||||
| 801 | + | // The job timeout counts from the receipt of the job, and the host enforces it | |||
| 802 | + | // even when the executor does not: the sandbox is stopped and the job ends | |||
| 803 | + | // timed_out. | |||
| 804 | + | func TestHostEnforcesTheJobTimeout(t *testing.T) { | |||
| 805 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, cancels <-chan *execpb.Cancel) { | |||
| 806 | + | stepStart(c, 1, "Set up job") | |||
| 807 | + | logLineFrame(c, 1, "stuck", false) | |||
| 808 | + | cn := <-cancels | |||
| 809 | + | if cn.Kind != execpb.CancelKind_CANCEL_KIND_TIMEOUT { | |||
| 810 | + | t.Errorf("kind %v", cn.Kind) | |||
| 811 | + | } | |||
| 812 | + | select {} // the executor ignores the cancel | |||
| 813 | + | }) | |||
| 814 | + | h.runner.Timing.TimeoutGrace = 200 * time.Millisecond | |||
| 815 | + | job := testJob("j1") | |||
| 816 | + | job.TimeoutMinutes = 1 | |||
| 817 | + | h.runner.Job = func(tok string) ermiteconnect.RunnerServiceClient { | |||
| 818 | + | return NewClient(h.url, tok, http.DefaultTransport) | |||
| 819 | + | } | |||
| 820 | + | h.runner.now = func() time.Time { return time.Now().Add(-59*time.Second - 700*time.Millisecond) } // received 59.7 s ago | |||
| 821 | + | h.srv.jobs = []*ermite.Job{job} | |||
| 822 | + | start := time.Now() | |||
| 823 | + | h.run(t) | |||
| 824 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 825 | + | if d := time.Since(start); d > 5*time.Second { | |||
| 826 | + | t.Fatalf("took %v", d) | |||
| 827 | + | } | |||
| 828 | + | h.srv.mu.Lock() | |||
| 829 | + | defer h.srv.mu.Unlock() | |||
| 830 | + | c := h.srv.completes[0] | |||
| 831 | + | if c.Conclusion != ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT || c.RunnerShutdown { | |||
| 832 | + | t.Fatalf("%v", c.Conclusion) | |||
| 833 | + | } | |||
| 834 | + | for _, u := range c.Steps { | |||
| 835 | + | if u.State != ermite.StepState_STEP_STATE_COMPLETED { | |||
| 836 | + | t.Errorf("step %d not completed", u.Number) | |||
| 837 | + | } | |||
| 838 | + | } | |||
| 839 | + | } | |||
| 840 | + | ||||
| 841 | + | // The remaining time, not the whole timeout, goes to the executor. | |||
| 842 | + | func TestRunCarriesTheRemainingDeadline(t *testing.T) { | |||
| 843 | + | var spec *execpb.JobSpec | |||
| 844 | + | inner := simpleScript("x") | |||
| 845 | + | h := newHarness(t, func(t *testing.T, c *execstream.Conn, run *execpb.Run, cn <-chan *execpb.Cancel) { | |||
| 846 | + | spec = run.Job | |||
| 847 | + | inner(t, c, run, cn) | |||
| 848 | + | }) | |||
| 849 | + | h.runner.now = func() time.Time { return time.Now().Add(-3 * time.Minute) } | |||
| 850 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} // 5 minutes | |||
| 851 | + | h.run(t) | |||
| 852 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 853 | + | if spec == nil || spec.TimeoutMinutes != 5 || spec.DeadlineMs < 110_000 || spec.DeadlineMs > 120_000 { | |||
| 854 | + | t.Fatalf("%v", spec) | |||
| 855 | + | } | |||
| 856 | + | } | |||
| 857 | + | ||||
| 858 | + | // ---- the guest is untrusted: step numbers and lines are checked (M3) | |||
| 859 | + | ||||
| 860 | + | // hostile runs a job whose executor sends the frames of body, and checks that | |||
| 861 | + | // the job fails, the sandbox is destroyed, the server never saw a violation of | |||
| 862 | + | // its contract, and a line in the log names the reason. | |||
| 863 | + | func hostile(t *testing.T, wantReason string, body script) *harness { | |||
| 864 | + | t.Helper() | |||
| 865 | + | h := newHarness(t, func(t *testing.T, c *execstream.Conn, run *execpb.Run, cn <-chan *execpb.Cancel) { | |||
| 866 | + | body(t, c, run, cn) | |||
| 867 | + | // A real executor would go on; this one waits for the host to cut it. | |||
| 868 | + | <-cn | |||
| 869 | + | }) | |||
| 870 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 871 | + | h.run(t) | |||
| 872 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 873 | + | h.srv.mu.Lock() | |||
| 874 | + | defer h.srv.mu.Unlock() | |||
| 875 | + | c := h.srv.completes[0] | |||
| 876 | + | if c.Conclusion != ermite.CiConclusion_CI_CONCLUSION_FAILURE || len(c.Outputs) != 0 { | |||
| 877 | + | t.Fatalf("conclusion %v outputs %v", c.Conclusion, c.Outputs) | |||
| 878 | + | } | |||
| 879 | + | found := false | |||
| 880 | + | for _, l := range h.srv.lines { | |||
| 881 | + | if strings.Contains(l, "broke the protocol") && strings.Contains(l, wantReason) { | |||
| 882 | + | found = true | |||
| 883 | + | } | |||
| 884 | + | } | |||
| 885 | + | if !found { | |||
| 886 | + | t.Fatalf("no error line mentioning %q in %q", wantReason, h.srv.lines) | |||
| 887 | + | } | |||
| 888 | + | for _, u := range c.Steps { | |||
| 889 | + | if u.State != ermite.StepState_STEP_STATE_COMPLETED || u.Number < 1 || u.Number > 1000 { | |||
| 890 | + | t.Errorf("invalid final step %v", u) | |||
| 891 | + | } | |||
| 892 | + | } | |||
| 893 | + | if h.driver.stops != 1 { | |||
| 894 | + | t.Errorf("the sandbox was not destroyed: stops=%d", h.driver.stops) | |||
| 895 | + | } | |||
| 896 | + | return h | |||
| 897 | + | } | |||
| 898 | + | ||||
| 899 | + | func TestGuestStepNumberZeroFailsTheJob(t *testing.T) { | |||
| 900 | + | hostile(t, "outside 1..1000", func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 901 | + | stepStart(c, 1, "Set up job") | |||
| 902 | + | stepStart(c, 0, "zero") | |||
| 903 | + | }) | |||
| 904 | + | } | |||
| 905 | + | ||||
| 906 | + | func TestGuestStepNumberOverTheLimitFailsTheJob(t *testing.T) { | |||
| 907 | + | hostile(t, "outside 1..1000", func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 908 | + | stepStart(c, 1, "Set up job") | |||
| 909 | + | stepStart(c, 1001, "far") | |||
| 910 | + | }) | |||
| 911 | + | } | |||
| 912 | + | ||||
| 913 | + | func TestGuestStepGoingBackFailsTheJob(t *testing.T) { | |||
| 914 | + | hostile(t, "started after step 3", func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 915 | + | stepStart(c, 1, "a") | |||
| 916 | + | stepStart(c, 3, "c") | |||
| 917 | + | stepStart(c, 2, "b") | |||
| 918 | + | }) | |||
| 919 | + | } | |||
| 920 | + | ||||
| 921 | + | func TestGuestStepRestartedFailsTheJob(t *testing.T) { | |||
| 922 | + | hostile(t, "started after step 1", func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 923 | + | stepStart(c, 1, "a") | |||
| 924 | + | stepDone(c, 1, "a", cOK) | |||
| 925 | + | stepStart(c, 1, "again") | |||
| 926 | + | }) | |||
| 927 | + | } | |||
| 928 | + | ||||
| 929 | + | func TestGuestCompletedStepIsNeverTouchedAgain(t *testing.T) { | |||
| 930 | + | h := hostile(t, "completed twice", func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 931 | + | stepStart(c, 1, "a") | |||
| 932 | + | stepDone(c, 1, "a", cOK) | |||
| 933 | + | stepDone(c, 1, "a", cFail) | |||
| 934 | + | }) | |||
| 935 | + | if u := h.srv.Result("j1").Steps[1]; u == nil || u.Conclusion != ermite.CiConclusion_CI_CONCLUSION_SUCCESS { | |||
| 936 | + | t.Errorf("a completed step was changed: %v", u.Conclusion) | |||
| 937 | + | } | |||
| 938 | + | } | |||
| 939 | + | ||||
| 940 | + | func TestGuestCompletingAnUnknownStepFailsTheJob(t *testing.T) { | |||
| 941 | + | hostile(t, "without having started", func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 942 | + | stepStart(c, 1, "a") | |||
| 943 | + | stepDone(c, 7, "ghost", cOK) | |||
| 944 | + | }) | |||
| 945 | + | } | |||
| 946 | + | ||||
| 947 | + | func TestGuestLogForAnUnstartedOrCompletedStepFailsTheJob(t *testing.T) { | |||
| 948 | + | hostile(t, "has not started", func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 949 | + | stepStart(c, 1, "a") | |||
| 950 | + | logLineFrame(c, 5, "x", false) | |||
| 951 | + | }) | |||
| 952 | + | hostile(t, "which is completed", func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 953 | + | stepStart(c, 1, "a") | |||
| 954 | + | stepDone(c, 1, "a", cOK) | |||
| 955 | + | logLineFrame(c, 1, "late", false) | |||
| 956 | + | }) | |||
| 957 | + | } | |||
| 958 | + | ||||
| 959 | + | func TestGuestLogGoingBackAStepFailsTheJob(t *testing.T) { | |||
| 960 | + | hostile(t, "after one for step 2", func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 961 | + | stepStart(c, 1, "a") | |||
| 962 | + | stepStart(c, 2, "b") | |||
| 963 | + | logLineFrame(c, 2, "x", false) | |||
| 964 | + | logLineFrame(c, 1, "y", false) | |||
| 965 | + | }) | |||
| 966 | + | } | |||
| 967 | + | ||||
| 968 | + | func TestGuestOversizedLineFailsTheJob(t *testing.T) { | |||
| 969 | + | hostile(t, "bytes (the limit is 65536)", func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 970 | + | stepStart(c, 1, "a") | |||
| 971 | + | logLineFrame(c, 1, strings.Repeat("x", 64*1024+1), false) | |||
| 972 | + | }) | |||
| 973 | + | } | |||
| 974 | + | ||||
| 975 | + | // Text that the server would refuse is repaired, not forwarded: a line feed | |||
| 976 | + | // makes more lines and long names are cut. (Invalid UTF-8 cannot reach the host: | |||
| 977 | + | // proto3 strings are validated when the frame is decoded, which fails the | |||
| 978 | + | // session; the executor replaces invalid bytes before it sends.) | |||
| 979 | + | func TestGuestTextIsMadeValidForTheServer(t *testing.T) { | |||
| 980 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 981 | + | stepStart(c, 1, strings.Repeat("é", 800)) // 1,600 bytes | |||
| 982 | + | logLineFrame(c, 1, "two\nlines", false) | |||
| 983 | + | stepDone(c, 1, "", cOK) | |||
| 984 | + | jobDone(c, cOK, nil, false) | |||
| 985 | + | }) | |||
| 986 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 987 | + | h.run(t) | |||
| 988 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 989 | + | h.srv.mu.Lock() | |||
| 990 | + | defer h.srv.mu.Unlock() | |||
| 991 | + | if strings.Join(h.srv.lines, "|") != "two|lines" { | |||
| 992 | + | t.Fatalf("%q", h.srv.lines) | |||
| 993 | + | } | |||
| 994 | + | if n := h.srv.steps[1].Name; len(n) > 1024 || !utf8.ValidString(n) { | |||
| 995 | + | t.Fatalf("name of %d bytes", len(n)) | |||
| 996 | + | } | |||
| 997 | + | } | |||
| 998 | + | ||||
| 999 | + | // A guest that sends steps without end cannot grow the memory of the host. | |||
| 1000 | + | func TestGuestFloodIsBounded(t *testing.T) { | |||
| 1001 | + | h := hostile(t, "outside 1..1000", func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 1002 | + | for n := 1; n <= 1100; n++ { | |||
| 1003 | + | stepStart(c, n, "s") | |||
| 1004 | + | stepDone(c, n, "s", cOK) | |||
| 1005 | + | } | |||
| 1006 | + | }) | |||
| 1007 | + | _ = h | |||
| 1008 | + | } | |||
| 1009 | + | ||||
| 1010 | + | // The server's line count is the truth (m6): when the answer to an AppendLog | |||
| 1011 | + | // says the server holds more lines than the request accounts for, the next | |||
| 1012 | + | // offset starts from the server's count and no gap or repeat is created. | |||
| 1013 | + | func TestAppendLogResyncsToTheServersCount(t *testing.T) { | |||
| 1014 | + | h := newHarness(t, func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) { | |||
| 1015 | + | stepStart(c, 1, "a") | |||
| 1016 | + | logLineFrame(c, 1, "l1", false) | |||
| 1017 | + | time.Sleep(150 * time.Millisecond) // a first request of its own | |||
| 1018 | + | logLineFrame(c, 1, "l2", false) | |||
| 1019 | + | logLineFrame(c, 1, "l3", false) | |||
| 1020 | + | stepDone(c, 1, "a", cOK) | |||
| 1021 | + | jobDone(c, cOK, nil, false) | |||
| 1022 | + | }) | |||
| 1023 | + | h.runner.Timing.LogFlush = 30 * time.Millisecond | |||
| 1024 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 1025 | + | // A line the runner never sent appears in the server's log (a retry whose | |||
| 1026 | + | // answer was lost, in a real server): the runner must continue after it. | |||
| 1027 | + | h.srv.mu.Lock() | |||
| 1028 | + | h.srv.appendHook = func(n int, r *ermite.AppendLogRequest) (bool, error) { | |||
| 1029 | + | if n == 1 { | |||
| 1030 | + | _, _ = h.srv.store.Apply(&ermite.AppendLogRequest{JobId: r.JobId, Step: 1, Offset: 0, Lines: []*ermite.LogLine{ | |||
| 1031 | + | {Time: timestamppb.Now(), Text: "planted"}, {Time: timestamppb.Now(), Text: "planted2"}}}) | |||
| 1032 | + | return false, nil | |||
| 1033 | + | } | |||
| 1034 | + | return false, nil | |||
| 1035 | + | } | |||
| 1036 | + | h.srv.mu.Unlock() | |||
| 1037 | + | h.run(t) | |||
| 1038 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 1039 | + | h.srv.mu.Lock() | |||
| 1040 | + | defer h.srv.mu.Unlock() | |||
| 1041 | + | // The first request (offset 0, "l1") was skipped by the server because the | |||
| 1042 | + | // planted lines already took offsets 0 and 1; the runner learns it holds 2 | |||
| 1043 | + | // lines (it expected 1), and goes on at offset 2: no gap, no repeat. | |||
| 1044 | + | if len(h.srv.lines) < 3 || h.srv.lines[0] != "planted" || h.srv.lines[len(h.srv.lines)-1] != "l3" { | |||
| 1045 | + | t.Fatalf("%q", h.srv.lines) | |||
| 1046 | + | } | |||
| 1047 | + | if !strings.Contains(h.logs.String(), "continuing from its count") { | |||
| 1048 | + | t.Fatalf("no warning about the resync:\n%s", h.logs.String()) | |||
| 1049 | + | } | |||
| 1050 | + | } | |||
| 1051 | + | ||||
| 1052 | + | // ---- M4: the server refuses the result itself | |||
| 1053 | + | ||||
| 1054 | + | func TestCompleteJobRefusedIsRetriedAsAFailureWithoutOutputs(t *testing.T) { | |||
| 1055 | + | h := newHarness(t, simpleScript("hello")) | |||
| 1056 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 1057 | + | h.srv.completeHook = func(n int) (bool, error) { | |||
| 1058 | + | if n == 1 { // e.g. an output the server's rules refuse | |||
| 1059 | + | return false, connect.NewError(connect.CodeInvalidArgument, io.EOF) | |||
| 1060 | + | } | |||
| 1061 | + | return false, nil | |||
| 1062 | + | } | |||
| 1063 | + | h.run(t) | |||
| 1064 | + | eventually(t, "two CompleteJob calls", func() bool { return h.srv.completeCount() == 2 }) | |||
| 1065 | + | time.Sleep(100 * time.Millisecond) | |||
| 1066 | + | h.srv.mu.Lock() | |||
| 1067 | + | defer h.srv.mu.Unlock() | |||
| 1068 | + | if len(h.srv.completes) != 2 { | |||
| 1069 | + | t.Fatalf("%d calls", len(h.srv.completes)) | |||
| 1070 | + | } | |||
| 1071 | + | first, second := h.srv.completes[0], h.srv.completes[1] | |||
| 1072 | + | if first.Conclusion != ermite.CiConclusion_CI_CONCLUSION_SUCCESS || first.Outputs["out"] != "v" { | |||
| 1073 | + | t.Fatalf("first: %v %v", first.Conclusion, first.Outputs) | |||
| 1074 | + | } | |||
| 1075 | + | if second.Conclusion != ermite.CiConclusion_CI_CONCLUSION_FAILURE || len(second.Outputs) != 0 || second.RunnerShutdown { | |||
| 1076 | + | t.Fatalf("second: %v %v", second.Conclusion, second.Outputs) | |||
| 1077 | + | } | |||
| 1078 | + | if len(second.Steps) != 3 { | |||
| 1079 | + | t.Fatalf("steps %v", second.Steps) | |||
| 1080 | + | } | |||
| 1081 | + | for _, u := range second.Steps { | |||
| 1082 | + | if u.State != ermite.StepState_STEP_STATE_COMPLETED || u.StartedAt == nil || u.CompletedAt == nil || u.CompletedAt.AsTime().Before(u.StartedAt.AsTime()) { | |||
| 1083 | + | t.Errorf("invalid step %v", u) | |||
| 1084 | + | } | |||
| 1085 | + | } | |||
| 1086 | + | last := h.srv.lines[len(h.srv.lines)-1] | |||
| 1087 | + | if !strings.HasPrefix(last, "##[error]The runner could not report the result of the job: invalid_argument") { | |||
| 1088 | + | t.Fatalf("last line %q", last) | |||
| 1089 | + | } | |||
| 1090 | + | } | |||
| 1091 | + | ||||
| 1092 | + | func TestCompleteJobRefusedTwiceGivesUp(t *testing.T) { | |||
| 1093 | + | h := newHarness(t, simpleScript("x")) | |||
| 1094 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 1095 | + | h.srv.completeHook = func(int) (bool, error) { | |||
| 1096 | + | return false, connect.NewError(connect.CodeFailedPrecondition, io.EOF) // not a JobEnded detail | |||
| 1097 | + | } | |||
| 1098 | + | h.run(t) | |||
| 1099 | + | eventually(t, "two calls", func() bool { return h.srv.completeCount() == 2 }) | |||
| 1100 | + | time.Sleep(200 * time.Millisecond) | |||
| 1101 | + | if n := h.srv.completeCount(); n != 2 { | |||
| 1102 | + | t.Fatalf("%d calls: one retry only", n) | |||
| 1103 | + | } | |||
| 1104 | + | if !strings.Contains(h.logs.String(), `"level":"error"`) || !strings.Contains(h.logs.String(), "refused again") { | |||
| 1105 | + | t.Fatalf("no error log:\n%s", h.logs.String()) | |||
| 1106 | + | } | |||
| 1107 | + | } | |||
| 1108 | + | ||||
| 1109 | + | // ---- G1: structured logs with the job's fields, warn for retries, error for refusals | |||
| 1110 | + | ||||
| 1111 | + | func TestLogsCarryTheJobFieldsAndLevels(t *testing.T) { | |||
| 1112 | + | h := newHarness(t, simpleScript("x")) | |||
| 1113 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 1114 | + | h.srv.appendHook = func(n int, _ *ermite.AppendLogRequest) (bool, error) { | |||
| 1115 | + | if n == 1 { | |||
| 1116 | + | return false, connect.NewError(connect.CodeUnavailable, io.EOF) | |||
| 1117 | + | } | |||
| 1118 | + | return false, nil | |||
| 1119 | + | } | |||
| 1120 | + | h.srv.beatHook = func(n int) error { return nil } | |||
| 1121 | + | h.run(t) | |||
| 1122 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 1123 | + | var retry, assigned map[string]any | |||
| 1124 | + | for _, l := range strings.Split(h.logs.String(), "\n") { | |||
| 1125 | + | if l == "" { | |||
| 1126 | + | continue | |||
| 1127 | + | } | |||
| 1128 | + | var m map[string]any | |||
| 1129 | + | if err := json.Unmarshal([]byte(l), &m); err != nil { | |||
| 1130 | + | t.Fatalf("not JSON: %q", l) | |||
| 1131 | + | } | |||
| 1132 | + | switch m["message"] { | |||
| 1133 | + | case "call failed, retrying": | |||
| 1134 | + | retry = m | |||
| 1135 | + | case "job assigned": | |||
| 1136 | + | assigned = m | |||
| 1137 | + | } | |||
| 1138 | + | } | |||
| 1139 | + | for name, m := range map[string]map[string]any{"retry": retry, "assigned": assigned} { | |||
| 1140 | + | if m == nil { | |||
| 1141 | + | t.Fatalf("no %s event in\n%s", name, h.logs.String()) | |||
| 1142 | + | } | |||
| 1143 | + | if m["job_id"] != "j1" || m["run_id"] != float64(7) || m["attempt"] != float64(1) { | |||
| 1144 | + | t.Errorf("%s event lacks the job fields: %v", name, m) | |||
| 1145 | + | } | |||
| 1146 | + | } | |||
| 1147 | + | if retry["level"] != "warn" || retry["call"] != "AppendLog" { | |||
| 1148 | + | t.Errorf("a retry is a warning: %v", retry) | |||
| 1149 | + | } | |||
| 1150 | + | } | |||
| 1151 | + | ||||
| 1152 | + | // The sizes of a sandbox come from ERMITE_RUNNER_JOB_CPUS, _JOB_MEMORY and | |||
| 1153 | + | // _JOB_DISK (sandbox.Config.Resolve, applied by the driver at Start): the host | |||
| 1154 | + | // asks for none, so that nothing in a job can change them. | |||
| 1155 | + | func TestSandboxSizesAreLeftToTheDriversConfiguration(t *testing.T) { | |||
| 1156 | + | h := newHarness(t, simpleScript("x")) | |||
| 1157 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 1158 | + | h.run(t) | |||
| 1159 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 1160 | + | h.driver.mu.Lock() | |||
| 1161 | + | defer h.driver.mu.Unlock() | |||
| 1162 | + | if len(h.driver.specs) != 1 { | |||
| 1163 | + | t.Fatalf("%v", h.driver.specs) | |||
| 1164 | + | } | |||
| 1165 | + | if s := h.driver.specs[0]; s.JobID != "j1" || s.CPUs != 0 || s.MemoryMiB != 0 || s.DiskMiB != 0 { | |||
| 1166 | + | t.Fatalf("%+v", s) | |||
| 1167 | + | } | |||
| 1168 | + | // And the configuration fills them (the real rule, in package sandbox). | |||
| 1169 | + | cfg, err := sandbox.LoadConfig(func(k string) string { | |||
| 1170 | + | return map[string]string{sandbox.EnvJobCPUs: "3", sandbox.EnvJobMemory: "2G", sandbox.EnvJobDisk: "10G"}[k] | |||
| 1171 | + | }) | |||
| 1172 | + | if err != nil { | |||
| 1173 | + | t.Fatal(err) | |||
| 1174 | + | } | |||
| 1175 | + | if got := cfg.Resolve(h.driver.specs[0]); got.CPUs != 3 || got.MemoryMiB != 2048 || got.DiskMiB != 10240 { | |||
| 1176 | + | t.Fatalf("%+v", got) | |||
| 1177 | + | } | |||
| 1178 | + | } | |||
| 1179 | + | ||||
| 1180 | + | // ---- M5: shutdown | |||
| 1181 | + | ||||
| 1182 | + | // A job whose acknowledgement is pending when the runner shuts down is left to | |||
| 1183 | + | // the server (it requeues it after 30 s): the sandbox is never started, no | |||
| 1184 | + | // result is reported, and Run returns promptly. | |||
| 1185 | + | func TestShutdownBeforeTheAcknowledgementLeavesTheJobToTheServer(t *testing.T) { | |||
| 1186 | + | h := newHarness(t, simpleScript("x")) | |||
| 1187 | + | h.srv.jobs = []*ermite.Job{testJob("j1")} | |||
| 1188 | + | h.srv.beatHook = func(int) error { return connect.NewError(connect.CodeUnavailable, io.EOF) } // never acknowledged | |||
| 1189 | + | stop := h.run(t) | |||
| 1190 | + | eventually(t, "a failed acknowledgement", func() bool { h.srv.mu.Lock(); defer h.srv.mu.Unlock(); return len(h.srv.beats) >= 2 }) | |||
| 1191 | + | start := time.Now() | |||
| 1192 | + | if err := stop(); err != nil { | |||
| 1193 | + | t.Fatal(err) | |||
| 1194 | + | } | |||
| 1195 | + | if time.Since(start) > 3*time.Second { | |||
| 1196 | + | t.Fatalf("Run took %v to return", time.Since(start)) | |||
| 1197 | + | } | |||
| 1198 | + | h.driver.mu.Lock() | |||
| 1199 | + | defer h.driver.mu.Unlock() | |||
| 1200 | + | if h.driver.news != 0 || h.driver.started != 0 { | |||
| 1201 | + | t.Fatalf("a sandbox was created for a job that was never acknowledged: news=%d started=%d", h.driver.news, h.driver.started) | |||
| 1202 | + | } | |||
| 1203 | + | if n := h.srv.completeCount(); n != 0 { | |||
| 1204 | + | t.Fatalf("CompleteJob was called %d times", n) | |||
| 1205 | + | } | |||
| 1206 | + | if !strings.Contains(h.logs.String(), "before the job was acknowledged") { | |||
| 1207 | + | t.Fatalf("no explanation in the log:\n%s", h.logs.String()) | |||
| 1208 | + | } | |||
| 1209 | + | } | |||
| 1210 | + | ||||
| 1211 | + | // The graceful shutdown of the defaults fits the time the unit gives. | |||
| 1212 | + | func TestShutdownBudgetFitsTheUnit(t *testing.T) { | |||
| 1213 | + | budget := Timing{}.ShutdownBudget() | |||
| 1214 | + | if budget != 90*time.Second { | |||
| 1215 | + | t.Fatalf("the budget is %v, README and contrib/ermite-runner.service say 90 s", budget) | |||
| 1216 | + | } | |||
| 1217 | + | unit, err := os.ReadFile("../../contrib/ermite-runner.service") | |||
| 1218 | + | if err != nil { | |||
| 1219 | + | t.Skip(err) | |||
| 1220 | + | } | |||
| 1221 | + | m := regexp.MustCompile(`(?m)^TimeoutStopSec=(\d+)$`).FindStringSubmatch(string(unit)) | |||
| 1222 | + | if m == nil { | |||
| 1223 | + | t.Fatal("no TimeoutStopSec in the unit") | |||
| 1224 | + | } | |||
| 1225 | + | secs, _ := strconv.Atoi(m[1]) | |||
| 1226 | + | if time.Duration(secs)*time.Second < budget+15*time.Second { | |||
| 1227 | + | t.Fatalf("TimeoutStopSec=%d does not leave 15 s of margin over the %v budget", secs, budget) | |||
| 1228 | + | } | |||
| 1229 | + | } | |||
| 1230 | + | ||||
| 1231 | + | // ---- m7: the state directory is private | |||
| 1232 | + | ||||
| 1233 | + | func TestSaveStateMakesAnExistingDirectoryPrivate(t *testing.T) { | |||
| 1234 | + | dir := filepath.Join(t.TempDir(), "state") | |||
| 1235 | + | if err := os.Mkdir(dir, 0o755); err != nil { | |||
| 1236 | + | t.Fatal(err) | |||
| 1237 | + | } | |||
| 1238 | + | if err := SaveState(dir, State{URL: "https://x", RunnerID: "r", Name: "n", RunnerToken: "ermiterun_t"}); err != nil { | |||
| 1239 | + | t.Fatal(err) | |||
| 1240 | + | } | |||
| 1241 | + | fi, err := os.Stat(dir) | |||
| 1242 | + | if err != nil || fi.Mode().Perm() != 0o700 { | |||
| 1243 | + | t.Fatalf("%v %v", fi, err) | |||
| 1244 | + | } | |||
| 1245 | + | if fi, _ := os.Stat(StateFile(dir)); fi.Mode().Perm() != 0o600 { | |||
| 1246 | + | t.Fatalf("%v", fi.Mode()) | |||
| 1247 | + | } | |||
| 1248 | + | } | |||
| 1249 | + | ||||
| 1250 | + | // mountNamespaceOptions are the [Service] options that give a unit a mount | |||
| 1251 | + | // namespace of its own. The unit must set none of them: in container mode the | |||
| 1252 | + | // runner loop-mounts the scratch disk of a job; the Docker daemon, in the host's | |||
| 1253 | + | // namespace, binds it into the container, and a mount made inside a private mount | |||
| 1254 | + | // namespace of the unit is invisible to it (GO-001). Besides the options that say | |||
| 1255 | + | // so (PrivateTmp, ProtectHome, ProtectSystem...), systemd builds a mount | |||
| 1256 | + | // namespace for ProtectKernelModules and ProtectKernelLogs too. | |||
| 1257 | + | var mountNamespaceOptions = []string{ | |||
| 1258 | + | "PrivateTmp", "PrivateDevices", "PrivateMounts", "PrivateUsers", "ProtectHome", "ProtectSystem", | |||
| 1259 | + | "ProtectKernelModules", "ProtectKernelLogs", "ProtectKernelTunables", "ProtectControlGroups", | |||
| 1260 | + | "ProtectProc", "ProcSubset", "ProtectHostname", "ReadOnlyPaths", "ReadWritePaths", "InaccessiblePaths", | |||
| 1261 | + | "ExecPaths", "NoExecPaths", "TemporaryFileSystem", "BindPaths", "BindReadOnlyPaths", "MountFlags", | |||
| 1262 | + | "MountAPIVFS", "RootDirectory", "RootImage", "RootEphemeral", "BindLogSockets", "LogNamespace", | |||
| 1263 | + | "PrivateIPC", "PrivatePIDs", "MountImages", "ExtensionImages", "ExtensionDirectories", | |||
| 1264 | + | } | |||
| 1265 | + | ||||
| 1266 | + | // unitMountNamespaceOptions returns the lines of the [Service] section of a unit | |||
| 1267 | + | // that set one of mountNamespaceOptions. Comments, which name the options on | |||
| 1268 | + | // purpose, and other sections are ignored. | |||
| 1269 | + | func unitMountNamespaceOptions(unit string) []string { | |||
| 1270 | + | var found []string | |||
| 1271 | + | inService := false | |||
| 1272 | + | for _, line := range strings.Split(unit, "\n") { | |||
| 1273 | + | line = strings.TrimSpace(line) | |||
| 1274 | + | if strings.HasPrefix(line, "[") { | |||
| 1275 | + | inService = line == "[Service]" | |||
| 1276 | + | continue | |||
| 1277 | + | } | |||
| 1278 | + | if !inService || line == "" || strings.HasPrefix(line, "#") { | |||
| 1279 | + | continue | |||
| 1280 | + | } | |||
| 1281 | + | key, _, _ := strings.Cut(line, "=") | |||
| 1282 | + | for _, f := range mountNamespaceOptions { | |||
| 1283 | + | if strings.EqualFold(strings.TrimSpace(key), f) { | |||
| 1284 | + | found = append(found, line) | |||
| 1285 | + | } | |||
| 1286 | + | } | |||
| 1287 | + | } | |||
| 1288 | + | return found | |||
| 1289 | + | } | |||
| 1290 | + | ||||
| 1291 | + | func TestUnitHasNoMountNamespaceOptions(t *testing.T) { | |||
| 1292 | + | unit, err := os.ReadFile("../../contrib/ermite-runner.service") | |||
| 1293 | + | if err != nil { | |||
| 1294 | + | t.Skip(err) | |||
| 1295 | + | } | |||
| 1296 | + | if found := unitMountNamespaceOptions(string(unit)); len(found) > 0 { | |||
| 1297 | + | t.Fatalf("the unit sets options that give it a mount namespace of its own, which hides the loop mounts from Docker: %q", found) | |||
| 1298 | + | } | |||
| 1299 | + | // The hardening that stays must still be there. | |||
| 1300 | + | for _, want := range []string{"NoNewPrivileges=yes", "RestrictSUIDSGID=yes", "ProtectClock=yes", "RestrictAddressFamilies="} { | |||
| 1301 | + | if !strings.Contains(string(unit), "\n"+want) { | |||
| 1302 | + | t.Errorf("the unit lost %s", want) | |||
| 1303 | + | } | |||
| 1304 | + | } | |||
| 1305 | + | } | |||
| 1306 | + | ||||
| 1307 | + | // The check sees the options in [Service] (also with spaces and another case), | |||
| 1308 | + | // and ignores comments and other sections. | |||
| 1309 | + | func TestUnitMountNamespaceCheckSeesOptions(t *testing.T) { | |||
| 1310 | + | unit := "[Unit]\nPrivateTmp=yes\n[Service]\nPrivateTmp=yes\n protecthome = read-only\nProtectKernelModules=yes\n# ProtectSystem=strict\nNoNewPrivileges=yes\n[Install]\nProtectSystem=yes\n" | |||
| 1311 | + | got := unitMountNamespaceOptions(unit) | |||
| 1312 | + | want := []string{"PrivateTmp=yes", "protecthome = read-only", "ProtectKernelModules=yes"} | |||
| 1313 | + | if strings.Join(got, "|") != strings.Join(want, "|") { | |||
| 1314 | + | t.Fatalf("%q", got) | |||
| 1315 | + | } | |||
| 1316 | + | } | |||
A internal/host/shutdown_test.go +99 -0
| @@ -0,0 +1,99 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "connectrpc.com/connect" | |||
| 12 | + | "github.com/bnema/zerowrap" | |||
| 13 | + | ||||
| 14 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 15 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // stubClient answers the calls of the final report. CompleteJob records the | |||
| 19 | + | // deadline of its context, then waits for the context to end. | |||
| 20 | + | type stubClient struct { | |||
| 21 | + | mu sync.Mutex | |||
| 22 | + | deadline time.Time | |||
| 23 | + | hang bool // AppendLog waits for its context to end | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (c *stubClient) UpdateJob(context.Context, *connect.Request[ermite.UpdateJobRequest]) (*connect.Response[ermite.UpdateJobResponse], error) { | |||
| 27 | + | return connect.NewResponse(&ermite.UpdateJobResponse{}), nil | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (c *stubClient) AppendLog(ctx context.Context, r *connect.Request[ermite.AppendLogRequest]) (*connect.Response[ermite.AppendLogResponse], error) { | |||
| 31 | + | c.mu.Lock() | |||
| 32 | + | hang := c.hang | |||
| 33 | + | c.mu.Unlock() | |||
| 34 | + | if hang { | |||
| 35 | + | <-ctx.Done() | |||
| 36 | + | return nil, ctx.Err() | |||
| 37 | + | } | |||
| 38 | + | return connect.NewResponse(&ermite.AppendLogResponse{NextOffset: r.Msg.Offset + int64(len(r.Msg.Lines))}), nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (c *stubClient) Heartbeat(context.Context, *connect.Request[ermite.HeartbeatRequest]) (*connect.Response[ermite.HeartbeatResponse], error) { | |||
| 42 | + | return connect.NewResponse(&ermite.HeartbeatResponse{}), nil | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (c *stubClient) WatchJob(context.Context, *connect.Request[ermite.WatchJobRequest]) (*connect.ServerStreamForClient[ermite.WatchJobResponse], error) { | |||
| 46 | + | return nil, connect.NewError(connect.CodeUnimplemented, nil) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (c *stubClient) CompleteJob(ctx context.Context, _ *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) { | |||
| 50 | + | c.mu.Lock() | |||
| 51 | + | c.deadline, _ = ctx.Deadline() | |||
| 52 | + | c.mu.Unlock() | |||
| 53 | + | return connect.NewResponse(&ermite.CompleteJobResponse{}), nil | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // The report of a job gets ShutdownReport once the runner's shutdown channel is | |||
| 57 | + | // closed, even when the stop timers did not mark the job as stopping (GO-004), | |||
| 58 | + | // and CompleteDeadline otherwise. | |||
| 59 | + | func TestReportBudgetFollowsTheShutdownChannel(t *testing.T) { | |||
| 60 | + | for _, tc := range []struct { | |||
| 61 | + | name string | |||
| 62 | + | shutdown bool | |||
| 63 | + | want time.Duration | |||
| 64 | + | }{ | |||
| 65 | + | {"running", false, time.Hour}, | |||
| 66 | + | {"shutdown", true, 30 * time.Second}, | |||
| 67 | + | } { | |||
| 68 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 69 | + | tm := Timing{ | |||
| 70 | + | CallTimeout: 24 * time.Hour, CompleteDeadline: time.Hour, ShutdownReport: 30 * time.Second, | |||
| 71 | + | BackoffMin: time.Millisecond, BackoffMax: time.Millisecond, | |||
| 72 | + | } | |||
| 73 | + | cl := &stubClient{} | |||
| 74 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 75 | + | defer cancel() | |||
| 76 | + | j := &jobRun{client: cl, t: tm, ctx: ctx, job: &ermite.Job{JobId: "j1"}, steps: map[int32]*ermite.StepUpdate{}, | |||
| 77 | + | cancelCh: make(chan *execpb.Cancel, 1), log: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logBuf{}})} | |||
| 78 | + | j.outbox = newOutbox(cl, "j1", tm, func(error) {}, j.log) | |||
| 79 | + | j.outbox.start(ctx) | |||
| 80 | + | shutdown := make(chan struct{}) | |||
| 81 | + | if tc.shutdown { | |||
| 82 | + | close(shutdown) | |||
| 83 | + | } | |||
| 84 | + | if j.isStopping() { | |||
| 85 | + | t.Fatal("the job must not be marked as stopping: only the channel is closed") | |||
| 86 | + | } | |||
| 87 | + | start := time.Now() | |||
| 88 | + | j.report(ctx, outcome{conclusion: ermite.CiConclusion_CI_CONCLUSION_SUCCESS}, shutdown) | |||
| 89 | + | cl.mu.Lock() | |||
| 90 | + | defer cl.mu.Unlock() | |||
| 91 | + | if cl.deadline.IsZero() { | |||
| 92 | + | t.Fatal("CompleteJob was not called") | |||
| 93 | + | } | |||
| 94 | + | if got := cl.deadline.Sub(start); got > tc.want+time.Second || got < tc.want-time.Second { | |||
| 95 | + | t.Fatalf("budget %v, want about %v", got, tc.want) | |||
| 96 | + | } | |||
| 97 | + | }) | |||
| 98 | + | } | |||
| 99 | + | } | |||
A internal/host/util_test.go +11 -0
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "google.golang.org/protobuf/types/known/durationpb" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func durationpbOf(d time.Duration) *durationpb.Duration { return durationpb.New(d) } | |||
M internal/sandbox/container/docker_api_test.go +1 -1
| @@ -62,7 +62,7 @@ func TestDockerAPIWiring(t *testing.T) { | |||||
| 62 | run := noMountRunner{} | 62 | run := noMountRunner{} | ||
| 63 | empty := filepath.Join(dir, "mountinfo") | 63 | empty := filepath.Join(dir, "mountinfo") | ||
| 64 | _ = os.WriteFile(empty, nil, 0o644) | 64 | _ = os.WriteFile(empty, nil, 0o644) | ||
| 65 | - | d, err := New(ctx, cfg, Options{Net: plumb, allowRootless: true, ConnectTimeout: 60 * time.Second, Run: run, | 65 | + | d, err := New(ctx, cfg, Options{Net: plumb, UnsafeAllowRootlessForTests: true, ConnectTimeout: 60 * time.Second, Run: run, |
| 66 | Mounter: &disk.Mounter{Run: run, Mountinfo: empty}}) | 66 | Mounter: &disk.Mounter{Run: run, Mountinfo: empty}}) | ||
| 67 | if err != nil { | 67 | if err != nil { | ||
| 68 | t.Fatal(err) | 68 | t.Fatal(err) | ||
M internal/sandbox/container/driver.go +6 -5
| @@ -53,10 +53,11 @@ type Options struct { | |||||
| 53 | Net netsetup.Plumbing | 53 | Net netsetup.Plumbing | ||
| 54 | // engine overrides the Docker client (tests). | 54 | // engine overrides the Docker client (tests). | ||
| 55 | engine engine | 55 | engine engine | ||
| 56 | - | // allowRootless lets tests exercise the Engine API against the rootless | 56 | + | // UnsafeAllowRootlessForTests lets tests exercise the Engine API and the executor against |
| 57 | - | // daemon of a workstation. Never set outside tests: a nested dockerd cannot | 57 | + | // the rootless daemon of a workstation. Never set outside tests: a nested |
| 58 | - | // work in a rootless daemon. | 58 | + | // dockerd cannot work in a rootless daemon, so the jobs would run without |
| 59 | - | allowRootless bool | 59 | + | // Docker. The factories of package drivers never set it. |
| 60 | + | UnsafeAllowRootlessForTests bool | |||
| 60 | // ConnectTimeout bounds container start + init + executor connection | 61 | // ConnectTimeout bounds container start + init + executor connection | ||
| 61 | // (default 90 s). | 62 | // (default 90 s). | ||
| 62 | ConnectTimeout time.Duration | 63 | ConnectTimeout time.Duration | ||
| @@ -133,7 +134,7 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||||
| 133 | if err != nil { | 134 | if err != nil { | ||
| 134 | return fail(fmt.Errorf("cannot reach the Docker daemon (DOCKER_HOST): %w", err)) | 135 | return fail(fmt.Errorf("cannot reach the Docker daemon (DOCKER_HOST): %w", err)) | ||
| 135 | } | 136 | } | ||
| 136 | - | if info.Rootless && !opt.allowRootless { | 137 | + | if info.Rootless && !opt.UnsafeAllowRootlessForTests { |
| 137 | return fail(errRootless) | 138 | return fail(errRootless) | ||
| 138 | } | 139 | } | ||
| 139 | if info.OSType != "" && info.OSType != "linux" { | 140 | if info.OSType != "" && info.OSType != "linux" { | ||
A internal/sandbox/drivers/drivers_test.go +43 -0
| @@ -0,0 +1,43 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package drivers | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "go/ast" | |||
| 7 | + | "go/parser" | |||
| 8 | + | "go/token" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // The container option that lets a driver run against a rootless Docker daemon | |||
| 13 | + | // exists for tests: with it the nested dockerd of the job cannot start, so jobs | |||
| 14 | + | // would run without Docker and with weaker isolation than the runner promises. | |||
| 15 | + | // Production code (this package builds the drivers of `ermite-runner run`) must | |||
| 16 | + | // never mention it, neither as a field of Options nor in a value. | |||
| 17 | + | func TestProductionFactoriesNeverSetTheTestOnlyRootlessOption(t *testing.T) { | |||
| 18 | + | fset := token.NewFileSet() | |||
| 19 | + | files, err := parser.ParseFile(fset, "drivers.go", nil, 0) | |||
| 20 | + | if err != nil { | |||
| 21 | + | t.Fatal(err) | |||
| 22 | + | } | |||
| 23 | + | found := false | |||
| 24 | + | ast.Inspect(files, func(n ast.Node) bool { | |||
| 25 | + | switch x := n.(type) { | |||
| 26 | + | case *ast.Ident: | |||
| 27 | + | if x.Name == "UnsafeAllowRootlessForTests" || x.Name == "AllowRootless" { | |||
| 28 | + | t.Errorf("%s: %s is a test-only option", fset.Position(x.Pos()), x.Name) | |||
| 29 | + | } | |||
| 30 | + | case *ast.CompositeLit: | |||
| 31 | + | if sel, ok := x.Type.(*ast.SelectorExpr); ok && sel.Sel.Name == "Options" { | |||
| 32 | + | found = true | |||
| 33 | + | if len(x.Elts) != 0 { | |||
| 34 | + | t.Errorf("%s: %s{...} sets options in production code", fset.Position(x.Pos()), sel.Sel.Name) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | return true | |||
| 39 | + | }) | |||
| 40 | + | if !found { | |||
| 41 | + | t.Fatal("drivers.go no longer builds container.Options{}: update this test") | |||
| 42 | + | } | |||
| 43 | + | } | |||
M internal/sandbox/integration/vm_test.go +107 -0
| @@ -8,7 +8,10 @@ import ( | |||||
| 8 | "strings" | 8 | "strings" | ||
| 9 | "syscall" | 9 | "syscall" | ||
| 10 | "testing" | 10 | "testing" | ||
| 11 | + | "time" | |||
| 11 | 12 | ||||
| 13 | + | "bnema.dev/ermite-runner/internal/execstream" | |||
| 14 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 12 | "bnema.dev/ermite-runner/internal/sandbox" | 15 | "bnema.dev/ermite-runner/internal/sandbox" | ||
| 13 | "bnema.dev/ermite-runner/internal/sandbox/drivers" | 16 | "bnema.dev/ermite-runner/internal/sandbox/drivers" | ||
| 14 | ) | 17 | ) | ||
| @@ -101,3 +104,107 @@ ip -6 addr show scope global | grep -c inet6 || true | |||||
| 101 | t.Errorf("the DNS forwarder on the gateway must answer:\n%s", r.out) | 104 | t.Errorf("the DNS forwarder on the gateway must answer:\n%s", r.out) | ||
| 102 | } | 105 | } | ||
| 103 | } | 106 | } | ||
| 107 | + | ||||
| 108 | + | // TestVMRunsAJob drives a job (not the stub script) through the executor in a | |||
| 109 | + | // micro-VM: the runner user, the work layout, file commands, outputs, a failing | |||
| 110 | + | // step and cancellation by SIGINT. The VM is rootful-free like the other VM | |||
| 111 | + | // tests (user + network namespace, jailer off). | |||
| 112 | + | func TestVMRunsAJob(t *testing.T) { | |||
| 113 | + | if os.Getenv("ERMITE_RUNNER_IT_VM") != "1" { | |||
| 114 | + | t.Skip("set ERMITE_RUNNER_IT_VM=1 (needs KVM, firecracker, a kernel and a base image; see README)") | |||
| 115 | + | } | |||
| 116 | + | need(t, "mkfs.ext4") | |||
| 117 | + | needNetAdmin(t) | |||
| 118 | + | ctx := ctxWithLog(t) | |||
| 119 | + | cfg := loadConfig(t, sandbox.VM) | |||
| 120 | + | if why := sandbox.VMUnavailable(cfg, sandbox.Prober{}); len(why) > 0 { | |||
| 121 | + | t.Fatalf("vm isolation is not available here: %s", strings.Join(why, "; ")) | |||
| 122 | + | } | |||
| 123 | + | drv, err := sandbox.Select(ctx, cfg, drivers.Factories(), sandbox.Prober{}) | |||
| 124 | + | if err != nil { | |||
| 125 | + | t.Fatal(err) | |||
| 126 | + | } | |||
| 127 | + | t.Cleanup(func() { | |||
| 128 | + | _ = drv.Close() | |||
| 129 | + | if d2, err := sandbox.Select(context.Background(), cfg, drivers.Factories(), sandbox.Prober{}); err == nil { | |||
| 130 | + | _ = d2.Reap(context.Background()) | |||
| 131 | + | _ = d2.Close() | |||
| 132 | + | } | |||
| 133 | + | }) | |||
| 134 | + | if err := drv.Reap(ctx); err != nil { | |||
| 135 | + | t.Fatal(err) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | run := func(yaml string, cancelAfter time.Duration) (steps []string, logs []string, res *execpb.JobResult, exit *execpb.Exit) { | |||
| 139 | + | t.Helper() | |||
| 140 | + | sb, err := drv.New() | |||
| 141 | + | if err != nil { | |||
| 142 | + | t.Fatal(err) | |||
| 143 | + | } | |||
| 144 | + | defer func() { | |||
| 145 | + | if err := sb.Stop(ctx); err != nil { | |||
| 146 | + | t.Errorf("stop: %v", err) | |||
| 147 | + | } | |||
| 148 | + | }() | |||
| 149 | + | conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "vm-job"}) | |||
| 150 | + | if err != nil { | |||
| 151 | + | t.Fatal(err) | |||
| 152 | + | } | |||
| 153 | + | var cancel chan *execpb.Cancel | |||
| 154 | + | if cancelAfter > 0 { | |||
| 155 | + | cancel = make(chan *execpb.Cancel, 1) | |||
| 156 | + | time.AfterFunc(cancelAfter, func() { cancel <- &execpb.Cancel{Reason: "test"} }) | |||
| 157 | + | } | |||
| 158 | + | _, exit, err = execstream.DriveJob(conn, &execpb.Run{Job: &execpb.JobSpec{ | |||
| 159 | + | WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello", RunnerName: "it", RunnerVersion: "test", | |||
| 160 | + | RunnerEnvironment: "self-hosted", Matrix: "{}", | |||
| 161 | + | Contexts: `{"github":{"repository":"octo/hello","event_name":"push","job":"j","ref":"refs/heads/main","sha":"0123456789abcdef0123456789abcdef01234567"}}`, | |||
| 162 | + | }}, cancel, execstream.JobHandler{ | |||
| 163 | + | StepStarted: func(s *execpb.StepStarted) { steps = append(steps, s.Name) }, | |||
| 164 | + | Log: func(l *execpb.Log) { logs = append(logs, l.Text) }, | |||
| 165 | + | JobResult: func(r *execpb.JobResult) { res = r }, | |||
| 166 | + | }) | |||
| 167 | + | if err != nil { | |||
| 168 | + | t.Fatalf("driving the job: %v", err) | |||
| 169 | + | } | |||
| 170 | + | return | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | _, logs, res, exit := run(`on: push | |||
| 174 | + | jobs: | |||
| 175 | + | j: | |||
| 176 | + | runs-on: x | |||
| 177 | + | outputs: | |||
| 178 | + | who: ${{ steps.a.outputs.who }} | |||
| 179 | + | steps: | |||
| 180 | + | - id: a | |||
| 181 | + | run: | | |||
| 182 | + | echo "who=$(id -un):$(id -u)" >> "$GITHUB_OUTPUT" | |||
| 183 | + | echo "pwd=$PWD home=$HOME" | |||
| 184 | + | echo "FOO=bar" >> "$GITHUB_ENV" | |||
| 185 | + | - run: echo "FOO=$FOO CI=$CI" | |||
| 186 | + | - run: exit 3 | |||
| 187 | + | - if: always() | |||
| 188 | + | run: echo still-here | |||
| 189 | + | `, 0) | |||
| 190 | + | all := strings.Join(logs, "\n") | |||
| 191 | + | if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_FAILURE || exit.Code == 0 { | |||
| 192 | + | t.Fatalf("result %v exit %v\n%s", res, exit, all) | |||
| 193 | + | } | |||
| 194 | + | if res.Outputs["who"] != "runner:1001" { | |||
| 195 | + | t.Errorf("steps ran as %q\n%s", res.Outputs["who"], all) | |||
| 196 | + | } | |||
| 197 | + | for _, w := range []string{"pwd=/home/runner/work/hello/hello home=/home/runner", "FOO=bar CI=true", "##[error]Process completed with exit code 3.", "still-here"} { | |||
| 198 | + | if !strings.Contains(all, w) { | |||
| 199 | + | t.Errorf("log lacks %q\n%s", w, all) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | start := time.Now() | |||
| 204 | + | _, logs, res, _ = run("on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: |\n trap 'echo got-int; exit 130' INT\n echo ready\n while :; do sleep 0.2; done\n", 8*time.Second) | |||
| 205 | + | all = strings.Join(logs, "\n") | |||
| 206 | + | if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_CANCELLED || !strings.Contains(all, "got-int") { | |||
| 207 | + | t.Fatalf("cancel: %v\n%s", res, all) | |||
| 208 | + | } | |||
| 209 | + | t.Logf("cancelled job ended %v after the start", time.Since(start)) | |||
| 210 | + | } | |||
M internal/sandbox/sandboxtest/sandboxtest.go +15 -0
| @@ -6,14 +6,29 @@ package sandboxtest | |||||
| 6 | import ( | 6 | import ( | ||
| 7 | "context" | 7 | "context" | ||
| 8 | "encoding/binary" | 8 | "encoding/binary" | ||
| 9 | + | "io" | |||
| 9 | "net/netip" | 10 | "net/netip" | ||
| 10 | "os" | 11 | "os" | ||
| 11 | "sync" | 12 | "sync" | ||
| 12 | "testing" | 13 | "testing" | ||
| 13 | 14 | ||||
| 15 | + | "bnema.dev/ermite-runner/internal/cmdrun" | |||
| 14 | "bnema.dev/ermite-runner/internal/netpolicy" | 16 | "bnema.dev/ermite-runner/internal/netpolicy" | ||
| 15 | ) | 17 | ) | ||
| 16 | 18 | ||||
| 19 | + | // NoMountRunner runs real commands except mount and umount, which succeed | |||
| 20 | + | // without doing anything: loop mounts need root, and the tests that use it | |||
| 21 | + | // replace them by plain directories (mkfs still runs). | |||
| 22 | + | type NoMountRunner struct{} | |||
| 23 | + | ||||
| 24 | + | // Run implements cmdrun.Runner. | |||
| 25 | + | func (NoMountRunner) Run(ctx context.Context, in io.Reader, name string, args ...string) ([]byte, error) { | |||
| 26 | + | if name == "mount" || name == "umount" { | |||
| 27 | + | return nil, nil | |||
| 28 | + | } | |||
| 29 | + | return cmdrun.Exec{}.Run(ctx, in, name, args...) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 17 | // WriteStaticELF writes a minimal statically linked ELF64 file (one PT_LOAD, no | 32 | // WriteStaticELF writes a minimal statically linked ELF64 file (one PT_LOAD, no | ||
| 18 | // PT_INTERP): enough for sandbox.CheckStatic, never executed. | 33 | // PT_INTERP): enough for sandbox.CheckStatic, never executed. | ||
| 19 | func WriteStaticELF(t testing.TB, path string) { | 34 | func WriteStaticELF(t testing.TB, path string) { | ||
M main.go +13 -6
| @@ -4,10 +4,10 @@ | |||||
| 4 | // its own Firecracker micro-VM, or in its own container when the host has no | 4 | // its own Firecracker micro-VM, or in its own container when the host has no | ||
| 5 | // KVM, and executes workflows compatible with GitHub Actions. | 5 | // KVM, and executes workflows compatible with GitHub Actions. | ||
| 6 | // | 6 | // | ||
| 7 | - | // Subcommands: version; init and exec run inside a sandbox (guest PID 1, and | 7 | + | // Subcommands: version; register stores a runner token obtained from ermite; |
| 8 | - | // the executor); sandbox-check runs one stub job through the selected isolation | 8 | + | // run fetches jobs from ermite and runs each in a sandbox; init and exec run |
| 9 | - | // mode and prints its output (phase D of the CI-1 plan: the host loop that | 9 | + | // inside a sandbox (guest PID 1, and the executor); sandbox-check runs one stub |
| 10 | - | // fetches jobs from ermite comes later). | 10 | + | // script through the selected isolation mode and prints its output. |
| 11 | package main | 11 | package main | ||
| 12 | 12 | ||||
| 13 | import ( | 13 | import ( | ||
| @@ -49,13 +49,17 @@ func run(args []string) int { | |||||
| 49 | return execCmd(ctx) | 49 | return execCmd(ctx) | ||
| 50 | case "sandbox-check": | 50 | case "sandbox-check": | ||
| 51 | return sandboxCheck(ctx, args[1:]) | 51 | return sandboxCheck(ctx, args[1:]) | ||
| 52 | + | case "register": | |||
| 53 | + | return registerCmd(ctx, args[1:]) | |||
| 54 | + | case "run": | |||
| 55 | + | return runCmd(ctx) | |||
| 52 | } | 56 | } | ||
| 53 | usage() | 57 | usage() | ||
| 54 | return 2 | 58 | return 2 | ||
| 55 | } | 59 | } | ||
| 56 | 60 | ||||
| 57 | func usage() { | 61 | func usage() { | ||
| 58 | - | fmt.Fprintln(os.Stderr, "usage: ermite-runner version | sandbox-check [script]\n (inside a sandbox: ermite-runner init | exec)") | 62 | + | fmt.Fprintln(os.Stderr, "usage: ermite-runner version | register [--token T] [--replace] | run | sandbox-check [script]\n (inside a sandbox: ermite-runner init | exec)") |
| 59 | } | 63 | } | ||
| 60 | 64 | ||||
| 61 | func envOr(key, def string) string { | 65 | func envOr(key, def string) string { | ||
| @@ -65,7 +69,7 @@ func envOr(key, def string) string { | |||||
| 65 | return def | 69 | return def | ||
| 66 | } | 70 | } | ||
| 67 | 71 | ||||
| 68 | - | // execCmd is `ermite-runner exec`, the stub executor. | 72 | + | // execCmd is `ermite-runner exec`, the executor of a sandbox. |
| 69 | func execCmd(ctx context.Context) int { | 73 | func execCmd(ctx context.Context) int { | ||
| 70 | log := zerowrap.FromCtx(ctx) | 74 | log := zerowrap.FromCtx(ctx) | ||
| 71 | target, err := guest.ParseTarget(os.Getenv("ERMITE_CONNECT")) | 75 | target, err := guest.ParseTarget(os.Getenv("ERMITE_CONNECT")) | ||
| @@ -77,6 +81,9 @@ func execCmd(ctx context.Context) int { | |||||
| 77 | Target: target, | 81 | Target: target, | ||
| 78 | Sandbox: envOr("ERMITE_SANDBOX", "container"), | 82 | Sandbox: envOr("ERMITE_SANDBOX", "container"), | ||
| 79 | Version: envOr("ERMITE_VERSION", version), | 83 | Version: envOr("ERMITE_VERSION", version), | ||
| 84 | + | // A test image without the user "runner" (ERMITE_RUNNER_ALLOW_ROOT_STEPS=1); | |||
| 85 | + | // a runner never sets it: the job refuses to start instead. | |||
| 86 | + | AllowRootSteps: os.Getenv("ERMITE_RUNNER_ALLOW_ROOT_STEPS") == "1", | |||
| 80 | }) | 87 | }) | ||
| 81 | if err != nil { | 88 | if err != nil { | ||
| 82 | log.Error().Err(err).Msg("exec") | 89 | log.Error().Err(err).Msg("exec") | ||