detached 96d8ce3

docs(readme): service installation, image requirements, job limits, end-to-end tests

commit
96d8ce34e975732c880bc980c07b9a213289b839
author
brice · · 2026-10-12 18:55 UTC
parent
012b708
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +90 -0
M README.md +90 -0
@@ -83,6 +83,83 @@ the host process; the sandbox receives the workflow and its contexts only.
8383 | `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) |
8484 | `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` |
8585
86+## Installing the service
87+
88+`contrib/ermite-runner.service` is a systemd unit. The runner runs as **root**
89+on the host (TAP devices, nftables, loop mounts, the jailer, containers); the
90+jobs never do: they run as user `runner` in a micro-VM or a container.
91+
92+```sh
93+sudo install -m 0755 ermite-runner /usr/local/bin/ermite-runner # a static build: CGO_ENABLED=0
94+sudo install -m 0644 contrib/ermite-runner.service /etc/systemd/system/
95+sudo install -d -m 0700 /etc/ermite-runner
96+sudo tee /etc/ermite-runner/env >/dev/null <<'ENV'
97+ERMITE_RUNNER_URL=https://ermite.example.org
98+ERMITE_RUNNER_NAME=igor
99+ERMITE_RUNNER_LABELS=ubuntu-latest,ubuntu-24.04
100+ERMITE_RUNNER_ISOLATION=auto
101+ERMITE_RUNNER_CAPACITY=2
102+ERMITE_RUNNER_JOB_CPUS=4
103+ERMITE_RUNNER_JOB_MEMORY=8G
104+ERMITE_RUNNER_JOB_DISK=64G
105+ENV
106+sudo chmod 0600 /etc/ermite-runner/env
107+ERMITE_RUNNER_STATE_DIR=/var/lib/ermite-runner ERMITE_RUNNER_URL=https://ermite.example.org \
108+ sudo -E ermite-runner register --token <token with runner:register>
109+sudo systemctl enable --now ermite-runner
110+```
111+
112+`systemctl stop` sends SIGTERM: running jobs are cancelled with
113+`runner_shutdown`, reported to ermite, and their sandboxes removed before the
114+service exits (the unit allows 120 s). A crash or a power cut leaves sandboxes
115+behind; the next start reaps them. Only one runner may live on a host (a
116+host-wide lock enforces it), because reaping removes everything labelled as the
117+runner's own.
118+
119+## The image
120+
121+A job runs in the image named by `ERMITE_RUNNER_IMAGE` (container: an OCI
122+reference; vm: the directory with `vmlinux` and `base.ext4`, both built from
123+`images/ubuntu-24.04/Dockerfile`). The runner binary is attached to the sandbox
124+by the host and is never part of the image. What the executor needs from the
125+image:
126+
127+- a user **`runner`** with uid and gid 1001 and the home `/home/runner`; the
128+ executor starts as root (init needs it to start `dockerd`) and runs every step
129+ as this user, with HOME, USER and LOGNAME set. Without such a user the steps
130+ run as root (a test image only);
131+- the directories `/home/runner/work` and `/opt/hostedtoolcache` owned by
132+ `runner` (`RUNNER_TOOL_CACHE`, which the executor reads from its environment
133+ like the official runner; each job gets `/home/runner/work/<repo>/<repo>` and
134+ `/home/runner/work/_temp`, cleaned at the start of the job);
135+- **`bash`** and **`sh`** (the default shell is `bash -e {0}` when bash exists,
136+ else `sh -e {0}`; like on hosted runners `shell: bash` adds
137+ `--noprofile --norc -eo pipefail`), `python3` and a `python` command for
138+ `shell: python`; `pwsh` is not part of the CI-1 image (a step that asks for it
139+ fails with `pwsh: command not found`);
140+- `dockerd` (and passwordless `sudo` for `runner`) for jobs that use Docker: the
141+ job gets its own daemon with its data root on the scratch disk;
142+- what workflows commonly use: git, curl, jq, build-essential, ca-certificates,
143+ tzdata, zip/unzip. Full parity with the hosted images (`actions/runner-images`)
144+ is a later phase.
145+
146+## Job limits
147+
148+Each job gets its own resources, set for the runner as a whole:
149+
150+| Variable | Default | Effect |
151+|---|---|---|
152+| `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. |
153+| `ERMITE_RUNNER_JOB_CPUS` | `2` | vCPUs of a micro-VM; CPU quota of a container |
154+| `ERMITE_RUNNER_JOB_MEMORY` | `4G` | guest memory (`M`, `G`, `T`; at least `256M`); a jailed VMM gets this plus 256 MiB of cgroup headroom |
155+| `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 |
156+| `ERMITE_RUNNER_JOB_PIDS` | `32768` | process limit of a container |
157+| `ERMITE_RUNNER_JOB_DISK_BANDWIDTH`, `_JOB_DISK_IOPS`, `_JOB_NET_BANDWIDTH` | `400`, `20000`, `1000` | VM rate limits (MiB/s, IOPS, Mbit/s); `0` disables |
158+
159+The job timeout is not a runner setting: it is the workflow's `timeout-minutes`
160+(360 by default), which ermite sends with the job and the executor enforces from
161+the moment it starts.
162+
86163 ## Configuration (sandbox part)
87164
88165 | Variable | Default | |
@@ -140,6 +217,19 @@ sudo env GOWORK=off ERMITE_RUNNER_IT_CONTAINER=1 ERMITE_RUNNER_IMAGE=ermite-runn
140217 DOCKER_HOST=unix:///var/run/docker.sock "$(command -v go)" test -count=1 -v ./internal/sandbox/integration -run Container
141218 ```
142219
220+**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 `AllowRootless`; 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.
221+
222+```sh
223+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
224+ERMITE_RUNNER_IT_E2E=1 ERMITE_RUNNER_TEST_IMAGE=ermite-runner-ubuntu-24.04:dev GOWORK=off go test -race -count=1 ./internal/e2e
225+```
226+
227+- `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".
228+- `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.
229+- `TestVMRunsAJob` (`internal/sandbox/integration`, run by `scripts/it-vm-userns.sh`) does the first of these in a micro-VM.
230+
231+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.
232+
143233 `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.
144234
145235 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.
@@ -83,6 +83,83 @@ the host process; the sandbox receives the workflow and its contexts only.
83 | `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) |83 | `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) |
84 | `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` |84 | `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` |
85 85
86+## Installing the service
87+
88+`contrib/ermite-runner.service` is a systemd unit. The runner runs as **root**
89+on the host (TAP devices, nftables, loop mounts, the jailer, containers); the
90+jobs never do: they run as user `runner` in a micro-VM or a container.
91+
92+```sh
93+sudo install -m 0755 ermite-runner /usr/local/bin/ermite-runner # a static build: CGO_ENABLED=0
94+sudo install -m 0644 contrib/ermite-runner.service /etc/systemd/system/
95+sudo install -d -m 0700 /etc/ermite-runner
96+sudo tee /etc/ermite-runner/env >/dev/null <<'ENV'
97+ERMITE_RUNNER_URL=https://ermite.example.org
98+ERMITE_RUNNER_NAME=igor
99+ERMITE_RUNNER_LABELS=ubuntu-latest,ubuntu-24.04
100+ERMITE_RUNNER_ISOLATION=auto
101+ERMITE_RUNNER_CAPACITY=2
102+ERMITE_RUNNER_JOB_CPUS=4
103+ERMITE_RUNNER_JOB_MEMORY=8G
104+ERMITE_RUNNER_JOB_DISK=64G
105+ENV
106+sudo chmod 0600 /etc/ermite-runner/env
107+ERMITE_RUNNER_STATE_DIR=/var/lib/ermite-runner ERMITE_RUNNER_URL=https://ermite.example.org \
108+ sudo -E ermite-runner register --token <token with runner:register>
109+sudo systemctl enable --now ermite-runner
110+```
111+
112+`systemctl stop` sends SIGTERM: running jobs are cancelled with
113+`runner_shutdown`, reported to ermite, and their sandboxes removed before the
114+service exits (the unit allows 120 s). A crash or a power cut leaves sandboxes
115+behind; the next start reaps them. Only one runner may live on a host (a
116+host-wide lock enforces it), because reaping removes everything labelled as the
117+runner's own.
118+
119+## The image
120+
121+A job runs in the image named by `ERMITE_RUNNER_IMAGE` (container: an OCI
122+reference; vm: the directory with `vmlinux` and `base.ext4`, both built from
123+`images/ubuntu-24.04/Dockerfile`). The runner binary is attached to the sandbox
124+by the host and is never part of the image. What the executor needs from the
125+image:
126+
127+- a user **`runner`** with uid and gid 1001 and the home `/home/runner`; the
128+ executor starts as root (init needs it to start `dockerd`) and runs every step
129+ as this user, with HOME, USER and LOGNAME set. Without such a user the steps
130+ run as root (a test image only);
131+- the directories `/home/runner/work` and `/opt/hostedtoolcache` owned by
132+ `runner` (`RUNNER_TOOL_CACHE`, which the executor reads from its environment
133+ like the official runner; each job gets `/home/runner/work/<repo>/<repo>` and
134+ `/home/runner/work/_temp`, cleaned at the start of the job);
135+- **`bash`** and **`sh`** (the default shell is `bash -e {0}` when bash exists,
136+ else `sh -e {0}`; like on hosted runners `shell: bash` adds
137+ `--noprofile --norc -eo pipefail`), `python3` and a `python` command for
138+ `shell: python`; `pwsh` is not part of the CI-1 image (a step that asks for it
139+ fails with `pwsh: command not found`);
140+- `dockerd` (and passwordless `sudo` for `runner`) for jobs that use Docker: the
141+ job gets its own daemon with its data root on the scratch disk;
142+- what workflows commonly use: git, curl, jq, build-essential, ca-certificates,
143+ tzdata, zip/unzip. Full parity with the hosted images (`actions/runner-images`)
144+ is a later phase.
145+
146+## Job limits
147+
148+Each job gets its own resources, set for the runner as a whole:
149+
150+| Variable | Default | Effect |
151+|---|---|---|
152+| `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. |
153+| `ERMITE_RUNNER_JOB_CPUS` | `2` | vCPUs of a micro-VM; CPU quota of a container |
154+| `ERMITE_RUNNER_JOB_MEMORY` | `4G` | guest memory (`M`, `G`, `T`; at least `256M`); a jailed VMM gets this plus 256 MiB of cgroup headroom |
155+| `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 |
156+| `ERMITE_RUNNER_JOB_PIDS` | `32768` | process limit of a container |
157+| `ERMITE_RUNNER_JOB_DISK_BANDWIDTH`, `_JOB_DISK_IOPS`, `_JOB_NET_BANDWIDTH` | `400`, `20000`, `1000` | VM rate limits (MiB/s, IOPS, Mbit/s); `0` disables |
158+
159+The job timeout is not a runner setting: it is the workflow's `timeout-minutes`
160+(360 by default), which ermite sends with the job and the executor enforces from
161+the moment it starts.
162+
86 ## Configuration (sandbox part)163 ## Configuration (sandbox part)
87 164
88 | Variable | Default | |165 | Variable | Default | |
@@ -140,6 +217,19 @@ sudo env GOWORK=off ERMITE_RUNNER_IT_CONTAINER=1 ERMITE_RUNNER_IMAGE=ermite-runn
140 DOCKER_HOST=unix:///var/run/docker.sock "$(command -v go)" test -count=1 -v ./internal/sandbox/integration -run Container217 DOCKER_HOST=unix:///var/run/docker.sock "$(command -v go)" test -count=1 -v ./internal/sandbox/integration -run Container
141 ```218 ```
142 219
220+**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 `AllowRootless`; 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.
221+
222+```sh
223+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
224+ERMITE_RUNNER_IT_E2E=1 ERMITE_RUNNER_TEST_IMAGE=ermite-runner-ubuntu-24.04:dev GOWORK=off go test -race -count=1 ./internal/e2e
225+```
226+
227+- `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".
228+- `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.
229+- `TestVMRunsAJob` (`internal/sandbox/integration`, run by `scripts/it-vm-userns.sh`) does the first of these in a micro-VM.
230+
231+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.
232+
143 `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.233 `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.
144 234
145 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.235 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close