detached 6be4205

chore(build): consistent image tag, exact kernel option matching, e2fsprogs check, CI proto drift check; docs for the new limits, host lock and ip_forward

commit
6be4205668eca2f6733daa5ea31d24612b4ae1f6
author
brice · · 2026-10-12 16:21 UTC
parent
146655a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +92 -37
M .forgejo/workflows/ci.yml +7 -0
@@ -38,4 +38,11 @@ jobs:
3838 go mod tidy
3939 git diff --exit-code -- go.mod go.sum
4040 - run: go vet ./...
41+ - name: Protobuf lint and drift
42+ # 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.
44+ run: |
45+ go tool buf lint
46+ go generate ./internal/execstream
47+ git diff --exit-code -- internal/execstream/execpb
4148 - run: go test -race ./...
@@ -38,4 +38,11 @@ jobs:
38 go mod tidy38 go mod tidy
39 git diff --exit-code -- go.mod go.sum39 git diff --exit-code -- go.mod go.sum
40 - run: go vet ./...40 - run: go vet ./...
41+ - name: Protobuf lint and drift
42+ # 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.
44+ run: |
45+ go tool buf lint
46+ go generate ./internal/execstream
47+ git diff --exit-code -- internal/execstream/execpb
41 - run: go test -race ./...48 - run: go test -race ./...
M README.md +31 -9
@@ -16,25 +16,37 @@ Both modes run the same executable, the runner's own, attached **read-only** to
1616 ### vm
1717
1818 - Firecracker is driven through its HTTP API on a unix socket with `net/http`, not through `firecracker-go-sdk` (its last release, v1.0.0 from 2022, predates Firecracker 1.17 and pulls CNI, go-openapi and netlink for a handful of PUT requests).
19-- Disks: the runner executable (read-only boot root, ext4 built with `mkfs.ext4 -d`, cached by the executable's hash), the shared read-only base image, and a per-job sparse scratch disk (`ERMITE_RUNNER_JOB_DISK`). The guest init builds an overlayfs root (lower = base, upper = scratch) and keeps dockerd's data root on the scratch disk.
20-- The jailer runs each VM as uid/gid `ERMITE_RUNNER_UID_BASE` + slot in its own chroot under the state directory. The kernel, the base image and the state directory must be on the same filesystem (files are hard-linked into the jail) and the kernel and base image must be world-readable.
19+- Disks: the runner executable (read-only boot root, ext4 built with `mkfs.ext4 -d`, cached by the executable's hash), the shared read-only base image, and a per-job sparse scratch disk (`ERMITE_RUNNER_JOB_DISK`). The guest init builds an overlayfs root (lower = base, upper = scratch) and keeps dockerd's data root on the scratch disk. The scratch disk and `eth0` are rate limited (defaults 400 MiB/s and 20000 IOPS, 1 Gbit/s each way; `ERMITE_RUNNER_JOB_DISK_BANDWIDTH` in MiB/s, `_JOB_DISK_IOPS`, `_JOB_NET_BANDWIDTH` in Mbit/s; 0 disables).
20+- The jailer runs each VM as uid/gid `ERMITE_RUNNER_UID_BASE` + slot in its own chroot under the state directory, in a new PID namespace and in a cgroup v2 with `memory.max` (guest memory + 256 MiB for the VMM) and `pids.max`. The kernel, the base image and the state directory must be on the same filesystem (files are hard-linked into the jail) and the kernel and base image must be world-readable.
2121 - One TAP (`ertap<slot>`) per VM with a /30 from `ERMITE_RUNNER_NETWORK` (default `10.200.0.0/16`), vsock for the executor stream, IPv6 disabled in the guest.
2222 - Needs root, or `CAP_NET_ADMIN` and the rights the jailer requires (the plan runs the host process as root under systemd).
2323
2424 ### container
2525
26-- Docker Engine API via the maintained client `github.com/moby/moby/client` (`DOCKER_HOST` honoured). Runtime `sysbox-runc` when the daemon lists it, else a **privileged** container, which is host-root equivalent: it is logged at start and its network policy is best effort.
26+- Docker Engine API via the maintained client `github.com/moby/moby/client` (`DOCKER_HOST` honoured). Runtime `sysbox-runc` when the daemon lists it, else a **privileged** container, which is host-root equivalent. That is allowed by default (ADR 0025) but never silent: the runner logs a warning at start and at the start of every job (`privileged container: host-root equivalent, network policy best effort`), `Driver.Isolation()` reports `container_privileged` (or `container_sysbox`, `vm`) for the registration with ermite, and `ERMITE_RUNNER_DENY_PRIVILEGED=1` refuses to start without sysbox.
2727 - A **rootless** Docker daemon is refused with a clear error (a nested dockerd needs a rootful daemon or sysbox).
28-- Per job: a bridge network (`erbr<slot>`), a scratch volume, a Unix socket in a bind-mounted per-job directory of the state directory. Every Docker object carries the label `dev.bnema.ermite-runner.job`; leftovers are reaped by label at start.
28+- Limits per job: memory with swap equal to memory (no swap), `ERMITE_RUNNER_JOB_PIDS` processes (default 32768), CPUs.
29+- Disk: the job's writable space is enforced like in `vm`: a sparse ext4 file of `ERMITE_RUNNER_JOB_DISK`, **loop-mounted on the host** (so this mode needs root, like the rest of it) and bind-mounted at `/scratch`. The nested dockerd's data root, `/home/runner/work` and `/tmp` are directories of that disk; the container's own writable layer stays small. A job that fills its disk gets "no space left on device" instead of filling the host.
30+- Per job: a bridge network (`erbr<slot>`), the scratch disk, a Unix socket in a bind-mounted per-job directory of the state directory (the job sees only that directory, not the scratch file). Every Docker object carries the label `dev.bnema.ermite-runner.job`; leftovers are reaped by label at start. The state directory path must keep the socket path under 107 bytes.
2931 - The Docker daemon must run on the same host as the runner (host paths are bind-mounted).
3032
33+### One runner per host
34+
35+Reaping removes every process, device, firewall rule and Docker object the runner owns, so two runners on one host would destroy each other's jobs. Both modes take an exclusive lock at a fixed host path, `/run/ermite-runner.lock` (`ERMITE_RUNNER_LOCK_FILE` overrides it, for tests), and refuse to start when it is held, whatever their state directories or isolation modes. The kernel drops the lock when the process dies. A runner in a container or in another mount namespace must share that path (or it is not protected): do not run it so.
36+
3137 ### Network policy (both modes)
3238
33-One nftables table, `inet ermite_runner`, replaced atomically (`nft -f -`) and deleted with the last sandbox; nothing else is touched. From a sandbox: to the host only DNS to its own gateway address, where the runner's DNS forwarder listens (UDP and TCP, forwarding to the resolvers of `/etc/resolv.conf`); no traffic to RFC 1918, `100.64.0.0/10`, link-local (including `169.254.169.254`), loopback, multicast or reserved ranges; no IPv6; no traffic between sandboxes; the rest is NATed. The golden rulesets are in `internal/netpolicy/testdata`.
39+One nftables table, `inet ermite_runner`, replaced atomically (`nft -f -`) and deleted with the last sandbox; nothing else is touched. From a sandbox: to the host only DNS to its own gateway address, where the runner's DNS forwarder listens (UDP and TCP, forwarding to the resolvers of `/etc/resolv.conf`; port 53 of a gateway is closed to every other interface); no traffic to RFC 1918, `100.64.0.0/10`, link-local (including `169.254.169.254`), loopback, the documentation, benchmarking and IETF-assigned ranges, multicast or reserved ranges; no IPv6; no traffic between sandboxes; the rest is NATed. A sandbox's own checks (source address, destination ranges) run before any "established" accept, so a guest cannot forge another's flow. The golden rulesets are in `internal/netpolicy/testdata`. The DNS forwarder limits each listener to 32 in-flight queries (UDP beyond that is dropped), 16 TCP connections, and 30 s per TCP connection.
40+
41+**Side effect on the host:** the runner turns `net.ipv4.ip_forward` on (NAT needs it) and leaves it on when it exits. Forwarding is host-wide, not scoped to the runner's interfaces: if the machine must not route for other networks, restrict it with its own firewall rules (the runner's table only governs traffic of its sandboxes).
42+
43+### Cancellation
44+
45+The host sends a `Cancel{reason}` frame (`execstream.Drive` takes a cancel channel, not a context). The executor signals the script's process group like the official runner: SIGINT at once, SIGTERM after 7.5 s, SIGKILL 2.5 s later, with the timers stopped as soon as the shell exits and nothing signalled afterwards (the group id could be reused). Background children that keep the output pipes open are killed when the shell exits, after a short drain. Init forwards SIGTERM and SIGINT to the executor.
3446
3547 ### Cleanup
3648
37-Everything a job creates (process, TAP or bridge, nftables rules, files, containers, networks, volumes) is removed by `Stop`, which also runs with a cancelled context, 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; one runner instance per state directory (flock).
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.
3850
3951 ## Configuration (sandbox part)
4052
@@ -44,7 +56,11 @@ Everything a job creates (process, TAP or bridge, nftables rules, files, contain
4456 | `ERMITE_RUNNER_STATE_DIR` | `/var/lib/ermite-runner` | absolute path |
4557 | `ERMITE_RUNNER_CAPACITY` | `1` | parallel jobs (slots) |
4658 | `ERMITE_RUNNER_IMAGE` | | VM: image directory (`vmlinux`, `base.ext4`; default `<state dir>/image`); container: OCI reference (default `ermite-runner-ubuntu-24.04:latest`) |
47-| `ERMITE_RUNNER_JOB_CPUS` / `_JOB_MEMORY` / `_JOB_DISK` | `2` / `4G` / `64G` | per job; sizes accept `M`, `G`, `T` |
59+| `ERMITE_RUNNER_JOB_CPUS` / `_JOB_MEMORY` / `_JOB_DISK` | `2` / `4G` / `64G` | per job; sizes accept `M`, `G`, `T`. A warning is logged at start when the state directory has less free space than capacity x disk (disks are sparse: it is only a warning) |
60+| `ERMITE_RUNNER_JOB_PIDS` | `32768` | container process limit |
61+| `ERMITE_RUNNER_DENY_PRIVILEGED` | `0` | `1` refuses container mode without sysbox |
62+| `ERMITE_RUNNER_LOCK_FILE` | `/run/ermite-runner.lock` | host-wide single-instance lock (tests only) |
63+| `ERMITE_RUNNER_JOB_DISK_BANDWIDTH`, `_JOB_DISK_IOPS`, `_JOB_NET_BANDWIDTH` | `400`, `20000`, `1000` | VM rate limits in MiB/s, IOPS and Mbit/s; 0 disables |
4864 | `ERMITE_RUNNER_UID_BASE` | `900000` | jailer uid pool start |
4965 | `ERMITE_RUNNER_NETWORK` | `10.200.0.0/16` | pool of /30 subnets, one per slot |
5066 | `ERMITE_RUNNER_FIRECRACKER`, `_JAILER`, `_KERNEL` | from `PATH` | binaries and kernel overrides |
@@ -57,6 +73,8 @@ scripts/build-kernel.sh tmp/image # downloads Linux 6.18 LTS from kernel.org,
5773 scripts/build-image.sh tmp/image # builds the OCI image with docker or podman, exports tmp/image/base.ext4 (no root needed)
5874 ```
5975
76+`scripts/build-image.sh` tags the OCI image `ermite-runner-ubuntu-24.04:latest`, the default of `ERMITE_RUNNER_IMAGE` in container mode, and needs e2fsprogs >= 1.47.1 (the runner itself needs >= 1.43 and checks it at start).
77+
6078 The kernel configuration is `images/kernel/config-6.18` (+ per-architecture fragments), checked after `olddefconfig`: the build fails if a listed option did not survive. arm64 builds (`ARCH_NAME=arm64`) but is not tested in CI-1.
6179
6280 ## Tests
@@ -80,15 +98,19 @@ sudo env GOWORK=off ERMITE_RUNNER_IT_VM=1 ERMITE_RUNNER_ISOLATION=vm \
8098 "$(command -v go)" test -count=1 -v ./internal/sandbox/integration -run VM
8199 ```
82100
83-**Container** (`ERMITE_RUNNER_IT_CONTAINER=1`): needs a rootful Docker daemon, `CAP_NET_ADMIN` and the OCI image (`scripts/build-image.sh`).
101+**Container** (`ERMITE_RUNNER_IT_CONTAINER=1`): needs a rootful Docker daemon, root (loop mounts, `CAP_NET_ADMIN`) and the OCI image (`scripts/build-image.sh`).
84102
85103 ```sh
86-sudo env GOWORK=off ERMITE_RUNNER_IT_CONTAINER=1 ERMITE_RUNNER_IMAGE=ermite-runner-ubuntu-24.04:dev \
104+sudo env GOWORK=off ERMITE_RUNNER_IT_CONTAINER=1 ERMITE_RUNNER_IMAGE=ermite-runner-ubuntu-24.04:latest \
87105 DOCKER_HOST=unix:///var/run/docker.sock "$(command -v go)" test -count=1 -v ./internal/sandbox/integration -run Container
88106 ```
89107
90108 `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.
91109
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.
111+
112+Protobuf: `go generate ./internal/execstream` (buf and protoc-gen-go are pinned as `tool` directives in go.mod); CI lints the proto and fails when the committed generated code differs.
113+
92114 `ermite-runner sandbox-check [script]` runs one stub job through the selected mode and prints its output: the smoke test of an installation.
93115
94116 ## License
@@ -16,25 +16,37 @@ Both modes run the same executable, the runner's own, attached **read-only** to
16 ### vm16 ### vm
17 17
18 - Firecracker is driven through its HTTP API on a unix socket with `net/http`, not through `firecracker-go-sdk` (its last release, v1.0.0 from 2022, predates Firecracker 1.17 and pulls CNI, go-openapi and netlink for a handful of PUT requests).18 - Firecracker is driven through its HTTP API on a unix socket with `net/http`, not through `firecracker-go-sdk` (its last release, v1.0.0 from 2022, predates Firecracker 1.17 and pulls CNI, go-openapi and netlink for a handful of PUT requests).
19-- Disks: the runner executable (read-only boot root, ext4 built with `mkfs.ext4 -d`, cached by the executable's hash), the shared read-only base image, and a per-job sparse scratch disk (`ERMITE_RUNNER_JOB_DISK`). The guest init builds an overlayfs root (lower = base, upper = scratch) and keeps dockerd's data root on the scratch disk.19+- Disks: the runner executable (read-only boot root, ext4 built with `mkfs.ext4 -d`, cached by the executable's hash), the shared read-only base image, and a per-job sparse scratch disk (`ERMITE_RUNNER_JOB_DISK`). The guest init builds an overlayfs root (lower = base, upper = scratch) and keeps dockerd's data root on the scratch disk. The scratch disk and `eth0` are rate limited (defaults 400 MiB/s and 20000 IOPS, 1 Gbit/s each way; `ERMITE_RUNNER_JOB_DISK_BANDWIDTH` in MiB/s, `_JOB_DISK_IOPS`, `_JOB_NET_BANDWIDTH` in Mbit/s; 0 disables).
20-- The jailer runs each VM as uid/gid `ERMITE_RUNNER_UID_BASE` + slot in its own chroot under the state directory. The kernel, the base image and the state directory must be on the same filesystem (files are hard-linked into the jail) and the kernel and base image must be world-readable.20+- The jailer runs each VM as uid/gid `ERMITE_RUNNER_UID_BASE` + slot in its own chroot under the state directory, in a new PID namespace and in a cgroup v2 with `memory.max` (guest memory + 256 MiB for the VMM) and `pids.max`. The kernel, the base image and the state directory must be on the same filesystem (files are hard-linked into the jail) and the kernel and base image must be world-readable.
21 - One TAP (`ertap<slot>`) per VM with a /30 from `ERMITE_RUNNER_NETWORK` (default `10.200.0.0/16`), vsock for the executor stream, IPv6 disabled in the guest.21 - One TAP (`ertap<slot>`) per VM with a /30 from `ERMITE_RUNNER_NETWORK` (default `10.200.0.0/16`), vsock for the executor stream, IPv6 disabled in the guest.
22 - Needs root, or `CAP_NET_ADMIN` and the rights the jailer requires (the plan runs the host process as root under systemd).22 - Needs root, or `CAP_NET_ADMIN` and the rights the jailer requires (the plan runs the host process as root under systemd).
23 23
24 ### container24 ### container
25 25
26-- Docker Engine API via the maintained client `github.com/moby/moby/client` (`DOCKER_HOST` honoured). Runtime `sysbox-runc` when the daemon lists it, else a **privileged** container, which is host-root equivalent: it is logged at start and its network policy is best effort.26+- Docker Engine API via the maintained client `github.com/moby/moby/client` (`DOCKER_HOST` honoured). Runtime `sysbox-runc` when the daemon lists it, else a **privileged** container, which is host-root equivalent. That is allowed by default (ADR 0025) but never silent: the runner logs a warning at start and at the start of every job (`privileged container: host-root equivalent, network policy best effort`), `Driver.Isolation()` reports `container_privileged` (or `container_sysbox`, `vm`) for the registration with ermite, and `ERMITE_RUNNER_DENY_PRIVILEGED=1` refuses to start without sysbox.
27 - A **rootless** Docker daemon is refused with a clear error (a nested dockerd needs a rootful daemon or sysbox).27 - A **rootless** Docker daemon is refused with a clear error (a nested dockerd needs a rootful daemon or sysbox).
28-- Per job: a bridge network (`erbr<slot>`), a scratch volume, a Unix socket in a bind-mounted per-job directory of the state directory. Every Docker object carries the label `dev.bnema.ermite-runner.job`; leftovers are reaped by label at start.28+- Limits per job: memory with swap equal to memory (no swap), `ERMITE_RUNNER_JOB_PIDS` processes (default 32768), CPUs.
29+- Disk: the job's writable space is enforced like in `vm`: a sparse ext4 file of `ERMITE_RUNNER_JOB_DISK`, **loop-mounted on the host** (so this mode needs root, like the rest of it) and bind-mounted at `/scratch`. The nested dockerd's data root, `/home/runner/work` and `/tmp` are directories of that disk; the container's own writable layer stays small. A job that fills its disk gets "no space left on device" instead of filling the host.
30+- Per job: a bridge network (`erbr<slot>`), the scratch disk, a Unix socket in a bind-mounted per-job directory of the state directory (the job sees only that directory, not the scratch file). Every Docker object carries the label `dev.bnema.ermite-runner.job`; leftovers are reaped by label at start. The state directory path must keep the socket path under 107 bytes.
29 - The Docker daemon must run on the same host as the runner (host paths are bind-mounted).31 - The Docker daemon must run on the same host as the runner (host paths are bind-mounted).
30 32
33+### One runner per host
34+
35+Reaping removes every process, device, firewall rule and Docker object the runner owns, so two runners on one host would destroy each other's jobs. Both modes take an exclusive lock at a fixed host path, `/run/ermite-runner.lock` (`ERMITE_RUNNER_LOCK_FILE` overrides it, for tests), and refuse to start when it is held, whatever their state directories or isolation modes. The kernel drops the lock when the process dies. A runner in a container or in another mount namespace must share that path (or it is not protected): do not run it so.
36+
31 ### Network policy (both modes)37 ### Network policy (both modes)
32 38
33-One nftables table, `inet ermite_runner`, replaced atomically (`nft -f -`) and deleted with the last sandbox; nothing else is touched. From a sandbox: to the host only DNS to its own gateway address, where the runner's DNS forwarder listens (UDP and TCP, forwarding to the resolvers of `/etc/resolv.conf`); no traffic to RFC 1918, `100.64.0.0/10`, link-local (including `169.254.169.254`), loopback, multicast or reserved ranges; no IPv6; no traffic between sandboxes; the rest is NATed. The golden rulesets are in `internal/netpolicy/testdata`.39+One nftables table, `inet ermite_runner`, replaced atomically (`nft -f -`) and deleted with the last sandbox; nothing else is touched. From a sandbox: to the host only DNS to its own gateway address, where the runner's DNS forwarder listens (UDP and TCP, forwarding to the resolvers of `/etc/resolv.conf`; port 53 of a gateway is closed to every other interface); no traffic to RFC 1918, `100.64.0.0/10`, link-local (including `169.254.169.254`), loopback, the documentation, benchmarking and IETF-assigned ranges, multicast or reserved ranges; no IPv6; no traffic between sandboxes; the rest is NATed. A sandbox's own checks (source address, destination ranges) run before any "established" accept, so a guest cannot forge another's flow. The golden rulesets are in `internal/netpolicy/testdata`. The DNS forwarder limits each listener to 32 in-flight queries (UDP beyond that is dropped), 16 TCP connections, and 30 s per TCP connection.
40+
41+**Side effect on the host:** the runner turns `net.ipv4.ip_forward` on (NAT needs it) and leaves it on when it exits. Forwarding is host-wide, not scoped to the runner's interfaces: if the machine must not route for other networks, restrict it with its own firewall rules (the runner's table only governs traffic of its sandboxes).
42+
43+### Cancellation
44+
45+The host sends a `Cancel{reason}` frame (`execstream.Drive` takes a cancel channel, not a context). The executor signals the script's process group like the official runner: SIGINT at once, SIGTERM after 7.5 s, SIGKILL 2.5 s later, with the timers stopped as soon as the shell exits and nothing signalled afterwards (the group id could be reused). Background children that keep the output pipes open are killed when the shell exits, after a short drain. Init forwards SIGTERM and SIGINT to the executor.
34 46
35 ### Cleanup47 ### Cleanup
36 48
37-Everything a job creates (process, TAP or bridge, nftables rules, files, containers, networks, volumes) is removed by `Stop`, which also runs with a cancelled context, 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; one runner instance per state directory (flock).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.
38 50
39 ## Configuration (sandbox part)51 ## Configuration (sandbox part)
40 52
@@ -44,7 +56,11 @@ Everything a job creates (process, TAP or bridge, nftables rules, files, contain
44 | `ERMITE_RUNNER_STATE_DIR` | `/var/lib/ermite-runner` | absolute path |56 | `ERMITE_RUNNER_STATE_DIR` | `/var/lib/ermite-runner` | absolute path |
45 | `ERMITE_RUNNER_CAPACITY` | `1` | parallel jobs (slots) |57 | `ERMITE_RUNNER_CAPACITY` | `1` | parallel jobs (slots) |
46 | `ERMITE_RUNNER_IMAGE` | | VM: image directory (`vmlinux`, `base.ext4`; default `<state dir>/image`); container: OCI reference (default `ermite-runner-ubuntu-24.04:latest`) |58 | `ERMITE_RUNNER_IMAGE` | | VM: image directory (`vmlinux`, `base.ext4`; default `<state dir>/image`); container: OCI reference (default `ermite-runner-ubuntu-24.04:latest`) |
47-| `ERMITE_RUNNER_JOB_CPUS` / `_JOB_MEMORY` / `_JOB_DISK` | `2` / `4G` / `64G` | per job; sizes accept `M`, `G`, `T` |59+| `ERMITE_RUNNER_JOB_CPUS` / `_JOB_MEMORY` / `_JOB_DISK` | `2` / `4G` / `64G` | per job; sizes accept `M`, `G`, `T`. A warning is logged at start when the state directory has less free space than capacity x disk (disks are sparse: it is only a warning) |
60+| `ERMITE_RUNNER_JOB_PIDS` | `32768` | container process limit |
61+| `ERMITE_RUNNER_DENY_PRIVILEGED` | `0` | `1` refuses container mode without sysbox |
62+| `ERMITE_RUNNER_LOCK_FILE` | `/run/ermite-runner.lock` | host-wide single-instance lock (tests only) |
63+| `ERMITE_RUNNER_JOB_DISK_BANDWIDTH`, `_JOB_DISK_IOPS`, `_JOB_NET_BANDWIDTH` | `400`, `20000`, `1000` | VM rate limits in MiB/s, IOPS and Mbit/s; 0 disables |
48 | `ERMITE_RUNNER_UID_BASE` | `900000` | jailer uid pool start |64 | `ERMITE_RUNNER_UID_BASE` | `900000` | jailer uid pool start |
49 | `ERMITE_RUNNER_NETWORK` | `10.200.0.0/16` | pool of /30 subnets, one per slot |65 | `ERMITE_RUNNER_NETWORK` | `10.200.0.0/16` | pool of /30 subnets, one per slot |
50 | `ERMITE_RUNNER_FIRECRACKER`, `_JAILER`, `_KERNEL` | from `PATH` | binaries and kernel overrides |66 | `ERMITE_RUNNER_FIRECRACKER`, `_JAILER`, `_KERNEL` | from `PATH` | binaries and kernel overrides |
@@ -57,6 +73,8 @@ scripts/build-kernel.sh tmp/image # downloads Linux 6.18 LTS from kernel.org,
57 scripts/build-image.sh tmp/image # builds the OCI image with docker or podman, exports tmp/image/base.ext4 (no root needed)73 scripts/build-image.sh tmp/image # builds the OCI image with docker or podman, exports tmp/image/base.ext4 (no root needed)
58 ```74 ```
59 75
76+`scripts/build-image.sh` tags the OCI image `ermite-runner-ubuntu-24.04:latest`, the default of `ERMITE_RUNNER_IMAGE` in container mode, and needs e2fsprogs >= 1.47.1 (the runner itself needs >= 1.43 and checks it at start).
77+
60 The kernel configuration is `images/kernel/config-6.18` (+ per-architecture fragments), checked after `olddefconfig`: the build fails if a listed option did not survive. arm64 builds (`ARCH_NAME=arm64`) but is not tested in CI-1.78 The kernel configuration is `images/kernel/config-6.18` (+ per-architecture fragments), checked after `olddefconfig`: the build fails if a listed option did not survive. arm64 builds (`ARCH_NAME=arm64`) but is not tested in CI-1.
61 79
62 ## Tests80 ## Tests
@@ -80,15 +98,19 @@ sudo env GOWORK=off ERMITE_RUNNER_IT_VM=1 ERMITE_RUNNER_ISOLATION=vm \
80 "$(command -v go)" test -count=1 -v ./internal/sandbox/integration -run VM98 "$(command -v go)" test -count=1 -v ./internal/sandbox/integration -run VM
81 ```99 ```
82 100
83-**Container** (`ERMITE_RUNNER_IT_CONTAINER=1`): needs a rootful Docker daemon, `CAP_NET_ADMIN` and the OCI image (`scripts/build-image.sh`).101+**Container** (`ERMITE_RUNNER_IT_CONTAINER=1`): needs a rootful Docker daemon, root (loop mounts, `CAP_NET_ADMIN`) and the OCI image (`scripts/build-image.sh`).
84 102
85 ```sh103 ```sh
86-sudo env GOWORK=off ERMITE_RUNNER_IT_CONTAINER=1 ERMITE_RUNNER_IMAGE=ermite-runner-ubuntu-24.04:dev \104+sudo env GOWORK=off ERMITE_RUNNER_IT_CONTAINER=1 ERMITE_RUNNER_IMAGE=ermite-runner-ubuntu-24.04:latest \
87 DOCKER_HOST=unix:///var/run/docker.sock "$(command -v go)" test -count=1 -v ./internal/sandbox/integration -run Container105 DOCKER_HOST=unix:///var/run/docker.sock "$(command -v go)" test -count=1 -v ./internal/sandbox/integration -run Container
88 ```106 ```
89 107
90 `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.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.
91 109
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.
111+
112+Protobuf: `go generate ./internal/execstream` (buf and protoc-gen-go are pinned as `tool` directives in go.mod); CI lints the proto and fails when the committed generated code differs.
113+
92 `ermite-runner sandbox-check [script]` runs one stub job through the selected mode and prints its output: the smoke test of an installation.114 `ermite-runner sandbox-check [script]` runs one stub job through the selected mode and prints its output: the smoke test of an installation.
93 115
94 ## License116 ## License
M internal/sandbox/integration/integration_test.go +22 -4
@@ -78,6 +78,14 @@ func loadConfig(t *testing.T, mode sandbox.Mode) sandbox.Config {
7878 return stateDir(t)
7979 case sandbox.EnvCapacity:
8080 return "2"
81+ case sandbox.EnvLockFile:
82+ // A lock of the test's own unless the caller chose one: the default
83+ // (/run) is not writable without root, and each test must be able to
84+ // take it after the previous one released it.
85+ if v := os.Getenv(k); v != "" {
86+ return v
87+ }
88+ return filepath.Join(stateDir(t), "host.lock")
8189 case sandbox.EnvJobDisk:
8290 if v := os.Getenv(k); v != "" {
8391 return v
@@ -199,6 +207,8 @@ func leftovers(stateDir string) []string {
199207 return nil
200208 })
201209 }
210+ // Any process whose command line carries one of our VM ids, whatever the
211+ // program is called (the same rule the driver's Reap uses).
202212 procs, _ := filepath.Glob("/proc/[0-9]*/cmdline")
203213 for _, p := range procs {
204214 b, err := os.ReadFile(p)
@@ -206,13 +216,21 @@ func leftovers(stateDir string) []string {
206216 continue
207217 }
208218 args := strings.Split(strings.TrimRight(string(b), "\x00"), "\x00")
209- if len(args) > 0 && filepath.Base(args[0]) == "firecracker" {
210- for i, a := range args {
211- if a == "--id" && i+1 < len(args) && sandbox.ValidID(args[i+1]) {
212- left = append(left, "process "+p+" "+args[i+1])
219+ var id string
220+ var marker bool
221+ for i, a := range args {
222+ switch a {
223+ case "--id":
224+ if i+1 < len(args) && sandbox.ValidID(args[i+1]) {
225+ id = args[i+1]
213226 }
227+ case "--api-sock", "--exec-file":
228+ marker = true
214229 }
215230 }
231+ if id != "" && marker {
232+ left = append(left, "process "+p+" "+id)
233+ }
216234 }
217235 return left
218236 }
@@ -78,6 +78,14 @@ func loadConfig(t *testing.T, mode sandbox.Mode) sandbox.Config {
78 return stateDir(t)78 return stateDir(t)
79 case sandbox.EnvCapacity:79 case sandbox.EnvCapacity:
80 return "2"80 return "2"
81+ case sandbox.EnvLockFile:
82+ // A lock of the test's own unless the caller chose one: the default
83+ // (/run) is not writable without root, and each test must be able to
84+ // take it after the previous one released it.
85+ if v := os.Getenv(k); v != "" {
86+ return v
87+ }
88+ return filepath.Join(stateDir(t), "host.lock")
81 case sandbox.EnvJobDisk:89 case sandbox.EnvJobDisk:
82 if v := os.Getenv(k); v != "" {90 if v := os.Getenv(k); v != "" {
83 return v91 return v
@@ -199,6 +207,8 @@ func leftovers(stateDir string) []string {
199 return nil207 return nil
200 })208 })
201 }209 }
210+ // Any process whose command line carries one of our VM ids, whatever the
211+ // program is called (the same rule the driver's Reap uses).
202 procs, _ := filepath.Glob("/proc/[0-9]*/cmdline")212 procs, _ := filepath.Glob("/proc/[0-9]*/cmdline")
203 for _, p := range procs {213 for _, p := range procs {
204 b, err := os.ReadFile(p)214 b, err := os.ReadFile(p)
@@ -206,13 +216,21 @@ func leftovers(stateDir string) []string {
206 continue216 continue
207 }217 }
208 args := strings.Split(strings.TrimRight(string(b), "\x00"), "\x00")218 args := strings.Split(strings.TrimRight(string(b), "\x00"), "\x00")
209- if len(args) > 0 && filepath.Base(args[0]) == "firecracker" {219+ var id string
210- for i, a := range args {220+ var marker bool
211- if a == "--id" && i+1 < len(args) && sandbox.ValidID(args[i+1]) {221+ for i, a := range args {
212- left = append(left, "process "+p+" "+args[i+1])222+ switch a {
223+ case "--id":
224+ if i+1 < len(args) && sandbox.ValidID(args[i+1]) {
225+ id = args[i+1]
213 }226 }
227+ case "--api-sock", "--exec-file":
228+ marker = true
214 }229 }
215 }230 }
231+ if id != "" && marker {
232+ left = append(left, "process "+p+" "+id)
233+ }
216 }234 }
217 return left235 return left
218 }236 }
M internal/sandbox/integration/vm_test.go +1 -1
@@ -28,7 +28,7 @@ func TestVMStubExecutor(t *testing.T) {
2828
2929 scenario(t, ctx, cfg, sandbox.VM, func(t *testing.T, sb sandbox.Sandbox) {
3030 // Kill the VMM process of the only running VM.
31- out, err := exec.Command("pgrep", "-f", "firecracker.* --id er").Output()
31+ out, err := exec.Command("pgrep", "-f", "--api-sock .*--id er|--id er[0-9a-f]{8} .*--api-sock|--id er[0-9a-f]{8} --api-sock").Output()
3232 if err != nil {
3333 t.Fatalf("no firecracker to kill: %v", err)
3434 }
@@ -28,7 +28,7 @@ func TestVMStubExecutor(t *testing.T) {
28 28
29 scenario(t, ctx, cfg, sandbox.VM, func(t *testing.T, sb sandbox.Sandbox) {29 scenario(t, ctx, cfg, sandbox.VM, func(t *testing.T, sb sandbox.Sandbox) {
30 // Kill the VMM process of the only running VM.30 // Kill the VMM process of the only running VM.
31- out, err := exec.Command("pgrep", "-f", "firecracker.* --id er").Output()31+ out, err := exec.Command("pgrep", "-f", "--api-sock .*--id er|--id er[0-9a-f]{8} .*--api-sock|--id er[0-9a-f]{8} --api-sock").Output()
32 if err != nil {32 if err != nil {
33 t.Fatalf("no firecracker to kill: %v", err)33 t.Fatalf("no firecracker to kill: %v", err)
34 }34 }
M internal/sandbox/space.go +1 -1
@@ -16,7 +16,7 @@ func FreeMiB(path string) (int64, error) {
1616 if err := unix.Statfs(path, &st); err != nil {
1717 return 0, err
1818 }
19- return int64(st.Bavail) * int64(st.Bsize) >> 20, nil
19+ return int64(st.Bavail) * st.Bsize >> 20, nil
2020 }
2121
2222 // WarnIfLowSpace logs a warning when the filesystem of dir has less free space
@@ -16,7 +16,7 @@ func FreeMiB(path string) (int64, error) {
16 if err := unix.Statfs(path, &st); err != nil {16 if err := unix.Statfs(path, &st); err != nil {
17 return 0, err17 return 0, err
18 }18 }
19- return int64(st.Bavail) * int64(st.Bsize) >> 20, nil19+ return int64(st.Bavail) * st.Bsize >> 20, nil
20 }20 }
21 21
22 // WarnIfLowSpace logs a warning when the filesystem of dir has less free space22 // WarnIfLowSpace logs a warning when the filesystem of dir has less free space
M internal/sandbox/vm/proc.go +0 -13
@@ -65,19 +65,6 @@ func vmIDFromCmdline(args []string) string {
6565 return ""
6666 }
6767
68-// readPidFile returns the pid the jailer wrote for a VM run in a PID namespace.
69-func readPidFile(path string) int {
70- b, err := os.ReadFile(path)
71- if err != nil {
72- return 0
73- }
74- pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
75- if err != nil || pid <= 1 {
76- return 0
77- }
78- return pid
79-}
80-
8168 // pidAlive reports whether pid exists and is not a zombie.
8269 func pidAlive(pid int) bool {
8370 if err := syscall.Kill(pid, 0); err != nil {
@@ -65,19 +65,6 @@ func vmIDFromCmdline(args []string) string {
65 return ""65 return ""
66 }66 }
67 67
68-// readPidFile returns the pid the jailer wrote for a VM run in a PID namespace.
69-func readPidFile(path string) int {
70- b, err := os.ReadFile(path)
71- if err != nil {
72- return 0
73- }
74- pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
75- if err != nil || pid <= 1 {
76- return 0
77- }
78- return pid
79-}
80-
81 // pidAlive reports whether pid exists and is not a zombie.68 // pidAlive reports whether pid exists and is not a zombie.
82 func pidAlive(pid int) bool {69 func pidAlive(pid int) bool {
83 if err := syscall.Kill(pid, 0); err != nil {70 if err := syscall.Kill(pid, 0); err != nil {
M internal/sandbox/vm/vm_test.go +1 -1
@@ -540,7 +540,7 @@ func TestExecDiskIsKeyedByExecutableHash(t *testing.T) {
540540 t.Fatal("exec disk rebuilt for the same executable")
541541 }
542542 for _, c := range e.run.Calls[n:] {
543- if c.Name == "mkfs.ext4" && !(len(c.Args) == 1 && c.Args[0] == "-V") {
543+ if c.Name == "mkfs.ext4" && (len(c.Args) != 1 || c.Args[0] != "-V") {
544544 t.Fatal("mkfs run again for an unchanged executable")
545545 }
546546 }
@@ -540,7 +540,7 @@ func TestExecDiskIsKeyedByExecutableHash(t *testing.T) {
540 t.Fatal("exec disk rebuilt for the same executable")540 t.Fatal("exec disk rebuilt for the same executable")
541 }541 }
542 for _, c := range e.run.Calls[n:] {542 for _, c := range e.run.Calls[n:] {
543- if c.Name == "mkfs.ext4" && !(len(c.Args) == 1 && c.Args[0] == "-V") {543+ if c.Name == "mkfs.ext4" && (len(c.Args) != 1 || c.Args[0] != "-V") {
544 t.Fatal("mkfs run again for an unchanged executable")544 t.Fatal("mkfs run again for an unchanged executable")
545 }545 }
546 }546 }
M sandbox_check.go +4 -0
@@ -4,6 +4,7 @@ package main
44
55 import (
66 "context"
7+ "errors"
78 "fmt"
89 "os"
910 "strings"
@@ -48,6 +49,9 @@ func sandboxCheck(ctx context.Context, args []string) int {
4849 defer cancel()
4950 if err := sb.Stop(sctx); err != nil {
5051 fmt.Fprintln(os.Stderr, "ermite-runner: cleanup:", err)
52+ if errors.Is(err, sandbox.ErrLeaked) {
53+ fmt.Fprintln(os.Stderr, "ermite-runner: something leaked: run again (the start reaps) or check the state directory")
54+ }
5155 }
5256 }()
5357 conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "sandbox-check"})
@@ -4,6 +4,7 @@ package main
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "fmt"8 "fmt"
8 "os"9 "os"
9 "strings"10 "strings"
@@ -48,6 +49,9 @@ func sandboxCheck(ctx context.Context, args []string) int {
48 defer cancel()49 defer cancel()
49 if err := sb.Stop(sctx); err != nil {50 if err := sb.Stop(sctx); err != nil {
50 fmt.Fprintln(os.Stderr, "ermite-runner: cleanup:", err)51 fmt.Fprintln(os.Stderr, "ermite-runner: cleanup:", err)
52+ if errors.Is(err, sandbox.ErrLeaked) {
53+ fmt.Fprintln(os.Stderr, "ermite-runner: something leaked: run again (the start reaps) or check the state directory")
54+ }
51 }55 }
52 }()56 }()
53 conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "sandbox-check"})57 conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "sandbox-check"})
M scripts/build-image.sh +12 -2
@@ -10,7 +10,8 @@
1010 #
1111 # Environment:
1212 # ENGINE docker or podman (default: first found)
13-# IMAGE_TAG OCI tag (default ermite-runner-ubuntu-24.04:dev)
13+# IMAGE_TAG OCI tag (default ermite-runner-ubuntu-24.04:latest, the
14+# default of ERMITE_RUNNER_IMAGE in container mode)
1415 # EXT4_SLACK free space added to the base image in MiB (default 512)
1516 #
1617 # Output: OUT_DIR/base.ext4 (read-only shared base disk; its UUID and size are
@@ -19,7 +20,7 @@ set -euo pipefail
1920
2021 root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
2122 out=${1:-$root/tmp/image}
22-tag=${IMAGE_TAG:-ermite-runner-ubuntu-24.04:dev}
23+tag=${IMAGE_TAG:-ermite-runner-ubuntu-24.04:latest}
2324 slack=${EXT4_SLACK:-512}
2425 engine=${ENGINE:-}
2526
@@ -31,6 +32,15 @@ fi
3132 [ -n "$engine" ] || { echo "need docker or podman" >&2; exit 1; }
3233 command -v mkfs.ext4 >/dev/null || { echo "need mkfs.ext4 (e2fsprogs >= 1.47.1 for tarball input)" >&2; exit 1; }
3334
35+# mkfs.ext4 -d with a tarball needs e2fsprogs 1.47.1; older releases only take a
36+# directory. mke2fs prints its version on stderr and may exit non-zero on -V.
37+ver=$(mkfs.ext4 -V 2>&1 | sed -n 's/^mke2fs \([0-9]*\.[0-9]*\.[0-9]*\).*/\1/p' | head -n1)
38+[ -n "$ver" ] || ver=$(mkfs.ext4 -V 2>&1 | sed -n 's/^mke2fs \([0-9]*\.[0-9]*\).*/\1.0/p' | head -n1)
39+if [ -z "$ver" ] || [ "$(printf '%s\n1.47.1\n' "$ver" | sort -V | head -n1)" != "1.47.1" ]; then
40+ echo "mkfs.ext4 is e2fsprogs ${ver:-of unknown version}, need >= 1.47.1 (mkfs.ext4 -d with a tarball)" >&2
41+ exit 1
42+fi
43+
3444 mkdir -p "$out"
3545 out=$(cd "$out" && pwd)
3646 "$engine" build -t "$tag" "$root/images/ubuntu-24.04"
@@ -10,7 +10,8 @@
10 #10 #
11 # Environment:11 # Environment:
12 # ENGINE docker or podman (default: first found)12 # ENGINE docker or podman (default: first found)
13-# IMAGE_TAG OCI tag (default ermite-runner-ubuntu-24.04:dev)13+# IMAGE_TAG OCI tag (default ermite-runner-ubuntu-24.04:latest, the
14+# default of ERMITE_RUNNER_IMAGE in container mode)
14 # EXT4_SLACK free space added to the base image in MiB (default 512)15 # EXT4_SLACK free space added to the base image in MiB (default 512)
15 #16 #
16 # Output: OUT_DIR/base.ext4 (read-only shared base disk; its UUID and size are17 # Output: OUT_DIR/base.ext4 (read-only shared base disk; its UUID and size are
@@ -19,7 +20,7 @@ set -euo pipefail
19 20
20 root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)21 root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
21 out=${1:-$root/tmp/image}22 out=${1:-$root/tmp/image}
22-tag=${IMAGE_TAG:-ermite-runner-ubuntu-24.04:dev}23+tag=${IMAGE_TAG:-ermite-runner-ubuntu-24.04:latest}
23 slack=${EXT4_SLACK:-512}24 slack=${EXT4_SLACK:-512}
24 engine=${ENGINE:-}25 engine=${ENGINE:-}
25 26
@@ -31,6 +32,15 @@ fi
31 [ -n "$engine" ] || { echo "need docker or podman" >&2; exit 1; }32 [ -n "$engine" ] || { echo "need docker or podman" >&2; exit 1; }
32 command -v mkfs.ext4 >/dev/null || { echo "need mkfs.ext4 (e2fsprogs >= 1.47.1 for tarball input)" >&2; exit 1; }33 command -v mkfs.ext4 >/dev/null || { echo "need mkfs.ext4 (e2fsprogs >= 1.47.1 for tarball input)" >&2; exit 1; }
33 34
35+# mkfs.ext4 -d with a tarball needs e2fsprogs 1.47.1; older releases only take a
36+# directory. mke2fs prints its version on stderr and may exit non-zero on -V.
37+ver=$(mkfs.ext4 -V 2>&1 | sed -n 's/^mke2fs \([0-9]*\.[0-9]*\.[0-9]*\).*/\1/p' | head -n1)
38+[ -n "$ver" ] || ver=$(mkfs.ext4 -V 2>&1 | sed -n 's/^mke2fs \([0-9]*\.[0-9]*\).*/\1.0/p' | head -n1)
39+if [ -z "$ver" ] || [ "$(printf '%s\n1.47.1\n' "$ver" | sort -V | head -n1)" != "1.47.1" ]; then
40+ echo "mkfs.ext4 is e2fsprogs ${ver:-of unknown version}, need >= 1.47.1 (mkfs.ext4 -d with a tarball)" >&2
41+ exit 1
42+fi
43+
34 mkdir -p "$out"44 mkdir -p "$out"
35 out=$(cd "$out" && pwd)45 out=$(cd "$out" && pwd)
36 "$engine" build -t "$tag" "$root/images/ubuntu-24.04"46 "$engine" build -t "$tag" "$root/images/ubuntu-24.04"
M scripts/build-kernel.sh +6 -4
@@ -41,10 +41,10 @@ if [ ! -f "$work/$tarball" ]; then
4141 curl -fsSL -o "$work/$tarball.part" "$base/$tarball"
4242 mv "$work/$tarball.part" "$work/$tarball"
4343 fi
44-if [ ! -f "$work/sha256sums.asc" ] || ! grep -q " $tarball\$" "$work/sha256sums.asc"; then
44+if [ ! -f "$work/sha256sums.asc" ] || ! grep -qF " $tarball" "$work/sha256sums.asc"; then
4545 curl -fsSL -o "$work/sha256sums.asc" "$base/sha256sums.asc"
4646 fi
47-want=$(grep " $tarball\$" "$work/sha256sums.asc" | awk '{print $1}' | head -n1)
47+want=$(awk -v f="$tarball" '$2 == f {print $1; exit}' "$work/sha256sums.asc")
4848 have=$(sha256sum "$work/$tarball" | awk '{print $1}')
4949 if [ -z "$want" ] || [ "$want" != "$have" ]; then
5050 echo "checksum mismatch for $tarball (want '$want', have '$have')" >&2
@@ -64,8 +64,10 @@ make olddefconfig >/dev/null
6464 # Fail if an option of the fragments was dropped by a missing dependency.
6565 missing=0
6666 while IFS== read -r key val; do
67- if grep -q "^$key=$val\$" .config; then continue; fi
68- if [ "$val" = n ] && ! grep -q "^$key=" .config; then continue; fi
67+ # Whole-line, fixed-string matches: option names contain no regex, but
68+ # CONFIG_FOO must never match CONFIG_FOO_BAR=y.
69+ if grep -qxF "$key=$val" .config; then continue; fi
70+ if [ "$val" = n ] && ! grep -q "^${key}=" .config; then continue; fi
6971 echo "kernel option not applied: $key=$val" >&2
7072 missing=1
7173 done < <(cat "$root/images/kernel/config-6.18" "$root/images/kernel/config-6.18.$arch_frag" | grep -E '^CONFIG_[A-Z0-9_]+=')
@@ -41,10 +41,10 @@ if [ ! -f "$work/$tarball" ]; then
41 curl -fsSL -o "$work/$tarball.part" "$base/$tarball"41 curl -fsSL -o "$work/$tarball.part" "$base/$tarball"
42 mv "$work/$tarball.part" "$work/$tarball"42 mv "$work/$tarball.part" "$work/$tarball"
43 fi43 fi
44-if [ ! -f "$work/sha256sums.asc" ] || ! grep -q " $tarball\$" "$work/sha256sums.asc"; then44+if [ ! -f "$work/sha256sums.asc" ] || ! grep -qF " $tarball" "$work/sha256sums.asc"; then
45 curl -fsSL -o "$work/sha256sums.asc" "$base/sha256sums.asc"45 curl -fsSL -o "$work/sha256sums.asc" "$base/sha256sums.asc"
46 fi46 fi
47-want=$(grep " $tarball\$" "$work/sha256sums.asc" | awk '{print $1}' | head -n1)47+want=$(awk -v f="$tarball" '$2 == f {print $1; exit}' "$work/sha256sums.asc")
48 have=$(sha256sum "$work/$tarball" | awk '{print $1}')48 have=$(sha256sum "$work/$tarball" | awk '{print $1}')
49 if [ -z "$want" ] || [ "$want" != "$have" ]; then49 if [ -z "$want" ] || [ "$want" != "$have" ]; then
50 echo "checksum mismatch for $tarball (want '$want', have '$have')" >&250 echo "checksum mismatch for $tarball (want '$want', have '$have')" >&2
@@ -64,8 +64,10 @@ make olddefconfig >/dev/null
64 # Fail if an option of the fragments was dropped by a missing dependency.64 # Fail if an option of the fragments was dropped by a missing dependency.
65 missing=065 missing=0
66 while IFS== read -r key val; do66 while IFS== read -r key val; do
67- if grep -q "^$key=$val\$" .config; then continue; fi67+ # Whole-line, fixed-string matches: option names contain no regex, but
68- if [ "$val" = n ] && ! grep -q "^$key=" .config; then continue; fi68+ # CONFIG_FOO must never match CONFIG_FOO_BAR=y.
69+ if grep -qxF "$key=$val" .config; then continue; fi
70+ if [ "$val" = n ] && ! grep -q "^${key}=" .config; then continue; fi
69 echo "kernel option not applied: $key=$val" >&271 echo "kernel option not applied: $key=$val" >&2
70 missing=172 missing=1
71 done < <(cat "$root/images/kernel/config-6.18" "$root/images/kernel/config-6.18.$arch_frag" | grep -E '^CONFIG_[A-Z0-9_]+=')73 done < <(cat "$root/images/kernel/config-6.18" "$root/images/kernel/config-6.18.$arch_frag" | grep -E '^CONFIG_[A-Z0-9_]+=')
M scripts/it-vm-userns.sh +7 -2
@@ -30,7 +30,12 @@ exec unshare --user --map-root-user --net bash -c '
3030 ip route add default via 192.0.2.254 dev dummy0
3131 export GOWORK=off ERMITE_RUNNER_IT_VM=1 \
3232 ERMITE_RUNNER_ISOLATION=vm ERMITE_RUNNER_IMAGE="$1" \
33- ERMITE_RUNNER_FIRECRACKER="$2" ERMITE_RUNNER_JAILER_MODE=off
33+ ERMITE_RUNNER_FIRECRACKER="$2" ERMITE_RUNNER_JAILER_MODE=off \
34+ ERMITE_RUNNER_LOCK_FILE="${TMPDIR:-/tmp}/ermite-runner-it-$$.lock"
3435 shift 2
35- exec go test -count=1 -v -timeout 20m ./internal/sandbox/integration "$@"
36+ rc=0
37+ go test -count=1 -v -timeout 10m ./internal/sandbox/integration "$@" || rc=$?
38+ # The packet-level firewall test needs only the namespace, not Firecracker.
39+ ERMITE_RUNNER_IT_NET=1 go test -count=1 -v -timeout 10m -run TwoGuests ./internal/netpolicy || rc=$?
40+ exit $rc
3641 ' _ "$img" "$fc" "$@"
@@ -30,7 +30,12 @@ exec unshare --user --map-root-user --net bash -c '
30 ip route add default via 192.0.2.254 dev dummy030 ip route add default via 192.0.2.254 dev dummy0
31 export GOWORK=off ERMITE_RUNNER_IT_VM=1 \31 export GOWORK=off ERMITE_RUNNER_IT_VM=1 \
32 ERMITE_RUNNER_ISOLATION=vm ERMITE_RUNNER_IMAGE="$1" \32 ERMITE_RUNNER_ISOLATION=vm ERMITE_RUNNER_IMAGE="$1" \
33- ERMITE_RUNNER_FIRECRACKER="$2" ERMITE_RUNNER_JAILER_MODE=off33+ ERMITE_RUNNER_FIRECRACKER="$2" ERMITE_RUNNER_JAILER_MODE=off \
34+ ERMITE_RUNNER_LOCK_FILE="${TMPDIR:-/tmp}/ermite-runner-it-$$.lock"
34 shift 235 shift 2
35- exec go test -count=1 -v -timeout 20m ./internal/sandbox/integration "$@"36+ rc=0
37+ go test -count=1 -v -timeout 10m ./internal/sandbox/integration "$@" || rc=$?
38+ # The packet-level firewall test needs only the namespace, not Firecracker.
39+ ERMITE_RUNNER_IT_NET=1 go test -count=1 -v -timeout 10m -run TwoGuests ./internal/netpolicy || rc=$?
40+ exit $rc
36 ' _ "$img" "$fc" "$@"41 ' _ "$img" "$fc" "$@"

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