detached 9b7473e

Merge pull request 'feat(sandbox): micro-VM and container job sandboxes' (#1) from feat/sandbox into main

commit
9b7473e4042aeb3c0d3627bdf6132c58f9e375af
author
bnema · · 2026-10-12 18:16 UTC
merge of
885c03f + ca0f9b5 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
70 files · +12120 -6
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 ./...
M README.md +106 -2
@@ -5,9 +5,113 @@ The CI runner of [ermite](https://git.bnema.dev/bnema/ermite). It runs workflows
55 - **`vm`**: a Firecracker micro-VM per job, destroyed at the end. Needs `/dev/kvm`.
66 - **`container`**: a container per job with its own Docker daemon, for hosts without KVM. Uses the sysbox runtime when available, else a privileged container.
77
8-`ERMITE_RUNNER_ISOLATION=auto` (default) picks `vm` when KVM is usable, else `container`.
8+`ERMITE_RUNNER_ISOLATION=auto` (default) picks `vm` when `/dev/kvm` opens read-write and Firecracker, the guest kernel and the base image exist, else `container`. The chosen mode and the reasons are logged at start.
99
10-Status: early development.
10+Status: early development. This tree has the job sandboxes (phase D of the CI-1 plan) with a stub executor; the host loop that fetches jobs from ermite comes later.
11+
12+## Sandboxes
13+
14+Both modes run the same executable, the runner's own, attached **read-only** to the sandbox (never baked into the image, so a runner upgrade needs no image rebuild): `ermite-runner init` is the entrypoint (PID 1 of a VM), then `ermite-runner exec` connects back to the host over a framed protobuf stream (`internal/execstream/exec.proto`; vsock in a VM, a Unix socket in a container). The executable must be static: build with `CGO_ENABLED=0`.
15+
16+### vm
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).
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.
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).
23+
24+### container
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. 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).
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.
31+- The Docker daemon must run on the same host as the runner (host paths are bind-mounted).
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+
37+### Network policy (both modes)
38+
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.
46+
47+### Cleanup
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.
50+
51+## Configuration (sandbox part)
52+
53+| Variable | Default | |
54+|---|---|---|
55+| `ERMITE_RUNNER_ISOLATION` | `auto` | `auto`, `vm` or `container` |
56+| `ERMITE_RUNNER_STATE_DIR` | `/var/lib/ermite-runner` | absolute path |
57+| `ERMITE_RUNNER_CAPACITY` | `1` | parallel jobs (slots) |
58+| `ERMITE_RUNNER_IMAGE` | | VM: image directory (`vmlinux`, `base.ext4`; default `<state dir>/image`); container: OCI reference (default `ermite-runner-ubuntu-24.04:latest`) |
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 |
64+| `ERMITE_RUNNER_UID_BASE` | `900000` | jailer uid pool start |
65+| `ERMITE_RUNNER_NETWORK` | `10.200.0.0/16` | pool of /30 subnets, one per slot |
66+| `ERMITE_RUNNER_FIRECRACKER`, `_JAILER`, `_KERNEL` | from `PATH` | binaries and kernel overrides |
67+| `ERMITE_RUNNER_JAILER_MODE` | `on` | `off` only for development and tests |
68+
69+## Images and kernel
70+
71+```sh
72+scripts/build-kernel.sh tmp/image # downloads Linux 6.18 LTS from kernel.org, builds vmlinux (needs bc, flex, bison, pahole, libelf, openssl headers)
73+scripts/build-image.sh tmp/image # builds the OCI image with docker or podman, exports tmp/image/base.ext4 (no root needed)
74+```
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+
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.
79+
80+## Tests
81+
82+```sh
83+GOWORK=off go vet ./... && GOWORK=off go test -race ./...
84+```
85+
86+Unit tests need nothing special (the drivers are tested with a fake Firecracker and a fake Docker engine). Integration tests are opt-in and use the real stub executor: output and exit codes come back, and nothing survives 10 jobs, including a killed one and a runner crash.
87+
88+**VM** (`ERMITE_RUNNER_IT_VM=1`): needs `/dev/kvm`, Firecracker, the kernel and base image from the scripts above.
89+
90+```sh
91+# Unprivileged: inside a user + network namespace (TAP, nftables and DNS on the TAP address work there).
92+# Jailer off: it needs real root. Everything else is the production path.
93+FIRECRACKER=/path/to/firecracker IMAGE_DIR=$PWD/tmp/image scripts/it-vm-userns.sh
94+
95+# Privileged, with the jailer (the production configuration):
96+sudo env GOWORK=off ERMITE_RUNNER_IT_VM=1 ERMITE_RUNNER_ISOLATION=vm \
97+ ERMITE_RUNNER_IMAGE=$PWD/tmp/image ERMITE_RUNNER_STATE_DIR=/var/lib/ermite-runner-it \
98+ "$(command -v go)" test -count=1 -v ./internal/sandbox/integration -run VM
99+```
100+
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`).
102+
103+```sh
104+sudo env GOWORK=off ERMITE_RUNNER_IT_CONTAINER=1 ERMITE_RUNNER_IMAGE=ermite-runner-ubuntu-24.04:latest \
105+ DOCKER_HOST=unix:///var/run/docker.sock "$(command -v go)" test -count=1 -v ./internal/sandbox/integration -run Container
106+```
107+
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.
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+
114+`ermite-runner sandbox-check [script]` runs one stub job through the selected mode and prints its output: the smoke test of an installation.
11115
12116 ## License
13117
A buf.gen.yaml +5 -0
@@ -0,0 +1,5 @@
1+version: v2
2+plugins:
3+ - local: [go, tool, protoc-gen-go]
4+ out: ../..
5+ opt: module=bnema.dev/ermite-runner
A buf.yaml +12 -0
@@ -0,0 +1,12 @@
1+version: v2
2+modules:
3+ - path: internal/execstream
4+lint:
5+ use:
6+ - STANDARD
7+ except:
8+ # The proto is internal to this module and lives next to its Go package.
9+ - PACKAGE_DIRECTORY_MATCH
10+breaking:
11+ use:
12+ - FILE
M go.mod +103 -0
@@ -1,3 +1,106 @@
11 module bnema.dev/ermite-runner
22
33 go 1.27.1
4+
5+require (
6+ github.com/bnema/zerowrap v1.4.1
7+ github.com/containerd/errdefs v1.0.0
8+ github.com/moby/moby/api v1.56.1
9+ github.com/moby/moby/client v0.6.2
10+ golang.org/x/sys v0.49.0
11+ google.golang.org/protobuf v1.36.12
12+)
13+
14+require (
15+ buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 // indirect
16+ buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2 // indirect
17+ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect
18+ buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260831211851-b4432e12a6e7.1 // indirect
19+ buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20260831211851-b4432e12a6e7.2 // indirect
20+ buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2 // indirect
21+ buf.build/go/app v0.2.1-0.20260824172350-b0e76892c61a // indirect
22+ buf.build/go/bufplugin v0.10.0 // indirect
23+ buf.build/go/bufprivateusage v0.1.0 // indirect
24+ buf.build/go/interrupt v1.1.0 // indirect
25+ buf.build/go/protovalidate v1.4.0 // indirect
26+ buf.build/go/protoyaml v0.7.0 // indirect
27+ buf.build/go/spdx v0.2.0 // indirect
28+ buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect
29+ cel.dev/cel-go v0.32.0 // indirect
30+ cel.dev/expr v0.25.3 // indirect
31+ connectrpc.com/connect v1.20.0 // indirect
32+ connectrpc.com/otelconnect v0.9.0 // indirect
33+ github.com/Microsoft/go-winio v0.6.2 // indirect
34+ github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
35+ github.com/bufbuild/buf v1.73.0 // indirect
36+ github.com/bufbuild/protocompile v0.14.2-0.20260910151042-7436f7c76201 // indirect
37+ github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b // indirect
38+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
39+ github.com/cli/browser v1.3.0 // indirect
40+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
41+ github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
42+ github.com/distribution/reference v0.6.0 // indirect
43+ github.com/docker/cli v29.8.0+incompatible // indirect
44+ github.com/docker/docker-credential-helpers v0.9.9 // indirect
45+ github.com/docker/go-connections v0.8.2 // indirect
46+ github.com/docker/go-units v0.5.0 // indirect
47+ github.com/felixge/httpsnoop v1.1.0 // indirect
48+ github.com/go-logr/logr v1.4.4 // indirect
49+ github.com/go-logr/stdr v1.2.2 // indirect
50+ github.com/gofrs/flock v0.13.1 // indirect
51+ 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
54+ github.com/jdx/go-netrc v1.0.0 // indirect
55+ github.com/klauspost/compress v1.20.0 // indirect
56+ github.com/klauspost/pgzip v1.2.6 // indirect
57+ github.com/mattn/go-colorable v0.1.15 // indirect
58+ github.com/mattn/go-isatty v0.0.24 // indirect
59+ github.com/moby/docker-image-spec v1.3.1 // indirect
60+ github.com/opencontainers/go-digest v1.0.0 // indirect
61+ github.com/opencontainers/image-spec v1.1.1 // indirect
62+ github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect
63+ github.com/quic-go/qpack v0.6.0 // indirect
64+ github.com/quic-go/quic-go v0.62.0 // indirect
65+ github.com/rivo/uniseg v0.4.7 // indirect
66+ github.com/rs/cors v1.11.1 // indirect
67+ github.com/rs/zerolog v1.35.1 // indirect
68+ github.com/russross/blackfriday/v2 v2.1.0 // indirect
69+ github.com/segmentio/asm v1.2.1 // indirect
70+ github.com/segmentio/encoding v0.5.4 // indirect
71+ github.com/sirupsen/logrus v1.10.2 // indirect
72+ github.com/spf13/cobra v1.10.2 // indirect
73+ github.com/spf13/pflag v1.0.10 // indirect
74+ github.com/tetratelabs/wazero v1.12.0 // indirect
75+ github.com/tidwall/btree v1.8.1 // indirect
76+ go.lsp.dev/jsonrpc2 v0.10.0 // indirect
77+ go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect
78+ go.lsp.dev/protocol v0.12.0 // indirect
79+ go.lsp.dev/uri v0.3.0 // indirect
80+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
81+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect
82+ go.opentelemetry.io/otel v1.46.0 // indirect
83+ go.opentelemetry.io/otel/metric v1.46.0 // indirect
84+ go.opentelemetry.io/otel/trace v1.46.0 // indirect
85+ go.uber.org/multierr v1.11.0 // indirect
86+ go.uber.org/zap v1.28.0 // indirect
87+ go.yaml.in/yaml/v3 v3.0.5 // indirect
88+ golang.org/x/crypto v0.56.0 // indirect
89+ golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
90+ golang.org/x/mod v0.40.0 // indirect
91+ golang.org/x/net v0.58.0 // indirect
92+ golang.org/x/sync v0.23.0 // indirect
93+ golang.org/x/term v0.45.0 // indirect
94+ golang.org/x/text v0.41.0 // indirect
95+ google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect
96+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 // indirect
97+ gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
98+ gopkg.in/yaml.v3 v3.0.1 // indirect
99+ mvdan.cc/xurls/v2 v2.6.0 // indirect
100+ pluginrpc.com/pluginrpc v0.5.0 // indirect
101+)
102+
103+tool (
104+ github.com/bufbuild/buf/cmd/buf
105+ google.golang.org/protobuf/cmd/protoc-gen-go
106+)
A go.sum +233 -0 generated file: collapsed
A images/kernel/config-6.18 +163 -0
@@ -0,0 +1,163 @@
1+# Minimal Linux 6.18 guest configuration for Firecracker micro-VMs running
2+# Docker (ermite-runner). This is a configuration *fragment*: scripts/build-kernel.sh
3+# merges it over `make tinyconfig` and resolves dependencies with olddefconfig,
4+# then fails if an option listed here did not survive. Per-architecture options
5+# are in config-6.18.x86_64 and config-6.18.arm64, merged after this file.
6+
7+# --- Core ---
8+CONFIG_64BIT=y
9+CONFIG_SMP=y
10+CONFIG_NR_CPUS=64
11+CONFIG_EXPERT=y
12+CONFIG_PRINTK=y
13+CONFIG_PRINTK_TIME=y
14+CONFIG_KALLSYMS=y
15+CONFIG_BUG=y
16+CONFIG_TTY=y
17+CONFIG_UNIX98_PTYS=y
18+CONFIG_MULTIUSER=y
19+CONFIG_FUTEX=y
20+CONFIG_EPOLL=y
21+CONFIG_EVENTFD=y
22+CONFIG_SIGNALFD=y
23+CONFIG_TIMERFD=y
24+CONFIG_AIO=y
25+CONFIG_FHANDLE=y
26+CONFIG_FILE_LOCKING=y
27+CONFIG_ADVISE_SYSCALLS=y
28+CONFIG_MEMBARRIER=y
29+CONFIG_POSIX_TIMERS=y
30+CONFIG_HIGH_RES_TIMERS=y
31+CONFIG_NO_HZ_IDLE=y
32+CONFIG_BINFMT_ELF=y
33+CONFIG_BINFMT_SCRIPT=y
34+CONFIG_SYSVIPC=y
35+CONFIG_POSIX_MQUEUE=y
36+CONFIG_KEYS=y
37+CONFIG_SECCOMP=y
38+CONFIG_SECCOMP_FILTER=y
39+CONFIG_BPF_SYSCALL=y
40+CONFIG_BPF_JIT=y
41+CONFIG_INOTIFY_USER=y
42+CONFIG_FANOTIFY=y
43+CONFIG_SHMEM=y
44+CONFIG_MODULES=n
45+
46+# --- Guest basics (x86-64 only options live in config-6.18.x86_64) ---
47+CONFIG_SERIAL_8250=y
48+CONFIG_SERIAL_8250_CONSOLE=y
49+CONFIG_HW_RANDOM=y
50+CONFIG_HW_RANDOM_VIRTIO=y
51+
52+# --- virtio (Firecracker uses virtio-mmio) ---
53+CONFIG_VIRTIO_MENU=y
54+CONFIG_VIRTIO=y
55+CONFIG_VIRTIO_MMIO=y
56+CONFIG_VIRTIO_MMIO_CMDLINE_DEVICES=y
57+CONFIG_VIRTIO_BLK=y
58+CONFIG_VIRTIO_NET=y
59+CONFIG_VSOCKETS=y
60+CONFIG_VIRTIO_VSOCKETS=y
61+
62+# --- Block, filesystems ---
63+CONFIG_BLOCK=y
64+CONFIG_BLK_DEV=y
65+CONFIG_PROC_FS=y
66+CONFIG_PROC_SYSCTL=y
67+CONFIG_SYSFS=y
68+CONFIG_TMPFS=y
69+CONFIG_TMPFS_POSIX_ACL=y
70+CONFIG_TMPFS_XATTR=y
71+CONFIG_DEVTMPFS=y
72+CONFIG_DEVTMPFS_MOUNT=y
73+CONFIG_SYSCTL=y
74+CONFIG_EXT4_FS=y
75+CONFIG_EXT4_FS_POSIX_ACL=y
76+CONFIG_EXT4_FS_SECURITY=y
77+CONFIG_OVERLAY_FS=y
78+CONFIG_FUSE_FS=y
79+CONFIG_CRYPTO=y
80+CONFIG_CRYPTO_CRC32C=y
81+
82+# --- Namespaces and cgroups v2 (Docker, runc) ---
83+CONFIG_NAMESPACES=y
84+CONFIG_UTS_NS=y
85+CONFIG_IPC_NS=y
86+CONFIG_USER_NS=y
87+CONFIG_PID_NS=y
88+CONFIG_NET_NS=y
89+CONFIG_TIME_NS=y
90+CONFIG_CGROUPS=y
91+CONFIG_CGROUP_PIDS=y
92+CONFIG_CGROUP_SCHED=y
93+CONFIG_FAIR_GROUP_SCHED=y
94+CONFIG_CFS_BANDWIDTH=y
95+CONFIG_CPUSETS=y
96+CONFIG_MEMCG=y
97+CONFIG_BLK_CGROUP=y
98+CONFIG_CGROUP_DEVICE=y
99+CONFIG_CGROUP_FREEZER=y
100+CONFIG_CGROUP_CPUACCT=y
101+CONFIG_CGROUP_BPF=y
102+CONFIG_HUGETLBFS=y
103+CONFIG_HUGETLB_PAGE=y
104+CONFIG_CGROUP_HUGETLB=y
105+
106+# --- Network ---
107+CONFIG_NET=y
108+CONFIG_NETDEVICES=y
109+CONFIG_NET_CORE=y
110+CONFIG_INET=y
111+CONFIG_IP_PNP=y
112+CONFIG_IPV6=y
113+CONFIG_UNIX=y
114+CONFIG_PACKET=y
115+CONFIG_TUN=y
116+CONFIG_VETH=y
117+CONFIG_BRIDGE=y
118+CONFIG_BRIDGE_NETFILTER=y
119+CONFIG_VLAN_8021Q=y
120+CONFIG_INET_DIAG=y
121+CONFIG_INET_UDP_DIAG=y
122+CONFIG_UNIX_DIAG=y
123+CONFIG_NETLINK_DIAG=y
124+CONFIG_SYN_COOKIES=y
125+
126+# --- netfilter: nftables, iptables compatibility, NAT ---
127+CONFIG_NETFILTER=y
128+CONFIG_NETFILTER_ADVANCED=y
129+CONFIG_NETFILTER_NETLINK=y
130+CONFIG_NF_CONNTRACK=y
131+CONFIG_NF_NAT=y
132+CONFIG_NF_TABLES=y
133+CONFIG_NF_TABLES_INET=y
134+CONFIG_NF_TABLES_IPV4=y
135+CONFIG_NFT_CT=y
136+CONFIG_NFT_NAT=y
137+CONFIG_NFT_MASQ=y
138+CONFIG_NFT_REJECT=y
139+CONFIG_NFT_LIMIT=y
140+CONFIG_NFT_LOG=y
141+CONFIG_NFT_COMPAT=y
142+CONFIG_NETFILTER_XTABLES=y
143+CONFIG_NETFILTER_XTABLES_LEGACY=y
144+CONFIG_NETFILTER_XT_NAT=y
145+CONFIG_NETFILTER_XT_MARK=y
146+CONFIG_NETFILTER_XT_CONNMARK=y
147+CONFIG_NETFILTER_XT_TARGET_MASQUERADE=y
148+CONFIG_NETFILTER_XT_TARGET_MARK=y
149+CONFIG_NETFILTER_XT_TARGET_CHECKSUM=y
150+CONFIG_NETFILTER_XT_TARGET_CONNMARK=y
151+CONFIG_NETFILTER_XT_MATCH_ADDRTYPE=y
152+CONFIG_NETFILTER_XT_MATCH_CONNTRACK=y
153+CONFIG_NETFILTER_XT_MATCH_COMMENT=y
154+CONFIG_NETFILTER_XT_MATCH_MARK=y
155+CONFIG_NETFILTER_XT_MATCH_MULTIPORT=y
156+CONFIG_IP_NF_IPTABLES_LEGACY=y
157+CONFIG_IP_NF_IPTABLES=y
158+CONFIG_IP_NF_FILTER=y
159+CONFIG_IP_NF_MANGLE=y
160+CONFIG_IP_NF_RAW=y
161+CONFIG_IP_NF_NAT=y
162+CONFIG_IP_NF_TARGET_MASQUERADE=y
163+CONFIG_IP_NF_TARGET_REJECT=y
A images/kernel/config-6.18.arm64 +11 -0
@@ -0,0 +1,11 @@
1+# arm64 specific guest options, merged after config-6.18. Not exercised in CI-1
2+# (no arm64 host); kept so that the build is reproducible there.
3+CONFIG_ARM64=y
4+CONFIG_ARM64_VA_BITS_48=y
5+CONFIG_ARM_GIC_V3=y
6+CONFIG_ARM_PSCI_FW=y
7+CONFIG_ARM_ARCH_TIMER=y
8+CONFIG_SERIAL_AMBA_PL011=y
9+CONFIG_SERIAL_AMBA_PL011_CONSOLE=y
10+CONFIG_RTC_CLASS=y
11+CONFIG_RTC_DRV_PL031=y
A images/kernel/config-6.18.x86_64 +11 -0
@@ -0,0 +1,11 @@
1+# x86-64 specific guest options, merged after config-6.18.
2+CONFIG_HYPERVISOR_GUEST=y
3+CONFIG_PARAVIRT=y
4+CONFIG_KVM_GUEST=y
5+CONFIG_IA32_EMULATION=y
6+CONFIG_SERIO=y
7+CONFIG_SERIO_I8042=y
8+CONFIG_ACPI=y
9+CONFIG_X86_MPPARSE=y
10+CONFIG_X86_LOCAL_APIC=y
11+CONFIG_X86_IO_APIC=y
A images/ubuntu-24.04/Dockerfile +43 -0
@@ -0,0 +1,43 @@
1+# SPDX-License-Identifier: MIT
2+#
3+# ermite-runner-ubuntu-24.04: the minimal CI-1 runner image. The same
4+# filesystem is used as an OCI image (container isolation) and exported to an
5+# ext4 base image (vm isolation) by scripts/build-image.sh. It holds the OS
6+# only: the runner executable is attached by the host at start (never baked
7+# in), so a runner upgrade does not need an image rebuild.
8+FROM ubuntu:24.04
9+
10+ARG DEBIAN_FRONTEND=noninteractive
11+
12+RUN apt-get update \
13+ && apt-get install -y --no-install-recommends \
14+ bash build-essential ca-certificates curl e2fsprogs git gnupg iproute2 \
15+ iptables jq kmod procps python3 sudo tzdata unzip xz-utils zip \
16+ && rm -rf /var/lib/apt/lists/*
17+
18+# Docker Engine from Docker's apt repository (the job gets its own daemon).
19+RUN install -m 0755 -d /etc/apt/keyrings \
20+ && curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc \
21+ && chmod a+r /etc/apt/keyrings/docker.asc \
22+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable" \
23+ > /etc/apt/sources.list.d/docker.list \
24+ && apt-get update \
25+ && apt-get install -y --no-install-recommends \
26+ docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
27+ && rm -rf /var/lib/apt/lists/*
28+
29+# Like GitHub-hosted runners: user "runner" (uid 1001), passwordless sudo,
30+# member of "docker", with the job layout directories.
31+RUN useradd --create-home --uid 1001 --shell /bin/bash runner \
32+ && usermod -aG docker runner \
33+ && echo 'runner ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/runner \
34+ && chmod 0440 /etc/sudoers.d/runner \
35+ && mkdir -p /home/runner/work /opt/hostedtoolcache /opt/ermite \
36+ && chown -R runner:runner /home/runner /opt/hostedtoolcache
37+
38+ENV RUNNER_TOOL_CACHE=/opt/hostedtoolcache \
39+ LANG=C.UTF-8
40+
41+# The runner executable is bind-mounted (container) or provided by the init
42+# disk (vm) here; its init subcommand starts the job's own Docker daemon.
43+ENTRYPOINT ["/opt/ermite/ermite-runner", "init"]
A internal/cmdrun/cmdrun.go +75 -0
@@ -0,0 +1,75 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package cmdrun runs host commands (ip, nft, mkfs.ext4) behind a small
4+// interface so that callers can be tested with a fake.
5+package cmdrun
6+
7+import (
8+ "bytes"
9+ "context"
10+ "fmt"
11+ "io"
12+ "os/exec"
13+ "strings"
14+)
15+
16+// Runner runs a command to completion and returns its combined output.
17+type Runner interface {
18+ Run(ctx context.Context, stdin io.Reader, name string, args ...string) ([]byte, error)
19+}
20+
21+// Exec runs real commands.
22+type Exec struct{}
23+
24+// Run implements Runner. A non-zero exit becomes an error carrying the command
25+// line and its output.
26+func (Exec) Run(ctx context.Context, stdin io.Reader, name string, args ...string) ([]byte, error) {
27+ cmd := exec.CommandContext(ctx, name, args...)
28+ cmd.Stdin = stdin
29+ var out bytes.Buffer
30+ cmd.Stdout = &out
31+ cmd.Stderr = &out
32+ if err := cmd.Run(); err != nil {
33+ return out.Bytes(), fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(out.String()))
34+ }
35+ return out.Bytes(), nil
36+}
37+
38+// Call is one recorded invocation of a Fake.
39+type Call struct {
40+ Name string
41+ Args []string
42+ Stdin string
43+}
44+
45+// String renders the call as a shell-like line.
46+func (c Call) String() string { return strings.TrimSpace(c.Name + " " + strings.Join(c.Args, " ")) }
47+
48+// Fake records calls and answers them with Handle (nil means success).
49+type Fake struct {
50+ Calls []Call
51+ Handle func(Call) ([]byte, error)
52+}
53+
54+// Run implements Runner.
55+func (f *Fake) Run(_ context.Context, stdin io.Reader, name string, args ...string) ([]byte, error) {
56+ c := Call{Name: name, Args: append([]string(nil), args...)}
57+ if stdin != nil {
58+ b, _ := io.ReadAll(stdin)
59+ c.Stdin = string(b)
60+ }
61+ f.Calls = append(f.Calls, c)
62+ if f.Handle != nil {
63+ return f.Handle(c)
64+ }
65+ return nil, nil
66+}
67+
68+// Lines returns the recorded calls as strings.
69+func (f *Fake) Lines() []string {
70+ out := make([]string, len(f.Calls))
71+ for i, c := range f.Calls {
72+ out[i] = c.String()
73+ }
74+ return out
75+}
A internal/dnsfwd/dnsfwd.go +361 -0
@@ -0,0 +1,361 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package dnsfwd is a small DNS forwarder for job sandboxes (stdlib only). It
4+// listens on the gateway address of each sandbox (UDP and TCP, port 53 in
5+// production) and relays queries unchanged to the host's resolvers; the network
6+// policy lets guests reach nothing else on the host. It does not parse queries
7+// beyond what framing needs, so it supports every record type and EDNS.
8+//
9+// Abuse limits, per listener (one listener per guest, so one guest cannot
10+// starve another): at most MaxInflight concurrent upstream exchanges (UDP
11+// queries beyond that are dropped, never queued), at most MaxTCPConns open TCP
12+// connections, an absolute lifetime of TCPLifetime per TCP connection (a client
13+// cannot hold a slot by trickling bytes), a deadline on every upstream
14+// exchange, and a cap on queries per TCP connection.
15+package dnsfwd
16+
17+import (
18+ "context"
19+ "encoding/binary"
20+ "errors"
21+ "fmt"
22+ "io"
23+ "net"
24+ "net/netip"
25+ "strconv"
26+ "sync"
27+ "sync/atomic"
28+ "time"
29+
30+ "github.com/bnema/zerowrap"
31+)
32+
33+const (
34+ // TCPLifetime is the absolute lifetime of a TCP connection, set at accept.
35+ TCPLifetime = 30 * time.Second
36+ // UpstreamTimeout is the deadline of one upstream attempt.
37+ UpstreamTimeout = 3 * time.Second
38+ // TCPIdleTimeout closes TCP client connections that stay silent.
39+ TCPIdleTimeout = 10 * time.Second
40+
41+ // MaxInflight bounds concurrent upstream exchanges per listener.
42+ MaxInflight = 32
43+ // MaxTCPConns bounds open TCP connections per listener.
44+ MaxTCPConns = 16
45+ // TCPMaxQueries caps queries per TCP connection.
46+ TCPMaxQueries = 64
47+
48+ maxUDP = 4096 // largest datagram relayed (EDNS0 sizes up to 4096)
49+ hdrLen = 12
50+)
51+
52+// Forwarder relays DNS queries. The zero value is not usable; use New.
53+type Forwarder struct {
54+ upstreams func() []string
55+ port int
56+ // Lifetime overrides TCPLifetime; set before Listen (tests).
57+ Lifetime time.Duration
58+
59+ mu sync.Mutex
60+ listeners map[netip.Addr]*listener
61+}
62+
63+type listener struct {
64+ udp net.PacketConn
65+ tcp net.Listener
66+ cancel context.CancelFunc
67+ done sync.WaitGroup
68+ // inflight and conns are the per-listener limits (counting semaphores).
69+ inflight chan struct{}
70+ conns chan struct{}
71+ // Dropped counts UDP queries dropped because the listener was saturated.
72+ dropped atomic.Int64
73+}
74+
75+// New returns a Forwarder. upstreams returns the "host:port" resolvers to try
76+// in order (see Resolvers.Addrs); port is the port to listen on (53, or another
77+// port in tests).
78+func New(upstreams func() []string, port int) *Forwarder {
79+ return &Forwarder{
80+ upstreams: upstreams,
81+ port: port,
82+ Lifetime: TCPLifetime,
83+ listeners: map[netip.Addr]*listener{},
84+ }
85+}
86+
87+// Listen starts serving on addr (UDP and TCP). It returns once both sockets are
88+// bound. Listening twice on the same address is an error.
89+func (f *Forwarder) Listen(ctx context.Context, addr netip.Addr) error {
90+ f.mu.Lock()
91+ defer f.mu.Unlock()
92+ if _, ok := f.listeners[addr]; ok {
93+ return fmt.Errorf("dnsfwd: already listening on %s", addr)
94+ }
95+ hp := net.JoinHostPort(addr.String(), strconv.Itoa(f.port))
96+ var lc net.ListenConfig
97+ pc, err := lc.ListenPacket(ctx, "udp", hp)
98+ if err != nil {
99+ return fmt.Errorf("dnsfwd: listen udp %s: %w", hp, err)
100+ }
101+ // With port 0 (tests) UDP and TCP must share the port the kernel picked.
102+ if f.port == 0 {
103+ hp = pc.LocalAddr().String()
104+ }
105+ tl, err := lc.Listen(ctx, "tcp", hp)
106+ if err != nil {
107+ _ = pc.Close()
108+ return fmt.Errorf("dnsfwd: listen tcp %s: %w", hp, err)
109+ }
110+ lctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
111+ l := &listener{udp: pc, tcp: tl, cancel: cancel, inflight: make(chan struct{}, MaxInflight), conns: make(chan struct{}, MaxTCPConns)}
112+ f.listeners[addr] = l
113+ log := zerowrap.FromCtx(ctx)
114+ l.done.Add(2)
115+ go func() { defer l.done.Done(); f.serveUDP(lctx, l, log) }()
116+ go func() { defer l.done.Done(); f.serveTCP(lctx, l, log) }()
117+ return nil
118+}
119+
120+// Addr returns the bound UDP address for addr, useful when port is 0.
121+func (f *Forwarder) Addr(addr netip.Addr) (net.Addr, bool) {
122+ f.mu.Lock()
123+ defer f.mu.Unlock()
124+ l, ok := f.listeners[addr]
125+ if !ok {
126+ return nil, false
127+ }
128+ return l.udp.LocalAddr(), true
129+}
130+
131+// Close stops listening on addr and waits for its goroutines. Unknown
132+// addresses are ignored.
133+func (f *Forwarder) Close(addr netip.Addr) {
134+ f.mu.Lock()
135+ l, ok := f.listeners[addr]
136+ delete(f.listeners, addr)
137+ f.mu.Unlock()
138+ if !ok {
139+ return
140+ }
141+ l.cancel()
142+ _ = l.udp.Close()
143+ _ = l.tcp.Close()
144+ l.done.Wait()
145+}
146+
147+// CloseAll stops every listener.
148+func (f *Forwarder) CloseAll() {
149+ f.mu.Lock()
150+ addrs := make([]netip.Addr, 0, len(f.listeners))
151+ for a := range f.listeners {
152+ addrs = append(addrs, a)
153+ }
154+ f.mu.Unlock()
155+ for _, a := range addrs {
156+ f.Close(a)
157+ }
158+}
159+
160+// Dropped returns how many UDP queries the listener on addr dropped because it
161+// was saturated.
162+func (f *Forwarder) Dropped(addr netip.Addr) int64 {
163+ f.mu.Lock()
164+ defer f.mu.Unlock()
165+ if l, ok := f.listeners[addr]; ok {
166+ return l.dropped.Load()
167+ }
168+ return 0
169+}
170+
171+func (f *Forwarder) serveUDP(ctx context.Context, l *listener, log zerowrap.Logger) {
172+ pc := l.udp
173+ buf := make([]byte, maxUDP)
174+ for {
175+ n, from, err := pc.ReadFrom(buf)
176+ if err != nil {
177+ if ctx.Err() == nil && !errors.Is(err, net.ErrClosed) {
178+ log.Warn().Err(err).Msg("dns forwarder: udp read")
179+ }
180+ return
181+ }
182+ if n < hdrLen {
183+ continue
184+ }
185+ select {
186+ case l.inflight <- struct{}{}:
187+ default:
188+ // Saturated: drop. The client retries; blocking here would let one
189+ // guest's flood stall the read loop.
190+ l.dropped.Add(1)
191+ continue
192+ }
193+ q := append([]byte(nil), buf[:n]...)
194+ go func() {
195+ defer func() { <-l.inflight }()
196+ resp := f.exchange(ctx, q, false)
197+ if len(resp) > 0 {
198+ _, _ = pc.WriteTo(resp, from)
199+ }
200+ }()
201+ }
202+}
203+
204+func (f *Forwarder) serveTCP(ctx context.Context, l *listener, log zerowrap.Logger) {
205+ var wg sync.WaitGroup
206+ defer wg.Wait()
207+ for {
208+ c, err := l.tcp.Accept()
209+ if err != nil {
210+ if ctx.Err() == nil && !errors.Is(err, net.ErrClosed) {
211+ log.Warn().Err(err).Msg("dns forwarder: tcp accept")
212+ }
213+ return
214+ }
215+ select {
216+ case l.conns <- struct{}{}:
217+ default:
218+ _ = c.Close() // too many open connections on this listener
219+ continue
220+ }
221+ wg.Add(1)
222+ go func() {
223+ defer wg.Done()
224+ defer func() { <-l.conns }()
225+ defer func() { _ = c.Close() }()
226+ stop := context.AfterFunc(ctx, func() { _ = c.Close() })
227+ defer stop()
228+ // Absolute deadline from accept: neither idle nor slow-loris
229+ // connections keep their slot past TCPLifetime.
230+ deadline := time.Now().Add(f.Lifetime)
231+ _ = c.SetDeadline(deadline)
232+ f.serveConn(ctx, l, c, deadline)
233+ }()
234+ }
235+}
236+
237+func (f *Forwarder) serveConn(ctx context.Context, l *listener, c net.Conn, deadline time.Time) {
238+ for range TCPMaxQueries {
239+ // The idle timeout can only shorten the absolute deadline.
240+ dl := time.Now().Add(TCPIdleTimeout)
241+ if deadline.Before(dl) {
242+ dl = deadline
243+ }
244+ _ = c.SetReadDeadline(dl)
245+ var lb [2]byte
246+ if _, err := io.ReadFull(c, lb[:]); err != nil {
247+ return
248+ }
249+ n := int(binary.BigEndian.Uint16(lb[:]))
250+ if n < hdrLen {
251+ return
252+ }
253+ q := make([]byte, n)
254+ if _, err := io.ReadFull(c, q); err != nil {
255+ return
256+ }
257+ select {
258+ case l.inflight <- struct{}{}:
259+ default:
260+ return // saturated: close, the client retries
261+ }
262+ resp := f.exchange(ctx, q, true)
263+ <-l.inflight
264+ if len(resp) == 0 || len(resp) > 0xffff {
265+ return
266+ }
267+ out := make([]byte, 2+len(resp))
268+ binary.BigEndian.PutUint16(out, uint16(len(resp)))
269+ copy(out[2:], resp)
270+ if _, err := c.Write(out); err != nil {
271+ return
272+ }
273+ }
274+}
275+
276+// exchange relays q to the upstreams in order and returns the first answer, or
277+// a SERVFAIL when none answers. The transport towards the upstream matches the
278+// client's: a client that fell back to TCP gets a complete answer.
279+func (f *Forwarder) exchange(ctx context.Context, q []byte, overTCP bool) []byte {
280+ for _, up := range f.upstreams() {
281+ var resp []byte
282+ var err error
283+ if overTCP {
284+ resp, err = exchangeTCP(ctx, up, q)
285+ } else {
286+ resp, err = exchangeUDP(ctx, up, q)
287+ }
288+ if err == nil && len(resp) >= hdrLen && resp[0] == q[0] && resp[1] == q[1] {
289+ return resp
290+ }
291+ }
292+ return servfail(q)
293+}
294+
295+func exchangeUDP(ctx context.Context, up string, q []byte) ([]byte, error) {
296+ ctx, cancel := context.WithTimeout(ctx, UpstreamTimeout)
297+ defer cancel()
298+ var d net.Dialer
299+ c, err := d.DialContext(ctx, "udp", up)
300+ if err != nil {
301+ return nil, err
302+ }
303+ defer func() { _ = c.Close() }()
304+ dl, _ := ctx.Deadline()
305+ _ = c.SetDeadline(dl)
306+ if _, err := c.Write(q); err != nil {
307+ return nil, err
308+ }
309+ buf := make([]byte, maxUDP)
310+ for {
311+ n, err := c.Read(buf)
312+ if err != nil {
313+ return nil, err
314+ }
315+ // Ignore datagrams that do not answer this query (ID mismatch).
316+ if n >= hdrLen && buf[0] == q[0] && buf[1] == q[1] {
317+ return append([]byte(nil), buf[:n]...), nil
318+ }
319+ }
320+}
321+
322+func exchangeTCP(ctx context.Context, up string, q []byte) ([]byte, error) {
323+ ctx, cancel := context.WithTimeout(ctx, UpstreamTimeout)
324+ defer cancel()
325+ var d net.Dialer
326+ c, err := d.DialContext(ctx, "tcp", up)
327+ if err != nil {
328+ return nil, err
329+ }
330+ defer func() { _ = c.Close() }()
331+ dl, _ := ctx.Deadline()
332+ _ = c.SetDeadline(dl)
333+ out := make([]byte, 2+len(q))
334+ binary.BigEndian.PutUint16(out, uint16(len(q)))
335+ copy(out[2:], q)
336+ if _, err := c.Write(out); err != nil {
337+ return nil, err
338+ }
339+ var l [2]byte
340+ if _, err := io.ReadFull(c, l[:]); err != nil {
341+ return nil, err
342+ }
343+ resp := make([]byte, binary.BigEndian.Uint16(l[:]))
344+ if _, err := io.ReadFull(c, resp); err != nil {
345+ return nil, err
346+ }
347+ return resp, nil
348+}
349+
350+// servfail builds a SERVFAIL reply for q: same ID and opcode, QR and RA set,
351+// RD copied, no records.
352+func servfail(q []byte) []byte {
353+ if len(q) < hdrLen {
354+ return nil
355+ }
356+ r := make([]byte, hdrLen)
357+ r[0], r[1] = q[0], q[1]
358+ r[2] = 0x80 | (q[2] & 0x79) // QR, opcode, RD kept; AA/TC cleared
359+ r[3] = 0x80 | 0x02 // RA, RCODE=SERVFAIL
360+ return r
361+}
A internal/dnsfwd/dnsfwd_test.go +442 -0
@@ -0,0 +1,442 @@
1+// SPDX-License-Identifier: MIT
2+
3+package dnsfwd
4+
5+import (
6+ "context"
7+ "encoding/binary"
8+ "errors"
9+ "io"
10+ "net"
11+ "net/netip"
12+ "os"
13+ "path/filepath"
14+ "reflect"
15+ "strings"
16+ "sync/atomic"
17+ "syscall"
18+ "testing"
19+ "time"
20+)
21+
22+// query builds a minimal query for name with the given ID.
23+func query(id uint16, name string) []byte {
24+ b := make([]byte, hdrLen)
25+ binary.BigEndian.PutUint16(b, id)
26+ b[2] = 0x01 // RD
27+ b[5] = 1 // QDCOUNT
28+ for _, l := range strings.Split(name, ".") {
29+ b = append(b, byte(len(l)))
30+ b = append(b, l...)
31+ }
32+ return append(b, 0, 0, 1, 0, 1) // root, A, IN
33+}
34+
35+// answer turns a query into a response carrying a recognisable payload.
36+func answer(q []byte) []byte {
37+ r := append([]byte(nil), q...)
38+ r[2] |= 0x80
39+ return append(r, "ANSWER"...)
40+}
41+
42+// fakeUpstream serves UDP and TCP on one port of 127.0.0.1 and returns it.
43+func fakeUpstream(t *testing.T, handle func(q []byte) []byte) string {
44+ t.Helper()
45+ pc, err := net.ListenPacket("udp", "127.0.0.1:0")
46+ if err != nil {
47+ t.Skip("no loopback:", err)
48+ }
49+ addr := pc.LocalAddr().String()
50+ tl, err := net.Listen("tcp", addr)
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ t.Cleanup(func() { _ = pc.Close(); _ = tl.Close() })
55+ go func() {
56+ buf := make([]byte, 4096)
57+ for {
58+ n, from, err := pc.ReadFrom(buf)
59+ if err != nil {
60+ return
61+ }
62+ if r := handle(append([]byte(nil), buf[:n]...)); r != nil {
63+ _, _ = pc.WriteTo(r, from)
64+ }
65+ }
66+ }()
67+ go func() {
68+ for {
69+ c, err := tl.Accept()
70+ if err != nil {
71+ return
72+ }
73+ go func() {
74+ defer func() { _ = c.Close() }()
75+ var l [2]byte
76+ if _, err := io.ReadFull(c, l[:]); err != nil {
77+ return
78+ }
79+ q := make([]byte, binary.BigEndian.Uint16(l[:]))
80+ if _, err := io.ReadFull(c, q); err != nil {
81+ return
82+ }
83+ r := handle(q)
84+ out := make([]byte, 2+len(r))
85+ binary.BigEndian.PutUint16(out, uint16(len(r)))
86+ copy(out[2:], r)
87+ _, _ = c.Write(out)
88+ }()
89+ }
90+ }()
91+ return addr
92+}
93+
94+func start(t *testing.T, ups ...string) (*Forwarder, string) {
95+ t.Helper()
96+ f := New(func() []string { return ups }, 0)
97+ ip := netip.MustParseAddr("127.0.0.1")
98+ if err := f.Listen(context.Background(), ip); err != nil {
99+ t.Fatal(err)
100+ }
101+ t.Cleanup(f.CloseAll)
102+ a, _ := f.Addr(ip)
103+ return f, a.String()
104+}
105+
106+func TestUDPAndTCPForwarding(t *testing.T) {
107+ up := fakeUpstream(t, answer)
108+ _, addr := start(t, up)
109+
110+ c, err := net.Dial("udp", addr)
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ defer func() { _ = c.Close() }()
115+ _ = c.SetDeadline(time.Now().Add(5 * time.Second))
116+ q := query(0x1234, "example.test")
117+ _, _ = c.Write(q)
118+ buf := make([]byte, 512)
119+ n, err := c.Read(buf)
120+ if err != nil {
121+ t.Fatal(err)
122+ }
123+ if string(buf[n-6:n]) != "ANSWER" || binary.BigEndian.Uint16(buf) != 0x1234 {
124+ t.Fatalf("udp answer: %x", buf[:n])
125+ }
126+
127+ tc, err := net.Dial("tcp", addr)
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ defer func() { _ = tc.Close() }()
132+ _ = tc.SetDeadline(time.Now().Add(5 * time.Second))
133+ // Two queries on one connection.
134+ for _, id := range []uint16{7, 8} {
135+ q := query(id, "tcp.test")
136+ framed := append([]byte{byte(len(q) >> 8), byte(len(q))}, q...)
137+ _, _ = tc.Write(framed)
138+ var l [2]byte
139+ if _, err := io.ReadFull(tc, l[:]); err != nil {
140+ t.Fatal(err)
141+ }
142+ r := make([]byte, binary.BigEndian.Uint16(l[:]))
143+ if _, err := io.ReadFull(tc, r); err != nil {
144+ t.Fatal(err)
145+ }
146+ if binary.BigEndian.Uint16(r) != id || string(r[len(r)-6:]) != "ANSWER" {
147+ t.Fatalf("tcp answer for %d: %x", id, r)
148+ }
149+ }
150+}
151+
152+func TestFailoverToSecondUpstream(t *testing.T) {
153+ dead := "127.0.0.1:1" // nothing listens: UDP read fails with ICMP refused
154+ live := fakeUpstream(t, answer)
155+ _, addr := start(t, dead, live)
156+ c, _ := net.Dial("udp", addr)
157+ defer func() { _ = c.Close() }()
158+ _ = c.SetDeadline(time.Now().Add(8 * time.Second))
159+ _, _ = c.Write(query(1, "a.test"))
160+ buf := make([]byte, 512)
161+ n, err := c.Read(buf)
162+ if err != nil || string(buf[n-6:n]) != "ANSWER" {
163+ t.Fatalf("n=%d err=%v", n, err)
164+ }
165+}
166+
167+func TestServfailWhenNoUpstreamAnswers(t *testing.T) {
168+ _, addr := start(t) // no upstream at all
169+ c, _ := net.Dial("udp", addr)
170+ defer func() { _ = c.Close() }()
171+ _ = c.SetDeadline(time.Now().Add(5 * time.Second))
172+ _, _ = c.Write(query(0xbeef, "a.test"))
173+ buf := make([]byte, 512)
174+ n, err := c.Read(buf)
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ if n != hdrLen || binary.BigEndian.Uint16(buf) != 0xbeef || buf[3]&0x0f != 2 || buf[2]&0x80 == 0 {
179+ t.Fatalf("not a SERVFAIL: %x", buf[:n])
180+ }
181+}
182+
183+func TestMismatchedUpstreamIDIgnored(t *testing.T) {
184+ // Upstream answers with a wrong ID: treated as no answer, client gets SERVFAIL.
185+ up := fakeUpstream(t, func(q []byte) []byte {
186+ r := answer(q)
187+ r[0] ^= 0xff
188+ return r
189+ })
190+ f, addr := start(t, up)
191+ _ = f
192+ c, _ := net.Dial("udp", addr)
193+ defer func() { _ = c.Close() }()
194+ _ = c.SetDeadline(time.Now().Add(2*UpstreamTimeout + 2*time.Second))
195+ _, _ = c.Write(query(5, "a.test"))
196+ buf := make([]byte, 512)
197+ n, err := c.Read(buf)
198+ if err != nil {
199+ t.Fatal(err)
200+ }
201+ if buf[3]&0x0f != 2 {
202+ t.Fatalf("want SERVFAIL, got %x", buf[:n])
203+ }
204+}
205+
206+func TestShortPacketsIgnored(t *testing.T) {
207+ up := fakeUpstream(t, answer)
208+ _, addr := start(t, up)
209+ c, _ := net.Dial("udp", addr)
210+ defer func() { _ = c.Close() }()
211+ _, _ = c.Write([]byte{1, 2, 3}) // dropped
212+ _, _ = c.Write(query(9, "a.test"))
213+ _ = c.SetDeadline(time.Now().Add(5 * time.Second))
214+ buf := make([]byte, 512)
215+ n, err := c.Read(buf)
216+ if err != nil || binary.BigEndian.Uint16(buf) != 9 {
217+ t.Fatalf("n=%d err=%v", n, err)
218+ }
219+}
220+
221+func TestListenTwiceAndClose(t *testing.T) {
222+ f := New(func() []string { return nil }, 0)
223+ ip := netip.MustParseAddr("127.0.0.1")
224+ if err := f.Listen(context.Background(), ip); err != nil {
225+ t.Skip(err)
226+ }
227+ if err := f.Listen(context.Background(), ip); err == nil {
228+ t.Fatal("second Listen on the same address must fail")
229+ }
230+ a, _ := f.Addr(ip)
231+ f.Close(ip)
232+ f.Close(ip) // idempotent
233+ if _, ok := f.Addr(ip); ok {
234+ t.Fatal("still listening after Close")
235+ }
236+ if pc, err := net.ListenPacket("udp", a.String()); err != nil {
237+ t.Fatalf("port not released: %v", err)
238+ } else {
239+ _ = pc.Close()
240+ }
241+}
242+
243+func TestServfailShape(t *testing.T) {
244+ if servfail([]byte{1}) != nil {
245+ t.Fatal("short query must give nil")
246+ }
247+ q := query(0xabcd, "x.test")
248+ q[2] = 0x79 // opcode bits + RD + AA
249+ r := servfail(q)
250+ if r[0] != 0xab || r[1] != 0xcd || r[2]&0x80 == 0 || r[3] != 0x82 {
251+ t.Fatalf("bad servfail %x", r)
252+ }
253+}
254+
255+func TestParseResolvConf(t *testing.T) {
256+ in := `# comment
257+nameserver 127.0.0.53
258+nameserver 127.0.0.53
259+nameserver 192.0.2.1 # trailing
260+nameserver 2001:db8::1
261+nameserver fe80::1%eth0
262+nameserver 0.0.0.0
263+nameserver not-an-ip
264+search example.test
265+options edns0
266+nameserver
267+`
268+ got := ParseResolvConf(strings.NewReader(in))
269+ want := []string{"127.0.0.53:53", "192.0.2.1:53", "[2001:db8::1]:53", "[fe80::1%eth0]:53"}
270+ if !reflect.DeepEqual(got, want) {
271+ t.Fatalf("got %v want %v", got, want)
272+ }
273+}
274+
275+func TestResolversReload(t *testing.T) {
276+ p := filepath.Join(t.TempDir(), "resolv.conf")
277+ if err := os.WriteFile(p, []byte("nameserver 192.0.2.1\n"), 0o644); err != nil {
278+ t.Fatal(err)
279+ }
280+ r := NewResolvers(p)
281+ if got := r.Addrs(); !reflect.DeepEqual(got, []string{"192.0.2.1:53"}) {
282+ t.Fatal(got)
283+ }
284+ if err := os.WriteFile(p, []byte("nameserver 192.0.2.99\nnameserver 192.0.2.1\n"), 0o644); err != nil {
285+ t.Fatal(err)
286+ }
287+ later := time.Now().Add(2 * time.Second)
288+ _ = os.Chtimes(p, later, later)
289+ if got := r.Addrs(); !reflect.DeepEqual(got, []string{"192.0.2.99:53", "192.0.2.1:53"}) {
290+ t.Fatal(got)
291+ }
292+ // Unreadable file keeps the last known list.
293+ _ = os.Remove(p)
294+ if got := r.Addrs(); len(got) != 2 {
295+ t.Fatalf("lost the last known list: %v", got)
296+ }
297+ if got := NewResolvers(filepath.Join(t.TempDir(), "missing")).Addrs(); len(got) != 0 {
298+ t.Fatal(got)
299+ }
300+}
301+
302+// S2: a listener holds at most MaxTCPConns connections; extra ones are closed
303+// at once, and a free slot is usable again after a close.
304+func TestTCPConnectionCapPerListener(t *testing.T) {
305+ up := fakeUpstream(t, answer)
306+ _, addr := start(t, up)
307+ var held []net.Conn
308+ for range MaxTCPConns {
309+ c, err := net.Dial("tcp", addr)
310+ if err != nil {
311+ t.Fatal(err)
312+ }
313+ held = append(held, c)
314+ defer func() { _ = c.Close() }()
315+ }
316+ // Let the accept loop register all of them.
317+ time.Sleep(200 * time.Millisecond)
318+ extra, err := net.Dial("tcp", addr)
319+ if err != nil {
320+ t.Fatal(err)
321+ }
322+ defer func() { _ = extra.Close() }()
323+ _ = extra.SetReadDeadline(time.Now().Add(3 * time.Second))
324+ if _, err := extra.Read(make([]byte, 1)); err != io.EOF && !errors.Is(err, syscall.ECONNRESET) {
325+ t.Fatalf("the connection over the cap must be closed by the server, got %v", err)
326+ }
327+ // Free one slot: a new connection is served.
328+ _ = held[0].Close()
329+ time.Sleep(200 * time.Millisecond)
330+ c, err := net.Dial("tcp", addr)
331+ if err != nil {
332+ t.Fatal(err)
333+ }
334+ defer func() { _ = c.Close() }()
335+ _ = c.SetDeadline(time.Now().Add(5 * time.Second))
336+ q := query(3, "slot.test")
337+ _, _ = c.Write(append([]byte{byte(len(q) >> 8), byte(len(q))}, q...))
338+ var l [2]byte
339+ if _, err := io.ReadFull(c, l[:]); err != nil {
340+ t.Fatalf("a freed slot must serve again: %v", err)
341+ }
342+}
343+
344+// S2: the lifetime is absolute from accept: a client that keeps the connection
345+// alive by trickling bytes still loses it.
346+func TestTCPAbsoluteDeadline(t *testing.T) {
347+ up := fakeUpstream(t, answer)
348+ f := New(func() []string { return []string{up} }, 0)
349+ f.Lifetime = 600 * time.Millisecond
350+ ip := netip.MustParseAddr("127.0.0.1")
351+ if err := f.Listen(context.Background(), ip); err != nil {
352+ t.Skip(err)
353+ }
354+ t.Cleanup(f.CloseAll)
355+ la, _ := f.Addr(ip)
356+ addr := la.String()
357+ c, err := net.Dial("tcp", addr)
358+ if err != nil {
359+ t.Fatal(err)
360+ }
361+ defer func() { _ = c.Close() }()
362+ start := time.Now()
363+ closed := make(chan struct{})
364+ go func() {
365+ _, _ = io.Copy(io.Discard, c) // returns when the server closes
366+ close(closed)
367+ }()
368+ // Trickle one byte every 100 ms, never finishing a frame: only an idle
369+ // timeout (10 s) would let this live; the absolute deadline must not.
370+ go func() {
371+ for i := 0; i < 40; i++ {
372+ if _, err := c.Write([]byte{0}); err != nil {
373+ return
374+ }
375+ time.Sleep(100 * time.Millisecond)
376+ }
377+ }()
378+ select {
379+ case <-closed:
380+ if d := time.Since(start); d > 3*time.Second {
381+ t.Fatalf("closed after %v, want about %v", d, f.Lifetime)
382+ }
383+ case <-time.After(5 * time.Second):
384+ t.Fatal("a trickling client kept its connection past the absolute deadline")
385+ }
386+}
387+
388+// S2/S9: a saturated listener drops UDP queries instead of blocking its read
389+// loop, and another listener (another guest) keeps answering.
390+func TestUDPSaturationDropsAndIsolatesListeners(t *testing.T) {
391+ release := make(chan struct{})
392+ slow := fakeUpstream(t, func(q []byte) []byte { <-release; return answer(q) })
393+ fast := fakeUpstream(t, answer)
394+ var useSlow atomic.Bool
395+ useSlow.Store(true)
396+ f := New(func() []string {
397+ if useSlow.Load() {
398+ return []string{slow}
399+ }
400+ return []string{fast}
401+ }, 0)
402+ a1, a2 := netip.MustParseAddr("127.0.0.1"), netip.MustParseAddr("127.0.0.2")
403+ if err := f.Listen(context.Background(), a1); err != nil {
404+ t.Skip(err)
405+ }
406+ if err := f.Listen(context.Background(), a2); err != nil {
407+ t.Skip(err)
408+ }
409+ t.Cleanup(f.CloseAll)
410+ t.Cleanup(func() { close(release) })
411+ l1, _ := f.Addr(a1)
412+ flood, err := net.Dial("udp", l1.String())
413+ if err != nil {
414+ t.Fatal(err)
415+ }
416+ defer func() { _ = flood.Close() }()
417+ for i := 0; i < MaxInflight*3; i++ {
418+ _, _ = flood.Write(query(uint16(i), "flood.test"))
419+ }
420+ deadline := time.Now().Add(3 * time.Second)
421+ for f.Dropped(a1) == 0 && time.Now().Before(deadline) {
422+ time.Sleep(20 * time.Millisecond)
423+ }
424+ if f.Dropped(a1) == 0 {
425+ t.Fatal("a saturated listener must drop queries")
426+ }
427+ // The other listener is unaffected: point the resolver at the fast upstream.
428+ useSlow.Store(false)
429+ l2, _ := f.Addr(a2)
430+ c, err := net.Dial("udp", l2.String())
431+ if err != nil {
432+ t.Fatal(err)
433+ }
434+ defer func() { _ = c.Close() }()
435+ _ = c.SetDeadline(time.Now().Add(4 * time.Second))
436+ _, _ = c.Write(query(77, "other-guest.test"))
437+ buf := make([]byte, 512)
438+ n, err := c.Read(buf)
439+ if err != nil || string(buf[n-6:n]) != "ANSWER" {
440+ t.Fatalf("another listener must not be starved by the flood: n=%d err=%v", n, err)
441+ }
442+}
A internal/dnsfwd/resolvers.go +86 -0
@@ -0,0 +1,86 @@
1+// SPDX-License-Identifier: MIT
2+
3+package dnsfwd
4+
5+import (
6+ "bufio"
7+ "net"
8+ "net/netip"
9+ "os"
10+ "strings"
11+ "sync"
12+ "time"
13+)
14+
15+// Resolvers reads the upstream nameservers from a resolv.conf-format file and
16+// re-reads it when it changes. Entries such as the systemd-resolved stub
17+// (127.0.0.53) are kept on purpose: the forwarder runs on the host, where the
18+// stub is reachable, and that stub is the only resolver that knows the host's
19+// split-DNS configuration.
20+type Resolvers struct {
21+ path string
22+
23+ mu sync.Mutex
24+ mtime time.Time
25+ size int64
26+ addrs []string
27+}
28+
29+// NewResolvers returns a Resolvers reading path (default /etc/resolv.conf).
30+func NewResolvers(path string) *Resolvers {
31+ if path == "" {
32+ path = "/etc/resolv.conf"
33+ }
34+ return &Resolvers{path: path}
35+}
36+
37+// Addrs returns the upstream "host:53" addresses, in file order. When the file
38+// cannot be read the last known list is returned.
39+func (r *Resolvers) Addrs() []string {
40+ r.mu.Lock()
41+ defer r.mu.Unlock()
42+ st, err := os.Stat(r.path)
43+ if err != nil {
44+ return r.addrs
45+ }
46+ if st.ModTime().Equal(r.mtime) && st.Size() == r.size && r.addrs != nil {
47+ return r.addrs
48+ }
49+ f, err := os.Open(r.path)
50+ if err != nil {
51+ return r.addrs
52+ }
53+ defer func() { _ = f.Close() }()
54+ r.addrs = ParseResolvConf(f)
55+ r.mtime, r.size = st.ModTime(), st.Size()
56+ return r.addrs
57+}
58+
59+// ParseResolvConf extracts "nameserver" entries as "ip:53" strings. Comments,
60+// unknown directives and invalid addresses are ignored; duplicates removed.
61+// IPv6 addresses with a zone (fe80::1%eth0) are kept with their zone.
62+func ParseResolvConf(f interface{ Read([]byte) (int, error) }) []string {
63+ var out []string
64+ seen := map[string]bool{}
65+ sc := bufio.NewScanner(f)
66+ for sc.Scan() {
67+ line := strings.TrimSpace(sc.Text())
68+ if i := strings.IndexAny(line, "#;"); i >= 0 {
69+ line = strings.TrimSpace(line[:i])
70+ }
71+ fields := strings.Fields(line)
72+ if len(fields) < 2 || fields[0] != "nameserver" {
73+ continue
74+ }
75+ a, err := netip.ParseAddr(fields[1])
76+ if err != nil || a.IsUnspecified() {
77+ continue
78+ }
79+ s := net.JoinHostPort(a.String(), "53")
80+ if !seen[s] {
81+ seen[s] = true
82+ out = append(out, s)
83+ }
84+ }
85+ return out
86+}
A internal/execstream/exec.proto +64 -0
@@ -0,0 +1,64 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Internal wire format between the host process of ermite-runner and the
4+// executor running inside a sandbox (vsock in a micro-VM, Unix socket in a
5+// container). Frames are length-prefixed on the stream (see frame.go); this file
6+// defines the payload. It is internal to ermite-runner: no compatibility is
7+// promised between runner versions, which is safe because the host attaches its
8+// own executable to every sandbox.
9+
10+syntax = "proto3";
11+
12+package ermite.runner.exec.v1;
13+
14+option go_package = "bnema.dev/ermite-runner/internal/execstream/execpb";
15+
16+// Frame is the only message on the stream.
17+message Frame {
18+ oneof body {
19+ Hello hello = 1; // executor -> host, first frame
20+ Run run = 2; // host -> executor, after Hello
21+ Output output = 3; // executor -> host
22+ Exit exit = 4; // executor -> host, last frame
23+ Cancel cancel = 5; // host -> executor
24+ }
25+}
26+
27+message Hello {
28+ string version = 1; // runner version of the executor
29+ string sandbox = 2; // "vm" or "container"
30+ string hostname = 3;
31+}
32+
33+// Run asks the executor to run a script. CI-1 stub executor: the script is run
34+// with `sh -c`; an empty script means the built-in diagnostics script.
35+message Run {
36+ string script = 1;
37+ map<string, string> env = 2;
38+}
39+
40+enum StreamKind {
41+ STREAM_KIND_UNSPECIFIED = 0;
42+ STREAM_KIND_STDOUT = 1;
43+ STREAM_KIND_STDERR = 2;
44+}
45+
46+message Output {
47+ StreamKind stream = 1;
48+ bytes data = 2;
49+ int64 unix_nano = 3;
50+}
51+
52+message Exit {
53+ int32 code = 1;
54+ string error = 2; // set when the executor failed to run the script at all
55+}
56+
57+// Cancel asks the executor to stop the running script. The executor escalates
58+// like the official runner: SIGINT to the script's process group at once,
59+// SIGTERM 7.5 s later, SIGKILL 2.5 s after that.
60+message Cancel {
61+ // reason is for the job log and diagnostics ("cancelled by the user",
62+ // "job timeout", ...); it never changes the behaviour.
63+ string reason = 1;
64+}
A internal/execstream/execpb/exec.pb.go +595 -0 generated file: collapsed
A internal/execstream/frame.go +147 -0
@@ -0,0 +1,147 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package execstream is the framed protobuf stream between the host process of
4+// ermite-runner and the executor running inside a sandbox.
5+//
6+// Wire format: each frame is a 4-byte big-endian length followed by that many
7+// bytes of a serialized execpb.Frame (see exec.proto). A frame is at most
8+// MaxFrame bytes; longer output is split by the sender. The stream is
9+// transport-agnostic: vsock in a micro-VM, a Unix socket in a container.
10+package execstream
11+
12+//go:generate go tool buf generate ../.. --template ../../buf.gen.yaml
13+
14+import (
15+ "bufio"
16+ "encoding/binary"
17+ "errors"
18+ "fmt"
19+ "io"
20+ "sync"
21+
22+ "google.golang.org/protobuf/proto"
23+
24+ "bnema.dev/ermite-runner/internal/execstream/execpb"
25+)
26+
27+// MaxFrame is the largest accepted serialized frame.
28+const MaxFrame = 1 << 20
29+
30+// ChunkSize is the largest output payload placed in one Output frame.
31+const ChunkSize = 32 * 1024
32+
33+// ErrFrameTooLarge is returned for a frame above MaxFrame, on either side.
34+var ErrFrameTooLarge = errors.New("execstream: frame too large")
35+
36+// Conn reads and writes frames on a byte stream. Send is safe for concurrent
37+// use; Recv must be called from one goroutine.
38+type Conn struct {
39+ rw io.ReadWriter
40+ br *bufio.Reader
41+ wmu sync.Mutex
42+}
43+
44+// NewConn wraps a byte stream.
45+func NewConn(rw io.ReadWriter) *Conn {
46+ return &Conn{rw: rw, br: bufio.NewReaderSize(rw, 64*1024)}
47+}
48+
49+// Send writes one frame.
50+func (c *Conn) Send(f *execpb.Frame) error {
51+ b, err := proto.Marshal(f)
52+ if err != nil {
53+ return fmt.Errorf("execstream: marshal: %w", err)
54+ }
55+ if len(b) > MaxFrame {
56+ return ErrFrameTooLarge
57+ }
58+ buf := make([]byte, 4+len(b))
59+ binary.BigEndian.PutUint32(buf, uint32(len(b)))
60+ copy(buf[4:], b)
61+ c.wmu.Lock()
62+ defer c.wmu.Unlock()
63+ _, err = c.rw.Write(buf)
64+ return err
65+}
66+
67+// Recv reads one frame. It returns io.EOF only on a clean end of stream between
68+// frames; a stream cut inside a frame yields io.ErrUnexpectedEOF.
69+func (c *Conn) Recv() (*execpb.Frame, error) {
70+ var hdr [4]byte
71+ if _, err := io.ReadFull(c.br, hdr[:]); err != nil {
72+ return nil, err
73+ }
74+ n := binary.BigEndian.Uint32(hdr[:])
75+ if n > MaxFrame {
76+ return nil, ErrFrameTooLarge
77+ }
78+ b := make([]byte, n)
79+ if _, err := io.ReadFull(c.br, b); err != nil {
80+ if errors.Is(err, io.EOF) {
81+ err = io.ErrUnexpectedEOF
82+ }
83+ return nil, err
84+ }
85+ f := &execpb.Frame{}
86+ if err := proto.Unmarshal(b, f); err != nil {
87+ return nil, fmt.Errorf("execstream: unmarshal: %w", err)
88+ }
89+ return f, nil
90+}
91+
92+// Drive is the host side of a session: it waits for the executor's Hello, sends
93+// run, passes every Output frame to onOutput, and returns the Exit frame.
94+//
95+// cancel, when non-nil, asks the executor to stop the script: the first value
96+// received is sent as one Cancel frame (the executor escalates SIGINT, SIGTERM,
97+// SIGKILL by itself); closing the channel without a value sends nothing. It is
98+// deliberately not a context: cancelling the session's context would also abort
99+// the reads that deliver the executor's Exit. Drive keeps waiting for Exit after
100+// a Cancel; the caller bounds that wait by stopping the sandbox, which closes
101+// 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) {
103+ c := NewConn(rw)
104+ first, err := c.Recv()
105+ if err != nil {
106+ return nil, nil, fmt.Errorf("execstream: waiting for hello: %w", err)
107+ }
108+ hello := first.GetHello()
109+ if hello == nil {
110+ return nil, nil, fmt.Errorf("execstream: first frame is not hello")
111+ }
112+ 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)
114+ }
115+
116+ done := make(chan struct{})
117+ defer close(done)
118+ if cancel != nil {
119+ go func() {
120+ select {
121+ case cn, ok := <-cancel:
122+ if ok {
123+ if cn == nil {
124+ cn = &execpb.Cancel{}
125+ }
126+ _ = c.Send(&execpb.Frame{Body: &execpb.Frame_Cancel{Cancel: cn}})
127+ }
128+ case <-done:
129+ }
130+ }()
131+ }
132+
133+ for {
134+ f, err := c.Recv()
135+ if err != nil {
136+ return hello, nil, fmt.Errorf("execstream: reading: %w", err)
137+ }
138+ switch b := f.Body.(type) {
139+ case *execpb.Frame_Output:
140+ if onOutput != nil {
141+ onOutput(b.Output)
142+ }
143+ case *execpb.Frame_Exit:
144+ return hello, b.Exit, nil
145+ }
146+ }
147+}
A internal/execstream/frame_test.go +138 -0
@@ -0,0 +1,138 @@
1+// SPDX-License-Identifier: MIT
2+
3+package execstream
4+
5+import (
6+ "bytes"
7+ "encoding/binary"
8+ "errors"
9+ "io"
10+ "net"
11+ "testing"
12+ "testing/iotest"
13+ "time"
14+
15+ "bnema.dev/ermite-runner/internal/execstream/execpb"
16+)
17+
18+func TestRoundTrip(t *testing.T) {
19+ var buf bytes.Buffer
20+ c := NewConn(&buf)
21+ in := []*execpb.Frame{
22+ {Body: &execpb.Frame_Hello{Hello: &execpb.Hello{Version: "v", Sandbox: "vm"}}},
23+ {Body: &execpb.Frame_Output{Output: &execpb.Output{Stream: execpb.StreamKind_STREAM_KIND_STDERR, Data: []byte("x\x00y")}}},
24+ {Body: &execpb.Frame_Exit{Exit: &execpb.Exit{Code: 3}}},
25+ }
26+ for _, f := range in {
27+ if err := c.Send(f); err != nil {
28+ t.Fatal(err)
29+ }
30+ }
31+ // One byte at a time: framing must not depend on read sizes.
32+ r := NewConn(struct {
33+ io.Reader
34+ io.Writer
35+ }{iotest.OneByteReader(&buf), io.Discard})
36+ for i := range in {
37+ out, err := r.Recv()
38+ if err != nil {
39+ t.Fatalf("frame %d: %v", i, err)
40+ }
41+ if out.String() != in[i].String() {
42+ t.Fatalf("frame %d: got %v want %v", i, out, in[i])
43+ }
44+ }
45+ if _, err := r.Recv(); err != io.EOF {
46+ t.Fatalf("want io.EOF at end, got %v", err)
47+ }
48+}
49+
50+func TestRecvErrors(t *testing.T) {
51+ huge := make([]byte, 4)
52+ binary.BigEndian.PutUint32(huge, MaxFrame+1)
53+ if _, err := NewConn(bytes.NewBuffer(huge)).Recv(); !errors.Is(err, ErrFrameTooLarge) {
54+ t.Fatalf("oversize: %v", err)
55+ }
56+ trunc := []byte{0, 0, 0, 9, 1, 2}
57+ if _, err := NewConn(bytes.NewBuffer(trunc)).Recv(); !errors.Is(err, io.ErrUnexpectedEOF) {
58+ t.Fatalf("truncated body: %v", err)
59+ }
60+ if _, err := NewConn(bytes.NewBuffer([]byte{0, 0})).Recv(); !errors.Is(err, io.ErrUnexpectedEOF) {
61+ t.Fatalf("truncated header: %v", err)
62+ }
63+ garbage := []byte{0, 0, 0, 3, 0xff, 0xff, 0xff}
64+ if _, err := NewConn(bytes.NewBuffer(garbage)).Recv(); err == nil {
65+ t.Fatal("garbage payload accepted")
66+ }
67+}
68+
69+func TestSendTooLarge(t *testing.T) {
70+ c := NewConn(struct {
71+ io.Reader
72+ io.Writer
73+ }{bytes.NewReader(nil), io.Discard})
74+ err := c.Send(&execpb.Frame{Body: &execpb.Frame_Output{Output: &execpb.Output{Data: make([]byte, MaxFrame)}}})
75+ if !errors.Is(err, ErrFrameTooLarge) {
76+ t.Fatalf("got %v", err)
77+ }
78+}
79+
80+func TestDrive(t *testing.T) {
81+ host, guest := net.Pipe()
82+ defer func() { _ = host.Close() }()
83+ go func() {
84+ defer func() { _ = guest.Close() }()
85+ g := NewConn(guest)
86+ _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{Sandbox: "container"}}})
87+ f, err := g.Recv()
88+ if err != nil || f.GetRun() == nil || f.GetRun().Script != "echo hi" {
89+ return
90+ }
91+ _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Output{Output: &execpb.Output{Data: []byte("hi\n")}}})
92+ _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{Code: 7}}})
93+ }()
94+ var got []byte
95+ hello, exit, err := Drive(host, &execpb.Run{Script: "echo hi"}, nil, func(o *execpb.Output) { got = append(got, o.Data...) })
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ if hello.Sandbox != "container" || exit.Code != 7 || string(got) != "hi\n" {
100+ t.Fatalf("hello=%v exit=%v out=%q", hello, exit, got)
101+ }
102+}
103+
104+func TestDriveCancelSendsCancel(t *testing.T) {
105+ host, guest := net.Pipe()
106+ defer func() { _ = host.Close() }()
107+ gotCancel := make(chan bool, 1)
108+ go func() {
109+ defer func() { _ = guest.Close() }()
110+ g := NewConn(guest)
111+ _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}})
112+ _, _ = g.Recv() // run
113+ f, err := g.Recv()
114+ gotCancel <- err == nil && f.GetCancel() != nil && f.GetCancel().Reason == "cancelled by the user"
115+ _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{Code: 130}}})
116+ }()
117+ cancel := make(chan *execpb.Cancel, 1)
118+ time.AfterFunc(50*time.Millisecond, func() { cancel <- &execpb.Cancel{Reason: "cancelled by the user"} })
119+ _, exit, err := Drive(host, &execpb.Run{}, cancel, nil)
120+ if err != nil || exit.Code != 130 {
121+ t.Fatalf("exit=%v err=%v", exit, err)
122+ }
123+ if !<-gotCancel {
124+ t.Fatal("guest did not receive a Cancel frame")
125+ }
126+}
127+
128+func TestDriveHelloMissing(t *testing.T) {
129+ host, guest := net.Pipe()
130+ defer func() { _ = host.Close() }()
131+ go func() {
132+ defer func() { _ = guest.Close() }()
133+ _ = NewConn(guest).Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{}}})
134+ }()
135+ if _, _, err := Drive(host, &execpb.Run{}, nil, nil); err == nil {
136+ t.Fatal("want error when the first frame is not hello")
137+ }
138+}
A internal/guest/cmdline.go +43 -0
@@ -0,0 +1,43 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package guest is the code that runs inside a sandbox: `ermite-runner init`
4+// (the guest PID 1) and `ermite-runner exec` (the executor).
5+package guest
6+
7+import "strings"
8+
9+// ParseCmdline splits a kernel command line into key=value pairs; a bare word
10+// has the value "". The last occurrence of a key wins. Values may be quoted
11+// with double quotes, as the kernel allows.
12+func ParseCmdline(s string) map[string]string {
13+ out := map[string]string{}
14+ for _, f := range splitCmdline(strings.TrimSpace(s)) {
15+ k, v, _ := strings.Cut(f, "=")
16+ out[k] = strings.Trim(v, `"`)
17+ }
18+ return out
19+}
20+
21+func splitCmdline(s string) []string {
22+ var out []string
23+ var b strings.Builder
24+ inQuote := false
25+ for _, r := range s {
26+ switch {
27+ case r == '"':
28+ inQuote = !inQuote
29+ b.WriteRune(r)
30+ case (r == ' ' || r == '\t' || r == '\n') && !inQuote:
31+ if b.Len() > 0 {
32+ out = append(out, b.String())
33+ b.Reset()
34+ }
35+ default:
36+ b.WriteRune(r)
37+ }
38+ }
39+ if b.Len() > 0 {
40+ out = append(out, b.String())
41+ }
42+ return out
43+}
A internal/guest/connect.go +87 -0
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import (
6+ "context"
7+ "fmt"
8+ "io"
9+ "net"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ "golang.org/x/sys/unix"
15+)
16+
17+// Target is where the executor connects back to: "unix:/path" (container) or
18+// "vsock:PORT" (vm; the host is CID 2).
19+type Target struct {
20+ Network string // "unix" or "vsock"
21+ Addr string // socket path, or the vsock port
22+}
23+
24+// ParseTarget parses "unix:/path" or "vsock:port".
25+func ParseTarget(s string) (Target, error) {
26+ kind, rest, ok := strings.Cut(s, ":")
27+ if !ok || rest == "" {
28+ return Target{}, fmt.Errorf("connect target %q: want unix:PATH or vsock:PORT", s)
29+ }
30+ switch kind {
31+ case "unix":
32+ return Target{Network: "unix", Addr: rest}, nil
33+ case "vsock":
34+ if p, err := strconv.ParseUint(rest, 10, 32); err != nil || p == 0 {
35+ return Target{}, fmt.Errorf("connect target %q: bad vsock port", s)
36+ }
37+ return Target{Network: "vsock", Addr: rest}, nil
38+ }
39+ return Target{}, fmt.Errorf("connect target %q: unknown kind %q", s, kind)
40+}
41+
42+func (t Target) String() string { return t.Network + ":" + t.Addr }
43+
44+// Dial connects to the host, retrying until ctx ends or timeout elapses: the
45+// host listens before the sandbox starts, but a vsock device may take a moment.
46+func (t Target) Dial(ctx context.Context, timeout time.Duration) (io.ReadWriteCloser, error) {
47+ ctx, cancel := context.WithTimeout(ctx, timeout)
48+ defer cancel()
49+ var last error
50+ for {
51+ var c io.ReadWriteCloser
52+ var err error
53+ if t.Network == "unix" {
54+ var d net.Dialer
55+ c, err = d.DialContext(ctx, "unix", t.Addr)
56+ } else {
57+ c, err = dialVsock(t.Addr)
58+ }
59+ if err == nil {
60+ return c, nil
61+ }
62+ last = err
63+ select {
64+ case <-ctx.Done():
65+ return nil, fmt.Errorf("connect to %s: %w (last error: %v)", t, ctx.Err(), last)
66+ case <-time.After(100 * time.Millisecond):
67+ }
68+ }
69+}
70+
71+// vsockHostCID is the well-known CID of the host.
72+const vsockHostCID = 2
73+
74+func dialVsock(port string) (io.ReadWriteCloser, error) {
75+ p, _ := strconv.ParseUint(port, 10, 32)
76+ fd, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
77+ if err != nil {
78+ return nil, err
79+ }
80+ // A blocking connect is fine: a Firecracker vsock either connects to the
81+ // host listener at once or fails.
82+ if err := unix.Connect(fd, &unix.SockaddrVM{CID: vsockHostCID, Port: uint32(p)}); err != nil {
83+ _ = unix.Close(fd)
84+ return nil, err
85+ }
86+ return newFDConn(fd)
87+}
A internal/guest/exec.go +276 -0
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "sync"
13+ "syscall"
14+ "time"
15+
16+ "bnema.dev/ermite-runner/internal/execstream"
17+ "bnema.dev/ermite-runner/internal/execstream/execpb"
18+)
19+
20+// DefaultScript is what the stub executor runs when the host sends no script.
21+const DefaultScript = "uname -a; id; cat /etc/os-release; docker info"
22+
23+// Cancellation timings, like the official runner's ProcessInvoker: SIGINT, then
24+// SIGTERM after 7.5 s, then SIGKILL 2.5 s later.
25+const (
26+ DefaultTermAfter = 7500 * time.Millisecond
27+ DefaultKillAfter = 2500 * time.Millisecond
28+ // DefaultWaitDelay bounds how long the executor waits for the output pipes
29+ // to close after the script's shell exited (background children may hold
30+ // them open).
31+ DefaultWaitDelay = 500 * time.Millisecond
32+)
33+
34+// ExecConfig configures the executor.
35+type ExecConfig struct {
36+ Target Target
37+ Sandbox string // "vm" or "container", reported in Hello
38+ Version string
39+ // Shell runs the script as `Shell -c script`; default /bin/sh.
40+ Shell string
41+ // ConnectTimeout bounds the connection attempts (default 30 s).
42+ ConnectTimeout time.Duration
43+ // RunTimeout bounds the wait for the host's Run frame (default 60 s).
44+ RunTimeout time.Duration
45+ // TermAfter and KillAfter are the escalation delays after a Cancel (or after
46+ // the executor's own context ends): SIGINT at once, SIGTERM after TermAfter,
47+ // SIGKILL KillAfter later. WaitDelay is the pipe-drain grace after the shell
48+ // exits. Zero means the defaults above.
49+ TermAfter, KillAfter, WaitDelay time.Duration
50+
51+ // signal sends a signal to a process group; tests replace it to observe the
52+ // escalation. Default: syscall.Kill(-pgid, sig).
53+ signal func(pgid int, sig syscall.Signal)
54+}
55+
56+func (c *ExecConfig) defaults() {
57+ if c.Shell == "" {
58+ c.Shell = "/bin/sh"
59+ }
60+ if c.ConnectTimeout == 0 {
61+ c.ConnectTimeout = 30 * time.Second
62+ }
63+ if c.RunTimeout == 0 {
64+ c.RunTimeout = 60 * time.Second
65+ }
66+ if c.TermAfter == 0 {
67+ c.TermAfter = DefaultTermAfter
68+ }
69+ if c.KillAfter == 0 {
70+ c.KillAfter = DefaultKillAfter
71+ }
72+ if c.WaitDelay == 0 {
73+ c.WaitDelay = DefaultWaitDelay
74+ }
75+ if c.signal == nil {
76+ c.signal = func(pgid int, sig syscall.Signal) { _ = syscall.Kill(-pgid, sig) }
77+ }
78+}
79+
80+// Exec is `ermite-runner exec`, the CI-1 stub executor: it connects back to the
81+// host, receives one Run, 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
83+// it reported. A Cancel frame, or the end of ctx, stops the script's process
84+// group with escalating signals (see ExecConfig).
85+func Exec(ctx context.Context, cfg ExecConfig) (int, error) {
86+ cfg.defaults()
87+ raw, err := cfg.Target.Dial(ctx, cfg.ConnectTimeout)
88+ if err != nil {
89+ return 1, err
90+ }
91+ defer func() { _ = raw.Close() }()
92+ return serve(ctx, raw, cfg)
93+}
94+
95+// frameWriter turns writes into Output frames of at most ChunkSize bytes. It is
96+// the command's Stdout/Stderr, so no goroutine of ours sits between the pipe
97+// and the stream.
98+type frameWriter struct {
99+ conn *execstream.Conn
100+ kind execpb.StreamKind
101+}
102+
103+func (w frameWriter) Write(p []byte) (int, error) {
104+ n := 0
105+ for len(p) > 0 {
106+ chunk := p
107+ if len(chunk) > execstream.ChunkSize {
108+ chunk = chunk[:execstream.ChunkSize]
109+ }
110+ err := w.conn.Send(&execpb.Frame{Body: &execpb.Frame_Output{Output: &execpb.Output{
111+ Stream: w.kind, Data: append([]byte(nil), chunk...), UnixNano: time.Now().UnixNano(),
112+ }}})
113+ if err != nil {
114+ return n, err
115+ }
116+ n += len(chunk)
117+ p = p[len(chunk):]
118+ }
119+ return n, nil
120+}
121+
122+// escalator signals a process group and escalates after a cancel. Once finish
123+// has been called (the shell was reaped) it never signals again: the group id
124+// could have been reused by then.
125+type escalator struct {
126+ cfg ExecConfig
127+ pgid int
128+
129+ mu sync.Mutex
130+ finished bool
131+ started bool
132+ timers []*time.Timer
133+}
134+
135+func (e *escalator) send(sig syscall.Signal) {
136+ e.mu.Lock()
137+ defer e.mu.Unlock()
138+ if !e.finished {
139+ e.cfg.signal(e.pgid, sig)
140+ }
141+}
142+
143+// cancel starts the escalation; only the first call does anything.
144+func (e *escalator) cancel() {
145+ e.mu.Lock()
146+ if e.finished || e.started {
147+ e.mu.Unlock()
148+ return
149+ }
150+ e.started = true
151+ e.cfg.signal(e.pgid, syscall.SIGINT)
152+ e.timers = append(e.timers,
153+ time.AfterFunc(e.cfg.TermAfter, func() { e.send(syscall.SIGTERM) }),
154+ time.AfterFunc(e.cfg.TermAfter+e.cfg.KillAfter, func() { e.send(syscall.SIGKILL) }),
155+ )
156+ e.mu.Unlock()
157+}
158+
159+// finish is called right after the shell was reaped: it kills what is left of
160+// the group (background children), stops the timers and forbids further signals.
161+func (e *escalator) finish() {
162+ e.mu.Lock()
163+ defer e.mu.Unlock()
164+ if e.finished {
165+ return
166+ }
167+ e.cfg.signal(e.pgid, syscall.SIGKILL)
168+ e.finished = true
169+ for _, t := range e.timers {
170+ t.Stop()
171+ }
172+}
173+
174+func serve(ctx context.Context, rw io.ReadWriteCloser, cfg ExecConfig) (int, error) {
175+ conn := execstream.NewConn(rw)
176+ host, _ := os.Hostname()
177+ if err := conn.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{
178+ Version: cfg.Version, Sandbox: cfg.Sandbox, Hostname: host,
179+ }}}); err != nil {
180+ return 1, fmt.Errorf("send hello: %w", err)
181+ }
182+
183+ // Wait for Run, bounded: a host that never answers must not pin the sandbox.
184+ type recv struct {
185+ f *execpb.Frame
186+ err error
187+ }
188+ first := make(chan recv, 1)
189+ go func() { f, err := conn.Recv(); first <- recv{f, err} }()
190+ var run *execpb.Run
191+ select {
192+ case r := <-first:
193+ if r.err != nil {
194+ return 1, fmt.Errorf("waiting for run: %w", r.err)
195+ }
196+ if run = r.f.GetRun(); run == nil {
197+ return 1, errors.New("first frame from host is not run")
198+ }
199+ case <-time.After(cfg.RunTimeout):
200+ return 1, errors.New("timed out waiting for the host's run frame")
201+ case <-ctx.Done():
202+ return 1, ctx.Err()
203+ }
204+
205+ script := run.GetScript()
206+ if script == "" {
207+ script = DefaultScript
208+ }
209+ cmd := exec.Command(cfg.Shell, "-c", script)
210+ cmd.Env = os.Environ()
211+ for k, v := range run.GetEnv() {
212+ cmd.Env = append(cmd.Env, k+"="+v)
213+ }
214+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
215+ // Frame writers as stdout/stderr: exec copies the pipes for us, and
216+ // WaitDelay bounds the wait when a background child keeps a pipe open.
217+ cmd.Stdout = frameWriter{conn, execpb.StreamKind_STREAM_KIND_STDOUT}
218+ cmd.Stderr = frameWriter{conn, execpb.StreamKind_STREAM_KIND_STDERR}
219+ cmd.WaitDelay = cfg.WaitDelay
220+ if err := cmd.Start(); err != nil {
221+ return sendExit(conn, 127, fmt.Errorf("start %s: %w", cfg.Shell, err))
222+ }
223+ esc := &escalator{cfg: cfg, pgid: cmd.Process.Pid}
224+
225+ // Frames from the host while the script runs: a Cancel starts the escalation;
226+ // a lost host kills the script rather than let it run unobserved.
227+ go func() {
228+ for {
229+ f, err := conn.Recv()
230+ if err != nil {
231+ esc.send(syscall.SIGKILL)
232+ return
233+ }
234+ if f.GetCancel() != nil {
235+ esc.cancel()
236+ }
237+ }
238+ }()
239+ // The executor's own context (SIGTERM forwarded by init) cancels the same way.
240+ stopCtx := context.AfterFunc(ctx, esc.cancel)
241+ defer stopCtx()
242+
243+ waitErr := cmd.Wait()
244+ esc.finish()
245+
246+ code := 0
247+ var ee *exec.ExitError
248+ switch {
249+ case waitErr == nil, errors.Is(waitErr, exec.ErrWaitDelay):
250+ // ErrWaitDelay: the shell exited cleanly but a background child kept the
251+ // pipes open; the group was killed above.
252+ case errors.As(waitErr, &ee):
253+ code = ee.ExitCode()
254+ if code < 0 { // killed by a signal: 128+signal like a shell
255+ if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
256+ code = 128 + int(ws.Signal())
257+ } else {
258+ code = 1
259+ }
260+ }
261+ default:
262+ return sendExit(conn, 1, waitErr)
263+ }
264+ return sendExit(conn, code, nil)
265+}
266+
267+func sendExit(c *execstream.Conn, code int, cause error) (int, error) {
268+ e := &execpb.Exit{Code: int32(code)}
269+ if cause != nil {
270+ e.Error = cause.Error()
271+ }
272+ if err := c.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: e}}); err != nil {
273+ return code, fmt.Errorf("send exit: %w", err)
274+ }
275+ return code, nil
276+}
A internal/guest/fdconn.go +20 -0
@@ -0,0 +1,20 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import (
6+ "io"
7+ "os"
8+
9+ "golang.org/x/sys/unix"
10+)
11+
12+// newFDConn wraps a connected stream socket (net.FileConn does not know
13+// AF_VSOCK) as a ReadWriteCloser registered with the runtime poller.
14+func newFDConn(fd int) (io.ReadWriteCloser, error) {
15+ if err := unix.SetNonblock(fd, true); err != nil {
16+ _ = unix.Close(fd)
17+ return nil, err
18+ }
19+ return os.NewFile(uintptr(fd), "vsock"), nil
20+}
A internal/guest/guest_test.go +411 -0
@@ -0,0 +1,411 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import (
6+ "context"
7+ "fmt"
8+ "net"
9+ "os"
10+ "path/filepath"
11+ "reflect"
12+ "strconv"
13+ "strings"
14+ "sync"
15+ "sync/atomic"
16+ "syscall"
17+ "testing"
18+ "time"
19+
20+ "bnema.dev/ermite-runner/internal/execstream"
21+ "bnema.dev/ermite-runner/internal/execstream/execpb"
22+)
23+
24+func TestParseCmdline(t *testing.T) {
25+ got := ParseCmdline("console=ttyS0 ro ermite.connect=vsock:1024 x=\"a b\" ermite.gw=10.0.0.1 ermite.gw=10.0.0.9 -- init\n")
26+ want := map[string]string{"console": "ttyS0", "ro": "", "ermite.connect": "vsock:1024", "x": "a b", "ermite.gw": "10.0.0.9", "--": "", "init": ""}
27+ if !reflect.DeepEqual(got, want) {
28+ t.Fatalf("got %v", got)
29+ }
30+ if len(ParseCmdline("")) != 0 {
31+ t.Fatal("empty command line")
32+ }
33+}
34+
35+func TestParseTarget(t *testing.T) {
36+ for in, want := range map[string]Target{
37+ "unix:/run/ermite/exec.sock": {"unix", "/run/ermite/exec.sock"},
38+ "vsock:1024": {"vsock", "1024"},
39+ } {
40+ got, err := ParseTarget(in)
41+ if err != nil || got != want || got.String() != in {
42+ t.Errorf("%q: %v %v", in, got, err)
43+ }
44+ }
45+ for _, in := range []string{"", "unix", "unix:", "vsock:0", "vsock:abc", "tcp:1.2.3.4:5", "vsock:99999999999"} {
46+ if _, err := ParseTarget(in); err == nil {
47+ t.Errorf("%q accepted", in)
48+ }
49+ }
50+}
51+
52+// host plays the host process on a unix socket and runs one session.
53+func host(t *testing.T, script string, cancelAfter time.Duration, tweak func(*ExecConfig)) (out, errOut string, exit *execpb.Exit, hello *execpb.Hello, code int, execErr error) {
54+ t.Helper()
55+ sock := filepath.Join(t.TempDir(), "s")
56+ ln, err := net.Listen("unix", sock)
57+ if err != nil {
58+ t.Skip(err)
59+ }
60+ defer func() { _ = ln.Close() }()
61+ type res struct {
62+ code int
63+ err error
64+ }
65+ done := make(chan res, 1)
66+ go func() {
67+ cfg := ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", Shell: "/bin/sh", RunTimeout: 5 * time.Second}
68+ if tweak != nil {
69+ tweak(&cfg)
70+ }
71+ c, err := Exec(context.Background(), cfg)
72+ done <- res{c, err}
73+ }()
74+ conn, err := ln.Accept()
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ defer func() { _ = conn.Close() }()
79+ var cancel chan *execpb.Cancel
80+ if cancelAfter > 0 {
81+ cancel = make(chan *execpb.Cancel, 1)
82+ time.AfterFunc(cancelAfter, func() { cancel <- &execpb.Cancel{Reason: "test"} })
83+ }
84+ var so, se strings.Builder
85+ hello, exit, err = execstream.Drive(conn, &execpb.Run{Script: script, Env: map[string]string{"STUB_VAR": "from-host"}}, cancel, func(o *execpb.Output) {
86+ if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR {
87+ se.Write(o.Data)
88+ } else {
89+ so.Write(o.Data)
90+ }
91+ })
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ r := <-done
96+ return so.String(), se.String(), exit, hello, r.code, r.err
97+}
98+
99+func TestExecStreamsOutputAndExitCode(t *testing.T) {
100+ out, errOut, exit, hello, code, err := host(t, `echo out-line; echo err-line >&2; echo "$STUB_VAR"; exit 3`, 0, nil)
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+ if out != "out-line\nfrom-host\n" || errOut != "err-line\n" {
105+ t.Fatalf("out=%q err=%q", out, errOut)
106+ }
107+ if exit.Code != 3 || code != 3 || exit.Error != "" {
108+ t.Fatalf("exit=%v code=%d", exit, code)
109+ }
110+ if hello.Sandbox != "container" || hello.Version != "test" || hello.Hostname == "" {
111+ t.Fatalf("hello=%v", hello)
112+ }
113+}
114+
115+func TestExecDefaultScriptRunsDiagnostics(t *testing.T) {
116+ // The default script is the CI-1 stub: uname, id, os-release, docker info.
117+ // docker may be absent here: only the layout of the output is checked.
118+ out, errOut, exit, _, _, _ := host(t, "", 0, nil)
119+ if !strings.Contains(out, "Linux") || !strings.Contains(out, "uid=") {
120+ t.Fatalf("uname/id missing: out=%q err=%q", out, errOut)
121+ }
122+ if exit == nil {
123+ t.Fatal("no exit frame")
124+ }
125+ if !strings.Contains(DefaultScript, "docker info") || !strings.Contains(DefaultScript, "os-release") {
126+ t.Fatal("default script must run what the plan says")
127+ }
128+}
129+
130+func TestExecLargeOutputIsChunkedAndComplete(t *testing.T) {
131+ out, _, exit, _, _, _ := host(t, `head -c 300000 /dev/zero | tr '\0' 'x'`, 0, nil)
132+ if len(out) != 300000 || exit.Code != 0 {
133+ t.Fatalf("len=%d exit=%v", len(out), exit)
134+ }
135+}
136+
137+func TestExecCancelSendsSIGINT(t *testing.T) {
138+ start := time.Now()
139+ out, _, exit, _, _, _ := host(t, `trap 'echo got-int; exit 130' INT; echo ready; while :; do sleep 0.05; done`, 500*time.Millisecond, nil)
140+ if !strings.Contains(out, "got-int") || exit.Code != 130 {
141+ t.Fatalf("out=%q exit=%v", out, exit)
142+ }
143+ if time.Since(start) > 5*time.Second {
144+ t.Fatal("cancellation too slow")
145+ }
146+}
147+
148+// sigRecorder replaces the signal function: it records (signal, time since
149+// start) and still delivers the signal.
150+type sigRecorder struct {
151+ mu sync.Mutex
152+ start time.Time
153+ got []string
154+ times []time.Duration
155+}
156+
157+func (r *sigRecorder) fn(pgid int, sig syscall.Signal) {
158+ r.mu.Lock()
159+ r.got = append(r.got, sig.String())
160+ r.times = append(r.times, time.Since(r.start))
161+ r.mu.Unlock()
162+ _ = syscall.Kill(-pgid, sig)
163+}
164+
165+// C1: a script that ignores SIGINT and SIGTERM is stopped by the escalation:
166+// SIGINT at once, SIGTERM after TermAfter, SIGKILL KillAfter later.
167+func TestExecCancelEscalatesToKill(t *testing.T) {
168+ rec := &sigRecorder{start: time.Now()}
169+ termAfter, killAfter := 400*time.Millisecond, 300*time.Millisecond
170+ _, _, exit, _, _, _ := host(t, `trap '' INT TERM; echo ready; while :; do sleep 0.05; done`, 300*time.Millisecond, func(c *ExecConfig) {
171+ c.TermAfter, c.KillAfter, c.signal = termAfter, killAfter, rec.fn
172+ })
173+ if exit.Code != 128+int32(syscall.SIGKILL) {
174+ t.Fatalf("a script ignoring INT and TERM must end by SIGKILL, exit=%v", exit)
175+ }
176+ rec.mu.Lock()
177+ defer rec.mu.Unlock()
178+ // SIGINT, SIGTERM, SIGKILL (from the escalation), then the final group kill
179+ // issued after the shell was reaped.
180+ if len(rec.got) < 3 || rec.got[0] != "interrupt" || rec.got[1] != "terminated" || rec.got[2] != "killed" {
181+ t.Fatalf("signals: %v", rec.got)
182+ }
183+ cancelAt := rec.times[0]
184+ if d := rec.times[1] - cancelAt; d < termAfter-50*time.Millisecond || d > termAfter+400*time.Millisecond {
185+ t.Errorf("SIGTERM %v after SIGINT, want about %v", d, termAfter)
186+ }
187+ if d := rec.times[2] - rec.times[1]; d < killAfter-50*time.Millisecond || d > killAfter+400*time.Millisecond {
188+ t.Errorf("SIGKILL %v after SIGTERM, want about %v", d, killAfter)
189+ }
190+}
191+
192+// C1 + C9: a script that exits on SIGINT stops the escalation: the timers are
193+// stopped when Wait returns, so no SIGTERM/SIGKILL follows, and nothing is
194+// signalled after the shell was reaped except the final group sweep.
195+func TestExecEscalationStopsWhenScriptExits(t *testing.T) {
196+ rec := &sigRecorder{start: time.Now()}
197+ _, _, exit, _, _, _ := host(t, `trap 'exit 130' INT; while :; do sleep 0.05; done`, 200*time.Millisecond, func(c *ExecConfig) {
198+ c.TermAfter, c.KillAfter, c.signal = 300*time.Millisecond, 300*time.Millisecond, rec.fn
199+ })
200+ if exit.Code != 130 {
201+ t.Fatalf("exit=%v", exit)
202+ }
203+ time.Sleep(1200 * time.Millisecond) // well past TermAfter+KillAfter
204+ rec.mu.Lock()
205+ defer rec.mu.Unlock()
206+ if len(rec.got) != 2 || rec.got[0] != "interrupt" || rec.got[1] != "killed" {
207+ t.Fatalf("want SIGINT then the final sweep only, got %v", rec.got)
208+ }
209+}
210+
211+// C9: after the shell was reaped the escalator never signals again, even if its
212+// timers had fired concurrently.
213+func TestEscalatorNeverSignalsAfterFinish(t *testing.T) {
214+ var n atomic.Int32
215+ e := &escalator{cfg: ExecConfig{TermAfter: time.Millisecond, KillAfter: time.Millisecond, signal: func(int, syscall.Signal) { n.Add(1) }}, pgid: 1 << 30}
216+ e.cancel()
217+ e.finish()
218+ after := n.Load()
219+ time.Sleep(50 * time.Millisecond)
220+ e.send(syscall.SIGTERM)
221+ e.cancel()
222+ if n.Load() != after {
223+ t.Fatalf("signalled %d times after finish", n.Load()-after)
224+ }
225+}
226+
227+// C2: a background child that keeps stdout open must not hang the executor: it
228+// reports the script's exit promptly and the child is killed.
229+func TestExecBackgroundChildDoesNotHoldTheSession(t *testing.T) {
230+ marker := filepath.Join(t.TempDir(), "child.pid")
231+ start := time.Now()
232+ out, _, exit, _, _, _ := host(t, `sleep 30 & echo $! > `+marker+`; echo parent-done`, 0, func(c *ExecConfig) { c.WaitDelay = 300 * time.Millisecond })
233+ if time.Since(start) > 5*time.Second {
234+ t.Fatalf("the session was held by a background child for %v", time.Since(start))
235+ }
236+ if exit == nil || exit.Code != 0 || !strings.Contains(out, "parent-done") {
237+ t.Fatalf("exit=%v out=%q", exit, out)
238+ }
239+ b, err := os.ReadFile(marker)
240+ if err != nil {
241+ t.Fatal(err)
242+ }
243+ pid, _ := strconv.Atoi(strings.TrimSpace(string(b)))
244+ deadline := time.Now().Add(3 * time.Second)
245+ for time.Now().Before(deadline) {
246+ if st, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err != nil || strings.Contains(string(st), ") Z") {
247+ return
248+ }
249+ time.Sleep(50 * time.Millisecond)
250+ }
251+ t.Fatalf("background child %d survived the job", pid)
252+}
253+
254+// C2: output written by the script before the shell exits is complete even when
255+// the stream is large and a background child holds the pipe.
256+func TestExecOutputCompleteWithBackgroundChild(t *testing.T) {
257+ out, _, exit, _, _, _ := host(t, `sleep 30 & head -c 200000 /dev/zero | tr '\0' 'y'`, 0, func(c *ExecConfig) { c.WaitDelay = 300 * time.Millisecond })
258+ if exit.Code != 0 || len(out) != 200000 {
259+ t.Fatalf("len=%d exit=%v", len(out), exit)
260+ }
261+}
262+
263+func TestExecKilledBySignalReports128PlusSignal(t *testing.T) {
264+ _, _, exit, _, _, _ := host(t, `kill -TERM $$`, 0, nil)
265+ if exit.Code != 128+int32(syscall.SIGTERM) {
266+ t.Fatalf("exit=%v", exit)
267+ }
268+}
269+
270+func TestExecMissingShell(t *testing.T) {
271+ sock := filepath.Join(t.TempDir(), "s")
272+ ln, err := net.Listen("unix", sock)
273+ if err != nil {
274+ t.Skip(err)
275+ }
276+ defer func() { _ = ln.Close() }()
277+ go func() {
278+ c, _ := ln.Accept()
279+ defer func() { _ = c.Close() }()
280+ _, exit, _ := execstream.Drive(c, &execpb.Run{Script: "true"}, nil, nil)
281+ if exit == nil || exit.Code != 127 || exit.Error == "" {
282+ t.Errorf("want 127 with an error message, got %v", exit)
283+ }
284+ }()
285+ code, _ := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Shell: "/nonexistent/sh", RunTimeout: 3 * time.Second})
286+ if code != 127 {
287+ t.Fatalf("code=%d", code)
288+ }
289+}
290+
291+func TestExecHostGoneKillsTheScript(t *testing.T) {
292+ sock := filepath.Join(t.TempDir(), "s")
293+ ln, err := net.Listen("unix", sock)
294+ if err != nil {
295+ t.Skip(err)
296+ }
297+ defer func() { _ = ln.Close() }()
298+ done := make(chan int, 1)
299+ go func() {
300+ c, _ := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Shell: "/bin/sh", RunTimeout: 3 * time.Second})
301+ done <- c
302+ }()
303+ conn, _ := ln.Accept()
304+ g := execstream.NewConn(conn)
305+ _, _ = g.Recv() // hello
306+ _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Run{Run: &execpb.Run{Script: "sleep 60"}}})
307+ time.Sleep(200 * time.Millisecond)
308+ _ = conn.Close() // the host died
309+ select {
310+ case code := <-done:
311+ if code == 0 {
312+ t.Fatalf("code=%d", code)
313+ }
314+ case <-time.After(10 * time.Second):
315+ t.Fatal("the executor kept running after the host went away")
316+ }
317+}
318+
319+func TestExecConnectTimeout(t *testing.T) {
320+ start := time.Now()
321+ code, err := Exec(context.Background(), ExecConfig{Target: Target{"unix", filepath.Join(t.TempDir(), "nobody")}, ConnectTimeout: 300 * time.Millisecond})
322+ if code == 0 || err == nil || time.Since(start) > 3*time.Second {
323+ t.Fatalf("code=%d err=%v after %v", code, err, time.Since(start))
324+ }
325+}
326+
327+func TestExecRunTimeout(t *testing.T) {
328+ sock := filepath.Join(t.TempDir(), "s")
329+ ln, err := net.Listen("unix", sock)
330+ if err != nil {
331+ t.Skip(err)
332+ }
333+ defer func() { _ = ln.Close() }()
334+ go func() { c, _ := ln.Accept(); time.Sleep(2 * time.Second); _ = c.Close() }()
335+ code, err := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, RunTimeout: 200 * time.Millisecond})
336+ if code == 0 || err == nil || !strings.Contains(err.Error(), "run frame") {
337+ t.Fatalf("code=%d err=%v", code, err)
338+ }
339+}
340+
341+func TestInitInContainerSkipsVMSetup(t *testing.T) {
342+ // In a container (not a VM's PID 1) Init must not mount anything: with a
343+ // command line that says "vm" but a pid that is not 1 it behaves as a
344+ // container, runs the payload and returns the executor's code.
345+ sock := filepath.Join(t.TempDir(), "s")
346+ ln, err := net.Listen("unix", sock)
347+ if err != nil {
348+ t.Skip(err)
349+ }
350+ defer func() { _ = ln.Close() }()
351+ go func() {
352+ c, err := ln.Accept()
353+ if err != nil {
354+ return
355+ }
356+ defer func() { _ = c.Close() }()
357+ _, _, _ = execstream.Drive(c, &execpb.Run{Script: "exit 0"}, nil, nil)
358+ }()
359+ code := Init(context.Background(), InitConfig{
360+ Cmdline: "ermite.sandbox=vm ermite.connect=unix:" + sock,
361+ Getpid: func() int { return 4242 },
362+ ExecArgs: []string{"/bin/sh", "-c", "exit 5"},
363+ SkipDocker: true,
364+ })
365+ // ExecArgs is a stand-in for the executor: its exit code is Init's.
366+ if code != 5 {
367+ t.Fatalf("code=%d", code)
368+ }
369+}
370+
371+func TestInitWithoutTargetFails(t *testing.T) {
372+ if code := Init(context.Background(), InitConfig{Cmdline: "ermite.sandbox=container", Getpid: func() int { return 77 }, ExecArgs: []string{"/bin/true"}, SkipDocker: true}); code == 0 {
373+ t.Fatal("init without a connect target must fail")
374+ }
375+}
376+
377+func TestWriteResolvConfNeedsGateway(t *testing.T) {
378+ if err := writeResolvConf(""); err == nil {
379+ t.Fatal("empty gateway accepted")
380+ }
381+}
382+
383+// C11: init forwards SIGTERM to the executor and finishes with the executor's
384+// own exit code, instead of dying with its child still running.
385+func TestInitForwardsSIGTERMToTheExecutor(t *testing.T) {
386+ sigs := make(chan os.Signal, 1)
387+ done := make(chan int, 1)
388+ marker := filepath.Join(t.TempDir(), "got-term")
389+ go func() {
390+ done <- Init(context.Background(), InitConfig{
391+ Cmdline: "ermite.sandbox=container ermite.connect=unix:/nonexistent",
392+ Getpid: func() int { return 4242 },
393+ SkipDocker: true,
394+ Signals: sigs,
395+ ExecArgs: []string{"/bin/sh", "-c", `trap 'echo term > ` + marker + `; exit 9' TERM; while :; do sleep 0.05; done`},
396+ })
397+ }()
398+ time.Sleep(500 * time.Millisecond)
399+ sigs <- syscall.SIGTERM
400+ select {
401+ case code := <-done:
402+ if code != 9 {
403+ t.Fatalf("init must return the executor's exit code, got %d", code)
404+ }
405+ case <-time.After(10 * time.Second):
406+ t.Fatal("init did not finish after SIGTERM")
407+ }
408+ if _, err := os.Stat(marker); err != nil {
409+ t.Fatal("the executor never received SIGTERM")
410+ }
411+}
A internal/guest/init.go +259 -0
@@ -0,0 +1,259 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "os/signal"
10+ "strings"
11+ "syscall"
12+ "time"
13+
14+ "github.com/bnema/zerowrap"
15+ "golang.org/x/sys/unix"
16+
17+ "bnema.dev/ermite-runner/internal/sandbox"
18+)
19+
20+// InitConfig configures Init. The zero value reads the real system; tests
21+// replace the fields.
22+type InitConfig struct {
23+ Version string
24+ // Cmdline is the kernel command line; empty reads /proc/cmdline.
25+ Cmdline string
26+ // Getpid reports the process id; Init only acts as a VM's PID 1 when it is 1.
27+ Getpid func() int
28+ // Signals delivers the termination signals to forward to the executor;
29+ // nil means "subscribe to SIGTERM and SIGINT". Tests inject their own.
30+ Signals <-chan os.Signal
31+ // SkipDocker does not start dockerd (tests, images without Docker).
32+ SkipDocker bool
33+ // ExecArgs is the executor command line; default
34+ // [GuestExecDir/GuestExecName exec].
35+ ExecArgs []string
36+}
37+
38+// Init is `ermite-runner init`: the entrypoint of every sandbox.
39+//
40+// In a micro-VM it is the kernel's init (PID 1, root = the read-only exec disk,
41+// /dev/vdb = shared base image, /dev/vdc = per-job scratch disk): it mounts the
42+// pseudo filesystems, sets the clock and the DNS server, builds an overlayfs
43+// root (lower = base, upper = scratch) and pivots into it, then does what a
44+// container does.
45+//
46+// In a container the root and the pseudo filesystems are ready: it skips all of
47+// that and only prepares cgroups. Then, in both: start dockerd with its data
48+// root on the scratch space, wait for its socket, run the executor
49+// (`ermite-runner exec`) as a child, reap orphans, stop dockerd, and finish by
50+// powering the VM off, or exiting with the executor's code in a container.
51+func Init(ctx context.Context, cfg InitConfig) int {
52+ log := zerowrap.FromCtx(ctx)
53+ if cfg.Getpid == nil {
54+ cfg.Getpid = os.Getpid
55+ }
56+ if cfg.ExecArgs == nil {
57+ cfg.ExecArgs = []string{sandbox.GuestExecDir + "/" + sandbox.GuestExecName, "exec"}
58+ }
59+ cmdline := cfg.Cmdline
60+ if cmdline == "" {
61+ b, err := os.ReadFile("/proc/cmdline")
62+ if err != nil && cfg.Getpid() == 1 {
63+ // A VM's init starts with nothing mounted: /proc is needed to read
64+ // the kernel command line that says what we are. setupVM reuses it.
65+ if merr := unix.Mount("proc", "/proc", "proc", 0, ""); merr == nil {
66+ b, err = os.ReadFile("/proc/cmdline")
67+ }
68+ }
69+ if err == nil {
70+ cmdline = string(b)
71+ }
72+ }
73+ args := ParseCmdline(cmdline)
74+ inVM := args[sandbox.CmdlineSandbox] == string(sandbox.VM) && cfg.Getpid() == 1
75+
76+ mode := string(sandbox.Container)
77+ if inVM {
78+ mode = string(sandbox.VM)
79+ if err := setupVM(log, args); err != nil {
80+ log.Error().Err(err).Msg("init: vm setup failed")
81+ powerOff()
82+ return 1
83+ }
84+ }
85+ code := runPayload(ctx, cfg, mode, args)
86+ if inVM {
87+ powerOff()
88+ }
89+ return code
90+}
91+
92+// defaultPath is the PATH of a sandbox whose init was started by the kernel
93+// with an empty environment.
94+const defaultPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
95+
96+func runPayload(ctx context.Context, cfg InitConfig, mode string, args map[string]string) int {
97+ log := zerowrap.FromCtx(ctx)
98+ if os.Getenv("PATH") == "" {
99+ _ = os.Setenv("PATH", defaultPath)
100+ }
101+ if os.Getenv("HOME") == "" {
102+ _ = os.Setenv("HOME", "/root")
103+ }
104+ if mode == string(sandbox.Container) {
105+ if err := nestCgroups(); err != nil {
106+ log.Warn().Err(err).Msg("init: cgroup setup (docker inside may not start)")
107+ }
108+ }
109+ target := args[sandbox.CmdlineConnect]
110+ if target == "" {
111+ target = os.Getenv("ERMITE_CONNECT")
112+ }
113+ if _, err := ParseTarget(target); err != nil {
114+ log.Error().Err(err).Msg("init: no executor target")
115+ return 1
116+ }
117+
118+ var dockerPid int
119+ if _, err := os.Stat("/usr/bin/dockerd"); err == nil && !cfg.SkipDocker {
120+ _ = os.MkdirAll(sandbox.GuestScratch+"/docker", 0o711)
121+ pid, err := spawn([]string{"/usr/bin/dockerd", "--data-root", sandbox.GuestScratch + "/docker",
122+ "--storage-driver", "overlay2", "--host", "unix:///var/run/docker.sock"}, nil)
123+ if err != nil {
124+ log.Error().Err(err).Msg("init: start dockerd")
125+ } else {
126+ dockerPid = pid
127+ }
128+ }
129+
130+ execPid, err := spawn(cfg.ExecArgs, append(os.Environ(),
131+ "ERMITE_CONNECT="+target, "ERMITE_SANDBOX="+mode, "ERMITE_VERSION="+cfg.Version))
132+ if err != nil {
133+ log.Error().Err(err).Msg("init: start executor")
134+ return 1
135+ }
136+ // SIGTERM/SIGINT (docker stop, or the host stopping the VM) go to the
137+ // executor, which cancels its script with the usual escalation and reports
138+ // its Exit; init then finishes normally instead of dying with children.
139+ sigs := cfg.Signals
140+ if sigs == nil {
141+ ch := make(chan os.Signal, 4)
142+ signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT)
143+ defer signal.Stop(ch)
144+ sigs = ch
145+ }
146+ stopFwd := make(chan struct{})
147+ defer close(stopFwd)
148+ go func() {
149+ for {
150+ select {
151+ case sig := <-sigs:
152+ if s, ok := sig.(syscall.Signal); ok {
153+ log.Info().Str("signal", s.String()).Msg("init: forwarding the signal to the executor")
154+ _ = syscall.Kill(-execPid, s) // the executor leads its own process group
155+ _ = syscall.Kill(execPid, s)
156+ }
157+ case <-stopFwd:
158+ return
159+ }
160+ }
161+ }()
162+ code := reapUntil(execPid)
163+ if dockerPid != 0 {
164+ _ = syscall.Kill(dockerPid, syscall.SIGTERM)
165+ deadline := time.Now().Add(5 * time.Second)
166+ for time.Now().Before(deadline) {
167+ if pid, _ := wait4NoHang(dockerPid); pid == dockerPid {
168+ break
169+ }
170+ time.Sleep(100 * time.Millisecond)
171+ }
172+ _ = syscall.Kill(dockerPid, syscall.SIGKILL)
173+ }
174+ return code
175+}
176+
177+// spawn starts argv[0] as a child (own process group) without os/exec, so that
178+// reapUntil is the only waiter and orphans can be reaped safely.
179+func spawn(argv, env []string) (int, error) {
180+ if env == nil {
181+ env = os.Environ()
182+ }
183+ return syscall.ForkExec(argv[0], argv, &syscall.ProcAttr{
184+ Env: env,
185+ Files: []uintptr{0, 1, 2},
186+ Sys: &syscall.SysProcAttr{Setpgid: true},
187+ })
188+}
189+
190+// reapUntil waits for every child (orphans included) and returns when pid
191+// exits, with its exit code (128+signal when killed).
192+func reapUntil(pid int) int {
193+ for {
194+ var ws syscall.WaitStatus
195+ got, err := syscall.Wait4(-1, &ws, 0, nil)
196+ if err == syscall.EINTR {
197+ continue
198+ }
199+ if err != nil { // ECHILD: no child left, the executor included
200+ return 1
201+ }
202+ if got == pid {
203+ switch {
204+ case ws.Exited():
205+ return ws.ExitStatus()
206+ case ws.Signaled():
207+ return 128 + int(ws.Signal())
208+ }
209+ return 1
210+ }
211+ }
212+}
213+
214+func wait4NoHang(pid int) (int, error) {
215+ var ws syscall.WaitStatus
216+ return syscall.Wait4(pid, &ws, syscall.WNOHANG, nil)
217+}
218+
219+func powerOff() {
220+ unix.Sync()
221+ // On x86 Firecracker has no ACPI power off: a reboot makes the VMM exit
222+ // (kernel cmdline reboot=k). On arm64 PSCI handles power off.
223+ _ = unix.Reboot(unix.LINUX_REBOOT_CMD_RESTART)
224+ _ = unix.Reboot(unix.LINUX_REBOOT_CMD_POWER_OFF)
225+}
226+
227+// nestCgroups prepares cgroup v2 for a nested dockerd in a container: a cgroup
228+// with member processes cannot delegate controllers to children, so move every
229+// process to an "init" child and enable the controllers (like the upstream
230+// docker-in-docker entrypoint). No-op without cgroup v2.
231+func nestCgroups() error {
232+ const root = "/sys/fs/cgroup"
233+ ctl, err := os.ReadFile(root + "/cgroup.controllers")
234+ if err != nil {
235+ return nil
236+ }
237+ if err := os.MkdirAll(root+"/init", 0o755); err != nil {
238+ return err
239+ }
240+ procs, err := os.ReadFile(root + "/cgroup.procs")
241+ if err != nil {
242+ return err
243+ }
244+ for _, p := range strings.Fields(string(procs)) {
245+ // Processes may exit meanwhile: ignore per-process errors.
246+ _ = os.WriteFile(root+"/init/cgroup.procs", []byte(p), 0o644)
247+ }
248+ var enable []string
249+ for _, c := range strings.Fields(string(ctl)) {
250+ enable = append(enable, "+"+c)
251+ }
252+ if len(enable) == 0 {
253+ return nil
254+ }
255+ if err := os.WriteFile(root+"/cgroup.subtree_control", []byte(strings.Join(enable, " ")), 0o644); err != nil {
256+ return fmt.Errorf("enable controllers: %w", err)
257+ }
258+ return nil
259+}
A internal/guest/vmsetup.go +203 -0
@@ -0,0 +1,203 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import (
6+ "fmt"
7+ "os"
8+ "strconv"
9+ "strings"
10+
11+ "github.com/bnema/zerowrap"
12+ "golang.org/x/sys/unix"
13+
14+ "bnema.dev/ermite-runner/internal/sandbox"
15+)
16+
17+// Devices of the micro-VM, in the order the host attaches them (virtio-blk).
18+const (
19+ devBase = "/dev/vdb" // shared read-only base image (ext4)
20+ devScratch = "/dev/vdc" // per-job scratch disk (ext4)
21+)
22+
23+// KernelArg names of the clock and DNS settings (not part of the contract
24+// constants because only init reads them).
25+const (
26+ cmdlineTime = "ermite.time" // host unix time at boot, seconds
27+)
28+
29+// setupVM turns the minimal boot root (the exec disk) into the job's root:
30+// overlayfs of base + scratch, with the exec disk bound read-only at
31+// /opt/ermite, then pivot_root into it and mount the pseudo filesystems.
32+func setupVM(log zerowrap.Logger, args map[string]string) error {
33+ // Pseudo filesystems of the boot root. Their mountpoints exist on the exec
34+ // disk (the root is read-only).
35+ for _, m := range []struct{ src, dst, typ string }{
36+ {"proc", "/proc", "proc"},
37+ {"sysfs", "/sys", "sysfs"},
38+ {"devtmpfs", "/dev", "devtmpfs"},
39+ } {
40+ // proc is mounted by Init (to read the command line) and devtmpfs by the
41+ // kernel (DEVTMPFS_MOUNT): mount only what is missing.
42+ if isMounted(m.dst) {
43+ continue
44+ }
45+ if err := mount(m.src, m.dst, m.typ, 0, ""); err != nil {
46+ return err
47+ }
48+ }
49+ // Firecracker has no RTC on x86: the guest clock starts at the epoch, which
50+ // breaks TLS. The host passes its time on the command line.
51+ if v, ok := args[cmdlineTime]; ok {
52+ if sec, err := strconv.ParseInt(v, 10, 64); err == nil && sec > 1_600_000_000 {
53+ if err := unix.ClockSettime(unix.CLOCK_REALTIME, &unix.Timespec{Sec: sec}); err != nil {
54+ log.Warn().Err(err).Msg("init: set clock")
55+ }
56+ }
57+ }
58+
59+ if err := mount(devBase, "/mnt/base", "ext4", unix.MS_RDONLY, ""); err != nil {
60+ return err
61+ }
62+ if err := mount(devScratch, "/mnt/scratch", "ext4", unix.MS_NOATIME, ""); err != nil {
63+ return err
64+ }
65+ for _, d := range []string{"/mnt/scratch/upper", "/mnt/scratch/work", "/mnt/scratch/docker"} {
66+ if err := os.MkdirAll(d, 0o755); err != nil {
67+ return err
68+ }
69+ }
70+ opts := "lowerdir=/mnt/base,upperdir=/mnt/scratch/upper,workdir=/mnt/scratch/work"
71+ if err := mount("overlay", "/mnt/newroot", "overlay", 0, opts); err != nil {
72+ return err
73+ }
74+ // The new root needs these mountpoints; they live in the (writable) upper.
75+ for _, d := range []string{"/mnt/newroot/proc", "/mnt/newroot/sys", "/mnt/newroot/dev",
76+ "/mnt/newroot/run", "/mnt/newroot/tmp", "/mnt/newroot/scratch", "/mnt/newroot/.oldroot",
77+ "/mnt/newroot" + sandbox.GuestExecDir} {
78+ if err := os.MkdirAll(d, 0o755); err != nil {
79+ return err
80+ }
81+ }
82+ // Scratch and the exec disk stay reachable after the pivot.
83+ if err := mount("/mnt/scratch", "/mnt/newroot"+sandbox.GuestScratch, "", unix.MS_BIND, ""); err != nil {
84+ return err
85+ }
86+ if err := mount("/", "/mnt/newroot"+sandbox.GuestExecDir, "", unix.MS_BIND, ""); err != nil {
87+ return err
88+ }
89+ if err := mount("", "/mnt/newroot"+sandbox.GuestExecDir, "", unix.MS_BIND|unix.MS_REMOUNT|unix.MS_RDONLY, ""); err != nil {
90+ return err
91+ }
92+ for _, d := range []string{"/proc", "/sys", "/dev"} {
93+ if err := mount(d, "/mnt/newroot"+d, "", unix.MS_MOVE, ""); err != nil {
94+ return err
95+ }
96+ }
97+ if err := unix.Chdir("/mnt/newroot"); err != nil {
98+ return err
99+ }
100+ if err := unix.PivotRoot(".", ".oldroot"); err != nil {
101+ return fmt.Errorf("pivot_root: %w", err)
102+ }
103+ if err := unix.Chroot("."); err != nil {
104+ return err
105+ }
106+ if err := unix.Chdir("/"); err != nil {
107+ return err
108+ }
109+ _ = unix.Unmount("/.oldroot", unix.MNT_DETACH)
110+
111+ for _, m := range []struct {
112+ src, dst, typ string
113+ flags uintptr
114+ data string
115+ }{
116+ {"devpts", "/dev/pts", "devpts", 0, "gid=5,mode=620,ptmxmode=666"},
117+ {"tmpfs", "/dev/shm", "tmpfs", unix.MS_NOSUID | unix.MS_NODEV, ""},
118+ {"tmpfs", "/run", "tmpfs", unix.MS_NOSUID | unix.MS_NODEV, "mode=755"},
119+ {"tmpfs", "/tmp", "tmpfs", unix.MS_NOSUID | unix.MS_NODEV, "mode=1777"},
120+ {"cgroup2", "/sys/fs/cgroup", "cgroup2", unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOEXEC, ""},
121+ } {
122+ if err := os.MkdirAll(m.dst, 0o755); err != nil {
123+ return err
124+ }
125+ if err := mount(m.src, m.dst, m.typ, m.flags, m.data); err != nil {
126+ // devpts and shm failures are not fatal for the stub.
127+ log.Warn().Err(err).Str("target", m.dst).Msg("init: mount")
128+ }
129+ }
130+ _ = os.Chmod("/tmp", 0o1777)
131+
132+ gw := args[sandbox.CmdlineGateway]
133+ if err := writeResolvConf(gw); err != nil {
134+ return err
135+ }
136+ _ = os.WriteFile("/etc/hostname", []byte("ermite-job\n"), 0o644)
137+ _ = os.WriteFile("/etc/hosts", []byte("127.0.0.1 localhost ermite-job\n"), 0o644)
138+ _ = unix.Sethostname([]byte("ermite-job"))
139+ if err := loUp(); err != nil {
140+ log.Warn().Err(err).Msg("init: loopback")
141+ }
142+ // IPv6 is disabled on the kernel command line (ipv6.disable=1); this is a
143+ // second line of defence when the module is built in but not disabled.
144+ _ = os.WriteFile("/proc/sys/net/ipv6/conf/all/disable_ipv6", []byte("1\n"), 0o644)
145+ return nil
146+}
147+
148+// writeResolvConf points the guest at the gateway, where the runner's DNS
149+// forwarder listens. /etc/resolv.conf may be a dangling symlink in the image
150+// (systemd-resolved stub): replace it.
151+func writeResolvConf(gw string) error {
152+ if gw == "" {
153+ return fmt.Errorf("kernel command line has no %s", sandbox.CmdlineGateway)
154+ }
155+ _ = os.Remove("/etc/resolv.conf")
156+ return os.WriteFile("/etc/resolv.conf", []byte("nameserver "+gw+"\noptions timeout:2 attempts:2\n"), 0o644)
157+}
158+
159+func mount(src, dst, typ string, flags uintptr, data string) error {
160+ if err := os.MkdirAll(dst, 0o755); err != nil && !os.IsExist(err) {
161+ // Read-only boot root: the mountpoint must already exist.
162+ if _, serr := os.Stat(dst); serr != nil {
163+ return fmt.Errorf("mountpoint %s: %w", dst, err)
164+ }
165+ }
166+ if err := unix.Mount(src, dst, typ, flags, data); err != nil {
167+ return fmt.Errorf("mount %s on %s (%s): %w", src, dst, strings.TrimSpace(typ), err)
168+ }
169+ return nil
170+}
171+
172+// loUp brings the loopback interface up (the kernel leaves it down).
173+func loUp() error {
174+ fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM|unix.SOCK_CLOEXEC, 0)
175+ if err != nil {
176+ return err
177+ }
178+ defer func() { _ = unix.Close(fd) }()
179+ ifr, err := unix.NewIfreq("lo")
180+ if err != nil {
181+ return err
182+ }
183+ if err := unix.IoctlIfreq(fd, unix.SIOCGIFFLAGS, ifr); err != nil {
184+ return err
185+ }
186+ ifr.SetUint16(ifr.Uint16() | unix.IFF_UP)
187+ return unix.IoctlIfreq(fd, unix.SIOCSIFFLAGS, ifr)
188+}
189+
190+// isMounted reports whether path is a mount point listed in /proc/self/mountinfo.
191+func isMounted(path string) bool {
192+ b, err := os.ReadFile("/proc/self/mountinfo")
193+ if err != nil {
194+ return false
195+ }
196+ for _, line := range strings.Split(string(b), "\n") {
197+ f := strings.Fields(line)
198+ if len(f) > 4 && f[4] == path {
199+ return true
200+ }
201+ }
202+ return false
203+}
A internal/netpolicy/netns_test.go +202 -0
@@ -0,0 +1,202 @@
1+// SPDX-License-Identifier: MIT
2+
3+package netpolicy
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "os/exec"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "bnema.dev/ermite-runner/internal/cmdrun"
15+)
16+
17+// TestRulesetBetweenTwoGuestsAndAForgedSource loads the real ruleset into the
18+// kernel and sends real packets: two guests (ertap0, ertap1) and an outside
19+// host hang off veth pairs, each in its own network namespace. It needs
20+// CAP_NET_ADMIN, which scripts/it-vm-userns.sh provides without root:
21+//
22+// ERMITE_RUNNER_IT_NET=1 unshare --user --map-root-user --net go test -run Netns ./internal/netpolicy
23+func TestRulesetBetweenTwoGuestsAndAForgedSource(t *testing.T) {
24+ if os.Getenv("ERMITE_RUNNER_IT_NET") != "1" {
25+ t.Skip("set ERMITE_RUNNER_IT_NET=1 inside a user+network namespace (scripts/it-vm-userns.sh)")
26+ }
27+ for _, c := range []string{"ip", "nft", "nsenter", "unshare", "python3"} {
28+ if _, err := exec.LookPath(c); err != nil {
29+ t.Skipf("%s not found", c)
30+ }
31+ }
32+ sh := func(args ...string) string {
33+ t.Helper()
34+ out, err := exec.Command(args[0], args[1:]...).CombinedOutput()
35+ if err != nil {
36+ t.Fatalf("%s: %v: %s", strings.Join(args, " "), err, out)
37+ }
38+ return strings.TrimSpace(string(out))
39+ }
40+ if out, err := exec.Command("ip", "link", "add", "ertapprobe", "type", "dummy").CombinedOutput(); err != nil {
41+ t.Skipf("no CAP_NET_ADMIN: %v: %s", err, out)
42+ }
43+ sh("ip", "link", "del", "ertapprobe")
44+ sh("ip", "link", "set", "lo", "up")
45+
46+ // Three machines: guest A, guest B, an outside host.
47+ pid := map[string]int{}
48+ for _, n := range []string{"a", "b", "ext"} {
49+ c := exec.Command("unshare", "--net", "sleep", "120")
50+ if err := c.Start(); err != nil {
51+ t.Fatal(err)
52+ }
53+ pid[n] = c.Process.Pid
54+ t.Cleanup(func() { _ = c.Process.Kill(); _ = c.Wait() })
55+ }
56+ time.Sleep(300 * time.Millisecond)
57+ in := func(n string, args ...string) string {
58+ t.Helper()
59+ return sh(append([]string{"nsenter", fmt.Sprintf("--net=/proc/%d/ns/net", pid[n])}, args...)...)
60+ }
61+ link := func(host, n string) {
62+ sh("ip", "link", "add", host, "type", "veth", "peer", "name", "v0", "netns", fmt.Sprint(pid[n]))
63+ in(n, "ip", "link", "set", "lo", "up")
64+ in(n, "ip", "link", "set", "v0", "up")
65+ sh("sysctl", "-qw", "net.ipv4.conf."+host+".rp_filter=0")
66+ }
67+ link("ertap0", "a")
68+ link("ertap1", "b")
69+ link("extif", "ext")
70+ sh("sysctl", "-qw", "net.ipv4.conf.all.rp_filter=0")
71+ sh("sysctl", "-qw", "net.ipv4.ip_forward=1")
72+
73+ gA := guest(t, "ertap0", 0) // 10.200.0.1 / .2
74+ gB := guest(t, "ertap1", 1) // 10.200.0.5 / .6
75+ for _, g := range []Guest{gA, gB} {
76+ sh("ip", "addr", "add", g.Gateway.String()+"/30", "dev", g.Iface)
77+ sh("ip", "link", "set", g.Iface, "up")
78+ }
79+ sh("ip", "addr", "add", "203.0.114.1/24", "dev", "extif")
80+ sh("ip", "link", "set", "extif", "up")
81+ in("a", "ip", "addr", "add", gA.Addr.String()+"/30", "dev", "v0")
82+ in("a", "ip", "route", "add", "default", "via", gA.Gateway.String())
83+ in("b", "ip", "addr", "add", gB.Addr.String()+"/30", "dev", "v0")
84+ in("b", "ip", "route", "add", "default", "via", gB.Gateway.String())
85+ in("ext", "ip", "addr", "add", "203.0.114.2/24", "dev", "v0")
86+ in("ext", "ip", "route", "add", "default", "via", "203.0.114.1")
87+ // Guest A holds guest B's address too, to be able to forge it.
88+ in("a", "ip", "addr", "add", gB.Addr.String()+"/32", "dev", "lo")
89+
90+ m := NewManager(cmdrun.Exec{}, "nft")
91+ ctx := context.Background()
92+ t.Cleanup(func() { _ = m.Reset(ctx) })
93+ for _, g := range []Guest{gA, gB} {
94+ if err := m.Add(ctx, g); err != nil {
95+ t.Fatal(err)
96+ }
97+ }
98+
99+ // An outside UDP server: logs each payload with the source it saw, and
100+ // answers "pong".
101+ logf := t.TempDir() + "/ext.log"
102+ const server = `
103+import socket,sys
104+s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.bind(("0.0.0.0",9999))
105+f=open(sys.argv[1],"a",buffering=1)
106+while True:
107+ d,a=s.recvfrom(100); f.write(d.decode()+" from "+a[0]+"\n"); s.sendto(b"pong",a)
108+`
109+ srv := exec.Command("nsenter", fmt.Sprintf("--net=/proc/%d/ns/net", pid["ext"]), "python3", "-c", server, logf)
110+ if err := srv.Start(); err != nil {
111+ t.Fatal(err)
112+ }
113+ t.Cleanup(func() { _ = srv.Process.Kill(); _ = srv.Wait() })
114+ time.Sleep(500 * time.Millisecond)
115+
116+ // udp sends one datagram from machine n (optionally bound to src:sport) and
117+ // reports whether an answer came back.
118+ const client = `
119+import socket,sys
120+dst,payload,src,sport=sys.argv[1],sys.argv[2],sys.argv[3],int(sys.argv[4])
121+s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.settimeout(2)
122+if src: s.bind((src,sport))
123+s.sendto(payload.encode(),(dst,int(sys.argv[5]) if len(sys.argv)>5 else 9999))
124+try: s.recv(10); print("answer")
125+except Exception: print("silence")
126+`
127+ udp := func(n, dst, payload, src string, sport int, dport int) string {
128+ return in(n, "python3", "-c", client, dst, payload, src, fmt.Sprint(sport), fmt.Sprint(dport))
129+ }
130+ seen := func() string { b, _ := os.ReadFile(logf); return string(b) }
131+
132+ // Control: a guest reaches the outside host and gets the reply (NAT and the
133+ // established path work, so the drops below are the policy, not a broken rig).
134+ if got := udp("a", "203.0.114.2", "ctl-a", "", 0, 9999); got != "answer" {
135+ t.Fatalf("control: guest A cannot reach the outside host: %s", got)
136+ }
137+
138+ // Guests cannot reach each other, directly or through the other's gateway.
139+ if got := udp("a", gB.Addr.String(), "a-to-b", "", 0, 9999); got != "silence" {
140+ t.Errorf("guest A reached guest B: %s", got)
141+ }
142+ // Forged reply (S1): B opens a flow to the outside host from port 5555 and
143+ // keeps listening. Guest A then forges a reply of that flow: source = the
144+ // outside host's address and port, destination = the host's masquerade
145+ // address and B's NAT port. Conntrack would map it onto B's flow and deliver
146+ // it to B as "established" traffic if an established-accept came before the
147+ // source check.
148+ in("a", "ip", "addr", "add", "203.0.114.2/32", "dev", "lo")
149+ bLog := t.TempDir() + "/b.log"
150+ const listener = `
151+import socket,sys
152+s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.settimeout(6)
153+s.bind(("10.200.0.6",5555)); s.sendto(b"open-flow",("203.0.114.2",9999))
154+f=open(sys.argv[1],"a",buffering=1)
155+try:
156+ while True:
157+ d,a=s.recvfrom(100); f.write(d.decode()+" from "+a[0]+"\n")
158+except Exception: pass
159+`
160+ bProc := exec.Command("nsenter", fmt.Sprintf("--net=/proc/%d/ns/net", pid["b"]), "python3", "-c", listener, bLog)
161+ if err := bProc.Start(); err != nil {
162+ t.Fatal(err)
163+ }
164+ time.Sleep(1500 * time.Millisecond)
165+ if got := udp("a", "203.0.114.1", "forged-reply-to-b", "203.0.114.2", 9999, 5555); got != "silence" {
166+ t.Errorf("unexpected answer to the forgery: %s", got)
167+ }
168+ _ = bProc.Wait()
169+ if b, _ := os.ReadFile(bLog); strings.Contains(string(b), "forged-reply-to-b") {
170+ t.Errorf("guest B received a reply forged by guest A through conntrack:\n%s", b)
171+ }
172+ // A forged source address on a plain forwarded packet is dropped as well.
173+ if got := udp("a", "203.0.114.2", "forged-src", gB.Addr.String(), 7777, 9999); got != "silence" {
174+ t.Errorf("a packet with another guest's source address got through: %s", got)
175+ }
176+ // S6: guest A cannot use guest B's DNS gateway, nor the host's other ports.
177+ if got := udp("a", gB.Gateway.String(), "dns-a-to-b-gw", "", 0, 53); got != "silence" {
178+ t.Errorf("guest A reached guest B's gateway on 53: %s", got)
179+ }
180+ // The outside host cannot open connections to a guest.
181+ if got := in("ext", "python3", "-c", client, gA.Addr.String(), "ext-to-a", "", "0", "9999"); got != "silence" {
182+ t.Errorf("the outside host reached guest A: %s", got)
183+ }
184+ // Special-use ranges (S10) are dropped even though the rig could route them.
185+ in("ext", "ip", "addr", "add", "198.51.100.7/32", "dev", "lo")
186+ sh("ip", "route", "add", "198.51.100.7/32", "via", "203.0.114.2")
187+ if got := udp("a", "198.51.100.7", "doc-range", "", 0, 9999); got != "silence" {
188+ t.Errorf("TEST-NET-2 reached: %s", got)
189+ }
190+
191+ out := seen()
192+ for _, bad := range []string{"forged-src", "a-to-b", "ext-to-a", "doc-range"} {
193+ if strings.Contains(out, bad) {
194+ t.Errorf("outside host saw %q:\n%s", bad, out)
195+ }
196+ }
197+ for _, good := range []string{"ctl-a", "open-flow"} {
198+ if !strings.Contains(out, good) {
199+ t.Errorf("outside host did not see %q:\n%s", good, out)
200+ }
201+ }
202+}
A internal/netpolicy/netpolicy.go +268 -0
@@ -0,0 +1,268 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package netpolicy generates and applies the nftables policy of job sandboxes.
4+//
5+// All rules live in one dedicated table, "inet ermite_runner", that is replaced
6+// atomically (one `nft -f -` transaction) whenever a sandbox is added or
7+// removed, and deleted when none is left. Nothing outside that table is touched.
8+//
9+// Policy for every guest interface (a TAP in a micro-VM, a bridge in a
10+// container):
11+// - to the host itself (input hook): only DNS (udp/tcp 53) to the gateway
12+// address of that guest, where the runner's DNS forwarder listens; every
13+// other packet is dropped, and port 53 of a gateway is closed to every
14+// other interface;
15+// - IPv6 from the guest is dropped (the guest also disables IPv6);
16+// - forwarded traffic must carry the guest's own source address;
17+// - forwarded traffic to RFC 1918, CGNAT 100.64.0.0/10 (VPN overlays),
18+// link-local 169.254.0.0/16 (cloud metadata 169.254.169.254), loopback,
19+// "this network", multicast and reserved ranges is dropped;
20+// - traffic towards a guest interface that does not belong to an established
21+// connection is dropped, so guests cannot reach each other;
22+// - everything else is NATed (masquerade) to the internet.
23+package netpolicy
24+
25+import (
26+ "context"
27+ "errors"
28+ "fmt"
29+ "net/netip"
30+ "sort"
31+ "strings"
32+ "sync"
33+
34+ "bnema.dev/ermite-runner/internal/cmdrun"
35+)
36+
37+// Table is the nftables table owned by the runner.
38+const Table = "ermite_runner"
39+
40+// IfacePrefixes are the interface name prefixes of sandboxes; wildcard rules
41+// are generated for them. TAP devices of micro-VMs, bridges of containers.
42+var IfacePrefixes = []string{"ertap", "erbr"}
43+
44+// blockedV4 are the destinations a guest may never reach.
45+var blockedV4 = []string{
46+ "0.0.0.0/8", // "this network"
47+ "10.0.0.0/8", // RFC 1918
48+ "100.64.0.0/10", // RFC 6598 shared address space (CGNAT, VPN overlays)
49+ "127.0.0.0/8", // loopback
50+ "169.254.0.0/16", // link-local, incl. the 169.254.169.254 metadata service
51+ "172.16.0.0/12", // RFC 1918
52+ "192.0.0.0/24", // IETF protocol assignments
53+ "192.0.2.0/24", // TEST-NET-1
54+ "192.168.0.0/16", // RFC 1918
55+ "198.18.0.0/15", // benchmarking
56+ "198.51.100.0/24", // TEST-NET-2
57+ "203.0.113.0/24", // TEST-NET-3
58+ "224.0.0.0/4", // multicast
59+ "240.0.0.0/4", // reserved, incl. limited broadcast
60+}
61+
62+// prefixSet renders IfacePrefixes as an nft anonymous set of interface wildcards.
63+func prefixSet() string {
64+ q := make([]string, len(IfacePrefixes))
65+ for i, p := range IfacePrefixes {
66+ q[i] = `"` + p + `*"`
67+ }
68+ return "{ " + strings.Join(q, ", ") + " }"
69+}
70+
71+// Guest is one sandbox network attachment.
72+type Guest struct {
73+ Iface string // host-side interface: TAP or bridge name
74+ Gateway netip.Addr // host address on Iface; the DNS forwarder listens here
75+ Addr netip.Addr // the guest's only allowed source address
76+}
77+
78+// Validate checks the fields used in generated rules.
79+func (g Guest) Validate() error {
80+ if !validIface(g.Iface) {
81+ return fmt.Errorf("netpolicy: invalid interface name %q", g.Iface)
82+ }
83+ if !g.Gateway.Is4() || !g.Addr.Is4() {
84+ return errors.New("netpolicy: guest and gateway must be IPv4 addresses")
85+ }
86+ return nil
87+}
88+
89+func validIface(s string) bool {
90+ if s == "" || len(s) > 15 {
91+ return false
92+ }
93+ for _, r := range s {
94+ if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' && r != '_' {
95+ return false
96+ }
97+ }
98+ return true
99+}
100+
101+// Net is the /30 given to one sandbox.
102+type Net struct {
103+ Prefix netip.Prefix // the /30
104+ Gateway netip.Addr // host side (.1)
105+ Guest netip.Addr // guest side (.2)
106+}
107+
108+// Subnet returns the slot-th /30 of base (an IPv4 prefix of at most /30).
109+func Subnet(base netip.Prefix, slot int) (Net, error) {
110+ if !base.Addr().Is4() || base.Bits() > 30 || slot < 0 {
111+ return Net{}, fmt.Errorf("netpolicy: bad base %s or slot %d", base, slot)
112+ }
113+ if slot >= 1<<(30-base.Bits()) {
114+ return Net{}, fmt.Errorf("netpolicy: slot %d does not fit in %s", slot, base)
115+ }
116+ b := base.Masked().Addr().As4()
117+ v := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
118+ v += uint32(slot) * 4
119+ addr := func(off uint32) netip.Addr {
120+ x := v + off
121+ return netip.AddrFrom4([4]byte{byte(x >> 24), byte(x >> 16), byte(x >> 8), byte(x)})
122+ }
123+ return Net{Prefix: netip.PrefixFrom(addr(0), 30), Gateway: addr(1), Guest: addr(2)}, nil
124+}
125+
126+// Ruleset returns the nft script that atomically replaces the runner table with
127+// the policy for guests. With no guest it only deletes the table. The output is
128+// deterministic (guests sorted by interface).
129+func Ruleset(guests []Guest) string {
130+ var b strings.Builder
131+ fmt.Fprintf(&b, "table inet %s\ndelete table inet %s\n", Table, Table)
132+ if len(guests) == 0 {
133+ return b.String()
134+ }
135+ gs := append([]Guest(nil), guests...)
136+ sort.Slice(gs, func(i, j int) bool { return gs[i].Iface < gs[j].Iface })
137+
138+ fmt.Fprintf(&b, "table inet %s {\n", Table)
139+ b.WriteString("\tset blocked_v4 {\n\t\ttype ipv4_addr\n\t\tflags interval\n\t\telements = { ")
140+ b.WriteString(strings.Join(blockedV4, ", "))
141+ b.WriteString(" }\n\t}\n")
142+
143+ b.WriteString("\tchain input {\n\t\ttype filter hook input priority filter; policy accept;\n")
144+ for _, p := range IfacePrefixes {
145+ fmt.Fprintf(&b, "\t\tiifname \"%s*\" meta nfproto ipv6 drop\n", p)
146+ }
147+ // The DNS forwarder of a guest answers that guest only: port 53 of a gateway
148+ // address is dropped from every other interface (other guests, the LAN).
149+ for _, g := range gs {
150+ for _, proto := range []string{"udp", "tcp"} {
151+ fmt.Fprintf(&b, "\t\tip daddr %s %s dport 53 iifname != \"%s\" drop\n", g.Gateway, proto, g.Iface)
152+ }
153+ }
154+ for _, g := range gs {
155+ for _, proto := range []string{"udp", "tcp"} {
156+ fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s %s dport 53 accept\n", g.Iface, g.Addr, g.Gateway, proto)
157+ }
158+ fmt.Fprintf(&b, "\t\tiifname \"%s\" drop\n", g.Iface)
159+ }
160+ // An interface with our prefix that no live guest owns (a leftover) is
161+ // dropped as well.
162+ for _, p := range IfacePrefixes {
163+ fmt.Fprintf(&b, "\t\tiifname \"%s*\" drop\n", p)
164+ }
165+ b.WriteString("\t}\n")
166+
167+ // Forward chain. Order matters: everything a guest sends is checked (family,
168+ // source address, destination ranges) BEFORE any "established" accept, or a
169+ // guest could forge the source of another guest's flow and have it accepted
170+ // as a reply.
171+ b.WriteString("\tchain forward {\n\t\ttype filter hook forward priority filter; policy accept;\n")
172+ for _, p := range IfacePrefixes {
173+ fmt.Fprintf(&b, "\t\tiifname \"%s*\" meta nfproto ipv6 drop\n", p)
174+ }
175+ for _, g := range gs {
176+ fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr != %s drop\n", g.Iface, g.Addr)
177+ fmt.Fprintf(&b, "\t\tiifname \"%s\" ip daddr @blocked_v4 drop\n", g.Iface)
178+ }
179+ // Replies from outside to a guest; never for packets that come from a guest.
180+ b.WriteString("\t\tiifname != " + prefixSet() + " oifname " + prefixSet() + " ct state established,related accept\n")
181+ for _, p := range IfacePrefixes {
182+ fmt.Fprintf(&b, "\t\toifname \"%s*\" drop\n", p)
183+ }
184+ for _, g := range gs {
185+ fmt.Fprintf(&b, "\t\tiifname \"%s\" accept\n", g.Iface)
186+ }
187+ for _, p := range IfacePrefixes {
188+ fmt.Fprintf(&b, "\t\tiifname \"%s*\" drop\n", p)
189+ }
190+ b.WriteString("\t}\n")
191+
192+ b.WriteString("\tchain postrouting {\n\t\ttype nat hook postrouting priority srcnat; policy accept;\n")
193+ for _, g := range gs {
194+ fmt.Fprintf(&b, "\t\tip saddr %s oifname != \"%s\" masquerade\n", g.Addr, g.Iface)
195+ }
196+ b.WriteString("\t}\n}\n")
197+ return b.String()
198+}
199+
200+// Manager applies the policy for the set of live guests.
201+type Manager struct {
202+ run cmdrun.Runner
203+ nft string
204+ mu sync.Mutex
205+ guests map[string]Guest
206+}
207+
208+// NewManager returns a Manager that runs nft (path or name) through run.
209+func NewManager(run cmdrun.Runner, nft string) *Manager {
210+ if nft == "" {
211+ nft = "nft"
212+ }
213+ return &Manager{run: run, nft: nft, guests: map[string]Guest{}}
214+}
215+
216+// Add installs (or replaces) the policy of g.
217+func (m *Manager) Add(ctx context.Context, g Guest) error {
218+ if err := g.Validate(); err != nil {
219+ return err
220+ }
221+ m.mu.Lock()
222+ defer m.mu.Unlock()
223+ prev, had := m.guests[g.Iface]
224+ m.guests[g.Iface] = g
225+ if err := m.applyLocked(ctx); err != nil {
226+ if had {
227+ m.guests[g.Iface] = prev
228+ } else {
229+ delete(m.guests, g.Iface)
230+ }
231+ return err
232+ }
233+ return nil
234+}
235+
236+// Remove drops the policy of iface; the table disappears with the last guest.
237+// Removing an unknown interface is not an error.
238+func (m *Manager) Remove(ctx context.Context, iface string) error {
239+ m.mu.Lock()
240+ defer m.mu.Unlock()
241+ if _, ok := m.guests[iface]; !ok {
242+ return nil
243+ }
244+ delete(m.guests, iface)
245+ return m.applyLocked(ctx)
246+}
247+
248+// Reset deletes the runner table whatever its content: used at start to reap
249+// the table of a previous run. Safe when the table does not exist.
250+func (m *Manager) Reset(ctx context.Context) error {
251+ m.mu.Lock()
252+ defer m.mu.Unlock()
253+ m.guests = map[string]Guest{}
254+ return m.applyLocked(ctx)
255+}
256+
257+func (m *Manager) applyLocked(ctx context.Context) error {
258+ gs := make([]Guest, 0, len(m.guests))
259+ for _, g := range m.guests {
260+ gs = append(gs, g)
261+ }
262+ // `table X` creates the table if missing so that the following delete cannot
263+ // fail; the whole script is one transaction.
264+ if _, err := m.run.Run(ctx, strings.NewReader(Ruleset(gs)), m.nft, "-f", "-"); err != nil {
265+ return fmt.Errorf("netpolicy: apply ruleset: %w", err)
266+ }
267+ return nil
268+}
A internal/netpolicy/netpolicy_test.go +234 -0
@@ -0,0 +1,234 @@
1+// SPDX-License-Identifier: MIT
2+
3+package netpolicy
4+
5+import (
6+ "context"
7+ "errors"
8+ "flag"
9+ "io"
10+ "net/netip"
11+ "os"
12+ "path/filepath"
13+ "strings"
14+ "testing"
15+
16+ "bnema.dev/ermite-runner/internal/cmdrun"
17+)
18+
19+var update = flag.Bool("update", false, "rewrite golden files")
20+
21+func golden(t *testing.T, name, got string) {
22+ t.Helper()
23+ p := filepath.Join("testdata", name)
24+ if *update {
25+ if err := os.MkdirAll("testdata", 0o755); err != nil {
26+ t.Fatal(err)
27+ }
28+ if err := os.WriteFile(p, []byte(got), 0o644); err != nil {
29+ t.Fatal(err)
30+ }
31+ }
32+ want, err := os.ReadFile(p)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if string(want) != got {
37+ t.Fatalf("%s differs from golden (run with -update to refresh):\n%s", name, got)
38+ }
39+}
40+
41+func guest(t *testing.T, iface string, slot int) Guest {
42+ t.Helper()
43+ n, err := Subnet(netip.MustParsePrefix("10.200.0.0/16"), slot)
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ return Guest{Iface: iface, Gateway: n.Gateway, Addr: n.Guest}
48+}
49+
50+func TestRulesetGolden(t *testing.T) {
51+ golden(t, "empty.nft", Ruleset(nil))
52+ golden(t, "one.nft", Ruleset([]Guest{guest(t, "ertap0", 0)}))
53+ // Order of input must not matter.
54+ two := Ruleset([]Guest{guest(t, "erbr1", 1), guest(t, "ertap0", 0)})
55+ if two != Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "erbr1", 1)}) {
56+ t.Fatal("ruleset depends on guest order")
57+ }
58+ golden(t, "two.nft", two)
59+}
60+
61+func TestRulesetPolicyInvariants(t *testing.T) {
62+ rs := Ruleset([]Guest{guest(t, "ertap0", 0)})
63+ for _, want := range []string{
64+ "169.254.0.0/16", "100.64.0.0/10", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
65+ `iifname "ertap0" drop`, "dport 53 accept", "masquerade", `iifname "ertap*" meta nfproto ipv6 drop`,
66+ } {
67+ if !strings.Contains(rs, want) {
68+ t.Errorf("ruleset lacks %q", want)
69+ }
70+ }
71+ // The drop for the host must come after the DNS accepts and the guest
72+ // table must never use a drop policy (it would break other tables' traffic).
73+ if strings.Contains(rs, "policy drop") {
74+ t.Error("base chains must keep policy accept")
75+ }
76+ if strings.Index(rs, "dport 53 accept") > strings.Index(rs, `iifname "ertap0" drop`) {
77+ t.Error("DNS accept must precede the catch-all drop")
78+ }
79+}
80+
81+// S1: nothing a guest sends may be accepted by a conntrack rule before the
82+// per-guest checks: a forged "reply" from guest A to guest B's flow would
83+// otherwise be accepted as established traffic.
84+func TestForwardChainChecksGuestTrafficBeforeEstablished(t *testing.T) {
85+ rs := Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "erbr1", 1)})
86+ fwd := rs[strings.Index(rs, "chain forward"):strings.Index(rs, "chain postrouting")]
87+ lines := strings.Split(fwd, "\n")
88+ idx := func(sub string) int {
89+ for i, l := range lines {
90+ if strings.Contains(l, sub) {
91+ return i
92+ }
93+ }
94+ t.Fatalf("forward chain lacks %q:\n%s", sub, fwd)
95+ return -1
96+ }
97+ est := idx("ct state established,related accept")
98+ for _, must := range []string{
99+ `iifname "ertap*" meta nfproto ipv6 drop`, `iifname "erbr*" meta nfproto ipv6 drop`,
100+ `iifname "ertap0" ip saddr != 10.200.0.2 drop`, `iifname "ertap0" ip daddr @blocked_v4 drop`,
101+ `iifname "erbr1" ip saddr != 10.200.0.6 drop`, `iifname "erbr1" ip daddr @blocked_v4 drop`,
102+ } {
103+ if idx(must) > est {
104+ t.Errorf("%q must come before the established accept", must)
105+ }
106+ }
107+ // And the established accept itself never applies to packets that enter
108+ // through a sandbox interface.
109+ if !strings.Contains(lines[est], `iifname != { "ertap*", "erbr*" }`) {
110+ t.Errorf("established accept must exclude sandbox ingress: %s", lines[est])
111+ }
112+}
113+
114+// S6: port 53 of a gateway address is closed to every interface but its guest's.
115+func TestInputChainClosesGatewayDNSToOtherInterfaces(t *testing.T) {
116+ rs := Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "ertap1", 1)})
117+ for _, want := range []string{
118+ `ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop`,
119+ `ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop`,
120+ `ip daddr 10.200.0.5 udp dport 53 iifname != "ertap1" drop`,
121+ `ip daddr 10.200.0.5 tcp dport 53 iifname != "ertap1" drop`,
122+ } {
123+ if !strings.Contains(rs, want) {
124+ t.Errorf("lacks %q", want)
125+ }
126+ }
127+}
128+
129+// S10: documentation, test-net and benchmarking ranges are blocked too.
130+func TestBlockedSetCoversSpecialUseRanges(t *testing.T) {
131+ rs := Ruleset([]Guest{guest(t, "ertap0", 0)})
132+ for _, r := range []string{"192.0.0.0/24", "192.0.2.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4"} {
133+ if !strings.Contains(rs, r) {
134+ t.Errorf("blocked set lacks %s", r)
135+ }
136+ }
137+}
138+
139+func TestSubnet(t *testing.T) {
140+ base := netip.MustParsePrefix("10.200.0.0/16")
141+ n, err := Subnet(base, 3)
142+ if err != nil {
143+ t.Fatal(err)
144+ }
145+ if n.Prefix.String() != "10.200.0.12/30" || n.Gateway.String() != "10.200.0.13" || n.Guest.String() != "10.200.0.14" {
146+ t.Fatalf("got %+v", n)
147+ }
148+ n, _ = Subnet(base, 64)
149+ if n.Gateway.String() != "10.200.1.1" {
150+ t.Fatalf("carry: %+v", n)
151+ }
152+ if _, err := Subnet(base, 1<<14); err == nil {
153+ t.Fatal("slot beyond the prefix accepted")
154+ }
155+ if _, err := Subnet(netip.MustParsePrefix("10.0.0.0/31"), 0); err == nil {
156+ t.Fatal("prefix longer than /30 accepted")
157+ }
158+ if _, err := Subnet(base, -1); err == nil {
159+ t.Fatal("negative slot accepted")
160+ }
161+}
162+
163+func TestGuestValidate(t *testing.T) {
164+ g := guest(t, "ertap0", 0)
165+ if err := g.Validate(); err != nil {
166+ t.Fatal(err)
167+ }
168+ for _, bad := range []string{"", "a b", `x"y`, "a;b", "waytoolonginterfacename", "x\ny"} {
169+ g.Iface = bad
170+ if g.Validate() == nil {
171+ t.Errorf("iface %q accepted", bad)
172+ }
173+ }
174+ g.Iface = "ok"
175+ g.Addr = netip.MustParseAddr("::1")
176+ if g.Validate() == nil {
177+ t.Error("IPv6 guest accepted")
178+ }
179+}
180+
181+func TestManager(t *testing.T) {
182+ f := &cmdrun.Fake{}
183+ m := NewManager(f, "nft")
184+ ctx := context.Background()
185+ if err := m.Add(ctx, guest(t, "ertap0", 0)); err != nil {
186+ t.Fatal(err)
187+ }
188+ if err := m.Add(ctx, guest(t, "ertap1", 1)); err != nil {
189+ t.Fatal(err)
190+ }
191+ if err := m.Remove(ctx, "ertap0"); err != nil {
192+ t.Fatal(err)
193+ }
194+ if err := m.Remove(ctx, "unknown"); err != nil {
195+ t.Fatal(err)
196+ }
197+ if len(f.Calls) != 3 {
198+ t.Fatalf("want 3 nft calls (unknown iface is a no-op), got %v", f.Lines())
199+ }
200+ last := f.Calls[2]
201+ if last.Name != "nft" || strings.Join(last.Args, " ") != "-f -" {
202+ t.Fatalf("bad command %v", last)
203+ }
204+ if strings.Contains(last.Stdin, "ertap0") || !strings.Contains(last.Stdin, "ertap1") {
205+ t.Fatalf("after removing ertap0 only ertap1 must remain:\n%s", last.Stdin)
206+ }
207+ if err := m.Remove(ctx, "ertap1"); err != nil {
208+ t.Fatal(err)
209+ }
210+ if got := f.Calls[3].Stdin; got != Ruleset(nil) {
211+ t.Fatalf("last removal must delete the table, got:\n%s", got)
212+ }
213+ if err := m.Reset(ctx); err != nil {
214+ t.Fatal(err)
215+ }
216+}
217+
218+func TestManagerRollsBackOnError(t *testing.T) {
219+ boom := errors.New("boom")
220+ f := &cmdrun.Fake{Handle: func(cmdrun.Call) ([]byte, error) { return nil, boom }}
221+ m := NewManager(f, "")
222+ if err := m.Add(context.Background(), guest(t, "ertap0", 0)); !errors.Is(err, boom) {
223+ t.Fatalf("got %v", err)
224+ }
225+ f.Handle = nil
226+ if err := m.Remove(context.Background(), "ertap0"); err != nil {
227+ t.Fatal(err)
228+ }
229+ if len(f.Calls) != 1 {
230+ t.Fatalf("a guest whose Add failed must not be tracked: %v", f.Lines())
231+ }
232+}
233+
234+var _ io.Reader = strings.NewReader("")
A internal/netpolicy/testdata/empty.nft +2 -0
@@ -0,0 +1,2 @@
1+table inet ermite_runner
2+delete table inet ermite_runner
A internal/netpolicy/testdata/one.nft +38 -0
@@ -0,0 +1,38 @@
1+table inet ermite_runner
2+delete table inet ermite_runner
3+table inet ermite_runner {
4+ set blocked_v4 {
5+ type ipv4_addr
6+ flags interval
7+ elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 }
8+ }
9+ chain input {
10+ type filter hook input priority filter; policy accept;
11+ iifname "ertap*" meta nfproto ipv6 drop
12+ iifname "erbr*" meta nfproto ipv6 drop
13+ ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop
14+ ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop
15+ iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 udp dport 53 accept
16+ iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 53 accept
17+ iifname "ertap0" drop
18+ iifname "ertap*" drop
19+ iifname "erbr*" drop
20+ }
21+ chain forward {
22+ type filter hook forward priority filter; policy accept;
23+ iifname "ertap*" meta nfproto ipv6 drop
24+ iifname "erbr*" meta nfproto ipv6 drop
25+ iifname "ertap0" ip saddr != 10.200.0.2 drop
26+ iifname "ertap0" ip daddr @blocked_v4 drop
27+ iifname != { "ertap*", "erbr*" } oifname { "ertap*", "erbr*" } ct state established,related accept
28+ oifname "ertap*" drop
29+ oifname "erbr*" drop
30+ iifname "ertap0" accept
31+ iifname "ertap*" drop
32+ iifname "erbr*" drop
33+ }
34+ chain postrouting {
35+ type nat hook postrouting priority srcnat; policy accept;
36+ ip saddr 10.200.0.2 oifname != "ertap0" masquerade
37+ }
38+}
A internal/netpolicy/testdata/two.nft +47 -0
@@ -0,0 +1,47 @@
1+table inet ermite_runner
2+delete table inet ermite_runner
3+table inet ermite_runner {
4+ set blocked_v4 {
5+ type ipv4_addr
6+ flags interval
7+ elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 }
8+ }
9+ chain input {
10+ type filter hook input priority filter; policy accept;
11+ iifname "ertap*" meta nfproto ipv6 drop
12+ iifname "erbr*" meta nfproto ipv6 drop
13+ ip daddr 10.200.0.5 udp dport 53 iifname != "erbr1" drop
14+ ip daddr 10.200.0.5 tcp dport 53 iifname != "erbr1" drop
15+ ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop
16+ ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop
17+ iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 udp dport 53 accept
18+ iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 tcp dport 53 accept
19+ iifname "erbr1" drop
20+ iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 udp dport 53 accept
21+ iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 53 accept
22+ iifname "ertap0" drop
23+ iifname "ertap*" drop
24+ iifname "erbr*" drop
25+ }
26+ chain forward {
27+ type filter hook forward priority filter; policy accept;
28+ iifname "ertap*" meta nfproto ipv6 drop
29+ iifname "erbr*" meta nfproto ipv6 drop
30+ iifname "erbr1" ip saddr != 10.200.0.6 drop
31+ iifname "erbr1" ip daddr @blocked_v4 drop
32+ iifname "ertap0" ip saddr != 10.200.0.2 drop
33+ iifname "ertap0" ip daddr @blocked_v4 drop
34+ iifname != { "ertap*", "erbr*" } oifname { "ertap*", "erbr*" } ct state established,related accept
35+ oifname "ertap*" drop
36+ oifname "erbr*" drop
37+ iifname "erbr1" accept
38+ iifname "ertap0" accept
39+ iifname "ertap*" drop
40+ iifname "erbr*" drop
41+ }
42+ chain postrouting {
43+ type nat hook postrouting priority srcnat; policy accept;
44+ ip saddr 10.200.0.6 oifname != "erbr1" masquerade
45+ ip saddr 10.200.0.2 oifname != "ertap0" masquerade
46+ }
47+}
A internal/sandbox/config.go +322 -0
@@ -0,0 +1,322 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "net/netip"
9+ "os"
10+ "path/filepath"
11+ "runtime"
12+ "strconv"
13+ "strings"
14+)
15+
16+// Environment variables read by LoadConfig. The other ERMITE_RUNNER_* variables
17+// (URL, NAME, LABELS, ...) belong to the host process, not to the sandboxes.
18+const (
19+ EnvIsolation = "ERMITE_RUNNER_ISOLATION"
20+ EnvStateDir = "ERMITE_RUNNER_STATE_DIR"
21+ EnvCapacity = "ERMITE_RUNNER_CAPACITY"
22+ EnvImage = "ERMITE_RUNNER_IMAGE"
23+ EnvJobCPUs = "ERMITE_RUNNER_JOB_CPUS"
24+ EnvJobMemory = "ERMITE_RUNNER_JOB_MEMORY"
25+ EnvJobDisk = "ERMITE_RUNNER_JOB_DISK"
26+ EnvUIDBase = "ERMITE_RUNNER_UID_BASE"
27+ EnvNetwork = "ERMITE_RUNNER_NETWORK"
28+ EnvFirecracker = "ERMITE_RUNNER_FIRECRACKER"
29+ EnvJailer = "ERMITE_RUNNER_JAILER"
30+ EnvJailerMode = "ERMITE_RUNNER_JAILER_MODE"
31+ EnvKernel = "ERMITE_RUNNER_KERNEL"
32+
33+ EnvJobPids = "ERMITE_RUNNER_JOB_PIDS"
34+ EnvDenyPrivileged = "ERMITE_RUNNER_DENY_PRIVILEGED"
35+ EnvLockFile = "ERMITE_RUNNER_LOCK_FILE"
36+ EnvJobDiskBW = "ERMITE_RUNNER_JOB_DISK_BANDWIDTH"
37+ EnvJobDiskIOPS = "ERMITE_RUNNER_JOB_DISK_IOPS"
38+ EnvJobNetBandwidth = "ERMITE_RUNNER_JOB_NET_BANDWIDTH"
39+)
40+
41+// Defaults.
42+const (
43+ DefaultStateDir = "/var/lib/ermite-runner"
44+ DefaultOCIImage = "ermite-runner-ubuntu-24.04:latest"
45+ DefaultUIDBase = 900000
46+ DefaultNetwork = "10.200.0.0/16"
47+ DefaultCPUs = 2
48+ DefaultMemoryMiB = 4096
49+ DefaultDiskMiB = 64 * 1024
50+ DefaultJobPids = 32768
51+ // DefaultLockFile is the single-instance lock of the host: reaping removes
52+ // everything the runner labels as its own, so two runners (even with
53+ // different state directories, or one in a container) must never coexist.
54+ DefaultLockFile = "/run/ermite-runner.lock"
55+ // Rate limits of a micro-VM, generous so that they only stop a runaway job
56+ // from starving the host: 400 MiB/s and 20000 IOPS per disk, 1 Gbit/s per
57+ // network direction. Zero disables a limit.
58+ DefaultDiskBandwidthMiB = 400
59+ DefaultDiskIOPS = 20000
60+ DefaultNetMbit = 1000
61+)
62+
63+// Config is the sandbox part of the runner configuration.
64+type Config struct {
65+ Isolation Mode
66+ StateDir string
67+ Capacity int
68+
69+ // Image is ERMITE_RUNNER_IMAGE: a directory (VM: holds the kernel and
70+ // base.ext4) or an OCI reference (container). A value that is empty or does
71+ // not fit the selected mode falls back to the default for that mode.
72+ Image string
73+
74+ JobCPUs int
75+ JobMemoryMiB int
76+ JobDiskMiB int
77+
78+ // UIDBase+slot is the uid and gid the jailer drops to.
79+ UIDBase int
80+ // Network is the pool of /30 subnets, one per slot (VM TAPs, container
81+ // bridges).
82+ Network netip.Prefix
83+
84+ // Firecracker and Jailer are binary paths; empty means look in PATH.
85+ Firecracker string
86+ Jailer string
87+ // Kernel overrides <image dir>/vmlinux (Image on arm64).
88+ Kernel string
89+ // JailerOff runs Firecracker without the jailer (development and tests as
90+ // an unprivileged user). Never use it in production: it only drops the
91+ // chroot, uid and namespace confinement of the VMM process.
92+ JailerOff bool
93+
94+ // KVMPath is the KVM device; tests point it elsewhere to hide /dev/kvm.
95+ KVMPath string
96+ // Executable is attached read-only to every sandbox. Empty means
97+ // os.Executable(). It must be a static binary (CGO_ENABLED=0).
98+ Executable string
99+ // DNSPort is the port of the DNS forwarder; 53 in production. Tests that
100+ // cannot bind it use another port with no guest.
101+ DNSPort int
102+
103+ // JobPids is the process limit of a job (container PidsLimit, VM pids.max
104+ // is derived from the vCPU count and is not affected).
105+ JobPids int
106+ // DenyPrivileged refuses the container mode when it would have to run
107+ // privileged (no sysbox).
108+ DenyPrivileged bool
109+ // LockFile is the host-wide single-instance lock.
110+ LockFile string
111+ // Rate limits of micro-VMs; zero disables.
112+ DiskBandwidthMiB, DiskIOPS, NetMbit int
113+}
114+
115+// LoadConfig reads the sandbox settings from getenv (os.Getenv in production).
116+func LoadConfig(getenv func(string) string) (Config, error) {
117+ c := Config{
118+ Isolation: Auto,
119+ StateDir: DefaultStateDir,
120+ Capacity: 1,
121+ JobCPUs: DefaultCPUs,
122+ JobMemoryMiB: DefaultMemoryMiB,
123+ JobDiskMiB: DefaultDiskMiB,
124+ UIDBase: DefaultUIDBase,
125+ KVMPath: "/dev/kvm",
126+ DNSPort: 53,
127+ JobPids: DefaultJobPids,
128+ LockFile: DefaultLockFile,
129+
130+ DiskBandwidthMiB: DefaultDiskBandwidthMiB,
131+ DiskIOPS: DefaultDiskIOPS,
132+ NetMbit: DefaultNetMbit,
133+ }
134+ var err error
135+ if v := getenv(EnvIsolation); v != "" {
136+ switch m := Mode(strings.ToLower(strings.TrimSpace(v))); m {
137+ case Auto, VM, Container:
138+ c.Isolation = m
139+ default:
140+ return c, fmt.Errorf("%s: %q is not auto, vm or container", EnvIsolation, v)
141+ }
142+ }
143+ if v := getenv(EnvStateDir); v != "" {
144+ c.StateDir = v
145+ }
146+ if !filepath.IsAbs(c.StateDir) {
147+ return c, fmt.Errorf("%s: %q must be an absolute path", EnvStateDir, c.StateDir)
148+ }
149+ if c.Capacity, err = intEnv(getenv, EnvCapacity, c.Capacity, 1, 1024); err != nil {
150+ return c, err
151+ }
152+ c.Image = getenv(EnvImage)
153+ if c.JobCPUs, err = intEnv(getenv, EnvJobCPUs, c.JobCPUs, 1, 128); err != nil {
154+ return c, err
155+ }
156+ if v := getenv(EnvJobMemory); v != "" {
157+ if c.JobMemoryMiB, err = ParseSizeMiB(v); err != nil || c.JobMemoryMiB < 256 {
158+ return c, fmt.Errorf("%s: %q: need a size of at least 256M", EnvJobMemory, v)
159+ }
160+ }
161+ if v := getenv(EnvJobDisk); v != "" {
162+ if c.JobDiskMiB, err = ParseSizeMiB(v); err != nil || c.JobDiskMiB < 1024 {
163+ return c, fmt.Errorf("%s: %q: need a size of at least 1G", EnvJobDisk, v)
164+ }
165+ }
166+ if c.UIDBase, err = intEnv(getenv, EnvUIDBase, c.UIDBase, 1000, 4_000_000_000); err != nil {
167+ return c, err
168+ }
169+ if c.UIDBase+c.Capacity > 4_294_967_294 {
170+ return c, fmt.Errorf("%s: %d + capacity overflows the uid range", EnvUIDBase, c.UIDBase)
171+ }
172+ net := DefaultNetwork
173+ if v := getenv(EnvNetwork); v != "" {
174+ net = v
175+ }
176+ if c.Network, err = netip.ParsePrefix(net); err != nil || !c.Network.Addr().Is4() || c.Network.Bits() > 30 {
177+ return c, fmt.Errorf("%s: %q is not an IPv4 prefix of at most /30", EnvNetwork, net)
178+ }
179+ if 1<<(30-c.Network.Bits()) < c.Capacity {
180+ return c, fmt.Errorf("%s: %s has fewer /30 subnets than the capacity %d", EnvNetwork, c.Network, c.Capacity)
181+ }
182+ if c.JobPids, err = intEnv(getenv, EnvJobPids, c.JobPids, 64, 4_194_304); err != nil {
183+ return c, err
184+ }
185+ switch strings.ToLower(strings.TrimSpace(getenv(EnvDenyPrivileged))) {
186+ case "", "0", "false", "no":
187+ case "1", "true", "yes":
188+ c.DenyPrivileged = true
189+ default:
190+ return c, fmt.Errorf("%s: %q is not 0 or 1", EnvDenyPrivileged, getenv(EnvDenyPrivileged))
191+ }
192+ if v := getenv(EnvLockFile); v != "" {
193+ if !filepath.IsAbs(v) {
194+ return c, fmt.Errorf("%s: %q must be an absolute path", EnvLockFile, v)
195+ }
196+ c.LockFile = v
197+ }
198+ if c.DiskBandwidthMiB, err = intEnv(getenv, EnvJobDiskBW, c.DiskBandwidthMiB, 0, 1_000_000); err != nil {
199+ return c, err
200+ }
201+ if c.DiskIOPS, err = intEnv(getenv, EnvJobDiskIOPS, c.DiskIOPS, 0, 100_000_000); err != nil {
202+ return c, err
203+ }
204+ if c.NetMbit, err = intEnv(getenv, EnvJobNetBandwidth, c.NetMbit, 0, 1_000_000); err != nil {
205+ return c, err
206+ }
207+ c.Firecracker = getenv(EnvFirecracker)
208+ c.Jailer = getenv(EnvJailer)
209+ c.Kernel = getenv(EnvKernel)
210+ switch strings.ToLower(getenv(EnvJailerMode)) {
211+ case "", "on":
212+ case "off":
213+ c.JailerOff = true
214+ default:
215+ return c, fmt.Errorf("%s: %q is not on or off", EnvJailerMode, getenv(EnvJailerMode))
216+ }
217+ return c, nil
218+}
219+
220+func intEnv(getenv func(string) string, key string, def, lo, hi int) (int, error) {
221+ v := getenv(key)
222+ if v == "" {
223+ return def, nil
224+ }
225+ n, err := strconv.Atoi(strings.TrimSpace(v))
226+ if err != nil || n < lo || n > hi {
227+ return 0, fmt.Errorf("%s: %q is not an integer in [%d, %d]", key, v, lo, hi)
228+ }
229+ return n, nil
230+}
231+
232+// ParseSizeMiB parses "4096", "512M", "4G", "64GiB", "1T" into MiB. A bare
233+// number is MiB. Suffixes are binary (1G = 1024 MiB) and case-insensitive.
234+func ParseSizeMiB(s string) (int, error) {
235+ s = strings.ToLower(strings.TrimSpace(s))
236+ s = strings.TrimSuffix(strings.TrimSuffix(s, "b"), "i")
237+ mult := 1
238+ if s != "" {
239+ switch s[len(s)-1] {
240+ case 'k':
241+ mult, s = 0, s[:len(s)-1]
242+ case 'm':
243+ mult, s = 1, s[:len(s)-1]
244+ case 'g':
245+ mult, s = 1024, s[:len(s)-1]
246+ case 't':
247+ mult, s = 1024*1024, s[:len(s)-1]
248+ }
249+ }
250+ n, err := strconv.Atoi(s)
251+ if err != nil || n <= 0 || mult == 0 {
252+ return 0, errors.New("invalid size")
253+ }
254+ if n > (1<<31-1)/mult {
255+ return 0, errors.New("size too large")
256+ }
257+ return n * mult, nil
258+}
259+
260+// ImageDir returns the VM image directory: Image when it is a directory path,
261+// else <StateDir>/image.
262+func (c Config) ImageDir() string {
263+ if c.Image != "" && looksLikePath(c.Image) {
264+ return c.Image
265+ }
266+ return filepath.Join(c.StateDir, "image")
267+}
268+
269+// OCIImage returns the container image reference: Image when it is not a
270+// directory path, else DefaultOCIImage.
271+func (c Config) OCIImage() string {
272+ if c.Image != "" && !looksLikePath(c.Image) {
273+ return c.Image
274+ }
275+ return DefaultOCIImage
276+}
277+
278+func looksLikePath(s string) bool {
279+ return strings.HasPrefix(s, "/") || strings.HasPrefix(s, "./") || strings.HasPrefix(s, "../")
280+}
281+
282+// KernelPath returns the guest kernel: Kernel, else <ImageDir>/vmlinux (x86-64)
283+// or <ImageDir>/Image (arm64).
284+func (c Config) KernelPath() string {
285+ if c.Kernel != "" {
286+ return c.Kernel
287+ }
288+ name := "vmlinux"
289+ if runtime.GOARCH == "arm64" {
290+ name = "Image"
291+ }
292+ return filepath.Join(c.ImageDir(), name)
293+}
294+
295+// BaseImagePath returns the shared read-only base disk.
296+func (c Config) BaseImagePath() string { return filepath.Join(c.ImageDir(), "base.ext4") }
297+
298+// ExecutablePath returns the binary attached to sandboxes.
299+func (c Config) ExecutablePath() (string, error) {
300+ if c.Executable != "" {
301+ return c.Executable, nil
302+ }
303+ p, err := os.Executable()
304+ if err != nil {
305+ return "", fmt.Errorf("sandbox: locate own executable: %w", err)
306+ }
307+ return filepath.EvalSymlinks(p)
308+}
309+
310+// Resolve fills the zero resource fields of a JobSpec from the config.
311+func (c Config) Resolve(s JobSpec) JobSpec {
312+ if s.CPUs <= 0 {
313+ s.CPUs = c.JobCPUs
314+ }
315+ if s.MemoryMiB <= 0 {
316+ s.MemoryMiB = c.JobMemoryMiB
317+ }
318+ if s.DiskMiB <= 0 {
319+ s.DiskMiB = c.JobDiskMiB
320+ }
321+ return s
322+}
A internal/sandbox/container/container_test.go +1012 -0
@@ -0,0 +1,1012 @@
1+// SPDX-License-Identifier: MIT
2+
3+package container
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "net"
11+ "net/netip"
12+ "os"
13+ "path/filepath"
14+ "slices"
15+ "strings"
16+ "sync"
17+ "testing"
18+ "time"
19+
20+ "github.com/bnema/zerowrap"
21+ "github.com/moby/moby/api/types/container"
22+
23+ "bnema.dev/ermite-runner/internal/cmdrun"
24+ "bnema.dev/ermite-runner/internal/execstream"
25+ "bnema.dev/ermite-runner/internal/execstream/execpb"
26+ "bnema.dev/ermite-runner/internal/sandbox"
27+ "bnema.dev/ermite-runner/internal/sandbox/disk"
28+ "bnema.dev/ermite-runner/internal/sandbox/sandboxtest"
29+)
30+
31+// fakeEngine plays the Docker daemon. When a container "starts", it plays the
32+// executor inside it by connecting to the bind-mounted socket.
33+type fakeEngine struct {
34+ mu sync.Mutex
35+ info daemonInfo
36+ infoErr error
37+ imageErr error
38+ networks map[string]networkSpec
39+ containers map[string]containerSpec
40+ failStart error
41+ noConnect bool
42+ exitOnRun bool
43+ removeErr error
44+ waiters map[string]chan exitResult
45+ seq int
46+ pulled []string
47+}
48+
49+func newFake() *fakeEngine {
50+ return &fakeEngine{
51+ info: daemonInfo{OSType: "linux", Runtimes: []string{"runc"}},
52+ networks: map[string]networkSpec{},
53+ containers: map[string]containerSpec{},
54+ waiters: map[string]chan exitResult{},
55+ }
56+}
57+
58+func (f *fakeEngine) Info(context.Context) (daemonInfo, error) { return f.info, f.infoErr }
59+func (f *fakeEngine) EnsureImage(_ context.Context, ref string) error {
60+ f.pulled = append(f.pulled, ref)
61+ return f.imageErr
62+}
63+func (f *fakeEngine) CreateNetwork(_ context.Context, s networkSpec) (string, error) {
64+ f.mu.Lock()
65+ defer f.mu.Unlock()
66+ f.seq++
67+ id := fmt.Sprintf("net%d", f.seq)
68+ f.networks[id] = s
69+ return id, nil
70+}
71+func (f *fakeEngine) RemoveNetwork(_ context.Context, id string) error {
72+ f.mu.Lock()
73+ defer f.mu.Unlock()
74+ delete(f.networks, id)
75+ return nil
76+}
77+func (f *fakeEngine) ListNetworks(context.Context, string) ([]string, error) {
78+ f.mu.Lock()
79+ defer f.mu.Unlock()
80+ var out []string
81+ for id, n := range f.networks {
82+ if _, ok := n.Labels[sandbox.LabelJob]; ok {
83+ out = append(out, id)
84+ }
85+ }
86+ return out, nil
87+}
88+func (f *fakeEngine) CreateContainer(_ context.Context, s containerSpec) (string, error) {
89+ f.mu.Lock()
90+ defer f.mu.Unlock()
91+ f.seq++
92+ id := fmt.Sprintf("ctr%d", f.seq)
93+ f.containers[id] = s
94+ f.waiters[id] = make(chan exitResult, 1)
95+ return id, nil
96+}
97+func (f *fakeEngine) StartContainer(_ context.Context, id string) error {
98+ f.mu.Lock()
99+ spec := f.containers[id]
100+ fail, noConnect, exitOnRun := f.failStart, f.noConnect, f.exitOnRun
101+ w := f.waiters[id]
102+ f.mu.Unlock()
103+ if fail != nil {
104+ return fail
105+ }
106+ if exitOnRun {
107+ w <- exitResult{Code: 125}
108+ return nil
109+ }
110+ if noConnect {
111+ return nil
112+ }
113+ // The host side of the bind mount is the source of the run-dir mount.
114+ var sock string
115+ for _, b := range spec.Binds {
116+ if b.Target == sandbox.ContainerRunDir {
117+ sock = filepath.Join(b.Source, sandbox.ContainerSocket)
118+ }
119+ }
120+ go func() {
121+ c, err := net.Dial("unix", sock)
122+ if err != nil {
123+ return
124+ }
125+ defer func() { _ = c.Close() }()
126+ g := execstream.NewConn(c)
127+ _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{Sandbox: "container"}}})
128+ if _, err := g.Recv(); err != nil {
129+ return
130+ }
131+ _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Output{Output: &execpb.Output{Data: []byte("hello from fake container\n")}}})
132+ _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{Code: 7}}})
133+ _, _ = g.Recv()
134+ }()
135+ return nil
136+}
137+func (f *fakeEngine) WaitExit(_ context.Context, id string) <-chan exitResult {
138+ f.mu.Lock()
139+ defer f.mu.Unlock()
140+ return f.waiters[id]
141+}
142+func (f *fakeEngine) RemoveContainer(_ context.Context, id string) error {
143+ f.mu.Lock()
144+ defer f.mu.Unlock()
145+ if f.removeErr != nil {
146+ return f.removeErr
147+ }
148+ delete(f.containers, id)
149+ return nil
150+}
151+func (f *fakeEngine) ListContainers(context.Context, string) ([]string, error) {
152+ f.mu.Lock()
153+ defer f.mu.Unlock()
154+ var out []string
155+ for id := range f.containers {
156+ out = append(out, id)
157+ }
158+ return out, nil
159+}
160+func (f *fakeEngine) LogsTail(context.Context, string, int) (string, error) {
161+ return "fake container log line", nil
162+}
163+func (f *fakeEngine) Close() error { return nil }
164+
165+func (f *fakeEngine) objects() []string {
166+ f.mu.Lock()
167+ defer f.mu.Unlock()
168+ var out []string
169+ for id := range f.containers {
170+ out = append(out, "container "+id)
171+ }
172+ for id := range f.networks {
173+ out = append(out, "network "+id)
174+ }
175+ return out
176+}
177+
178+// fakeMounts plays mount/umount: it records mounts and writes a mountinfo file
179+// that disk.Mounter reads.
180+type fakeMounts struct {
181+ mu sync.Mutex
182+ mounted map[string]bool
183+ file string
184+ failUm error
185+}
186+
187+func (m *fakeMounts) sync() {
188+ var b strings.Builder
189+ for d := range m.mounted {
190+ fmt.Fprintf(&b, "40 28 7:0 / %s rw - ext4 /dev/loop0 rw\n", d)
191+ }
192+ _ = os.WriteFile(m.file, []byte(b.String()), 0o644)
193+}
194+
195+func (m *fakeMounts) handle(c cmdrun.Call) ([]byte, error) {
196+ m.mu.Lock()
197+ defer m.mu.Unlock()
198+ switch c.Name {
199+ case "mkfs.ext4":
200+ if len(c.Args) == 1 && c.Args[0] == "-V" {
201+ return []byte("mke2fs 1.47.4 (6-Mar-2025)\n"), nil
202+ }
203+ case "mount":
204+ m.mounted[c.Args[len(c.Args)-1]] = true
205+ m.sync()
206+ case "umount":
207+ if m.failUm != nil {
208+ return nil, m.failUm
209+ }
210+ delete(m.mounted, c.Args[0])
211+ m.sync()
212+ }
213+ return nil, nil
214+}
215+
216+func (m *fakeMounts) count() int { m.mu.Lock(); defer m.mu.Unlock(); return len(m.mounted) }
217+
218+type cenv struct {
219+ cfg sandbox.Config
220+ eng *fakeEngine
221+ plumb *sandboxtest.Plumbing
222+ mounts *fakeMounts
223+ run *cmdrun.Fake
224+}
225+
226+func newCEnv(t *testing.T) *cenv {
227+ t.Helper()
228+ // A short directory: the executor socket path is limited to 107 bytes and
229+ // t.TempDir embeds the (long) test name.
230+ dir, err := os.MkdirTemp("", "erc")
231+ if err != nil {
232+ t.Fatal(err)
233+ }
234+ t.Cleanup(func() { _ = os.RemoveAll(dir) })
235+ exe := filepath.Join(dir, "runner")
236+ sandboxtest.WriteStaticELF(t, exe)
237+ fm := &fakeMounts{mounted: map[string]bool{}, file: filepath.Join(dir, "mountinfo")}
238+ fm.sync()
239+ return &cenv{
240+ eng: newFake(),
241+ plumb: sandboxtest.NewPlumbing(),
242+ mounts: fm,
243+ run: &cmdrun.Fake{Handle: fm.handle},
244+ cfg: sandbox.Config{
245+ Isolation: sandbox.Container, StateDir: filepath.Join(dir, "state"), Capacity: 2,
246+ JobCPUs: 2, JobMemoryMiB: 1024, JobDiskMiB: 1024, UIDBase: 900000,
247+ Network: netip.MustParsePrefix("10.200.0.0/16"), Executable: exe, DNSPort: 0,
248+ LockFile: filepath.Join(dir, "host.lock"), JobPids: 32768,
249+ },
250+ }
251+}
252+
253+func (e *cenv) opts() Options {
254+ return Options{engine: e.eng, Net: e.plumb, ConnectTimeout: 3 * time.Second, Run: e.run,
255+ Mounter: &disk.Mounter{Run: e.run, Mountinfo: e.mounts.file}}
256+}
257+
258+func (e *cenv) driver(t *testing.T) *Driver {
259+ t.Helper()
260+ d, err := New(context.Background(), e.cfg, e.opts())
261+ if err != nil {
262+ t.Fatal(err)
263+ }
264+ t.Cleanup(func() { _ = d.Close() })
265+ return d
266+}
267+
268+func withTimeout(o Options, d time.Duration) Options { o.ConnectTimeout = d; return o }
269+
270+func (e *cenv) leftovers() []string {
271+ out := e.eng.objects()
272+ if n := e.mounts.count(); n != 0 {
273+ out = append(out, fmt.Sprintf("%d scratch mounts", n))
274+ }
275+ if n := e.plumb.Live(); n != 0 {
276+ out = append(out, fmt.Sprintf("%d attached guests", n))
277+ }
278+ ents, _ := os.ReadDir(filepath.Join(e.cfg.StateDir, "containers"))
279+ for _, d := range ents {
280+ out = append(out, "dir "+d.Name())
281+ }
282+ return out
283+}
284+
285+func stub(t *testing.T, conn sandbox.Conn) (string, int) {
286+ t.Helper()
287+ var out strings.Builder
288+ _, exit, err := execstream.Drive(conn, &execpb.Run{}, nil, func(o *execpb.Output) { out.Write(o.Data) })
289+ if err != nil {
290+ t.Fatal(err)
291+ }
292+ return out.String(), int(exit.Code)
293+}
294+
295+func TestRootlessDockerIsRefused(t *testing.T) {
296+ e := newCEnv(t)
297+ e.eng.info.Rootless = true
298+ _, err := New(context.Background(), e.cfg, e.opts())
299+ if err == nil || !strings.Contains(err.Error(), "rootless") || !strings.Contains(err.Error(), "rootful") {
300+ t.Fatalf("want a clear rootless error, got %v", err)
301+ }
302+}
303+
304+func TestRuntimeSelection(t *testing.T) {
305+ e := newCEnv(t)
306+ d := e.driver(t)
307+ if !d.Privileged() || d.runtime != "" {
308+ t.Fatal("without sysbox the driver must run privileged")
309+ }
310+ e2 := newCEnv(t)
311+ e2.eng.info.Runtimes = []string{"runc", "sysbox-runc"}
312+ d2 := e2.driver(t)
313+ if d2.Privileged() || d2.runtime != SysboxRuntime {
314+ t.Fatal("sysbox listed: must use it, unprivileged")
315+ }
316+}
317+
318+func TestNewErrors(t *testing.T) {
319+ e := newCEnv(t)
320+ e.eng.infoErr = errors.New("connection refused")
321+ if _, err := New(context.Background(), e.cfg, e.opts()); err == nil || !strings.Contains(err.Error(), "DOCKER_HOST") {
322+ t.Fatalf("got %v", err)
323+ }
324+ e = newCEnv(t)
325+ e.eng.imageErr = errors.New("no such image")
326+ if _, err := New(context.Background(), e.cfg, e.opts()); err == nil {
327+ t.Fatal("missing image accepted")
328+ }
329+ e = newCEnv(t)
330+ e.eng.info.OSType = "windows"
331+ if _, err := New(context.Background(), e.cfg, e.opts()); err == nil {
332+ t.Fatal("windows daemon accepted")
333+ }
334+ e = newCEnv(t)
335+ e.cfg.Executable = "/bin/sh"
336+ if _, err := New(context.Background(), e.cfg, e.opts()); err == nil {
337+ t.Skip("/bin/sh is static")
338+ }
339+}
340+
341+func TestStartRunStop(t *testing.T) {
342+ e := newCEnv(t)
343+ d := e.driver(t)
344+ sb, err := d.New()
345+ if err != nil {
346+ t.Fatal(err)
347+ }
348+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "job-7", CPUs: 3, MemoryMiB: 2048})
349+ if err != nil {
350+ t.Fatal(err)
351+ }
352+ out, code := stub(t, conn)
353+ if out != "hello from fake container\n" || code != 7 {
354+ t.Fatalf("out=%q code=%d", out, code)
355+ }
356+
357+ // What was asked of Docker.
358+ e.eng.mu.Lock()
359+ var spec containerSpec
360+ for _, s := range e.eng.containers {
361+ spec = s
362+ }
363+ var netSpec networkSpec
364+ for _, n := range e.eng.networks {
365+ netSpec = n
366+ }
367+ e.eng.mu.Unlock()
368+ if !spec.Privileged || spec.Runtime != "" {
369+ t.Errorf("privileged fallback expected: %+v", spec)
370+ }
371+ if spec.Labels[sandbox.LabelJob] != "job-7" || netSpec.Labels[sandbox.LabelJob] != "job-7" {
372+ t.Errorf("every object must carry the job label: %v / %v", spec.Labels, netSpec.Labels)
373+ }
374+ if spec.MemoryMiB != 2048 || spec.CPUs != 3 {
375+ t.Errorf("resources: %+v", spec)
376+ }
377+ if !slices.Equal(spec.Entrypoint, []string{"/opt/ermite/ermite-runner", "init"}) {
378+ t.Errorf("entrypoint: %v", spec.Entrypoint)
379+ }
380+ var roExe, runDir bool
381+ for _, b := range spec.Binds {
382+ if b.Target == "/opt/ermite/ermite-runner" && b.ReadOnly && b.Source == e.cfg.Executable {
383+ roExe = true
384+ }
385+ if b.Target == sandbox.ContainerRunDir && !b.ReadOnly {
386+ runDir = true
387+ }
388+ }
389+ if !roExe || !runDir {
390+ t.Errorf("binds: %+v", spec.Binds)
391+ }
392+ var scratch, work, tmp bool
393+ for _, b := range spec.Binds {
394+ switch b.Target {
395+ case sandbox.GuestScratch:
396+ scratch = strings.HasSuffix(b.Source, "/scratch") && !b.ReadOnly
397+ case "/home/runner/work":
398+ work = strings.HasSuffix(b.Source, "/scratch/work")
399+ case "/tmp":
400+ tmp = strings.HasSuffix(b.Source, "/scratch/tmp")
401+ }
402+ }
403+ if !scratch || !work || !tmp {
404+ t.Errorf("scratch, workspace and /tmp must come from the loop-mounted disk: %+v", spec.Binds)
405+ }
406+ if spec.DNS.String() != "10.200.0.1" || spec.IPv4.String() != "10.200.0.2" || netSpec.Bridge != "erbr0" || netSpec.Subnet.String() != "10.200.0.0/30" {
407+ t.Errorf("network: %+v %+v", spec, netSpec)
408+ }
409+ if !slices.Contains(spec.Env, "ERMITE_CONNECT=unix:/run/ermite/exec.sock") {
410+ t.Errorf("env: %v", spec.Env)
411+ }
412+ if e.plumb.Live() != 1 {
413+ t.Fatal("firewall policy not attached")
414+ }
415+
416+ if err := sb.Stop(context.Background()); err != nil {
417+ t.Fatal(err)
418+ }
419+ if left := e.leftovers(); len(left) != 0 {
420+ t.Fatalf("leftovers: %v", left)
421+ }
422+ if err := sb.Stop(context.Background()); err != nil {
423+ t.Fatalf("Stop must be idempotent: %v", err)
424+ }
425+ if d.slots.InUse() != 0 {
426+ t.Fatal("slot not released")
427+ }
428+}
429+
430+func TestSysboxJobIsNotPrivileged(t *testing.T) {
431+ e := newCEnv(t)
432+ e.eng.info.Runtimes = []string{"sysbox-runc", "runc"}
433+ d := e.driver(t)
434+ sb, _ := d.New()
435+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
436+ if err != nil {
437+ t.Fatal(err)
438+ }
439+ stub(t, conn)
440+ e.eng.mu.Lock()
441+ for _, s := range e.eng.containers {
442+ if s.Privileged || s.Runtime != "sysbox-runc" {
443+ t.Errorf("sysbox job: %+v", s)
444+ }
445+ }
446+ e.eng.mu.Unlock()
447+ _ = sb.Stop(context.Background())
448+}
449+
450+func TestStartFailuresCleanEverything(t *testing.T) {
451+ cases := map[string]func(*cenv){
452+ "container cannot start": func(e *cenv) { e.eng.failStart = errors.New("oci runtime error") },
453+ "container exits early": func(e *cenv) { e.eng.exitOnRun = true },
454+ "executor never connects": func(e *cenv) {
455+ e.eng.noConnect = true
456+ },
457+ "firewall attach fails": func(e *cenv) { e.plumb.FailNext = errors.New("nft broke") },
458+ }
459+ for name, setup := range cases {
460+ t.Run(name, func(t *testing.T) {
461+ e := newCEnv(t)
462+ setup(e)
463+ d, err := New(context.Background(), e.cfg, withTimeout(e.opts(), 300*time.Millisecond))
464+ if err != nil {
465+ t.Fatal(err)
466+ }
467+ defer func() { _ = d.Close() }()
468+ sb, _ := d.New()
469+ _, err = sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
470+ if err == nil {
471+ t.Fatal("Start succeeded")
472+ }
473+ if left := e.leftovers(); len(left) != 0 {
474+ t.Fatalf("leftovers: %v (err %v)", left, err)
475+ }
476+ if d.slots.InUse() != 0 {
477+ t.Fatal("slot leaked")
478+ }
479+ })
480+ }
481+}
482+
483+func TestFailedStartIncludesContainerLogs(t *testing.T) {
484+ e := newCEnv(t)
485+ e.eng.exitOnRun = true
486+ d := e.driver(t)
487+ sb, _ := d.New()
488+ _, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
489+ if err == nil || !strings.Contains(err.Error(), "fake container log line") {
490+ t.Fatalf("got %v", err)
491+ }
492+}
493+
494+func TestCleanupFailureKeepsSlotAndIsRetriedByReap(t *testing.T) {
495+ e := newCEnv(t)
496+ d := e.driver(t)
497+ sb, _ := d.New()
498+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
499+ if err != nil {
500+ t.Fatal(err)
501+ }
502+ stub(t, conn)
503+ e.eng.removeErr = errors.New("daemon busy")
504+ if err := sb.Stop(context.Background()); err == nil {
505+ t.Fatal("Stop must report the failed removal")
506+ }
507+ if d.slots.InUse() != 1 {
508+ t.Fatal("the slot must stay taken while its bridge and subnet still exist")
509+ }
510+ e.eng.removeErr = nil
511+ if err := d.Reap(context.Background()); err != nil {
512+ t.Fatal(err)
513+ }
514+ if left := e.leftovers(); len(left) != 0 {
515+ t.Fatalf("Reap left %v", left)
516+ }
517+}
518+
519+func TestReapRemovesLabelledObjectsOnly(t *testing.T) {
520+ e := newCEnv(t)
521+ d := e.driver(t)
522+ ctx := context.Background()
523+ // Leftovers of a crashed run, and an unrelated user's network.
524+ _, _ = e.eng.CreateContainer(ctx, containerSpec{Name: "old", Labels: map[string]string{sandbox.LabelJob: "x"}})
525+ _, _ = e.eng.CreateNetwork(ctx, networkSpec{Name: "old", Labels: map[string]string{sandbox.LabelJob: "x"}})
526+ _, _ = e.eng.CreateNetwork(ctx, networkSpec{Name: "mine", Labels: map[string]string{"app": "other"}})
527+ _ = os.MkdirAll(filepath.Join(e.cfg.StateDir, "containers", "er00112233"), 0o700)
528+ _ = os.MkdirAll(filepath.Join(e.cfg.StateDir, "containers", "keep-me"), 0o700)
529+
530+ if err := d.Reap(ctx); err != nil {
531+ t.Fatal(err)
532+ }
533+ left := e.leftovers()
534+ slices.Sort(left)
535+ if len(left) != 2 || !strings.HasPrefix(left[0], "dir keep-me") || !strings.HasPrefix(left[1], "network") {
536+ t.Fatalf("only the unrelated network and directory may remain: %v", left)
537+ }
538+ if _, err := os.Stat(filepath.Join(e.cfg.StateDir, "containers", "keep-me")); err != nil {
539+ t.Fatal("Reap removed a directory that is not ours")
540+ }
541+ if e.plumb.Resets != 1 {
542+ t.Fatal("nftables table not reset")
543+ }
544+}
545+
546+func TestCapacityAndSlotIsolation(t *testing.T) {
547+ e := newCEnv(t)
548+ d := e.driver(t)
549+ a, _ := d.New()
550+ b, _ := d.New()
551+ if _, err := d.New(); !errors.Is(err, sandbox.ErrCapacity) {
552+ t.Fatalf("got %v", err)
553+ }
554+ for _, sb := range []sandbox.Sandbox{a, b} {
555+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
556+ if err != nil {
557+ t.Fatal(err)
558+ }
559+ stub(t, conn)
560+ }
561+ ifaces := map[string]bool{}
562+ for iface := range e.plumb.Attached {
563+ ifaces[iface] = true
564+ }
565+ if !ifaces["erbr0"] || !ifaces["erbr1"] {
566+ t.Fatalf("each job needs its own bridge: %v", ifaces)
567+ }
568+ _ = a.Stop(context.Background())
569+ _ = b.Stop(context.Background())
570+ if left := e.leftovers(); len(left) != 0 {
571+ t.Fatalf("leftovers: %v", left)
572+ }
573+}
574+
575+func TestStopWithCancelledContext(t *testing.T) {
576+ e := newCEnv(t)
577+ d := e.driver(t)
578+ sb, _ := d.New()
579+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
580+ if err != nil {
581+ t.Fatal(err)
582+ }
583+ stub(t, conn)
584+ ctx, cancel := context.WithCancel(context.Background())
585+ cancel()
586+ if err := sb.Stop(ctx); err != nil {
587+ t.Fatal(err)
588+ }
589+ if left := e.leftovers(); len(left) != 0 {
590+ t.Fatalf("leftovers: %v", left)
591+ }
592+}
593+
594+func TestDoubleStartRefused(t *testing.T) {
595+ e := newCEnv(t)
596+ d := e.driver(t)
597+ sb, _ := d.New()
598+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
599+ if err != nil {
600+ t.Fatal(err)
601+ }
602+ stub(t, conn)
603+ if _, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}); err == nil {
604+ t.Fatal("second Start accepted")
605+ }
606+ _ = sb.Stop(context.Background())
607+}
608+
609+func mustPrefix(s string) netip.Prefix { return netip.MustParsePrefix(s) }
610+
611+func logger(buf *bytes.Buffer) context.Context {
612+ return zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: buf}))
613+}
614+
615+// S4: the privileged fallback is allowed by default, warned about at the start
616+// of EVERY job, reported as the effective isolation, and refused on demand.
617+func TestPrivilegedWarnedAtEveryJobAndReportedAsIsolation(t *testing.T) {
618+ e := newCEnv(t)
619+ var buf bytes.Buffer
620+ ctx := logger(&buf)
621+ d, err := New(ctx, e.cfg, e.opts())
622+ if err != nil {
623+ t.Fatal(err)
624+ }
625+ defer func() { _ = d.Close() }()
626+ if d.Isolation() != sandbox.IsolationContainerPrivileged || d.Mode() != sandbox.Container {
627+ t.Fatalf("isolation %q", d.Isolation())
628+ }
629+ const msg = "privileged container: host-root equivalent, network policy best effort"
630+ if n := strings.Count(buf.String(), msg); n != 1 {
631+ t.Fatalf("one warning at driver start, got %d", n)
632+ }
633+ for i := range 3 {
634+ sb, _ := d.New()
635+ conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: fmt.Sprint("job", i)})
636+ if err != nil {
637+ t.Fatal(err)
638+ }
639+ stub(t, conn)
640+ _ = sb.Stop(ctx)
641+ }
642+ if n := strings.Count(buf.String(), msg); n != 4 {
643+ t.Fatalf("the warning must be logged at every job start (1 + 3), got %d:\n%s", n, buf.String())
644+ }
645+ if !strings.Contains(buf.String(), `"job":"job2"`) {
646+ t.Fatal("the warning must name the job")
647+ }
648+}
649+
650+func TestSysboxIsReportedAndSilent(t *testing.T) {
651+ e := newCEnv(t)
652+ e.eng.info.Runtimes = []string{"runc", "sysbox-runc"}
653+ var buf bytes.Buffer
654+ ctx := logger(&buf)
655+ d, err := New(ctx, e.cfg, e.opts())
656+ if err != nil {
657+ t.Fatal(err)
658+ }
659+ defer func() { _ = d.Close() }()
660+ if d.Isolation() != sandbox.IsolationContainerSysbox {
661+ t.Fatalf("isolation %q", d.Isolation())
662+ }
663+ sb, _ := d.New()
664+ conn, _ := sb.Start(ctx, sandbox.JobSpec{JobID: "j"})
665+ stub(t, conn)
666+ _ = sb.Stop(ctx)
667+ if strings.Contains(buf.String(), "host-root equivalent") {
668+ t.Fatal("no privileged warning with sysbox")
669+ }
670+}
671+
672+func TestDenyPrivileged(t *testing.T) {
673+ e := newCEnv(t)
674+ e.cfg.DenyPrivileged = true
675+ _, err := New(context.Background(), e.cfg, e.opts())
676+ if err == nil || !strings.Contains(err.Error(), "ERMITE_RUNNER_DENY_PRIVILEGED") || !strings.Contains(err.Error(), "sysbox") {
677+ t.Fatalf("got %v", err)
678+ }
679+ // The host lock must be free again after a refused start.
680+ if l, err := sandbox.AcquireHostLock(e.cfg.LockFile); err != nil {
681+ t.Fatalf("a failed New must release the host lock: %v", err)
682+ } else {
683+ _ = l.Release()
684+ }
685+ // With sysbox available the setting does nothing.
686+ e2 := newCEnv(t)
687+ e2.cfg.DenyPrivileged = true
688+ e2.eng.info.Runtimes = []string{"sysbox-runc"}
689+ d, err := New(context.Background(), e2.cfg, e2.opts())
690+ if err != nil {
691+ t.Fatalf("deny-privileged with sysbox must work: %v", err)
692+ }
693+ _ = d.Close()
694+}
695+
696+// S5: pids limit from the config and no swap.
697+func TestHostConfigPidsAndSwap(t *testing.T) {
698+ hc := hostConfigFor(containerSpec{Network: "n", MemoryMiB: 2048, CPUs: 3, Pids: 4096, DNS: netip.MustParseAddr("10.200.0.1")})
699+ if hc.Memory != 2048<<20 || hc.MemorySwap != hc.Memory {
700+ t.Errorf("memory %d swap %d: swap must equal memory (no swap)", hc.Memory, hc.MemorySwap)
701+ }
702+ if hc.PidsLimit == nil || *hc.PidsLimit != 4096 {
703+ t.Errorf("pids limit %v", hc.PidsLimit)
704+ }
705+ if hc.NanoCPUs != 3_000_000_000 {
706+ t.Errorf("cpus %d", hc.NanoCPUs)
707+ }
708+ if hc.Sysctls["net.ipv6.conf.all.disable_ipv6"] != "1" {
709+ t.Error("IPv6 must be disabled in the container")
710+ }
711+ if hostConfigFor(containerSpec{Network: "n"}).PidsLimit != nil {
712+ t.Error("no pids limit configured: leave it unset")
713+ }
714+}
715+
716+func TestJobPidsFlowsFromConfig(t *testing.T) {
717+ e := newCEnv(t)
718+ e.cfg.JobPids = 777
719+ d := e.driver(t)
720+ sb, _ := d.New()
721+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
722+ if err != nil {
723+ t.Fatal(err)
724+ }
725+ stub(t, conn)
726+ defer func() { _ = sb.Stop(context.Background()) }()
727+ e.eng.mu.Lock()
728+ defer e.eng.mu.Unlock()
729+ for _, s := range e.eng.containers {
730+ if s.Pids != 777 {
731+ t.Fatalf("spec pids %d", s.Pids)
732+ }
733+ }
734+}
735+
736+// C4: the disk is enforced by a loop-mounted ext4 file of ERMITE_RUNNER_JOB_DISK,
737+// mounted while the job runs and unmounted (before the files are removed) after.
738+func TestScratchDiskIsLoopMountedAndSized(t *testing.T) {
739+ e := newCEnv(t)
740+ d := e.driver(t)
741+ sb, _ := d.New()
742+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j", DiskMiB: 2048})
743+ if err != nil {
744+ t.Fatal(err)
745+ }
746+ stub(t, conn)
747+ if e.mounts.count() != 1 {
748+ t.Fatalf("mounts: %d", e.mounts.count())
749+ }
750+ var mkfs, mount string
751+ for _, l := range e.run.Lines() {
752+ if strings.HasPrefix(l, "mkfs.ext4") && strings.Contains(l, "scratch.ext4") {
753+ mkfs = l
754+ }
755+ if strings.HasPrefix(l, "mount ") {
756+ mount = l
757+ }
758+ }
759+ if !strings.Contains(mkfs, "ermite-scratch") || !strings.Contains(mount, "loop") || !strings.Contains(mount, "scratch.ext4") {
760+ t.Fatalf("mkfs=%q mount=%q", mkfs, mount)
761+ }
762+ // The scratch file has the configured size (sparse) and is not visible to the job.
763+ matches, _ := filepath.Glob(filepath.Join(e.cfg.StateDir, "containers", sb.ID(), "scratch.ext4"))
764+ if len(matches) != 1 {
765+ t.Fatal("no scratch file")
766+ }
767+ if st, _ := os.Stat(matches[0]); st.Size() != 2048<<20 {
768+ t.Fatalf("size %d", st.Size())
769+ }
770+ e.eng.mu.Lock()
771+ for _, s := range e.eng.containers {
772+ for _, b := range s.Binds {
773+ if strings.HasSuffix(b.Source, "scratch.ext4") || b.Source == filepath.Join(e.cfg.StateDir, "containers", sb.ID()) {
774+ t.Errorf("the job must not see the disk file or the whole run directory: %+v", b)
775+ }
776+ }
777+ }
778+ e.eng.mu.Unlock()
779+ if err := sb.Stop(context.Background()); err != nil {
780+ t.Fatal(err)
781+ }
782+ // Order: umount comes before the directory removal (nothing below a mount is deleted).
783+ lines := e.run.Lines()
784+ if lines[len(lines)-1] != "umount "+filepath.Join(e.cfg.StateDir, "containers", sb.ID(), "scratch") {
785+ t.Fatalf("last command: %s", lines[len(lines)-1])
786+ }
787+ if left := e.leftovers(); len(left) != 0 {
788+ t.Fatalf("leftovers: %v", left)
789+ }
790+}
791+
792+// C5: when the container cannot be removed it may still run: keep the firewall
793+// policy, the bridge, the mount and the files; report ErrLeaked; keep the slot;
794+// let Reap finish.
795+func TestStopKeepsPolicyWhenTheContainerMayStillRun(t *testing.T) {
796+ e := newCEnv(t)
797+ d := e.driver(t)
798+ sb, _ := d.New()
799+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
800+ if err != nil {
801+ t.Fatal(err)
802+ }
803+ stub(t, conn)
804+ e.eng.removeErr = errors.New("daemon busy")
805+ err = sb.Stop(context.Background())
806+ if !errors.Is(err, sandbox.ErrLeaked) {
807+ t.Fatalf("want ErrLeaked, got %v", err)
808+ }
809+ if e.plumb.Live() != 1 {
810+ t.Fatal("the firewall policy must stay while the container may run")
811+ }
812+ e.eng.mu.Lock()
813+ nets := len(e.eng.networks)
814+ e.eng.mu.Unlock()
815+ if nets != 1 || e.mounts.count() != 1 {
816+ t.Fatalf("the bridge and the scratch mount must stay: nets=%d mounts=%d", nets, e.mounts.count())
817+ }
818+ if _, err := os.Stat(filepath.Join(e.cfg.StateDir, "containers", sb.ID(), "scratch.ext4")); err != nil {
819+ t.Fatal("the scratch file must stay")
820+ }
821+ if err := sb.Stop(context.Background()); !errors.Is(err, sandbox.ErrLeaked) {
822+ t.Fatalf("second Stop: %v", err)
823+ }
824+ // C8: the slot is taken, a new sandbox gets another slot, Reap is busy while it runs.
825+ other, err := d.New()
826+ if err != nil {
827+ t.Fatal(err)
828+ }
829+ if err := d.Reap(context.Background()); !errors.Is(err, sandbox.ErrBusy) {
830+ t.Fatalf("Reap while a sandbox exists: %v", err)
831+ }
832+ _ = other.Stop(context.Background())
833+ e.eng.removeErr = nil
834+ if err := d.Reap(context.Background()); err != nil {
835+ t.Fatal(err)
836+ }
837+ if left := e.leftovers(); len(left) != 0 {
838+ t.Fatalf("leftovers after Reap: %v", left)
839+ }
840+ if d.slots.InUse() != 0 {
841+ t.Fatal("Reap must free the leaked slot")
842+ }
843+}
844+
845+// A scratch disk that cannot be unmounted keeps its files: nothing is deleted
846+// below a live mount.
847+func TestStopNeverDeletesBelowALiveMount(t *testing.T) {
848+ e := newCEnv(t)
849+ d := e.driver(t)
850+ sb, _ := d.New()
851+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
852+ if err != nil {
853+ t.Fatal(err)
854+ }
855+ stub(t, conn)
856+ e.mounts.failUm = errors.New("target is busy")
857+ if err := sb.Stop(context.Background()); !errors.Is(err, sandbox.ErrLeaked) {
858+ t.Fatalf("got %v", err)
859+ }
860+ if _, err := os.Stat(filepath.Join(e.cfg.StateDir, "containers", sb.ID(), "scratch.ext4")); err != nil {
861+ t.Fatal("files below a mount that is still there were deleted")
862+ }
863+ e.mounts.failUm = nil
864+ if err := d.Reap(context.Background()); err != nil {
865+ t.Fatal(err)
866+ }
867+ if e.mounts.count() != 0 {
868+ t.Fatal("Reap must unmount leftover scratch disks")
869+ }
870+ if left := e.leftovers(); len(left) != 0 {
871+ t.Fatalf("leftovers: %v", left)
872+ }
873+}
874+
875+// C7: Stop aborts a boot in progress instead of waiting for the timeout.
876+func TestStopAbortsABootInProgress(t *testing.T) {
877+ e := newCEnv(t)
878+ e.eng.noConnect = true
879+ o := e.opts()
880+ o.ConnectTimeout = time.Minute
881+ d, err := New(context.Background(), e.cfg, o)
882+ if err != nil {
883+ t.Fatal(err)
884+ }
885+ defer func() { _ = d.Close() }()
886+ sb, _ := d.New()
887+ started := make(chan error, 1)
888+ go func() { _, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}); started <- err }()
889+ time.Sleep(500 * time.Millisecond)
890+ t0 := time.Now()
891+ if err := sb.Stop(context.Background()); err != nil {
892+ t.Fatal(err)
893+ }
894+ select {
895+ case err := <-started:
896+ if !errors.Is(err, sandbox.ErrAborted) {
897+ t.Fatalf("Start must report the abort, got %v", err)
898+ }
899+ case <-time.After(10 * time.Second):
900+ t.Fatal("Start was not aborted")
901+ }
902+ if time.Since(t0) > 5*time.Second {
903+ t.Fatalf("Stop took %v", time.Since(t0))
904+ }
905+ if left := e.leftovers(); len(left) != 0 {
906+ t.Fatalf("leftovers: %v", left)
907+ }
908+}
909+
910+// C16: ID names the Docker objects.
911+func TestSandboxIDNamesDockerObjects(t *testing.T) {
912+ e := newCEnv(t)
913+ d := e.driver(t)
914+ sb, _ := d.New()
915+ if !sandbox.ValidID(sb.ID()) {
916+ t.Fatal(sb.ID())
917+ }
918+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
919+ if err != nil {
920+ t.Fatal(err)
921+ }
922+ stub(t, conn)
923+ defer func() { _ = sb.Stop(context.Background()) }()
924+ e.eng.mu.Lock()
925+ defer e.eng.mu.Unlock()
926+ for _, s := range e.eng.containers {
927+ if s.Name != "ermite-"+sb.ID() {
928+ t.Fatalf("container name %q", s.Name)
929+ }
930+ }
931+}
932+
933+// S3: a second runner on the same host is refused whatever its state directory.
934+func TestHostLockAcrossStateDirs(t *testing.T) {
935+ e := newCEnv(t)
936+ d := e.driver(t)
937+ _ = d
938+ e2 := newCEnv(t)
939+ e2.cfg.LockFile = e.cfg.LockFile // same host, another state directory
940+ _, err := New(context.Background(), e2.cfg, e2.opts())
941+ if err == nil || !strings.Contains(err.Error(), "another ermite-runner is running on this host") {
942+ t.Fatalf("got %v", err)
943+ }
944+}
945+
946+// C10: a cancelled wait is reported as cancelled, never as a clean exit.
947+func TestAwaitExit(t *testing.T) {
948+ res := make(chan container.WaitResponse, 1)
949+ errc := make(chan error, 1)
950+ res <- container.WaitResponse{StatusCode: 3}
951+ if r := <-awaitExit(context.Background(), res, errc); r.Code != 3 || r.Err != nil {
952+ t.Fatalf("%+v", r)
953+ }
954+ errc <- errors.New("daemon gone")
955+ if r := <-awaitExit(context.Background(), make(chan container.WaitResponse), errc); r.Code != -1 || r.Err == nil {
956+ t.Fatalf("%+v", r)
957+ }
958+ // Both ready and the context already cancelled: the cancellation wins.
959+ ctx, cancel := context.WithCancel(context.Background())
960+ cancel()
961+ for range 50 {
962+ res2 := make(chan container.WaitResponse, 1)
963+ res2 <- container.WaitResponse{StatusCode: 0}
964+ if r := <-awaitExit(ctx, res2, make(chan error)); r.Err == nil || !errors.Is(r.Err, context.Canceled) || r.Code != -1 {
965+ t.Fatalf("a cancelled wait must not look like a clean exit: %+v", r)
966+ }
967+ }
968+}
969+
970+func TestFreeSpaceWarningInContainerMode(t *testing.T) {
971+ e := newCEnv(t)
972+ var buf bytes.Buffer
973+ o := e.opts()
974+ o.FreeSpace = func(string) (int64, error) { return 1, nil }
975+ d, err := New(logger(&buf), e.cfg, o)
976+ if err != nil {
977+ t.Fatal(err)
978+ }
979+ defer func() { _ = d.Close() }()
980+ if !strings.Contains(buf.String(), "less free space") {
981+ t.Fatalf("no warning: %s", buf.String())
982+ }
983+}
984+
985+func TestMkfsTooOldRefusedAtStart(t *testing.T) {
986+ e := newCEnv(t)
987+ run := &cmdrun.Fake{Handle: func(c cmdrun.Call) ([]byte, error) {
988+ if c.Name == "mkfs.ext4" {
989+ return []byte("mke2fs 1.42.9 (4-Feb-2014)\n"), nil
990+ }
991+ return nil, nil
992+ }}
993+ o := e.opts()
994+ o.Run = run
995+ if _, err := New(context.Background(), e.cfg, o); err == nil || !strings.Contains(err.Error(), "1.43") {
996+ t.Fatalf("got %v", err)
997+ }
998+}
999+
1000+func TestSocketPathTooLongIsExplained(t *testing.T) {
1001+ e := newCEnv(t)
1002+ e.cfg.StateDir = filepath.Join(e.cfg.StateDir, strings.Repeat("d", 100))
1003+ d := e.driver(t)
1004+ sb, _ := d.New()
1005+ _, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
1006+ if err == nil || !strings.Contains(err.Error(), "108-byte limit") && !strings.Contains(err.Error(), "limit of a Unix socket") {
1007+ t.Fatalf("got %v", err)
1008+ }
1009+ if left := e.leftovers(); len(left) != 0 {
1010+ t.Fatalf("leftovers: %v", left)
1011+ }
1012+}
A internal/sandbox/container/docker_api_test.go +145 -0
@@ -0,0 +1,145 @@
1+// SPDX-License-Identifier: MIT
2+
3+package container
4+
5+import (
6+ "context"
7+ "io"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "bnema.dev/ermite-runner/internal/cmdrun"
16+ "bnema.dev/ermite-runner/internal/execstream"
17+ "bnema.dev/ermite-runner/internal/execstream/execpb"
18+ "bnema.dev/ermite-runner/internal/sandbox"
19+ "bnema.dev/ermite-runner/internal/sandbox/disk"
20+ "bnema.dev/ermite-runner/internal/sandbox/sandboxtest"
21+)
22+
23+// TestDockerAPIWiring runs the real Docker Engine calls (network with a named
24+// bridge, labelled container, bind mounts, wait, removal, reaping by
25+// label) against whatever daemon DOCKER_HOST points at, including a rootless
26+// one. It does not test isolation (a rootless daemon cannot run the nested
27+// dockerd): the integration test in internal/sandbox/integration does.
28+//
29+// ERMITE_RUNNER_IT_DOCKER_API=1 ERMITE_RUNNER_TEST_IMAGE=<image> go test -run DockerAPIWiring ./internal/sandbox/container
30+func TestDockerAPIWiring(t *testing.T) {
31+ if os.Getenv("ERMITE_RUNNER_IT_DOCKER_API") != "1" {
32+ t.Skip("set ERMITE_RUNNER_IT_DOCKER_API=1 (needs a reachable Docker daemon)")
33+ }
34+ image := os.Getenv("ERMITE_RUNNER_TEST_IMAGE")
35+ if image == "" {
36+ t.Fatal("set ERMITE_RUNNER_TEST_IMAGE to an image with /bin/sh (e.g. the runner image)")
37+ }
38+ if _, err := exec.LookPath("docker"); err != nil {
39+ t.Skip("docker CLI needed for the verification")
40+ }
41+ // A static test executable that plays the executor: the entrypoint of the
42+ // container is "<exe> init", and init runs "<exe> exec"... replaced here by a
43+ // tiny shell executor that speaks the stream through a Go helper binary.
44+ dir := t.TempDir()
45+ helper := filepath.Join(dir, "ermite-runner")
46+ build := exec.Command("go", "build", "-o", helper, "bnema.dev/ermite-runner")
47+ build.Env = append(os.Environ(), "CGO_ENABLED=0", "GOWORK=off")
48+ build.Dir = repoRoot(t)
49+ if out, err := build.CombinedOutput(); err != nil {
50+ t.Fatalf("build runner: %v\n%s", err, out)
51+ }
52+
53+ plumb := sandboxtest.NewPlumbing()
54+ cfg := sandbox.Config{
55+ Isolation: sandbox.Container, StateDir: filepath.Join(dir, "state"), Capacity: 1, Image: image,
56+ JobCPUs: 1, JobMemoryMiB: 512, JobDiskMiB: 1024, UIDBase: 900000,
57+ Network: mustPrefix("10.213.0.0/16"), Executable: helper, DNSPort: 0,
58+ LockFile: filepath.Join(dir, "host.lock"), JobPids: 4096,
59+ }
60+ ctx := context.Background()
61+ // Loop mounts need root: replace them by plain directories (mkfs still runs).
62+ run := noMountRunner{}
63+ empty := filepath.Join(dir, "mountinfo")
64+ _ = os.WriteFile(empty, nil, 0o644)
65+ d, err := New(ctx, cfg, Options{Net: plumb, allowRootless: true, ConnectTimeout: 60 * time.Second, Run: run,
66+ Mounter: &disk.Mounter{Run: run, Mountinfo: empty}})
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ defer func() { _ = d.Close() }()
71+ if err := d.Reap(ctx); err != nil {
72+ t.Fatal(err)
73+ }
74+
75+ for i := range 2 {
76+ sb, err := d.New()
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "api-wiring"})
81+ if err != nil {
82+ t.Fatalf("job %d: %v", i, err)
83+ }
84+ var out strings.Builder
85+ hello, exit, err := execstream.Drive(conn, &execpb.Run{Script: "echo wired; id -u; exit 4"}, nil, func(o *execpb.Output) { out.Write(o.Data) })
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ if hello.Sandbox != "container" || exit.Code != 4 || !strings.HasPrefix(out.String(), "wired\n") {
90+ t.Fatalf("hello=%v exit=%v out=%q", hello, exit, out.String())
91+ }
92+ // While running, Docker knows our objects by label.
93+ if i == 0 {
94+ if ids := dockerLines(t, "ps", "-aq", "--filter", "label="+sandbox.LabelJob); len(ids) != 1 {
95+ t.Fatalf("expected one labelled container, got %v", ids)
96+ }
97+ if n := dockerLines(t, "network", "ls", "-q", "--filter", "label="+sandbox.LabelJob); len(n) != 1 {
98+ t.Fatalf("expected one labelled network, got %v", n)
99+ }
100+ }
101+ if err := sb.Stop(ctx); err != nil {
102+ t.Fatal(err)
103+ }
104+ }
105+ for _, args := range [][]string{
106+ {"ps", "-aq", "--filter", "label=" + sandbox.LabelJob},
107+ {"network", "ls", "-q", "--filter", "label=" + sandbox.LabelJob},
108+ } {
109+ if l := dockerLines(t, args...); len(l) != 0 {
110+ t.Fatalf("docker %v left %v", args, l)
111+ }
112+ }
113+ if plumb.Live() != 0 {
114+ t.Fatal("firewall attachments leaked")
115+ }
116+}
117+
118+// noMountRunner runs real commands except mount and umount, which succeed
119+// without doing anything.
120+type noMountRunner struct{}
121+
122+func (noMountRunner) Run(ctx context.Context, in io.Reader, name string, args ...string) ([]byte, error) {
123+ if name == "mount" || name == "umount" {
124+ return nil, nil
125+ }
126+ return cmdrun.Exec{}.Run(ctx, in, name, args...)
127+}
128+
129+func dockerLines(t *testing.T, args ...string) []string {
130+ t.Helper()
131+ out, err := exec.Command("docker", args...).Output()
132+ if err != nil {
133+ t.Fatalf("docker %v: %v", args, err)
134+ }
135+ return strings.Fields(string(out))
136+}
137+
138+func repoRoot(t *testing.T) string {
139+ t.Helper()
140+ out, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}").Output()
141+ if err != nil {
142+ t.Fatal(err)
143+ }
144+ return strings.TrimSpace(string(out))
145+}
A internal/sandbox/container/driver.go +637 -0
@@ -0,0 +1,637 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package container is the container sandbox, for hosts without KVM: one
4+// container per job with its own Docker daemon inside (the sysbox runtime when
5+// the daemon lists it, else a privileged container), on its own bridge network
6+// and with its own scratch disk. The scratch disk is a sparse ext4 file of
7+// ERMITE_RUNNER_JOB_DISK, loop-mounted on the host (so it needs root, like the
8+// rest of this mode) and bind-mounted at /scratch: the nested dockerd keeps its
9+// data root there and the job workspace is a directory of it, so a job cannot
10+// write more than its disk size to the host (the container's own writable layer
11+// stays small: nothing big is written outside /scratch and /home/runner/work).
12+// Everything Docker-side carries the label sandbox.LabelJob so that Reap finds
13+// it after a crash.
14+//
15+// The executor stream is a Unix socket in a per-job directory of the runner's
16+// state directory, bind-mounted into the container. The runner executable is
17+// bind-mounted read-only. Both paths are host paths given to the Docker daemon:
18+// the daemon must run on the same host (and mount namespace) as the runner.
19+package container
20+
21+import (
22+ "context"
23+ "errors"
24+ "fmt"
25+ "net"
26+ "net/netip"
27+ "os"
28+ "path/filepath"
29+ "slices"
30+ "strings"
31+ "sync"
32+ "time"
33+
34+ "github.com/bnema/zerowrap"
35+
36+ "bnema.dev/ermite-runner/internal/cmdrun"
37+ "bnema.dev/ermite-runner/internal/netpolicy"
38+ "bnema.dev/ermite-runner/internal/sandbox"
39+ "bnema.dev/ermite-runner/internal/sandbox/disk"
40+ "bnema.dev/ermite-runner/internal/sandbox/netsetup"
41+)
42+
43+// SysboxRuntime is the name of the sysbox runtime in `docker info`.
44+const SysboxRuntime = "sysbox-runc"
45+
46+// bridgePrefix must match the "erbr*" wildcard of the nftables policy.
47+const bridgePrefix = "erbr"
48+
49+// Options are the testable knobs of the driver.
50+type Options struct {
51+ Run cmdrun.Runner
52+ // Net overrides the network plumbing (tests).
53+ Net netsetup.Plumbing
54+ // engine overrides the Docker client (tests).
55+ engine engine
56+ // allowRootless lets tests exercise the Engine API against the rootless
57+ // daemon of a workstation. Never set outside tests: a nested dockerd cannot
58+ // work in a rootless daemon.
59+ allowRootless bool
60+ // ConnectTimeout bounds container start + init + executor connection
61+ // (default 90 s).
62+ ConnectTimeout time.Duration
63+ // Mounter overrides the loop mounts (tests).
64+ Mounter *disk.Mounter
65+ // FreeSpace overrides statfs for the free-space warning (tests).
66+ FreeSpace func(string) (int64, error)
67+}
68+
69+// Driver implements sandbox.Driver for containers.
70+type Driver struct {
71+ cfg sandbox.Config
72+ opt Options
73+ eng engine
74+ net netsetup.Plumbing
75+ mnt disk.Mounter
76+ lock *sandbox.HostLock
77+ image string
78+ runtime string // "" when privileged
79+ privileged bool
80+ exe string
81+
82+ mu sync.Mutex
83+ slots *sandbox.Slots
84+}
85+
86+// New takes the host-wide lock, connects to the Docker daemon (DOCKER_HOST
87+// honoured) and checks it: not rootless, the job image present (pulled if
88+// missing), the runtime decided (sysbox when listed; else privileged, unless
89+// ERMITE_RUNNER_DENY_PRIVILEGED=1, which refuses). IPv4 forwarding is enabled
90+// host-wide and stays on (see the README).
91+func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) {
92+ if opt.Run == nil {
93+ opt.Run = cmdrun.Exec{}
94+ }
95+ if opt.ConnectTimeout == 0 {
96+ opt.ConnectTimeout = 90 * time.Second
97+ }
98+ d := &Driver{cfg: cfg, opt: opt, image: cfg.OCIImage(), slots: sandbox.NewSlots(cfg.Capacity)}
99+ if opt.Mounter != nil {
100+ d.mnt = *opt.Mounter
101+ } else {
102+ d.mnt = disk.Mounter{Run: opt.Run}
103+ }
104+ exe, err := cfg.ExecutablePath()
105+ if err != nil {
106+ return nil, err
107+ }
108+ if err := sandbox.CheckStatic(exe); err != nil {
109+ return nil, err
110+ }
111+ d.exe = exe
112+
113+ if d.lock, err = sandbox.AcquireHostLock(cfg.LockFile); err != nil {
114+ return nil, err
115+ }
116+ eng := opt.engine
117+ if eng == nil {
118+ e, err := newDockerEngine()
119+ if err != nil {
120+ _ = d.lock.Release()
121+ return nil, err
122+ }
123+ eng = e
124+ }
125+ d.eng = eng
126+ fail := func(err error) (*Driver, error) {
127+ _ = eng.Close()
128+ _ = d.lock.Release()
129+ return nil, err
130+ }
131+
132+ info, err := eng.Info(ctx)
133+ if err != nil {
134+ return fail(fmt.Errorf("cannot reach the Docker daemon (DOCKER_HOST): %w", err))
135+ }
136+ if info.Rootless && !opt.allowRootless {
137+ return fail(errRootless)
138+ }
139+ if info.OSType != "" && info.OSType != "linux" {
140+ return fail(fmt.Errorf("the Docker daemon runs %s containers, need linux", info.OSType))
141+ }
142+ log := zerowrap.FromCtx(ctx)
143+ if slices.Contains(info.Runtimes, SysboxRuntime) {
144+ d.runtime = SysboxRuntime
145+ log.Info().Str("runtime", SysboxRuntime).Msg("container isolation: using the sysbox runtime")
146+ } else {
147+ if cfg.DenyPrivileged {
148+ return fail(fmt.Errorf("the sysbox runtime is not available and %s=1 forbids privileged containers (install sysbox, or unset the setting to accept host-root-equivalent jobs)", sandbox.EnvDenyPrivileged))
149+ }
150+ d.privileged = true
151+ log.Warn().Msg(privilegedWarning)
152+ }
153+ if err := disk.CheckMkfs(ctx, opt.Run); err != nil {
154+ return fail(err)
155+ }
156+ if err := os.MkdirAll(cfg.StateDir, 0o755); err != nil {
157+ return fail(fmt.Errorf("state directory: %w", err))
158+ }
159+ sandbox.WarnIfLowSpace(ctx, cfg.StateDir, cfg.Capacity, cfg.JobDiskMiB, opt.FreeSpace)
160+ if err := eng.EnsureImage(ctx, d.image); err != nil {
161+ return fail(err)
162+ }
163+ d.net = opt.Net
164+ if d.net == nil {
165+ d.net = netsetup.New(opt.Run, cfg.DNSPort, "")
166+ }
167+ if err := d.net.EnableForwarding(); err != nil {
168+ return fail(err)
169+ }
170+ return d, nil
171+}
172+
173+// maxSockPath is the longest Unix socket path Linux accepts (108 with the NUL).
174+const maxSockPath = 107
175+
176+// privilegedWarning is logged when the driver starts and again at every job
177+// start: a privileged container is host-root equivalent.
178+const privilegedWarning = "privileged container: host-root equivalent, network policy best effort"
179+
180+// Mode implements sandbox.Driver.
181+func (d *Driver) Mode() sandbox.Mode { return sandbox.Container }
182+
183+// Isolation implements sandbox.Driver.
184+func (d *Driver) Isolation() sandbox.Isolation {
185+ if d.privileged {
186+ return sandbox.IsolationContainerPrivileged
187+ }
188+ return sandbox.IsolationContainerSysbox
189+}
190+
191+// Privileged reports whether jobs run privileged (no sysbox).
192+func (d *Driver) Privileged() bool { return d.privileged }
193+
194+// Close implements sandbox.Driver.
195+func (d *Driver) Close() error {
196+ if d.net != nil {
197+ d.net.Close()
198+ }
199+ err := d.eng.Close()
200+ return errors.Join(err, d.lock.Release())
201+}
202+
203+// New implements sandbox.Driver.
204+func (d *Driver) New() (sandbox.Sandbox, error) {
205+ id, err := sandbox.NewID()
206+ if err != nil {
207+ return nil, err
208+ }
209+ d.mu.Lock()
210+ defer d.mu.Unlock()
211+ slot, err := d.slots.Acquire()
212+ if err != nil {
213+ return nil, err
214+ }
215+ return &containerSandbox{d: d, slot: slot, id: id}, nil
216+}
217+
218+func (d *Driver) release(slot int) {
219+ d.mu.Lock()
220+ d.slots.Release(slot)
221+ d.mu.Unlock()
222+}
223+
224+func (d *Driver) markLeaked(slot int) {
225+ d.mu.Lock()
226+ d.slots.MarkLeaked(slot)
227+ d.mu.Unlock()
228+}
229+
230+func (d *Driver) runDir(id string) string { return filepath.Join(d.cfg.StateDir, "containers", id) }
231+
232+// sockDir holds only the executor socket: it is the one directory of the job
233+// that is bind-mounted (as /run/ermite), so the job never sees the scratch disk
234+// file, nor the other host paths of its own sandbox.
235+func (d *Driver) sockDir(id string) string { return filepath.Join(d.runDir(id), "sock") }
236+
237+// scratchDir is where the loop-mounted scratch disk of a job is mounted.
238+func (d *Driver) scratchDir(id string) string { return filepath.Join(d.runDir(id), "scratch") }
239+
240+// workDir is the job workspace, a directory of the scratch disk.
241+func (d *Driver) workDir(id string) string { return filepath.Join(d.scratchDir(id), "work") }
242+
243+// Where the job workspace and its temporary files appear in the container.
244+const (
245+ containerWorkspace = "/home/runner/work"
246+ containerTmp = "/tmp"
247+)
248+
249+type containerSandbox struct {
250+ d *Driver
251+ slot int
252+ id string
253+ gate sandbox.BootGate
254+
255+ mu sync.Mutex
256+ started bool
257+ stopped bool
258+ leakErr error
259+ netID string
260+ container string
261+ mounted bool
262+ listener net.Listener
263+ conn net.Conn
264+ iface string
265+ gateway netip.Addr
266+ attached bool
267+}
268+
269+// ID implements sandbox.Sandbox.
270+func (s *containerSandbox) ID() string { return s.id }
271+
272+// Start implements sandbox.Sandbox.
273+func (s *containerSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Conn, error) {
274+ // A Stop from another goroutine aborts the boot through the gate.
275+ bootCtx, err := s.gate.Begin(ctx)
276+ if err != nil {
277+ return nil, fmt.Errorf("container sandbox: %w", err)
278+ }
279+ defer s.gate.End()
280+
281+ s.mu.Lock()
282+ defer s.mu.Unlock()
283+ if s.started || s.stopped {
284+ return nil, errors.New("container sandbox: Start called twice or after Stop")
285+ }
286+ s.started = true
287+ d := s.d
288+ log := zerowrap.FromCtx(ctx)
289+ spec = d.cfg.Resolve(spec)
290+ if d.privileged {
291+ // At every job start, not just at driver start: the job log and the
292+ // operator's logs must say what kind of isolation this job got.
293+ log.Warn().Str("job", spec.JobID).Str("container", s.id).Msg(privilegedWarning)
294+ }
295+ conn, err := s.run(bootCtx, spec)
296+ if err != nil {
297+ var tail string
298+ if s.container != "" {
299+ tail, _ = d.eng.LogsTail(context.WithoutCancel(ctx), s.container, 40)
300+ }
301+ cerr := s.cleanupLocked(context.WithoutCancel(ctx))
302+ if tail != "" {
303+ err = fmt.Errorf("%w\ncontainer log tail:\n%s", err, tail)
304+ }
305+ return nil, errors.Join(err, cerr)
306+ }
307+ log.Info().Str("container", s.container).Int("slot", s.slot).Str("job", spec.JobID).Bool("privileged", d.privileged).Msg("container running")
308+ return conn, nil
309+}
310+
311+// run runs the boot stages in order; each records what it created for cleanup.
312+func (s *containerSandbox) run(ctx context.Context, spec sandbox.JobSpec) (sandbox.Conn, error) {
313+ netw, err := netpolicy.Subnet(s.d.cfg.Network, s.slot)
314+ if err != nil {
315+ return nil, err
316+ }
317+ accepted, err := s.runSocket()
318+ if err != nil {
319+ return nil, err
320+ }
321+ if err := s.runScratch(ctx, spec); err != nil {
322+ return nil, err
323+ }
324+ labels := map[string]string{sandbox.LabelJob: spec.JobID}
325+ if err := s.runNetwork(ctx, netw, labels); err != nil {
326+ return nil, err
327+ }
328+ exited, err := s.runContainer(ctx, spec, netw, labels)
329+ if err != nil {
330+ return nil, err
331+ }
332+ return s.waitExecutor(ctx, accepted, exited)
333+}
334+
335+type acceptResult struct {
336+ c net.Conn
337+ err error
338+}
339+
340+// runSocket creates the per-job directory and listens on the executor socket in
341+// it. The directory exists before anything on the Docker side, so a crash
342+// leaves a recognisable one.
343+func (s *containerSandbox) runSocket() (<-chan acceptResult, error) {
344+ d := s.d
345+ for _, dir := range []string{d.runDir(s.id), d.sockDir(s.id)} {
346+ if err := os.MkdirAll(dir, 0o711); err != nil {
347+ return nil, err
348+ }
349+ if err := os.Chmod(dir, 0o711); err != nil {
350+ return nil, err
351+ }
352+ }
353+ dir := d.sockDir(s.id)
354+ sock := filepath.Join(dir, sandbox.ContainerSocket)
355+ // sockaddr_un.sun_path holds 108 bytes: say so instead of "invalid argument".
356+ if len(sock) > maxSockPath {
357+ return nil, fmt.Errorf("executor socket path %q is %d bytes, over the %d-byte limit of a Unix socket: use a shorter %s", sock, len(sock), maxSockPath, sandbox.EnvStateDir)
358+ }
359+ ln, err := net.Listen("unix", sock)
360+ if err != nil {
361+ return nil, fmt.Errorf("listen executor socket: %w", err)
362+ }
363+ s.listener = ln
364+ // The container's root may be a mapped (sysbox) user: it must be able to
365+ // connect. The path is private to the job (random id, 0711 directory).
366+ if err := os.Chmod(sock, 0o666); err != nil {
367+ return nil, err
368+ }
369+ accepted := make(chan acceptResult, 1)
370+ go func() {
371+ c, err := ln.Accept()
372+ accepted <- acceptResult{c, err}
373+ }()
374+ return accepted, nil
375+}
376+
377+// runScratch creates the sparse ext4 scratch disk (ERMITE_RUNNER_JOB_DISK) and
378+// loop-mounts it; the workspace directory lives on it.
379+func (s *containerSandbox) runScratch(ctx context.Context, spec sandbox.JobSpec) error {
380+ d := s.d
381+ file := filepath.Join(d.runDir(s.id), "scratch.ext4")
382+ if err := disk.CreateScratch(ctx, d.opt.Run, file, spec.DiskMiB, -1); err != nil {
383+ return err
384+ }
385+ mnt := d.scratchDir(s.id)
386+ if err := os.MkdirAll(mnt, 0o755); err != nil {
387+ return err
388+ }
389+ if err := d.mnt.Mount(ctx, file, mnt); err != nil {
390+ return err
391+ }
392+ s.mounted = true
393+ // Workspace, temp and the nested dockerd's data root on the scratch disk. The
394+ // executor (phase G) sets the job layout and ownership inside the sandbox.
395+ for _, sub := range []string{"work", "tmp", "docker"} {
396+ if err := os.MkdirAll(filepath.Join(mnt, sub), 0o755); err != nil {
397+ return err
398+ }
399+ }
400+ return os.Chmod(filepath.Join(mnt, "tmp"), 0o1777)
401+}
402+
403+// runNetwork creates the job's bridge and installs the firewall policy.
404+func (s *containerSandbox) runNetwork(ctx context.Context, netw netpolicy.Net, labels map[string]string) error {
405+ d := s.d
406+ s.iface = fmt.Sprintf("%s%d", bridgePrefix, s.slot)
407+ s.gateway = netw.Gateway
408+ netID, err := d.eng.CreateNetwork(ctx, networkSpec{Name: "ermite-" + s.id, Bridge: s.iface, Subnet: netw.Prefix, Gateway: netw.Gateway, Labels: labels})
409+ if err != nil {
410+ return err
411+ }
412+ s.netID = netID
413+ s.attached = true
414+ return d.net.Attach(ctx, netpolicy.Guest{Iface: s.iface, Gateway: netw.Gateway, Addr: netw.Guest})
415+}
416+
417+// runContainer creates and starts the container.
418+func (s *containerSandbox) runContainer(ctx context.Context, spec sandbox.JobSpec, netw netpolicy.Net, labels map[string]string) (<-chan exitResult, error) {
419+ d := s.d
420+ entry := sandbox.GuestExecDir + "/" + sandbox.GuestExecName
421+ cid, err := d.eng.CreateContainer(ctx, containerSpec{
422+ Name: "ermite-" + s.id,
423+ Image: d.image,
424+ Entrypoint: []string{entry, "init"},
425+ Env: []string{"ERMITE_CONNECT=unix:" + sandbox.ContainerRunDir + "/" + sandbox.ContainerSocket},
426+ Labels: labels,
427+ Runtime: d.runtime,
428+ Privileged: d.privileged,
429+ Network: "ermite-" + s.id,
430+ IPv4: netw.Guest,
431+ DNS: netw.Gateway,
432+ Binds: []bindMount{
433+ {Source: d.exe, Target: entry, ReadOnly: true},
434+ {Source: d.sockDir(s.id), Target: sandbox.ContainerRunDir},
435+ {Source: d.scratchDir(s.id), Target: sandbox.GuestScratch},
436+ {Source: d.workDir(s.id), Target: containerWorkspace},
437+ {Source: filepath.Join(d.scratchDir(s.id), "tmp"), Target: containerTmp},
438+ },
439+ MemoryMiB: spec.MemoryMiB,
440+ CPUs: spec.CPUs,
441+ Pids: d.cfg.JobPids,
442+ })
443+ if err != nil {
444+ return nil, err
445+ }
446+ s.container = cid
447+ exited := d.eng.WaitExit(ctx, cid)
448+ if err := d.eng.StartContainer(ctx, cid); err != nil {
449+ return nil, fmt.Errorf("start container: %w", err)
450+ }
451+ return exited, nil
452+}
453+
454+// waitExecutor waits for the executor to connect. Only ctx (the caller's
455+// context, or Stop through the boot gate), the container exiting, and the
456+// timeout end the wait.
457+func (s *containerSandbox) waitExecutor(ctx context.Context, accepted <-chan acceptResult, exited <-chan exitResult) (sandbox.Conn, error) {
458+ timer := time.NewTimer(s.d.opt.ConnectTimeout)
459+ defer timer.Stop()
460+ select {
461+ case r := <-accepted:
462+ if r.err != nil {
463+ return nil, fmt.Errorf("accept executor connection: %w", r.err)
464+ }
465+ s.conn = r.c
466+ return r.c, nil
467+ case ex := <-exited:
468+ if ex.Err != nil {
469+ return nil, fmt.Errorf("waiting for the container: %w", ex.Err)
470+ }
471+ return nil, fmt.Errorf("container exited (code %d) before the executor connected", ex.Code)
472+ case <-timer.C:
473+ return nil, fmt.Errorf("executor did not connect within %s", s.d.opt.ConnectTimeout)
474+ case <-ctx.Done():
475+ return nil, context.Cause(ctx)
476+ }
477+}
478+
479+// Stop implements sandbox.Sandbox.
480+func (s *containerSandbox) Stop(ctx context.Context) error {
481+ // Abort a boot in progress first: Start holds s.mu for its whole duration.
482+ s.gate.Abort()
483+ s.mu.Lock()
484+ defer s.mu.Unlock()
485+ if s.stopped {
486+ return s.leakErr
487+ }
488+ if !s.started {
489+ s.releaseLocked()
490+ return nil
491+ }
492+ return s.cleanupLocked(context.WithoutCancel(ctx))
493+}
494+
495+func (s *containerSandbox) releaseLocked() {
496+ s.stopped = true
497+ s.d.release(s.slot)
498+}
499+
500+// cleanupLocked removes the container first; only when it is confirmed gone are
501+// the firewall policy, the network, the scratch mount and the files removed. A
502+// container that may still run keeps its policy and bridge: tearing the policy
503+// down under it would remove the isolation it relies on. Failures mark the
504+// sandbox as leaked (sandbox.ErrLeaked): its slot stays taken until Reap.
505+func (s *containerSandbox) cleanupLocked(ctx context.Context) error {
506+ d := s.d
507+ var errs []error
508+ if s.conn != nil {
509+ _ = s.conn.Close()
510+ s.conn = nil
511+ }
512+ if s.listener != nil {
513+ _ = s.listener.Close()
514+ s.listener = nil
515+ }
516+ if s.container != "" {
517+ if err := d.eng.RemoveContainer(ctx, s.container); err != nil {
518+ errs = append(errs, fmt.Errorf("remove container: %w", err))
519+ } else {
520+ s.container = ""
521+ }
522+ }
523+ if s.container == "" {
524+ if s.attached {
525+ if err := d.net.Detach(ctx, s.iface, s.gateway); err != nil {
526+ errs = append(errs, err)
527+ } else {
528+ s.attached = false
529+ }
530+ }
531+ if s.netID != "" {
532+ if err := d.eng.RemoveNetwork(ctx, s.netID); err != nil {
533+ errs = append(errs, fmt.Errorf("remove network: %w", err))
534+ } else {
535+ s.netID = ""
536+ }
537+ }
538+ if s.mounted {
539+ if err := d.mnt.Unmount(ctx, d.scratchDir(s.id)); err != nil {
540+ errs = append(errs, err)
541+ } else {
542+ s.mounted = false
543+ }
544+ }
545+ // Never delete files below a mount that is still there.
546+ if !s.mounted {
547+ if err := os.RemoveAll(d.runDir(s.id)); err != nil {
548+ errs = append(errs, err)
549+ }
550+ }
551+ }
552+ if len(errs) == 0 {
553+ s.releaseLocked()
554+ return nil
555+ }
556+ s.stopped = true
557+ d.markLeaked(s.slot)
558+ s.leakErr = fmt.Errorf("%w: container sandbox %s: %w", sandbox.ErrLeaked, s.id, errors.Join(errs...))
559+ log := zerowrap.FromCtx(ctx)
560+ log.Error().Err(s.leakErr).Str("job", s.id).Msg("container cleanup incomplete; call Reap to finish it")
561+ return s.leakErr
562+}
563+
564+// Reap implements sandbox.Driver: remove every container and network carrying
565+// the job label, the nftables table, the scratch mounts and the per-job
566+// directories, and free the slots of leaked sandboxes. It returns ErrBusy while
567+// a sandbox is running.
568+func (d *Driver) Reap(ctx context.Context) error {
569+ log := zerowrap.FromCtx(ctx)
570+ d.mu.Lock()
571+ busy := d.slots.Busy()
572+ d.mu.Unlock()
573+ if busy > 0 {
574+ return fmt.Errorf("%w (%d running)", sandbox.ErrBusy, busy)
575+ }
576+ var errs []error
577+ ids, err := d.eng.ListContainers(ctx, sandbox.LabelJob)
578+ if err != nil {
579+ errs = append(errs, fmt.Errorf("list containers: %w", err))
580+ }
581+ for _, id := range ids {
582+ log.Warn().Str("container", id).Msg("removing leftover job container")
583+ if err := d.eng.RemoveContainer(ctx, id); err != nil {
584+ errs = append(errs, err)
585+ }
586+ }
587+ nets, err := d.eng.ListNetworks(ctx, sandbox.LabelJob)
588+ if err != nil {
589+ errs = append(errs, fmt.Errorf("list networks: %w", err))
590+ }
591+ for _, n := range nets {
592+ log.Warn().Str("network", n).Msg("removing leftover job network")
593+ if err := d.eng.RemoveNetwork(ctx, n); err != nil {
594+ errs = append(errs, err)
595+ }
596+ }
597+ if err := d.net.Reset(ctx); err != nil {
598+ errs = append(errs, fmt.Errorf("reset nftables table: %w", err))
599+ }
600+ // Unmount before deleting: a directory with a live mount below it must not
601+ // be removed (and EBUSY would be the best case).
602+ root := filepath.Join(d.cfg.StateDir, "containers")
603+ mounts, merr := d.mnt.MountPoints()
604+ if merr != nil {
605+ errs = append(errs, fmt.Errorf("read mounts: %w", merr))
606+ }
607+ stuck := map[string]bool{}
608+ for _, mp := range mounts {
609+ rel, ok := strings.CutPrefix(mp, root+"/")
610+ if !ok {
611+ continue
612+ }
613+ id, _, _ := strings.Cut(rel, "/")
614+ if !sandbox.ValidID(id) {
615+ continue
616+ }
617+ log.Warn().Str("mount", mp).Msg("unmounting leftover scratch disk")
618+ if err := d.mnt.Unmount(ctx, mp); err != nil {
619+ stuck[id] = true
620+ errs = append(errs, err)
621+ }
622+ }
623+ ents, _ := os.ReadDir(root)
624+ for _, e := range ents {
625+ if sandbox.ValidID(e.Name()) && !stuck[e.Name()] {
626+ if err := os.RemoveAll(filepath.Join(root, e.Name())); err != nil {
627+ errs = append(errs, err)
628+ }
629+ }
630+ }
631+ if len(errs) == 0 {
632+ d.mu.Lock()
633+ d.slots.ReleaseLeaked()
634+ d.mu.Unlock()
635+ }
636+ return errors.Join(errs...)
637+}
A internal/sandbox/container/engine.go +284 -0
@@ -0,0 +1,284 @@
1+// SPDX-License-Identifier: MIT
2+
3+package container
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "net/netip"
11+
12+ cerrdefs "github.com/containerd/errdefs"
13+ "github.com/moby/moby/api/types/container"
14+ "github.com/moby/moby/api/types/mount"
15+ "github.com/moby/moby/api/types/network"
16+ "github.com/moby/moby/client"
17+)
18+
19+// engine is the part of the Docker Engine API the driver uses. The real
20+// implementation wraps github.com/moby/moby/client (the maintained client of
21+// the Moby project; github.com/docker/docker/client is its frozen
22+// predecessor); tests use a fake.
23+type engine interface {
24+ Info(ctx context.Context) (daemonInfo, error)
25+ // EnsureImage returns nil when ref exists locally, pulling it otherwise.
26+ EnsureImage(ctx context.Context, ref string) error
27+ CreateNetwork(ctx context.Context, spec networkSpec) (string, error)
28+ RemoveNetwork(ctx context.Context, id string) error
29+ ListNetworks(ctx context.Context, labelKey string) ([]string, error)
30+ CreateContainer(ctx context.Context, spec containerSpec) (string, error)
31+ StartContainer(ctx context.Context, id string) error
32+ // WaitExit returns a channel that yields the exit code once the container
33+ // stops running (or -1 and an error when the wait itself fails).
34+ WaitExit(ctx context.Context, id string) <-chan exitResult
35+ RemoveContainer(ctx context.Context, id string) error
36+ ListContainers(ctx context.Context, labelKey string) ([]string, error)
37+ LogsTail(ctx context.Context, id string, lines int) (string, error)
38+ Close() error
39+}
40+
41+type exitResult struct {
42+ Code int
43+ Err error
44+}
45+
46+type daemonInfo struct {
47+ Rootless bool
48+ Runtimes []string
49+ OSType string
50+}
51+
52+type networkSpec struct {
53+ Name string
54+ Bridge string // host bridge device name
55+ Subnet netip.Prefix
56+ Gateway netip.Addr
57+ Labels map[string]string
58+}
59+
60+type containerSpec struct {
61+ Name string
62+ Image string
63+ Entrypoint []string
64+ Env []string
65+ Labels map[string]string
66+ Runtime string // "" = daemon default
67+ Privileged bool
68+ Network string
69+ IPv4 netip.Addr
70+ DNS netip.Addr
71+ Binds []bindMount
72+ MemoryMiB int // also the swap limit: no swap
73+ CPUs int
74+ Pids int // PidsLimit
75+}
76+
77+type bindMount struct {
78+ Source, Target string
79+ ReadOnly bool
80+}
81+
82+// dockerEngine implements engine with the moby client.
83+type dockerEngine struct{ cli *client.Client }
84+
85+// newDockerEngine connects like the docker CLI: DOCKER_HOST, DOCKER_TLS_VERIFY,
86+// DOCKER_CERT_PATH, DOCKER_API_VERSION, with API version negotiation.
87+func newDockerEngine() (*dockerEngine, error) {
88+ cli, err := client.New(client.FromEnv)
89+ if err != nil {
90+ return nil, fmt.Errorf("docker client: %w", err)
91+ }
92+ return &dockerEngine{cli: cli}, nil
93+}
94+
95+func (e *dockerEngine) Close() error { return e.cli.Close() }
96+
97+func (e *dockerEngine) Info(ctx context.Context) (daemonInfo, error) {
98+ r, err := e.cli.Info(ctx, client.InfoOptions{})
99+ if err != nil {
100+ return daemonInfo{}, fmt.Errorf("docker info: %w", err)
101+ }
102+ di := daemonInfo{OSType: r.Info.OSType}
103+ for _, o := range r.Info.SecurityOptions {
104+ if o == "name=rootless" {
105+ di.Rootless = true
106+ }
107+ }
108+ for name := range r.Info.Runtimes {
109+ di.Runtimes = append(di.Runtimes, name)
110+ }
111+ return di, nil
112+}
113+
114+func (e *dockerEngine) EnsureImage(ctx context.Context, ref string) error {
115+ if _, err := e.cli.ImageInspect(ctx, ref); err == nil {
116+ return nil
117+ } else if !cerrdefs.IsNotFound(err) {
118+ return fmt.Errorf("inspect image %s: %w", ref, err)
119+ }
120+ rc, err := e.cli.ImagePull(ctx, ref, client.ImagePullOptions{})
121+ if err != nil {
122+ return fmt.Errorf("image %s is not present and cannot be pulled (build it with scripts/build-image.sh): %w", ref, err)
123+ }
124+ defer func() { _ = rc.Close() }()
125+ if err := rc.Wait(ctx); err != nil {
126+ return fmt.Errorf("pull image %s: %w", ref, err)
127+ }
128+ return nil
129+}
130+
131+func (e *dockerEngine) CreateNetwork(ctx context.Context, s networkSpec) (string, error) {
132+ f := false
133+ r, err := e.cli.NetworkCreate(ctx, s.Name, client.NetworkCreateOptions{
134+ Driver: "bridge",
135+ EnableIPv6: &f,
136+ IPAM: &network.IPAM{Config: []network.IPAMConfig{{
137+ Subnet: s.Subnet, Gateway: s.Gateway,
138+ }}},
139+ Options: map[string]string{
140+ "com.docker.network.bridge.name": s.Bridge,
141+ // The runner's nftables table does the NAT, with its own policy.
142+ "com.docker.network.bridge.enable_ip_masquerade": "false",
143+ },
144+ Labels: s.Labels,
145+ })
146+ if err != nil {
147+ return "", fmt.Errorf("create network %s: %w", s.Name, err)
148+ }
149+ return r.ID, nil
150+}
151+
152+func (e *dockerEngine) RemoveNetwork(ctx context.Context, id string) error {
153+ _, err := e.cli.NetworkRemove(ctx, id, client.NetworkRemoveOptions{})
154+ return ignoreNotFound(err)
155+}
156+
157+func (e *dockerEngine) ListNetworks(ctx context.Context, key string) ([]string, error) {
158+ r, err := e.cli.NetworkList(ctx, client.NetworkListOptions{Filters: client.Filters{}.Add("label", key)})
159+ if err != nil {
160+ return nil, err
161+ }
162+ var ids []string
163+ for _, n := range r.Items {
164+ ids = append(ids, n.ID)
165+ }
166+ return ids, nil
167+}
168+
169+// hostConfigFor builds the Docker host configuration of a job container.
170+func hostConfigFor(s containerSpec) *container.HostConfig {
171+ hc := &container.HostConfig{
172+ NetworkMode: container.NetworkMode(s.Network),
173+ Privileged: s.Privileged,
174+ Runtime: s.Runtime,
175+ CgroupnsMode: container.CgroupnsModePrivate,
176+ DNS: []netip.Addr{s.DNS},
177+ Sysctls: map[string]string{"net.ipv6.conf.all.disable_ipv6": "1", "net.ipv6.conf.default.disable_ipv6": "1"},
178+ }
179+ if s.MemoryMiB > 0 {
180+ hc.Memory = int64(s.MemoryMiB) << 20
181+ hc.MemorySwap = hc.Memory // swap = memory: the job cannot use swap
182+ }
183+ if s.CPUs > 0 {
184+ hc.NanoCPUs = int64(s.CPUs) * 1_000_000_000
185+ }
186+ if s.Pids > 0 {
187+ p := int64(s.Pids)
188+ hc.PidsLimit = &p
189+ }
190+ for _, b := range s.Binds {
191+ hc.Mounts = append(hc.Mounts, mount.Mount{Type: mount.TypeBind, Source: b.Source, Target: b.Target, ReadOnly: b.ReadOnly,
192+ BindOptions: &mount.BindOptions{CreateMountpoint: true}})
193+ }
194+ return hc
195+}
196+
197+func (e *dockerEngine) CreateContainer(ctx context.Context, s containerSpec) (string, error) {
198+ hc := hostConfigFor(s)
199+ cfg := &container.Config{
200+ Image: s.Image,
201+ Entrypoint: s.Entrypoint,
202+ Cmd: []string{},
203+ Env: s.Env,
204+ Labels: s.Labels,
205+ Hostname: "ermite-job",
206+ }
207+ nc := &network.NetworkingConfig{EndpointsConfig: map[string]*network.EndpointSettings{
208+ s.Network: {IPAMConfig: &network.EndpointIPAMConfig{IPv4Address: s.IPv4}},
209+ }}
210+ r, err := e.cli.ContainerCreate(ctx, client.ContainerCreateOptions{Name: s.Name, Config: cfg, HostConfig: hc, NetworkingConfig: nc})
211+ if err != nil {
212+ return "", fmt.Errorf("create container: %w", err)
213+ }
214+ return r.ID, nil
215+}
216+
217+func (e *dockerEngine) StartContainer(ctx context.Context, id string) error {
218+ _, err := e.cli.ContainerStart(ctx, id, client.ContainerStartOptions{})
219+ return err
220+}
221+
222+func (e *dockerEngine) WaitExit(ctx context.Context, id string) <-chan exitResult {
223+ w := e.cli.ContainerWait(ctx, id, client.ContainerWaitOptions{Condition: container.WaitConditionNextExit})
224+ return awaitExit(ctx, w.Result, w.Error)
225+}
226+
227+// awaitExit turns the two channels of a Docker wait into one result. A context
228+// that ended is reported as such (code -1, ctx.Err()), whichever channel the
229+// select picked: a cancelled wait must never look like a clean exit.
230+func awaitExit(ctx context.Context, result <-chan container.WaitResponse, errc <-chan error) <-chan exitResult {
231+ out := make(chan exitResult, 1)
232+ go func() {
233+ var res exitResult
234+ select {
235+ case r := <-result:
236+ res = exitResult{Code: int(r.StatusCode)}
237+ case err := <-errc:
238+ res = exitResult{Code: -1, Err: err}
239+ case <-ctx.Done():
240+ res = exitResult{Code: -1, Err: ctx.Err()}
241+ }
242+ if err := ctx.Err(); err != nil {
243+ res = exitResult{Code: -1, Err: err}
244+ }
245+ out <- res
246+ }()
247+ return out
248+}
249+
250+func (e *dockerEngine) RemoveContainer(ctx context.Context, id string) error {
251+ _, err := e.cli.ContainerRemove(ctx, id, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
252+ return ignoreNotFound(err)
253+}
254+
255+func (e *dockerEngine) ListContainers(ctx context.Context, key string) ([]string, error) {
256+ r, err := e.cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: client.Filters{}.Add("label", key)})
257+ if err != nil {
258+ return nil, err
259+ }
260+ var ids []string
261+ for _, c := range r.Items {
262+ ids = append(ids, c.ID)
263+ }
264+ return ids, nil
265+}
266+
267+func (e *dockerEngine) LogsTail(ctx context.Context, id string, lines int) (string, error) {
268+ rc, err := e.cli.ContainerLogs(ctx, id, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: fmt.Sprint(lines)})
269+ if err != nil {
270+ return "", err
271+ }
272+ defer func() { _ = rc.Close() }()
273+ b, err := io.ReadAll(io.LimitReader(rc, 64<<10))
274+ return string(b), err
275+}
276+
277+func ignoreNotFound(err error) error {
278+ if err == nil || cerrdefs.IsNotFound(err) {
279+ return nil
280+ }
281+ return err
282+}
283+
284+var errRootless = errors.New("the Docker daemon is rootless: a job needs its own nested dockerd, which requires a rootful daemon (or the sysbox runtime); point DOCKER_HOST at a rootful daemon or use ERMITE_RUNNER_ISOLATION=vm")
A internal/sandbox/contract.go +30 -0
@@ -0,0 +1,30 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+// The host/guest contract shared by the drivers and the guest side
6+// (internal/guest). Changing a value here changes both sides at once because
7+// the host always attaches its own executable to the sandbox.
8+const (
9+ // VsockPort is the vsock port the executor connects to on the host (CID 2).
10+ VsockPort = 1024
11+ // GuestExecDir and GuestExecName locate the runner executable inside the
12+ // sandbox (read-only), in both modes.
13+ GuestExecDir = "/opt/ermite"
14+ GuestExecName = "ermite-runner"
15+ // ContainerRunDir is the per-job directory bind-mounted into containers;
16+ // ContainerSocket is the executor stream socket inside it.
17+ ContainerRunDir = "/run/ermite"
18+ ContainerSocket = "exec.sock"
19+ // GuestScratch is where the per-job scratch disk (VM) or volume (container)
20+ // is mounted; dockerd keeps its data root there.
21+ GuestScratch = "/scratch"
22+
23+ // LabelJob marks every Docker object created for a job; reaping lists by it.
24+ LabelJob = "dev.bnema.ermite-runner.job"
25+
26+ // Kernel command line keys of the VM (read from /proc/cmdline by init).
27+ CmdlineSandbox = "ermite.sandbox" // "vm"
28+ CmdlineConnect = "ermite.connect" // "vsock:1024"
29+ CmdlineGateway = "ermite.gw" // host side of the /30, also the DNS server
30+)
A internal/sandbox/core_test.go +161 -0
@@ -0,0 +1,161 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+ "time"
13+)
14+
15+// S3: one runner per host, whatever its state directory.
16+func TestHostLockIsExclusiveAndReleasable(t *testing.T) {
17+ p := filepath.Join(t.TempDir(), "run", "ermite-runner.lock")
18+ a, err := AcquireHostLock(p)
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ if _, err := AcquireHostLock(p); err == nil || !strings.Contains(err.Error(), "another ermite-runner is running on this host") {
23+ t.Fatalf("a second runner must be refused with a clear message, got %v", err)
24+ }
25+ if err := a.Release(); err != nil {
26+ t.Fatal(err)
27+ }
28+ if err := a.Release(); err != nil {
29+ t.Fatalf("double release: %v", err)
30+ }
31+ b, err := AcquireHostLock(p)
32+ if err != nil {
33+ t.Fatalf("the lock must be free after Release: %v", err)
34+ }
35+ _ = b.Release()
36+ var nilLock *HostLock
37+ if err := nilLock.Release(); err != nil {
38+ t.Fatal(err)
39+ }
40+}
41+
42+func TestHostLockErrorNamesTheOverride(t *testing.T) {
43+ _, err := AcquireHostLock("/proc/nope/ermite.lock")
44+ if err == nil || !strings.Contains(err.Error(), EnvLockFile) {
45+ t.Fatalf("got %v", err)
46+ }
47+}
48+
49+func TestConfigNewSettings(t *testing.T) {
50+ c, err := LoadConfig(env(nil))
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ if c.JobPids != 32768 || c.DenyPrivileged || c.LockFile != "/run/ermite-runner.lock" ||
55+ c.DiskBandwidthMiB != 400 || c.DiskIOPS != 20000 || c.NetMbit != 1000 {
56+ t.Fatalf("%+v", c)
57+ }
58+ c, err = LoadConfig(env(map[string]string{
59+ EnvJobPids: "1000", EnvDenyPrivileged: "1", EnvLockFile: "/tmp/x.lock",
60+ EnvJobDiskBW: "0", EnvJobDiskIOPS: "5000", EnvJobNetBandwidth: "100",
61+ }))
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ if c.JobPids != 1000 || !c.DenyPrivileged || c.LockFile != "/tmp/x.lock" || c.DiskBandwidthMiB != 0 || c.DiskIOPS != 5000 || c.NetMbit != 100 {
66+ t.Fatalf("%+v", c)
67+ }
68+ for name, m := range map[string]map[string]string{
69+ "pids low": {EnvJobPids: "3"},
70+ "deny": {EnvDenyPrivileged: "maybe"},
71+ "lock rel": {EnvLockFile: "run/x.lock"},
72+ "bw negative": {EnvJobDiskBW: "-1"},
73+ "net not int": {EnvJobNetBandwidth: "fast"},
74+ } {
75+ if _, err := LoadConfig(env(m)); err == nil {
76+ t.Errorf("%s accepted", name)
77+ }
78+ }
79+}
80+
81+// C8: leaked slots stay taken until ReleaseLeaked; Busy ignores them.
82+func TestSlotsLeakedLifecycle(t *testing.T) {
83+ s := NewSlots(3)
84+ a, _ := s.Acquire()
85+ b, _ := s.Acquire()
86+ s.MarkLeaked(a)
87+ if s.InUse() != 2 || s.Busy() != 1 {
88+ t.Fatalf("InUse=%d Busy=%d", s.InUse(), s.Busy())
89+ }
90+ c, _ := s.Acquire()
91+ if c == a || c == b {
92+ t.Fatalf("a leaked slot must not be handed out again: %d", c)
93+ }
94+ s.Release(a) // a normal release also clears the leak flag
95+ if s.InUse() != 2 {
96+ t.Fatal(s.InUse())
97+ }
98+ s.MarkLeaked(b)
99+ s.ReleaseLeaked()
100+ if s.InUse() != 1 || s.Busy() != 1 {
101+ t.Fatalf("after ReleaseLeaked InUse=%d Busy=%d", s.InUse(), s.Busy())
102+ }
103+}
104+
105+func TestErrLeakedIsMatchable(t *testing.T) {
106+ err := errors.Join(errors.New("remove container: busy"), ErrLeaked)
107+ if !errors.Is(err, ErrLeaked) {
108+ t.Fatal("errors.Is must see ErrLeaked through a join")
109+ }
110+}
111+
112+// C7: Abort cancels a boot in progress and forbids a later one.
113+func TestBootGate(t *testing.T) {
114+ var g BootGate
115+ ctx, err := g.Begin(context.Background())
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ go func() { time.Sleep(20 * time.Millisecond); g.Abort() }()
120+ select {
121+ case <-ctx.Done():
122+ if !errors.Is(context.Cause(ctx), ErrAborted) {
123+ t.Fatalf("cause %v", context.Cause(ctx))
124+ }
125+ case <-time.After(2 * time.Second):
126+ t.Fatal("Abort did not cancel the boot context")
127+ }
128+ g.End()
129+ if _, err := g.Begin(context.Background()); !errors.Is(err, ErrAborted) {
130+ t.Fatalf("Begin after Abort: %v", err)
131+ }
132+ // Parent cancellation still works, and End releases the context.
133+ var g2 BootGate
134+ parent, cancel := context.WithCancel(context.Background())
135+ c2, _ := g2.Begin(parent)
136+ cancel()
137+ <-c2.Done()
138+ g2.End()
139+ g2.End()
140+}
141+
142+func TestWarnIfLowSpace(t *testing.T) {
143+ var buf bytes.Buffer
144+ ctx := logCtx(&buf)
145+ WarnIfLowSpace(ctx, "/x", 2, 64*1024, func(string) (int64, error) { return 10 * 1024, nil })
146+ if !strings.Contains(buf.String(), "less free space") || !strings.Contains(buf.String(), `"worst_case_mib":131072`) {
147+ t.Fatalf("expected a warning, got %q", buf.String())
148+ }
149+ buf.Reset()
150+ WarnIfLowSpace(ctx, "/x", 2, 1024, func(string) (int64, error) { return 10 * 1024, nil })
151+ if buf.Len() != 0 {
152+ t.Fatalf("no warning expected: %q", buf.String())
153+ }
154+ WarnIfLowSpace(ctx, "/x", 1, 1, func(string) (int64, error) { return 0, errors.New("statfs failed") })
155+ if !strings.Contains(buf.String(), "cannot check") {
156+ t.Fatalf("a statfs error must be logged: %q", buf.String())
157+ }
158+ if n, err := FreeMiB(t.TempDir()); err != nil || n <= 0 {
159+ t.Fatalf("FreeMiB: %d %v", n, err)
160+ }
161+}
A internal/sandbox/disk/disk.go +83 -0
@@ -0,0 +1,83 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package disk creates the per-job scratch disks (sparse ext4 files) and, for
4+// the container mode, loop-mounts them. Both isolation modes size a job's
5+// writable space the same way: a sparse file of ERMITE_RUNNER_JOB_DISK.
6+package disk
7+
8+import (
9+ "context"
10+ "fmt"
11+ "os"
12+ "regexp"
13+ "strconv"
14+ "strings"
15+ "syscall"
16+
17+ "bnema.dev/ermite-runner/internal/cmdrun"
18+)
19+
20+// CreateScratch creates the sparse scratch disk of sizeMiB at path and formats
21+// it. The file is created exclusively without following symlinks and handed to
22+// owner (fchown on the open descriptor, never by path) when owner >= 0.
23+func CreateScratch(ctx context.Context, run cmdrun.Runner, path string, sizeMiB, owner int) error {
24+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0o600)
25+ if err != nil {
26+ return err
27+ }
28+ if owner >= 0 {
29+ if err := f.Chown(owner, owner); err != nil {
30+ _ = f.Close()
31+ return fmt.Errorf("chown scratch disk: %w", err)
32+ }
33+ }
34+ if err := f.Truncate(int64(sizeMiB) << 20); err != nil {
35+ _ = f.Close()
36+ return err
37+ }
38+ if err := f.Close(); err != nil {
39+ return err
40+ }
41+ // No journal and no reserved blocks: it is a throw-away disk. Lazy table
42+ // initialisation keeps creation instant and the file sparse.
43+ if _, err := run.Run(ctx, nil, "mkfs.ext4", "-q", "-F", "-L", "ermite-scratch", "-m", "0",
44+ "-O", "^has_journal,^resize_inode", "-E", "lazy_itable_init=1,nodiscard", path); err != nil {
45+ return fmt.Errorf("format scratch disk: %w", err)
46+ }
47+ return nil
48+}
49+
50+var mkfsVersionRe = regexp.MustCompile(`(?m)^mke2fs (\d+)\.(\d+)(?:\.(\d+))?`)
51+
52+// MinMkfs is the oldest e2fsprogs the runner accepts: mkfs.ext4 -d (populate
53+// from a directory, without root) appeared in 1.43. scripts/build-image.sh needs
54+// 1.47.1 to populate from a tarball.
55+const MinMkfs = "1.43"
56+
57+// CheckMkfs fails when mkfs.ext4 is missing or older than MinMkfs.
58+func CheckMkfs(ctx context.Context, run cmdrun.Runner) error {
59+ out, err := run.Run(ctx, nil, "mkfs.ext4", "-V")
60+ // mke2fs prints its version on stderr and exits 0 or 1 depending on the
61+ // release: judge by the output, not by the exit status.
62+ maj, min, ok := parseMkfsVersion(string(out))
63+ if !ok {
64+ if err != nil {
65+ return fmt.Errorf("mkfs.ext4 not usable (install e2fsprogs >= %s): %w", MinMkfs, err)
66+ }
67+ return fmt.Errorf("cannot read the mkfs.ext4 version from %q (need e2fsprogs >= %s)", strings.TrimSpace(string(out)), MinMkfs)
68+ }
69+ if maj < 1 || (maj == 1 && min < 43) {
70+ return fmt.Errorf("mkfs.ext4 is e2fsprogs %d.%d, need >= %s for mkfs.ext4 -d", maj, min, MinMkfs)
71+ }
72+ return nil
73+}
74+
75+func parseMkfsVersion(s string) (maj, min int, ok bool) {
76+ m := mkfsVersionRe.FindStringSubmatch(s)
77+ if m == nil {
78+ return 0, 0, false
79+ }
80+ maj, _ = strconv.Atoi(m[1])
81+ min, _ = strconv.Atoi(m[2])
82+ return maj, min, true
83+}
A internal/sandbox/disk/disk_test.go +126 -0
@@ -0,0 +1,126 @@
1+// SPDX-License-Identifier: MIT
2+
3+package disk
4+
5+import (
6+ "context"
7+ "errors"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+
13+ "bnema.dev/ermite-runner/internal/cmdrun"
14+)
15+
16+func TestCreateScratchIsSparseExclusiveAndNoFollow(t *testing.T) {
17+ dir := t.TempDir()
18+ run := &cmdrun.Fake{}
19+ p := filepath.Join(dir, "scratch.ext4")
20+ if err := CreateScratch(context.Background(), run, p, 64, os.Getuid()); err != nil {
21+ t.Fatal(err)
22+ }
23+ st, _ := os.Stat(p)
24+ if st.Size() != 64<<20 || st.Mode().Perm() != 0o600 {
25+ t.Fatalf("%v", st)
26+ }
27+ if got := run.Lines(); len(got) != 1 || !strings.Contains(got[0], "mkfs.ext4") || !strings.Contains(got[0], "^has_journal") {
28+ t.Fatalf("mkfs call: %v", got)
29+ }
30+ if err := CreateScratch(context.Background(), run, p, 64, -1); err == nil {
31+ t.Fatal("an existing file must be refused")
32+ }
33+ victim := filepath.Join(dir, "victim")
34+ _ = os.WriteFile(victim, []byte("x"), 0o600)
35+ link := filepath.Join(dir, "link")
36+ _ = os.Symlink(victim, link)
37+ if err := CreateScratch(context.Background(), run, link, 1, -1); err == nil {
38+ t.Fatal("a symlink must be refused")
39+ }
40+ // mkfs failure is reported.
41+ bad := &cmdrun.Fake{Handle: func(cmdrun.Call) ([]byte, error) { return nil, errors.New("boom") }}
42+ if err := CreateScratch(context.Background(), bad, filepath.Join(dir, "x.ext4"), 1, -1); err == nil || !strings.Contains(err.Error(), "format scratch disk") {
43+ t.Fatalf("%v", err)
44+ }
45+}
46+
47+// C14: the mkfs.ext4 version is checked.
48+func TestCheckMkfs(t *testing.T) {
49+ ver := func(out string, err error) cmdrun.Runner {
50+ return &cmdrun.Fake{Handle: func(cmdrun.Call) ([]byte, error) { return []byte(out), err }}
51+ }
52+ for name, tc := range map[string]struct {
53+ r cmdrun.Runner
54+ ok bool
55+ }{
56+ "current": {ver("mke2fs 1.47.4 (6-Mar-2025)\n", nil), true},
57+ "exit 1 on -V": {ver("mke2fs 1.46.5 (30-Dec-2021)\nUsing EXT2FS Library version 1.46.5\n", errors.New("exit status 1")), true},
58+ "minimum": {ver("mke2fs 1.43 (17-May-2016)\n", nil), true},
59+ "too old": {ver("mke2fs 1.42.13 (17-May-2015)\n", nil), false},
60+ "major 0": {ver("mke2fs 0.9 (1-Jan-2000)\n", nil), false},
61+ "garbage": {ver("hello\n", nil), false},
62+ "missing": {ver("", errors.New("executable file not found")), false},
63+ } {
64+ err := CheckMkfs(context.Background(), tc.r)
65+ if (err == nil) != tc.ok {
66+ t.Errorf("%s: err=%v ok=%v", name, err, tc.ok)
67+ }
68+ }
69+ if err := CheckMkfs(context.Background(), ver("mke2fs 1.42.9 (4-Feb-2014)", nil)); err == nil || !strings.Contains(err.Error(), "1.43") {
70+ t.Errorf("the error must name the minimum: %v", err)
71+ }
72+}
73+
74+const mountinfo = `22 28 0:21 / /sys rw,nosuid shared:7 - sysfs sysfs rw
75+36 28 7:0 / /var/lib/ermite-runner/containers/er00112233/scratch rw,nosuid,nodev shared:9 - ext4 /dev/loop0 rw
76+37 28 7:1 / /srv/with\040space rw - ext4 /dev/loop1 rw
77+`
78+
79+func TestMounterIsMountedAndUnmount(t *testing.T) {
80+ mi := filepath.Join(t.TempDir(), "mountinfo")
81+ _ = os.WriteFile(mi, []byte(mountinfo), 0o644)
82+ f := &cmdrun.Fake{}
83+ m := Mounter{Run: f, Mountinfo: mi}
84+ for dir, want := range map[string]bool{
85+ "/var/lib/ermite-runner/containers/er00112233/scratch": true,
86+ "/srv/with space": true,
87+ "/var/lib/ermite-runner/containers/er00112233": false,
88+ "/nowhere": false,
89+ } {
90+ if got, err := m.IsMounted(dir); err != nil || got != want {
91+ t.Errorf("%s: %v %v", dir, got, err)
92+ }
93+ }
94+ if err := m.Unmount(context.Background(), "/nowhere"); err != nil || len(f.Calls) != 0 {
95+ t.Fatalf("a directory that is not mounted needs no umount: %v %v", err, f.Lines())
96+ }
97+ if err := m.Unmount(context.Background(), "/var/lib/ermite-runner/containers/er00112233/scratch"); err != nil {
98+ t.Fatal(err)
99+ }
100+ if got := f.Lines(); len(got) != 1 || got[0] != "umount /var/lib/ermite-runner/containers/er00112233/scratch" {
101+ t.Fatalf("%v", got)
102+ }
103+ if err := m.Mount(context.Background(), "/s/scratch.ext4", "/s/mnt"); err != nil {
104+ t.Fatal(err)
105+ }
106+ if last := f.Lines()[1]; !strings.Contains(last, "loop,noatime") || !strings.HasSuffix(last, "/s/scratch.ext4 /s/mnt") {
107+ t.Fatalf("mount call: %s", last)
108+ }
109+ bad := Mounter{Run: &cmdrun.Fake{Handle: func(cmdrun.Call) ([]byte, error) { return nil, errors.New("busy") }}, Mountinfo: mi}
110+ if err := bad.Unmount(context.Background(), "/srv/with space"); err == nil {
111+ t.Fatal("umount failure must be reported")
112+ }
113+ if _, err := (Mounter{Mountinfo: "/nonexistent/mountinfo"}).IsMounted("/x"); err == nil {
114+ t.Fatal("an unreadable mountinfo must be an error, not 'not mounted'")
115+ }
116+}
117+
118+func TestUnescapeMountinfo(t *testing.T) {
119+ for in, want := range map[string]string{
120+ `/plain`: "/plain", `/a\040b`: "/a b", `/tab\011x`: "/tab\tx", `/bs\134x`: `/bs\x`, `/bad\9zz`: `/bad\9zz`, `/end\04`: `/end\04`,
121+ } {
122+ if got := unescapeMountinfo(in); got != want {
123+ t.Errorf("%q: got %q want %q", in, got, want)
124+ }
125+ }
126+}
A internal/sandbox/disk/mount.go +127 -0
@@ -0,0 +1,127 @@
1+// SPDX-License-Identifier: MIT
2+
3+package disk
4+
5+import (
6+ "bufio"
7+ "context"
8+ "fmt"
9+ "os"
10+ "strings"
11+
12+ "bnema.dev/ermite-runner/internal/cmdrun"
13+)
14+
15+// Mounter loop-mounts and unmounts scratch disks. It needs root (loop devices),
16+// like the rest of the container mode.
17+type Mounter struct {
18+ Run cmdrun.Runner
19+ // Mountinfo is /proc/self/mountinfo; tests point it at a fixture.
20+ Mountinfo string
21+}
22+
23+// Mount loop-mounts the ext4 file at dir (which must exist). The mount is
24+// deliberately not nosuid/nodev: the nested dockerd keeps its overlay2 layers
25+// here, and overlayfs opens the real files of a layer through the layer's own
26+// mount, so device nodes and setuid binaries of container images would stop
27+// working on a nodev/nosuid backing mount.
28+func (m Mounter) Mount(ctx context.Context, file, dir string) error {
29+ if _, err := m.Run.Run(ctx, nil, "mount", "-o", "loop,noatime", "-t", "ext4", file, dir); err != nil {
30+ return fmt.Errorf("loop-mount scratch disk: %w", err)
31+ }
32+ return nil
33+}
34+
35+// Unmount unmounts dir; the loop device is released with it (mount -o loop sets
36+// autoclear). A directory that is not a mount point is not an error.
37+func (m Mounter) Unmount(ctx context.Context, dir string) error {
38+ mounted, err := m.IsMounted(dir)
39+ if err != nil {
40+ return err
41+ }
42+ if !mounted {
43+ return nil
44+ }
45+ if _, err := m.Run.Run(ctx, nil, "umount", dir); err != nil {
46+ return fmt.Errorf("unmount scratch disk: %w", err)
47+ }
48+ return nil
49+}
50+
51+// IsMounted reports whether dir is a mount point.
52+func (m Mounter) IsMounted(dir string) (bool, error) {
53+ mp, err := m.MountPoints()
54+ if err != nil {
55+ return false, err
56+ }
57+ for _, p := range mp {
58+ if p == dir {
59+ return true, nil
60+ }
61+ }
62+ return false, nil
63+}
64+
65+// MountPoints lists every mount point of the mount namespace.
66+func (m Mounter) MountPoints() ([]string, error) {
67+ path := m.Mountinfo
68+ if path == "" {
69+ path = "/proc/self/mountinfo"
70+ }
71+ f, err := os.Open(path)
72+ if err != nil {
73+ return nil, err
74+ }
75+ defer func() { _ = f.Close() }()
76+ var out []string
77+ sc := bufio.NewScanner(f)
78+ sc.Buffer(make([]byte, 64<<10), 1<<20)
79+ for sc.Scan() {
80+ if mp, ok := mountPointOf(sc.Text()); ok {
81+ out = append(out, mp)
82+ }
83+ }
84+ return out, sc.Err()
85+}
86+
87+// mountPointOf extracts the mount point (5th field) of a mountinfo line, with
88+// the kernel's octal escapes (\040 for a space) decoded.
89+func mountPointOf(line string) (string, bool) {
90+ f := strings.Fields(line)
91+ if len(f) < 5 {
92+ return "", false
93+ }
94+ return unescapeMountinfo(f[4]), true
95+}
96+
97+func unescapeMountinfo(s string) string {
98+ if !strings.Contains(s, `\`) {
99+ return s
100+ }
101+ var b strings.Builder
102+ for i := 0; i < len(s); i++ {
103+ if s[i] == '\\' && i+3 < len(s) {
104+ if v, ok := octal3(s[i+1 : i+4]); ok {
105+ b.WriteByte(v)
106+ i += 3
107+ continue
108+ }
109+ }
110+ b.WriteByte(s[i])
111+ }
112+ return b.String()
113+}
114+
115+func octal3(s string) (byte, bool) {
116+ if len(s) != 3 {
117+ return 0, false
118+ }
119+ var v int
120+ for _, c := range s {
121+ if c < '0' || c > '7' {
122+ return 0, false
123+ }
124+ v = v*8 + int(c-'0')
125+ }
126+ return byte(v), v < 256
127+}
A internal/sandbox/drivers/drivers.go +24 -0
@@ -0,0 +1,24 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package drivers wires the sandbox drivers into sandbox.Select.
4+package drivers
5+
6+import (
7+ "context"
8+
9+ "bnema.dev/ermite-runner/internal/sandbox"
10+ "bnema.dev/ermite-runner/internal/sandbox/container"
11+ "bnema.dev/ermite-runner/internal/sandbox/vm"
12+)
13+
14+// Factories returns the real vm and container driver factories.
15+func Factories() sandbox.Factories {
16+ return sandbox.Factories{
17+ VM: func(ctx context.Context, cfg sandbox.Config) (sandbox.Driver, error) {
18+ return vm.New(ctx, cfg, vm.Options{})
19+ },
20+ Container: func(ctx context.Context, cfg sandbox.Config) (sandbox.Driver, error) {
21+ return container.New(ctx, cfg, container.Options{})
22+ },
23+ }
24+}
A internal/sandbox/gate.go +55 -0
@@ -0,0 +1,55 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "context"
7+ "errors"
8+ "sync"
9+)
10+
11+// BootGate lets Stop abort a Start in progress. Start holds the sandbox's own
12+// mutex for the whole boot (up to the boot timeout), so Stop could otherwise
13+// only wait for it; with a gate, Stop first calls Abort, which cancels the
14+// context Start runs its boot with, and then takes the mutex.
15+type BootGate struct {
16+ mu sync.Mutex
17+ cancel context.CancelFunc
18+ aborted bool
19+}
20+
21+// ErrAborted is the cause of a boot cancelled by Stop.
22+var ErrAborted = errors.New("sandbox: start aborted by Stop")
23+
24+// Begin returns the context to boot with: parent, cancelled by Abort. It fails
25+// when Abort was already called (Stop before Start).
26+func (g *BootGate) Begin(parent context.Context) (context.Context, error) {
27+ g.mu.Lock()
28+ defer g.mu.Unlock()
29+ if g.aborted {
30+ return nil, ErrAborted
31+ }
32+ ctx, cancel := context.WithCancelCause(parent)
33+ g.cancel = func() { cancel(ErrAborted) }
34+ return ctx, nil
35+}
36+
37+// End must be called when the boot finished, successfully or not.
38+func (g *BootGate) End() {
39+ g.mu.Lock()
40+ defer g.mu.Unlock()
41+ if g.cancel != nil {
42+ g.cancel()
43+ g.cancel = nil
44+ }
45+}
46+
47+// Abort cancels a boot in progress and forbids a later Begin. It never blocks.
48+func (g *BootGate) Abort() {
49+ g.mu.Lock()
50+ defer g.mu.Unlock()
51+ g.aborted = true
52+ if g.cancel != nil {
53+ g.cancel()
54+ }
55+}
A internal/sandbox/id.go +30 -0
@@ -0,0 +1,30 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "crypto/rand"
7+ "encoding/hex"
8+ "fmt"
9+ "strings"
10+)
11+
12+// NewID returns "er" + 8 random hex digits: short (socket paths), unique enough
13+// for concurrent sandboxes, and safe in file, interface and Docker object names.
14+func NewID() (string, error) {
15+ var b [4]byte
16+ if _, err := rand.Read(b[:]); err != nil {
17+ return "", fmt.Errorf("random id: %w", err)
18+ }
19+ return "er" + hex.EncodeToString(b[:]), nil
20+}
21+
22+// ValidID reports whether id has the form produced by NewID. It guards paths and
23+// process matches built from ids read back from disk or from /proc.
24+func ValidID(id string) bool {
25+ if len(id) != 10 || !strings.HasPrefix(id, "er") {
26+ return false
27+ }
28+ _, err := hex.DecodeString(id[2:])
29+ return err == nil
30+}
A internal/sandbox/integration/container_test.go +43 -0
@@ -0,0 +1,43 @@
1+// SPDX-License-Identifier: MIT
2+
3+package integration
4+
5+import (
6+ "os"
7+ "os/exec"
8+ "strings"
9+ "testing"
10+
11+ "bnema.dev/ermite-runner/internal/sandbox"
12+ "bnema.dev/ermite-runner/internal/sandbox/drivers"
13+)
14+
15+func TestContainerStubExecutor(t *testing.T) {
16+ if os.Getenv("ERMITE_RUNNER_IT_CONTAINER") != "1" {
17+ t.Skip("set ERMITE_RUNNER_IT_CONTAINER=1 (needs a rootful Docker daemon and the runner image; see README)")
18+ }
19+ need(t, "docker")
20+ needNetAdmin(t)
21+ ctx := ctxWithLog(t)
22+ cfg := loadConfig(t, sandbox.Container)
23+ // A rootless daemon is refused with a clear error: nothing to test there.
24+ if _, err := drivers.Factories().Container(ctx, cfg); err != nil {
25+ if strings.Contains(err.Error(), "rootless") {
26+ t.Skipf("the Docker daemon is rootless, as designed the driver refuses it: %v", err)
27+ }
28+ t.Fatal(err)
29+ }
30+ scenario(t, ctx, cfg, sandbox.Container, func(t *testing.T, sb sandbox.Sandbox) {
31+ out, err := exec.Command("docker", "ps", "-q", "--filter", "label="+sandbox.LabelJob).Output()
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ ids := strings.Fields(string(out))
36+ if len(ids) == 0 {
37+ t.Fatal("no job container to kill")
38+ }
39+ if b, err := exec.Command("docker", append([]string{"kill"}, ids...)...).CombinedOutput(); err != nil {
40+ t.Fatalf("docker kill: %v: %s", err, b)
41+ }
42+ })
43+}
A internal/sandbox/integration/integration_test.go +380 -0
@@ -0,0 +1,380 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package integration holds the end-to-end tests of the sandbox drivers with the
4+// real stub executor: output comes back, exit codes are reported, and nothing
5+// survives 10 jobs, including killed ones. They need privileges and host
6+// software, so they are opt-in:
7+//
8+// ERMITE_RUNNER_IT_VM=1 Firecracker + KVM + kernel + base image,
9+// CAP_NET_ADMIN (TAP, nftables, port 53 on the
10+// TAP address)
11+// ERMITE_RUNNER_IT_CONTAINER=1 a ROOTFUL Docker daemon (DOCKER_HOST) and the
12+// runner image, CAP_NET_ADMIN for nftables
13+//
14+// See README.md ("Integration tests") for the exact commands, including how to
15+// run the VM test without root inside a user and network namespace.
16+package integration
17+
18+import (
19+ "context"
20+ "fmt"
21+ "os"
22+ "os/exec"
23+ "path/filepath"
24+ "regexp"
25+ "strings"
26+ "testing"
27+ "time"
28+
29+ "github.com/bnema/zerowrap"
30+
31+ "bnema.dev/ermite-runner/internal/execstream"
32+ "bnema.dev/ermite-runner/internal/execstream/execpb"
33+ "bnema.dev/ermite-runner/internal/netpolicy"
34+ "bnema.dev/ermite-runner/internal/sandbox"
35+ "bnema.dev/ermite-runner/internal/sandbox/drivers"
36+)
37+
38+// stubScript is the CI-1 stub job of the plan.
39+const stubScript = "uname -a; id; cat /etc/os-release; docker info"
40+
41+func ctxWithLog(t *testing.T) context.Context {
42+ return zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "console", Output: testWriter{t}}))
43+}
44+
45+type testWriter struct{ t *testing.T }
46+
47+func (w testWriter) Write(p []byte) (int, error) {
48+ w.t.Log(strings.TrimRight(string(p), "\n"))
49+ return len(p), nil
50+}
51+
52+// buildRunner builds the static runner executable the sandboxes attach.
53+func buildRunner(t *testing.T) string {
54+ t.Helper()
55+ out, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}").Output()
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ exe := filepath.Join(t.TempDir(), "ermite-runner")
60+ cmd := exec.Command("go", "build", "-o", exe, "bnema.dev/ermite-runner")
61+ cmd.Dir = strings.TrimSpace(string(out))
62+ cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOWORK=off")
63+ if b, err := cmd.CombinedOutput(); err != nil {
64+ t.Fatalf("build runner: %v\n%s", err, b)
65+ }
66+ return exe
67+}
68+
69+// loadConfig reads the sandbox settings from the environment, like the runner
70+// does, over a private state directory.
71+func loadConfig(t *testing.T, mode sandbox.Mode) sandbox.Config {
72+ t.Helper()
73+ getenv := func(k string) string {
74+ switch k {
75+ case sandbox.EnvIsolation:
76+ return string(mode)
77+ case sandbox.EnvStateDir:
78+ return stateDir(t)
79+ case sandbox.EnvCapacity:
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")
89+ case sandbox.EnvJobDisk:
90+ if v := os.Getenv(k); v != "" {
91+ return v
92+ }
93+ return "4G"
94+ case sandbox.EnvJobMemory:
95+ if v := os.Getenv(k); v != "" {
96+ return v
97+ }
98+ return "1024"
99+ }
100+ return os.Getenv(k)
101+ }
102+ cfg, err := sandbox.LoadConfig(getenv)
103+ if err != nil {
104+ t.Fatal(err)
105+ }
106+ cfg.Executable = buildRunner(t)
107+ return cfg
108+}
109+
110+var stateDirs = map[*testing.T]string{}
111+
112+func stateDir(t *testing.T) string {
113+ if d, ok := stateDirs[t]; ok {
114+ return d
115+ }
116+ base := os.Getenv("ERMITE_RUNNER_IT_STATE_DIR")
117+ if base == "" {
118+ base = t.TempDir()
119+ } else {
120+ base = filepath.Join(base, fmt.Sprintf("it-%d", time.Now().UnixNano()))
121+ _ = os.MkdirAll(base, 0o755)
122+ t.Cleanup(func() { _ = os.RemoveAll(base) })
123+ }
124+ stateDirs[t] = base
125+ return base
126+}
127+
128+type result struct {
129+ out, errOut string
130+ hello *execpb.Hello
131+ exit *execpb.Exit
132+}
133+
134+func runJob(ctx context.Context, conn sandbox.Conn, script string) (result, error) {
135+ var so, se strings.Builder
136+ hello, exit, err := execstream.Drive(conn, &execpb.Run{Script: script}, nil, func(o *execpb.Output) {
137+ if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR {
138+ se.Write(o.Data)
139+ } else {
140+ so.Write(o.Data)
141+ }
142+ })
143+ return result{so.String(), se.String(), hello, exit}, err
144+}
145+
146+// need skips the test unless every command is available.
147+func need(t *testing.T, cmds ...string) {
148+ t.Helper()
149+ for _, c := range cmds {
150+ if _, err := exec.LookPath(c); err != nil {
151+ t.Skipf("%s not found in PATH", c)
152+ }
153+ }
154+}
155+
156+// needNetAdmin skips unless the process may create TAP devices and nftables
157+// tables (root, CAP_NET_ADMIN, or a user+network namespace).
158+func needNetAdmin(t *testing.T) {
159+ t.Helper()
160+ need(t, "ip", "nft")
161+ const probe = "ertapprobe"
162+ if out, err := exec.Command("ip", "tuntap", "add", "dev", probe, "mode", "tap").CombinedOutput(); err != nil {
163+ t.Skipf("cannot create a TAP device (need CAP_NET_ADMIN): %v: %s", err, strings.TrimSpace(string(out)))
164+ }
165+ _ = exec.Command("ip", "link", "del", "dev", probe).Run()
166+ if out, err := exec.Command("nft", "list", "tables").CombinedOutput(); err != nil {
167+ t.Skipf("cannot use nftables (need CAP_NET_ADMIN): %v: %s", err, strings.TrimSpace(string(out)))
168+ }
169+}
170+
171+// audit fails the test when anything of a job survives: network devices, the
172+// nftables table, state files, processes.
173+func audit(t *testing.T, stateDir string) {
174+ t.Helper()
175+ deadline := time.Now().Add(5 * time.Second)
176+ var left []string
177+ for {
178+ left = leftovers(stateDir)
179+ if len(left) == 0 || time.Now().After(deadline) {
180+ break
181+ }
182+ time.Sleep(100 * time.Millisecond)
183+ }
184+ if len(left) != 0 {
185+ t.Fatalf("leftovers after the jobs:\n %s", strings.Join(left, "\n "))
186+ }
187+}
188+
189+var tapRe = regexp.MustCompile(`(?m)^\d+: (ertap\d+|erbr\d+)[:@]`)
190+
191+func leftovers(stateDir string) []string {
192+ var left []string
193+ if out, err := exec.Command("ip", "-o", "link", "show").Output(); err == nil {
194+ for _, m := range tapRe.FindAllStringSubmatch(string(out), -1) {
195+ left = append(left, "network device "+m[1])
196+ }
197+ }
198+ if out, err := exec.Command("nft", "list", "tables").Output(); err == nil && strings.Contains(string(out), netpolicy.Table) {
199+ left = append(left, "nftables table "+netpolicy.Table)
200+ }
201+ for _, d := range []string{"vm", "vms", "jail", "containers"} {
202+ _ = filepath.WalkDir(filepath.Join(stateDir, d), func(p string, e os.DirEntry, err error) error {
203+ if err == nil && p != filepath.Join(stateDir, d) {
204+ rel, _ := filepath.Rel(stateDir, p)
205+ left = append(left, "file "+rel)
206+ }
207+ return nil
208+ })
209+ }
210+ for pid, id := range vmmProcs() {
211+ left = append(left, fmt.Sprintf("process %d %s", pid, id))
212+ }
213+ return left
214+}
215+
216+// vmmProcs returns the processes whose command line carries one of our VM ids
217+// (and --api-sock or --exec-file), whatever the program is called: the rule the
218+// driver's Reap uses.
219+func vmmProcs() map[int]string {
220+ out := map[int]string{}
221+ procs, _ := filepath.Glob("/proc/[0-9]*/cmdline")
222+ for _, p := range procs {
223+ b, err := os.ReadFile(p)
224+ if err != nil {
225+ continue
226+ }
227+ args := strings.Split(strings.TrimRight(string(b), "\x00"), "\x00")
228+ var id string
229+ var marker bool
230+ for i, a := range args {
231+ switch a {
232+ case "--id":
233+ if i+1 < len(args) && sandbox.ValidID(args[i+1]) {
234+ id = args[i+1]
235+ }
236+ case "--api-sock", "--exec-file":
237+ marker = true
238+ }
239+ }
240+ if id == "" || !marker {
241+ continue
242+ }
243+ var pid int
244+ if _, err := fmt.Sscanf(p, "/proc/%d/cmdline", &pid); err == nil {
245+ out[pid] = id
246+ }
247+ }
248+ return out
249+}
250+
251+func vmmPIDs() []int {
252+ var pids []int
253+ for pid := range vmmProcs() {
254+ pids = append(pids, pid)
255+ }
256+ return pids
257+}
258+
259+func checkStubOutput(t *testing.T, r result, wantSandbox string) {
260+ t.Helper()
261+ if r.hello == nil || r.hello.Sandbox != wantSandbox {
262+ t.Errorf("hello: %v", r.hello)
263+ }
264+ for _, want := range []string{"Linux", "uid=", "Ubuntu", "Server Version"} {
265+ if !strings.Contains(r.out, want) {
266+ t.Errorf("output lacks %q:\n%s\nstderr:\n%s", want, r.out, r.errOut)
267+ }
268+ }
269+ if r.exit == nil || r.exit.Code != 0 {
270+ t.Errorf("stub exit: %v\nstderr: %s", r.exit, r.errOut)
271+ }
272+}
273+
274+// jobs runs the common scenario on a driver: 10 jobs, 2 at a time where
275+// capacity allows, including a failing job, a job killed from outside, and a
276+// job abandoned by a "crashed" runner; then audits.
277+func scenario(t *testing.T, ctx context.Context, cfg sandbox.Config, mode sandbox.Mode, killSandbox func(t *testing.T, sb sandbox.Sandbox)) {
278+ t.Helper()
279+ drv, err := sandbox.Select(ctx, cfg, drivers.Factories(), sandbox.Prober{})
280+ if err != nil {
281+ t.Fatal(err)
282+ }
283+ if drv.Mode() != mode {
284+ t.Fatalf("driver mode %s, want %s", drv.Mode(), mode)
285+ }
286+ // Whatever happens below (a Fatal included), leave nothing behind on the host.
287+ t.Cleanup(func() {
288+ _ = drv.Close()
289+ d2, err := sandbox.Select(context.Background(), cfg, drivers.Factories(), sandbox.Prober{})
290+ if err != nil {
291+ t.Logf("emergency cleanup: %v", err)
292+ return
293+ }
294+ _ = d2.Reap(context.Background())
295+ _ = d2.Close()
296+ })
297+ if err := drv.Reap(ctx); err != nil {
298+ t.Fatalf("initial reap: %v", err)
299+ }
300+
301+ start := func(id string) (sandbox.Sandbox, sandbox.Conn) {
302+ sb, err := drv.New()
303+ if err != nil {
304+ t.Fatal(err)
305+ }
306+ conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: id})
307+ if err != nil {
308+ _ = sb.Stop(ctx)
309+ t.Fatalf("job %s: %v", id, err)
310+ }
311+ return sb, conn
312+ }
313+
314+ // Jobs 1-7: the stub, in pairs (capacity 2) except the last.
315+ for i := 1; i <= 7; i++ {
316+ sb, conn := start(fmt.Sprintf("stub-%d", i))
317+ r, err := runJob(ctx, conn, stubScript)
318+ if err != nil {
319+ t.Fatalf("job %d: %v", i, err)
320+ }
321+ checkStubOutput(t, r, string(mode))
322+ if err := sb.Stop(ctx); err != nil {
323+ t.Fatalf("job %d stop: %v", i, err)
324+ }
325+ }
326+
327+ // Job 8: a failing script: the exit code comes back.
328+ {
329+ sb, conn := start("failing")
330+ r, err := runJob(ctx, conn, "echo before; echo oops >&2; exit 42")
331+ if err != nil {
332+ t.Fatal(err)
333+ }
334+ if r.exit.Code != 42 || !strings.Contains(r.out, "before") || !strings.Contains(r.errOut, "oops") {
335+ t.Errorf("failing job: %+v", r)
336+ }
337+ if err := sb.Stop(ctx); err != nil {
338+ t.Fatal(err)
339+ }
340+ }
341+
342+ // Job 9: killed from outside while running (the VMM or the container dies).
343+ {
344+ sb, conn := start("killed")
345+ done := make(chan error, 1)
346+ go func() { _, err := runJob(ctx, conn, "echo started; sleep 600"); done <- err }()
347+ time.Sleep(2 * time.Second)
348+ killSandbox(t, sb)
349+ select {
350+ case err := <-done:
351+ if err == nil {
352+ t.Error("a killed sandbox must make the session fail")
353+ }
354+ case <-time.After(30 * time.Second):
355+ t.Error("session did not end after the sandbox was killed")
356+ }
357+ if err := sb.Stop(ctx); err != nil {
358+ t.Fatalf("stop after kill: %v", err)
359+ }
360+ }
361+
362+ // Job 10: the runner itself dies mid-job (no Stop, driver closed); the next
363+ // start reaps everything.
364+ {
365+ _, conn := start("abandoned")
366+ go func() { _, _ = runJob(ctx, conn, "sleep 600") }()
367+ time.Sleep(2 * time.Second)
368+ _ = drv.Close() // what a crash does: locks and listeners vanish, the sandbox stays
369+ _ = conn.Close()
370+ drv2, err := sandbox.Select(ctx, cfg, drivers.Factories(), sandbox.Prober{})
371+ if err != nil {
372+ t.Fatal(err)
373+ }
374+ defer func() { _ = drv2.Close() }()
375+ if err := drv2.Reap(ctx); err != nil {
376+ t.Fatalf("reap after crash: %v", err)
377+ }
378+ }
379+ audit(t, cfg.StateDir)
380+}
A internal/sandbox/integration/vm_test.go +103 -0
@@ -0,0 +1,103 @@
1+// SPDX-License-Identifier: MIT
2+
3+package integration
4+
5+import (
6+ "context"
7+ "os"
8+ "strings"
9+ "syscall"
10+ "testing"
11+
12+ "bnema.dev/ermite-runner/internal/sandbox"
13+ "bnema.dev/ermite-runner/internal/sandbox/drivers"
14+)
15+
16+func TestVMStubExecutor(t *testing.T) {
17+ if os.Getenv("ERMITE_RUNNER_IT_VM") != "1" {
18+ t.Skip("set ERMITE_RUNNER_IT_VM=1 (needs KVM, firecracker, a kernel and a base image; see README)")
19+ }
20+ need(t, "mkfs.ext4")
21+ needNetAdmin(t)
22+ ctx := ctxWithLog(t)
23+ cfg := loadConfig(t, sandbox.VM)
24+ if why := sandbox.VMUnavailable(cfg, sandbox.Prober{}); len(why) > 0 {
25+ t.Fatalf("vm isolation is not available here: %s", strings.Join(why, "; "))
26+ }
27+
28+ scenario(t, ctx, cfg, sandbox.VM, func(t *testing.T, sb sandbox.Sandbox) {
29+ // Kill every VMM process of ours (found like Reap finds them: by id).
30+ pids := vmmPIDs()
31+ if len(pids) == 0 {
32+ t.Fatal("no VMM process to kill")
33+ }
34+ for _, pid := range pids {
35+ _ = syscall.Kill(pid, syscall.SIGKILL)
36+ }
37+ })
38+}
39+
40+// The guest must reach DNS on its gateway and nothing else on the host.
41+func TestVMNetworkPolicy(t *testing.T) {
42+ if os.Getenv("ERMITE_RUNNER_IT_VM") != "1" {
43+ t.Skip("set ERMITE_RUNNER_IT_VM=1")
44+ }
45+ need(t, "mkfs.ext4")
46+ needNetAdmin(t)
47+ ctx := ctxWithLog(t)
48+ cfg := loadConfig(t, sandbox.VM)
49+ drv, err := sandbox.Select(ctx, cfg, drivers.Factories(), sandbox.Prober{})
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ defer func() { _ = drv.Close() }()
54+ _ = drv.Reap(ctx)
55+ sb, err := drv.New()
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ defer func() { _ = sb.Stop(context.Background()) }()
60+ conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "netpolicy"})
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ // 10.200.0.1 is the gateway of slot 0.
65+ script := `
66+probe() { timeout 4 bash -c "echo > /dev/tcp/$1/$2" >/dev/null 2>&1; echo "$1:$2 rc=$?"; }
67+probe 10.200.0.1 22 # host SSH-like port: must be DROPPED (timeout, rc=124), not refused
68+probe 10.200.0.1 8080
69+probe 169.254.169.254 80 # cloud metadata
70+probe 100.64.0.1 80 # CGNAT / VPN overlay
71+probe 192.168.0.1 80 # RFC 1918
72+probe 172.16.0.1 80
73+probe 10.0.0.1 80
74+python3 - <<'PY'
75+import socket
76+q=bytes.fromhex("abcd01000001000000000000076578616d706c6503636f6d0000010001")
77+s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.settimeout(8); s.sendto(q,("10.200.0.1",53))
78+try:
79+ r=s.recv(512); print("dns answered", len(r))
80+except Exception as e: print("dns silent", e)
81+PY
82+ip -6 addr show scope global | grep -c inet6 || true
83+`
84+ r, err := runJob(ctx, conn, script)
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ t.Log("\n" + r.out)
89+ for _, line := range strings.Split(r.out, "\n") {
90+ if !strings.Contains(line, " rc=") {
91+ continue
92+ }
93+ if strings.HasSuffix(line, "rc=0") {
94+ t.Errorf("a guest reached a forbidden destination: %s", line)
95+ }
96+ }
97+ if !strings.Contains(r.out, "10.200.0.1:22 rc=124") {
98+ t.Errorf("host port must be dropped (timeout), got:\n%s", r.out)
99+ }
100+ if !strings.Contains(r.out, "dns answered") {
101+ t.Errorf("the DNS forwarder on the gateway must answer:\n%s", r.out)
102+ }
103+}
A internal/sandbox/lock.go +49 -0
@@ -0,0 +1,49 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+
10+ "golang.org/x/sys/unix"
11+)
12+
13+// HostLock is the host-wide single-instance lock. Reaping removes every process,
14+// device, firewall rule and Docker object labelled as the runner's own, so it is
15+// only safe when no other runner of the same host is alive: the lock lives at a
16+// fixed path of the host (DefaultLockFile), not in a state directory, which two
17+// runners could choose differently, and it is taken by both isolation modes.
18+type HostLock struct{ f *os.File }
19+
20+// AcquireHostLock takes the lock at path (the default when empty) without
21+// waiting. The kernel releases it when the process dies, so a crash never leaves
22+// it stuck.
23+func AcquireHostLock(path string) (*HostLock, error) {
24+ if path == "" {
25+ path = DefaultLockFile
26+ }
27+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
28+ return nil, fmt.Errorf("host lock %s: %w (set %s for another path)", path, err, EnvLockFile)
29+ }
30+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
31+ if err != nil {
32+ return nil, fmt.Errorf("host lock %s: %w (set %s for another path)", path, err, EnvLockFile)
33+ }
34+ if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
35+ _ = f.Close()
36+ return nil, fmt.Errorf("another ermite-runner is running on this host (lock %s): only one runner per host is supported, because reaping removes everything the runner owns: %w", path, err)
37+ }
38+ return &HostLock{f: f}, nil
39+}
40+
41+// Release drops the lock; releasing twice is fine.
42+func (l *HostLock) Release() error {
43+ if l == nil || l.f == nil {
44+ return nil
45+ }
46+ err := l.f.Close()
47+ l.f = nil
48+ return err
49+}
A internal/sandbox/netsetup/netsetup.go +91 -0
@@ -0,0 +1,91 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package netsetup is the host-side network plumbing shared by both sandbox
4+// drivers: the nftables policy (package netpolicy), the DNS forwarder (package
5+// dnsfwd) and IPv4 forwarding. Each driver creates its own interface (a TAP or
6+// a Docker bridge) and then Attaches it here.
7+package netsetup
8+
9+import (
10+ "context"
11+ "errors"
12+ "fmt"
13+ "net/netip"
14+ "os"
15+ "strings"
16+
17+ "bnema.dev/ermite-runner/internal/cmdrun"
18+ "bnema.dev/ermite-runner/internal/dnsfwd"
19+ "bnema.dev/ermite-runner/internal/netpolicy"
20+)
21+
22+// Plumbing is the host network surface a driver needs. Net implements it; tests
23+// of the drivers use a fake because binding gateway addresses and loading nft
24+// rules needs privileges.
25+type Plumbing interface {
26+ EnableForwarding() error
27+ Attach(ctx context.Context, g netpolicy.Guest) error
28+ Detach(ctx context.Context, iface string, gateway netip.Addr) error
29+ Reset(ctx context.Context) error
30+ Close()
31+}
32+
33+var _ Plumbing = (*Net)(nil)
34+
35+// Net is the host network state of one driver.
36+type Net struct {
37+ Policy *netpolicy.Manager
38+ DNS *dnsfwd.Forwarder
39+ // ForwardFile is the ip_forward sysctl; overridden in tests.
40+ ForwardFile string
41+}
42+
43+// New builds a Net: nft runs through run, DNS listens on dnsPort and forwards
44+// to the resolvers of resolvConf ("" = /etc/resolv.conf).
45+func New(run cmdrun.Runner, dnsPort int, resolvConf string) *Net {
46+ r := dnsfwd.NewResolvers(resolvConf)
47+ return &Net{
48+ Policy: netpolicy.NewManager(run, "nft"),
49+ DNS: dnsfwd.New(r.Addrs, dnsPort),
50+ ForwardFile: "/proc/sys/net/ipv4/ip_forward",
51+ }
52+}
53+
54+// EnableForwarding turns on IPv4 forwarding (NAT needs it). It is a no-op when
55+// already on, and an error that names the fix when it cannot be turned on.
56+func (n *Net) EnableForwarding() error {
57+ b, err := os.ReadFile(n.ForwardFile)
58+ if err == nil && strings.TrimSpace(string(b)) == "1" {
59+ return nil
60+ }
61+ if err := os.WriteFile(n.ForwardFile, []byte("1\n"), 0o644); err != nil {
62+ return fmt.Errorf("enable IPv4 forwarding (%s): %w; set net.ipv4.ip_forward=1", n.ForwardFile, err)
63+ }
64+ return nil
65+}
66+
67+// Attach installs the firewall policy of g and starts the DNS forwarder on its
68+// gateway address, which must already be configured on the host interface.
69+// On error nothing stays installed.
70+func (n *Net) Attach(ctx context.Context, g netpolicy.Guest) error {
71+ if err := n.Policy.Add(ctx, g); err != nil {
72+ return err
73+ }
74+ if err := n.DNS.Listen(ctx, g.Gateway); err != nil {
75+ return errors.Join(err, n.Policy.Remove(context.WithoutCancel(ctx), g.Iface))
76+ }
77+ return nil
78+}
79+
80+// Detach undoes Attach. It is idempotent and keeps going after an error.
81+func (n *Net) Detach(ctx context.Context, iface string, gateway netip.Addr) error {
82+ n.DNS.Close(gateway)
83+ return n.Policy.Remove(ctx, iface)
84+}
85+
86+// Reset removes the runner's nftables table: used at startup to reap a previous
87+// run. Only the table is touched, never other tables.
88+func (n *Net) Reset(ctx context.Context) error { return n.Policy.Reset(ctx) }
89+
90+// Close stops every DNS listener.
91+func (n *Net) Close() { n.DNS.CloseAll() }
A internal/sandbox/netsetup/netsetup_test.go +89 -0
@@ -0,0 +1,89 @@
1+// SPDX-License-Identifier: MIT
2+
3+package netsetup
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/netip"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+
14+ "bnema.dev/ermite-runner/internal/cmdrun"
15+ "bnema.dev/ermite-runner/internal/netpolicy"
16+)
17+
18+func guest() netpolicy.Guest {
19+ return netpolicy.Guest{Iface: "ertap0", Gateway: netip.MustParseAddr("127.0.0.1"), Addr: netip.MustParseAddr("10.200.0.2")}
20+}
21+
22+func TestAttachDetach(t *testing.T) {
23+ f := &cmdrun.Fake{}
24+ n := New(f, 0, "")
25+ t.Cleanup(n.Close)
26+ ctx := context.Background()
27+ if err := n.Attach(ctx, guest()); err != nil {
28+ t.Skip("cannot bind loopback:", err)
29+ }
30+ if _, ok := n.DNS.Addr(guest().Gateway); !ok {
31+ t.Fatal("DNS forwarder not listening on the gateway")
32+ }
33+ if err := n.Detach(ctx, "ertap0", guest().Gateway); err != nil {
34+ t.Fatal(err)
35+ }
36+ if _, ok := n.DNS.Addr(guest().Gateway); ok {
37+ t.Fatal("DNS forwarder still listening after Detach")
38+ }
39+ if len(f.Calls) != 2 || f.Calls[1].Stdin != netpolicy.Ruleset(nil) {
40+ t.Fatalf("calls: %v", f.Lines())
41+ }
42+ if err := n.Detach(ctx, "ertap0", guest().Gateway); err != nil {
43+ t.Fatalf("Detach must be idempotent: %v", err)
44+ }
45+}
46+
47+func TestAttachRollsBackPolicyWhenDNSFails(t *testing.T) {
48+ f := &cmdrun.Fake{}
49+ n := New(f, 0, "")
50+ t.Cleanup(n.Close)
51+ ctx := context.Background()
52+ if err := n.Attach(ctx, guest()); err != nil {
53+ t.Skip(err)
54+ }
55+ before := len(f.Calls)
56+ if err := n.Attach(ctx, guest()); err == nil { // already listening
57+ t.Fatal("second Attach on the same gateway must fail")
58+ }
59+ if len(f.Calls) != before+2 {
60+ t.Fatalf("expected add then rollback remove, got %v", f.Lines())
61+ }
62+}
63+
64+func TestAttachPolicyError(t *testing.T) {
65+ boom := errors.New("nft failed")
66+ n := New(&cmdrun.Fake{Handle: func(cmdrun.Call) ([]byte, error) { return nil, boom }}, 0, "")
67+ if err := n.Attach(context.Background(), guest()); !errors.Is(err, boom) {
68+ t.Fatalf("got %v", err)
69+ }
70+ if _, ok := n.DNS.Addr(guest().Gateway); ok {
71+ t.Fatal("DNS must not start when the policy failed")
72+ }
73+}
74+
75+func TestEnableForwarding(t *testing.T) {
76+ n := New(&cmdrun.Fake{}, 0, "")
77+ n.ForwardFile = filepath.Join(t.TempDir(), "ip_forward")
78+ _ = os.WriteFile(n.ForwardFile, []byte("0\n"), 0o644)
79+ if err := n.EnableForwarding(); err != nil {
80+ t.Fatal(err)
81+ }
82+ if b, _ := os.ReadFile(n.ForwardFile); strings.TrimSpace(string(b)) != "1" {
83+ t.Fatalf("not enabled: %q", b)
84+ }
85+ n.ForwardFile = filepath.Join(t.TempDir(), "missing", "ip_forward")
86+ if err := n.EnableForwarding(); err == nil || !strings.Contains(err.Error(), "ip_forward") {
87+ t.Fatalf("want a clear error, got %v", err)
88+ }
89+}
A internal/sandbox/sandbox.go +176 -0
@@ -0,0 +1,176 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package sandbox defines the isolation boundary of a CI job: one Sandbox per
4+// job, either a Firecracker micro-VM (package vm) or a container with its own
5+// Docker daemon (package container). Both run the same executable
6+// (`ermite-runner init`, then `ermite-runner exec`) and expose the same framed
7+// stream (package execstream) to the host process.
8+package sandbox
9+
10+import (
11+ "context"
12+ "errors"
13+ "io"
14+)
15+
16+// Mode is an isolation mode.
17+type Mode string
18+
19+// Isolation modes. Auto is a setting only: a Driver always reports VM or
20+// Container.
21+const (
22+ Auto Mode = "auto"
23+ VM Mode = "vm"
24+ Container Mode = "container"
25+)
26+
27+// Isolation is the effective isolation of a driver, reported by the host at
28+// registration and shown on the job page.
29+type Isolation string
30+
31+// Effective isolations.
32+const (
33+ IsolationVM Isolation = "vm"
34+ IsolationContainerSysbox Isolation = "container_sysbox"
35+ IsolationContainerPrivileged Isolation = "container_privileged"
36+)
37+
38+// ErrCapacity is returned by Driver.New when every slot is in use.
39+var ErrCapacity = errors.New("sandbox: no free slot")
40+
41+// ErrLeaked is wrapped by the error of Start or Stop when the cleanup of a
42+// sandbox is incomplete: something it created (process, network device,
43+// firewall rules, files, containers) may still exist and its slot stays taken.
44+// The host should stop assigning jobs to this runner slot, let running jobs
45+// finish and call Driver.Reap, which retries the cleanup and frees the slot.
46+var ErrLeaked = errors.New("sandbox: cleanup incomplete, resources leaked")
47+
48+// ErrBusy is returned by Driver.Reap while sandboxes are running: reaping
49+// removes everything labelled as ours and would destroy them.
50+var ErrBusy = errors.New("sandbox: cannot reap while sandboxes are running")
51+
52+// JobSpec describes the sandbox of one job. Zero resource fields take the
53+// configured defaults.
54+type JobSpec struct {
55+ // JobID identifies the job in logs, labels and leftover reports. It never
56+ // appears in file or interface names.
57+ JobID string
58+ // CPUs, MemoryMiB and DiskMiB size the sandbox (ERMITE_RUNNER_JOB_*).
59+ CPUs int
60+ MemoryMiB int
61+ DiskMiB int
62+}
63+
64+// Conn is the executor stream: bytes in both directions, framed by execstream.
65+// Closing it ends the session from the host side.
66+type Conn io.ReadWriteCloser
67+
68+// Sandbox isolates one job. A Sandbox is single use: Start once, then Stop.
69+type Sandbox interface {
70+ // ID is the sandbox's identifier ("er" + 8 hex digits), assigned by
71+ // Driver.New. It names the sandbox's files and Docker objects, so logs and
72+ // leftover reports can be matched with them.
73+ ID() string
74+ // Start creates the sandbox, boots it and waits for the executor inside to
75+ // connect back. It aborts when ctx ends or when Stop is called from another
76+ // goroutine. On error everything created so far has been removed, or the
77+ // error wraps ErrLeaked.
78+ Start(ctx context.Context, spec JobSpec) (Conn, error)
79+ // Stop destroys the sandbox and everything it created (processes, network
80+ // devices, firewall rules, files). It is idempotent, safe after a failed or
81+ // never-made Start, runs to completion even when ctx is already done, and
82+ // aborts a Start in progress. When something could not be removed the error
83+ // wraps ErrLeaked.
84+ Stop(ctx context.Context) error
85+}
86+
87+// Driver creates sandboxes of one mode and reaps what a previous run left.
88+type Driver interface {
89+ // Mode is VM or Container.
90+ Mode() Mode
91+ // Isolation is the effective isolation (vm, container_sysbox,
92+ // container_privileged), for registration with ermite.
93+ Isolation() Isolation
94+ // New returns a fresh Sandbox bound to a free slot (ErrCapacity if none).
95+ // The slot is released by Stop, unless the cleanup leaked (ErrLeaked).
96+ New() (Sandbox, error)
97+ // Reap removes everything a previous run of the runner left behind
98+ // (leftover processes, devices, firewall rules, containers, files) and
99+ // frees the slots of leaked sandboxes. Call it once at startup, before any
100+ // New, and after ErrLeaked when no sandbox is running; it returns ErrBusy
101+ // while one is.
102+ Reap(ctx context.Context) error
103+ // Close releases the driver's own resources (DNS forwarder, API client,
104+ // host lock). Sandboxes still running are not stopped.
105+ Close() error
106+}
107+
108+// Slots hands out small integers, lowest first. Slots identify a sandbox's uid,
109+// network device and /30 subnet, so two live sandboxes never collide. A slot
110+// whose sandbox leaked stays taken (its device name and subnet may still be in
111+// use) until ReleaseLeaked.
112+type Slots struct {
113+ used []bool
114+ leaked []bool
115+}
116+
117+// NewSlots returns a pool of n slots.
118+func NewSlots(n int) *Slots { return &Slots{used: make([]bool, n), leaked: make([]bool, n)} }
119+
120+// Acquire returns the lowest free slot, or ErrCapacity. Callers serialise
121+// access (the drivers hold their mutex).
122+func (s *Slots) Acquire() (int, error) {
123+ for i, u := range s.used {
124+ if !u {
125+ s.used[i] = true
126+ return i, nil
127+ }
128+ }
129+ return 0, ErrCapacity
130+}
131+
132+// Release frees a slot; releasing a free or unknown slot is a no-op.
133+func (s *Slots) Release(i int) {
134+ if i >= 0 && i < len(s.used) {
135+ s.used[i], s.leaked[i] = false, false
136+ }
137+}
138+
139+// MarkLeaked keeps slot i taken after a failed cleanup.
140+func (s *Slots) MarkLeaked(i int) {
141+ if i >= 0 && i < len(s.used) {
142+ s.used[i], s.leaked[i] = true, true
143+ }
144+}
145+
146+// ReleaseLeaked frees every leaked slot: the caller has reaped what they held.
147+func (s *Slots) ReleaseLeaked() {
148+ for i := range s.used {
149+ if s.leaked[i] {
150+ s.used[i], s.leaked[i] = false, false
151+ }
152+ }
153+}
154+
155+// InUse reports how many slots are taken, leaked ones included.
156+func (s *Slots) InUse() int {
157+ n := 0
158+ for _, u := range s.used {
159+ if u {
160+ n++
161+ }
162+ }
163+ return n
164+}
165+
166+// Busy reports how many slots belong to a sandbox that is not leaked: running,
167+// starting, or handed out by New and not yet stopped.
168+func (s *Slots) Busy() int {
169+ n := 0
170+ for i, u := range s.used {
171+ if u && !s.leaked[i] {
172+ n++
173+ }
174+ }
175+ return n
176+}
A internal/sandbox/sandbox_test.go +271 -0
@@ -0,0 +1,271 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+
14+ "github.com/bnema/zerowrap"
15+)
16+
17+func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
18+
19+func TestLoadConfigDefaults(t *testing.T) {
20+ c, err := LoadConfig(env(nil))
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ if c.Isolation != Auto || c.Capacity != 1 || c.UIDBase != 900000 || c.StateDir != DefaultStateDir ||
25+ c.JobCPUs != 2 || c.JobMemoryMiB != 4096 || c.JobDiskMiB != 64*1024 || c.Network.String() != "10.200.0.0/16" || c.DNSPort != 53 {
26+ t.Fatalf("%+v", c)
27+ }
28+}
29+
30+func TestLoadConfigValues(t *testing.T) {
31+ c, err := LoadConfig(env(map[string]string{
32+ EnvIsolation: "VM", EnvStateDir: "/srv/er", EnvCapacity: "4", EnvImage: "/images/u24",
33+ EnvJobCPUs: "8", EnvJobMemory: "16G", EnvJobDisk: "100G", EnvUIDBase: "910000",
34+ EnvNetwork: "10.50.0.0/24", EnvJailerMode: "off", EnvFirecracker: "/x/fc", EnvKernel: "/k",
35+ }))
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if c.Isolation != VM || c.Capacity != 4 || c.JobMemoryMiB != 16384 || c.JobDiskMiB != 100*1024 || c.UIDBase != 910000 ||
40+ !c.JailerOff || c.Network.String() != "10.50.0.0/24" || c.ImageDir() != "/images/u24" || c.KernelPath() != "/k" ||
41+ c.BaseImagePath() != "/images/u24/base.ext4" || c.OCIImage() != DefaultOCIImage {
42+ t.Fatalf("%+v", c)
43+ }
44+ c, _ = LoadConfig(env(map[string]string{EnvImage: "registry.example/ermite:1"}))
45+ if c.OCIImage() != "registry.example/ermite:1" || c.ImageDir() != filepath.Join(DefaultStateDir, "image") {
46+ t.Fatalf("OCI ref misread: %q %q", c.OCIImage(), c.ImageDir())
47+ }
48+}
49+
50+func TestLoadConfigErrors(t *testing.T) {
51+ for name, m := range map[string]map[string]string{
52+ "isolation": {EnvIsolation: "docker"},
53+ "relative": {EnvStateDir: "state"},
54+ "capacity 0": {EnvCapacity: "0"},
55+ "capacity abc": {EnvCapacity: "abc"},
56+ "cpus": {EnvJobCPUs: "0"},
57+ "memory": {EnvJobMemory: "10M"},
58+ "disk": {EnvJobDisk: "100M"},
59+ "uid base": {EnvUIDBase: "10"},
60+ "network v6": {EnvNetwork: "fd00::/64"},
61+ "network /31": {EnvNetwork: "10.0.0.0/31"},
62+ "network few": {EnvNetwork: "10.0.0.0/30", EnvCapacity: "2"},
63+ "jailer mode": {EnvJailerMode: "maybe"},
64+ } {
65+ if _, err := LoadConfig(env(m)); err == nil {
66+ t.Errorf("%s: accepted", name)
67+ }
68+ }
69+}
70+
71+func TestParseSizeMiB(t *testing.T) {
72+ for in, want := range map[string]int{"4096": 4096, "512M": 512, "4G": 4096, "64GiB": 65536, "1t": 1048576, " 2g ": 2048, "1024MB": 1024} {
73+ if got, err := ParseSizeMiB(in); err != nil || got != want {
74+ t.Errorf("%q: %d %v", in, got, err)
75+ }
76+ }
77+ for _, in := range []string{"", "0", "-1", "G", "12K", "1x", "99999999999T"} {
78+ if _, err := ParseSizeMiB(in); err == nil {
79+ t.Errorf("%q accepted", in)
80+ }
81+ }
82+}
83+
84+func TestResolve(t *testing.T) {
85+ c, _ := LoadConfig(env(nil))
86+ s := c.Resolve(JobSpec{JobID: "j", CPUs: 6})
87+ if s.CPUs != 6 || s.MemoryMiB != 4096 || s.DiskMiB != 64*1024 || s.JobID != "j" {
88+ t.Fatalf("%+v", s)
89+ }
90+}
91+
92+func TestSlots(t *testing.T) {
93+ s := NewSlots(2)
94+ a, _ := s.Acquire()
95+ b, _ := s.Acquire()
96+ if a != 0 || b != 1 || s.InUse() != 2 {
97+ t.Fatal(a, b)
98+ }
99+ if _, err := s.Acquire(); !errors.Is(err, ErrCapacity) {
100+ t.Fatal(err)
101+ }
102+ s.Release(0)
103+ s.Release(0)
104+ s.Release(99)
105+ if c, _ := s.Acquire(); c != 0 {
106+ t.Fatalf("lowest free slot first, got %d", c)
107+ }
108+}
109+
110+func TestIDs(t *testing.T) {
111+ id, err := NewID()
112+ if err != nil || !ValidID(id) {
113+ t.Fatal(id, err)
114+ }
115+ for _, bad := range []string{"", "er", "er12345", "xx12345678", "er1234567g", "er../../etc", "er123456789"} {
116+ if ValidID(bad) {
117+ t.Errorf("%q valid", bad)
118+ }
119+ }
120+}
121+
122+// fakeDriver is a Driver that records its construction.
123+type fakeDriver struct{ mode Mode }
124+
125+func (f fakeDriver) Mode() Mode { return f.mode }
126+func (f fakeDriver) Isolation() Isolation { return Isolation(f.mode) }
127+func (fakeDriver) New() (Sandbox, error) { return nil, nil }
128+func (fakeDriver) Reap(context.Context) error { return nil }
129+func (fakeDriver) Close() error { return nil }
130+
131+func facts(built *[]Mode) Factories {
132+ return Factories{
133+ VM: func(context.Context, Config) (Driver, error) { *built = append(*built, VM); return fakeDriver{VM}, nil },
134+ Container: func(context.Context, Config) (Driver, error) {
135+ *built = append(*built, Container)
136+ return fakeDriver{Container}, nil
137+ },
138+ }
139+}
140+
141+// goodProber reports a host where every vm prerequisite exists.
142+func goodProber() Prober {
143+ return Prober{
144+ OpenKVM: func(string) error { return nil },
145+ LookPath: func(n string) (string, error) { return "/usr/bin/" + n, nil },
146+ Stat: func(string) error { return nil },
147+ }
148+}
149+
150+func logCtx(buf *bytes.Buffer) context.Context {
151+ return zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: buf}))
152+}
153+
154+func TestSelectAutoPicksVMWhenEverythingIsThere(t *testing.T) {
155+ var built []Mode
156+ var log bytes.Buffer
157+ cfg, _ := LoadConfig(env(nil))
158+ d, err := Select(logCtx(&log), cfg, facts(&built), goodProber())
159+ if err != nil || d.Mode() != VM || len(built) != 1 {
160+ t.Fatalf("%v %v %v", d, err, built)
161+ }
162+ if !strings.Contains(log.String(), `"mode":"vm"`) || !strings.Contains(log.String(), "reason") {
163+ t.Fatalf("mode and reason must be logged: %s", log.String())
164+ }
165+}
166+
167+// AC4: auto picks container when /dev/kvm is hidden, and says why.
168+func TestSelectAutoFallsBackWithoutKVM(t *testing.T) {
169+ var built []Mode
170+ var log bytes.Buffer
171+ cfg, _ := LoadConfig(env(nil))
172+ cfg.KVMPath = filepath.Join(t.TempDir(), "kvm") // hidden: does not exist
173+ p := goodProber()
174+ p.OpenKVM = Prober{}.withDefaults().OpenKVM // the real open, on the hidden path
175+ d, err := Select(logCtx(&log), cfg, facts(&built), p)
176+ if err != nil || d.Mode() != Container {
177+ t.Fatalf("%v %v", d, err)
178+ }
179+ out := log.String()
180+ if !strings.Contains(out, `"mode":"container"`) || !strings.Contains(out, "not usable read-write") {
181+ t.Fatalf("fallback reason not logged: %s", out)
182+ }
183+}
184+
185+func TestSelectAutoFallbackReasons(t *testing.T) {
186+ cfg, _ := LoadConfig(env(nil))
187+ cases := map[string]Prober{
188+ "firecracker not found": {OpenKVM: func(string) error { return nil }, LookPath: func(n string) (string, error) {
189+ if n == "firecracker" {
190+ return "", os.ErrNotExist
191+ }
192+ return "/x", nil
193+ }, Stat: func(string) error { return nil }},
194+ "jailer not found": {OpenKVM: func(string) error { return nil }, LookPath: func(n string) (string, error) {
195+ if n == "jailer" {
196+ return "", os.ErrNotExist
197+ }
198+ return "/x", nil
199+ }, Stat: func(string) error { return nil }},
200+ "guest kernel": {OpenKVM: func(string) error { return nil }, LookPath: goodProber().LookPath, Stat: func(p string) error {
201+ if strings.HasSuffix(p, "vmlinux") {
202+ return os.ErrNotExist
203+ }
204+ return nil
205+ }},
206+ "base image": {OpenKVM: func(string) error { return nil }, LookPath: goodProber().LookPath, Stat: func(p string) error {
207+ if strings.HasSuffix(p, "base.ext4") {
208+ return os.ErrNotExist
209+ }
210+ return nil
211+ }},
212+ }
213+ for want, p := range cases {
214+ var built []Mode
215+ d, err := Select(context.Background(), cfg, facts(&built), p)
216+ if err != nil || d.Mode() != Container {
217+ t.Errorf("%s: %v %v", want, d, err)
218+ }
219+ if why := VMUnavailable(cfg, p); len(why) != 1 || !strings.Contains(why[0], want) {
220+ t.Errorf("%s: reasons %v", want, why)
221+ }
222+ }
223+}
224+
225+func TestSelectExplicitModes(t *testing.T) {
226+ cfg, _ := LoadConfig(env(map[string]string{EnvIsolation: "vm"}))
227+ var built []Mode
228+ bad := Prober{OpenKVM: func(string) error { return os.ErrPermission }, LookPath: goodProber().LookPath, Stat: goodProber().Stat}
229+ if _, err := Select(context.Background(), cfg, facts(&built), bad); err == nil || !strings.Contains(err.Error(), "kvm") || len(built) != 0 {
230+ t.Fatalf("vm must fail loudly without KVM and build nothing: %v %v", err, built)
231+ }
232+ cfg.Isolation = Container
233+ d, err := Select(context.Background(), cfg, facts(&built), bad)
234+ if err != nil || d.Mode() != Container {
235+ t.Fatalf("container never probes KVM: %v %v", d, err)
236+ }
237+ cfg.Isolation = Mode("x")
238+ if _, err := Select(context.Background(), cfg, facts(&built), bad); err == nil {
239+ t.Fatal("unknown mode accepted")
240+ }
241+}
242+
243+func TestSelectDoesNotMaskBrokenVM(t *testing.T) {
244+ cfg, _ := LoadConfig(env(nil))
245+ boom := errors.New("kernel does not boot")
246+ f := Factories{
247+ VM: func(context.Context, Config) (Driver, error) { return nil, boom },
248+ Container: func(context.Context, Config) (Driver, error) { t.Fatal("must not fall back silently"); return nil, nil },
249+ }
250+ if _, err := Select(context.Background(), cfg, f, goodProber()); !errors.Is(err, boom) {
251+ t.Fatalf("got %v", err)
252+ }
253+ if _, err := Select(context.Background(), cfg, Factories{}, goodProber()); err == nil {
254+ t.Fatal("missing factory accepted")
255+ }
256+}
257+
258+func TestCheckStatic(t *testing.T) {
259+ dir := t.TempDir()
260+ if err := CheckStatic(filepath.Join(dir, "missing")); err == nil {
261+ t.Fatal("missing file accepted")
262+ }
263+ txt := filepath.Join(dir, "script")
264+ _ = os.WriteFile(txt, []byte("#!/bin/sh\n"), 0o755)
265+ if err := CheckStatic(txt); err == nil || !strings.Contains(err.Error(), "CGO_ENABLED=0") {
266+ t.Fatalf("script accepted: %v", err)
267+ }
268+ if err := CheckStatic("/bin/sh"); err == nil {
269+ t.Skip("/bin/sh is static here")
270+ }
271+}
A internal/sandbox/sandboxtest/sandboxtest.go +92 -0
@@ -0,0 +1,92 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package sandboxtest has helpers shared by the tests of the sandbox drivers.
4+package sandboxtest
5+
6+import (
7+ "context"
8+ "encoding/binary"
9+ "net/netip"
10+ "os"
11+ "sync"
12+ "testing"
13+
14+ "bnema.dev/ermite-runner/internal/netpolicy"
15+)
16+
17+// WriteStaticELF writes a minimal statically linked ELF64 file (one PT_LOAD, no
18+// PT_INTERP): enough for sandbox.CheckStatic, never executed.
19+func WriteStaticELF(t testing.TB, path string) {
20+ t.Helper()
21+ var b [64 + 56]byte
22+ copy(b[:], "\x7fELF")
23+ b[4], b[5], b[6] = 2, 1, 1 // 64-bit, little endian, version
24+ binary.LittleEndian.PutUint16(b[16:], 2) // ET_EXEC
25+ binary.LittleEndian.PutUint16(b[18:], 62) // x86-64
26+ binary.LittleEndian.PutUint32(b[20:], 1)
27+ binary.LittleEndian.PutUint64(b[32:], 64) // e_phoff
28+ binary.LittleEndian.PutUint16(b[52:], 64) // e_ehsize
29+ binary.LittleEndian.PutUint16(b[54:], 56) // e_phentsize
30+ binary.LittleEndian.PutUint16(b[56:], 1) // e_phnum
31+ binary.LittleEndian.PutUint32(b[64:], 1) // PT_LOAD
32+ if err := os.WriteFile(path, b[:], 0o755); err != nil {
33+ t.Fatal(err)
34+ }
35+}
36+
37+// Plumbing is a netsetup.Plumbing that records what is attached.
38+type Plumbing struct {
39+ mu sync.Mutex
40+ Attached map[string]netpolicy.Guest
41+ Events []string
42+ Resets int
43+ FailNext error
44+}
45+
46+// NewPlumbing returns an empty Plumbing.
47+func NewPlumbing() *Plumbing { return &Plumbing{Attached: map[string]netpolicy.Guest{}} }
48+
49+// EnableForwarding implements netsetup.Plumbing.
50+func (p *Plumbing) EnableForwarding() error { return nil }
51+
52+// Attach implements netsetup.Plumbing.
53+func (p *Plumbing) Attach(_ context.Context, g netpolicy.Guest) error {
54+ p.mu.Lock()
55+ defer p.mu.Unlock()
56+ if p.FailNext != nil {
57+ err := p.FailNext
58+ p.FailNext = nil
59+ return err
60+ }
61+ p.Attached[g.Iface] = g
62+ p.Events = append(p.Events, "attach "+g.Iface)
63+ return nil
64+}
65+
66+// Detach implements netsetup.Plumbing.
67+func (p *Plumbing) Detach(_ context.Context, iface string, _ netip.Addr) error {
68+ p.mu.Lock()
69+ defer p.mu.Unlock()
70+ delete(p.Attached, iface)
71+ p.Events = append(p.Events, "detach "+iface)
72+ return nil
73+}
74+
75+// Reset implements netsetup.Plumbing.
76+func (p *Plumbing) Reset(context.Context) error {
77+ p.mu.Lock()
78+ defer p.mu.Unlock()
79+ p.Resets++
80+ p.Attached = map[string]netpolicy.Guest{}
81+ return nil
82+}
83+
84+// Close implements netsetup.Plumbing.
85+func (p *Plumbing) Close() {}
86+
87+// Live returns the number of attached guests.
88+func (p *Plumbing) Live() int {
89+ p.mu.Lock()
90+ defer p.mu.Unlock()
91+ return len(p.Attached)
92+}
A internal/sandbox/select.go +156 -0
@@ -0,0 +1,156 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "context"
7+ "debug/elf"
8+ "errors"
9+ "fmt"
10+ "os"
11+ "os/exec"
12+ "strings"
13+
14+ "github.com/bnema/zerowrap"
15+)
16+
17+// Factory builds the driver of one mode. It is injected so that this package
18+// does not import the drivers (which import it).
19+type Factory func(ctx context.Context, cfg Config) (Driver, error)
20+
21+// Factories are the available drivers.
22+type Factories struct {
23+ VM Factory
24+ Container Factory
25+}
26+
27+// Prober checks the host. The zero value probes the real host; tests replace
28+// fields.
29+type Prober struct {
30+ // OpenKVM opens the KVM device read-write and closes it.
31+ OpenKVM func(path string) error
32+ // LookPath finds a binary in PATH.
33+ LookPath func(name string) (string, error)
34+ // Stat reports whether a path exists.
35+ Stat func(path string) error
36+}
37+
38+func (p Prober) withDefaults() Prober {
39+ if p.OpenKVM == nil {
40+ p.OpenKVM = func(path string) error {
41+ f, err := os.OpenFile(path, os.O_RDWR, 0)
42+ if err != nil {
43+ return err
44+ }
45+ return f.Close()
46+ }
47+ }
48+ if p.LookPath == nil {
49+ p.LookPath = exec.LookPath
50+ }
51+ if p.Stat == nil {
52+ p.Stat = func(path string) error { _, err := os.Stat(path); return err }
53+ }
54+ return p
55+}
56+
57+// VMUnavailable lists why the vm mode cannot run on this host: an empty result
58+// means every prerequisite (readable-writable KVM, Firecracker, jailer unless
59+// disabled, guest kernel, base image) is there.
60+func VMUnavailable(cfg Config, p Prober) []string {
61+ p = p.withDefaults()
62+ var why []string
63+ if err := p.OpenKVM(cfg.KVMPath); err != nil {
64+ why = append(why, fmt.Sprintf("%s not usable read-write: %v", cfg.KVMPath, err))
65+ }
66+ if cfg.Firecracker != "" {
67+ if err := p.Stat(cfg.Firecracker); err != nil {
68+ why = append(why, fmt.Sprintf("firecracker %s: %v", cfg.Firecracker, err))
69+ }
70+ } else if _, err := p.LookPath("firecracker"); err != nil {
71+ why = append(why, "firecracker not found in PATH")
72+ }
73+ if !cfg.JailerOff {
74+ if cfg.Jailer != "" {
75+ if err := p.Stat(cfg.Jailer); err != nil {
76+ why = append(why, fmt.Sprintf("jailer %s: %v", cfg.Jailer, err))
77+ }
78+ } else if _, err := p.LookPath("jailer"); err != nil {
79+ why = append(why, "jailer not found in PATH")
80+ }
81+ }
82+ if err := p.Stat(cfg.KernelPath()); err != nil {
83+ why = append(why, fmt.Sprintf("guest kernel %s: %v", cfg.KernelPath(), err))
84+ }
85+ if err := p.Stat(cfg.BaseImagePath()); err != nil {
86+ why = append(why, fmt.Sprintf("base image %s: %v", cfg.BaseImagePath(), err))
87+ }
88+ return why
89+}
90+
91+// Select picks the isolation mode and builds its driver. For auto it chooses vm
92+// when VMUnavailable is empty, container otherwise. For vm it fails when a
93+// prerequisite is missing; for container it never probes the VM side. The
94+// chosen mode and the reasons are logged with the logger of ctx. A driver
95+// factory error is returned as is (auto does not mask a broken vm setup by
96+// silently running jobs with weaker isolation).
97+func Select(ctx context.Context, cfg Config, f Factories, p Prober) (Driver, error) {
98+ log := zerowrap.FromCtx(ctx)
99+ mode := cfg.Isolation
100+ var reasons []string
101+ switch mode {
102+ case VM, Auto:
103+ reasons = VMUnavailable(cfg, p)
104+ if mode == VM && len(reasons) > 0 {
105+ return nil, fmt.Errorf("isolation vm is not available: %s", strings.Join(reasons, "; "))
106+ }
107+ if len(reasons) == 0 {
108+ mode = VM
109+ } else {
110+ mode = Container
111+ }
112+ case Container:
113+ default:
114+ return nil, fmt.Errorf("unknown isolation %q", mode)
115+ }
116+
117+ build := f.VM
118+ if mode == Container {
119+ build = f.Container
120+ }
121+ if build == nil {
122+ return nil, fmt.Errorf("isolation %s: no driver compiled in", mode)
123+ }
124+ ev := log.Info().Str("configured", string(cfg.Isolation)).Str("mode", string(mode))
125+ switch {
126+ case cfg.Isolation == Auto && mode == VM:
127+ ev = ev.Str("reason", "KVM, firecracker, kernel and base image are available")
128+ case cfg.Isolation == Auto:
129+ ev = ev.Strs("reasons", reasons)
130+ default:
131+ ev = ev.Str("reason", "set by "+EnvIsolation)
132+ }
133+ ev.Msg("isolation mode chosen")
134+ d, err := build(ctx, cfg)
135+ if err != nil {
136+ return nil, fmt.Errorf("isolation %s: %w", mode, err)
137+ }
138+ return d, nil
139+}
140+
141+// CheckStatic fails unless the executable at path is a statically linked ELF
142+// binary: it runs as PID 1 on a root disk that holds nothing else, and in an
143+// image of another distribution.
144+func CheckStatic(path string) error {
145+ f, err := elf.Open(path)
146+ if err != nil {
147+ return fmt.Errorf("%s is not an ELF executable (%w); build the runner with CGO_ENABLED=0", path, err)
148+ }
149+ defer func() { _ = f.Close() }()
150+ for _, p := range f.Progs {
151+ if p.Type == elf.PT_INTERP {
152+ return errors.New(path + " is dynamically linked; build the runner with CGO_ENABLED=0")
153+ }
154+ }
155+ return nil
156+}
A internal/sandbox/space.go +43 -0
@@ -0,0 +1,43 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "context"
7+
8+ "github.com/bnema/zerowrap"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// FreeMiB returns the space available to unprivileged users on the filesystem
13+// of path, in MiB.
14+func FreeMiB(path string) (int64, error) {
15+ var st unix.Statfs_t
16+ if err := unix.Statfs(path, &st); err != nil {
17+ return 0, err
18+ }
19+ return int64(st.Bavail) * st.Bsize >> 20, nil
20+}
21+
22+// WarnIfLowSpace logs a warning when the filesystem of dir has less free space
23+// than capacity sandboxes of diskMiB would need if they all filled their sparse
24+// scratch disk. It is only a warning: scratch disks are sparse and jobs rarely
25+// fill them, so a smaller filesystem works until it does not. free is FreeMiB in
26+// production.
27+func WarnIfLowSpace(ctx context.Context, dir string, capacity, diskMiB int, free func(string) (int64, error)) {
28+ if free == nil {
29+ free = FreeMiB
30+ }
31+ log := zerowrap.FromCtx(ctx)
32+ have, err := free(dir)
33+ if err != nil {
34+ log.Warn().Err(err).Str("dir", dir).Msg("cannot check the free space of the state directory")
35+ return
36+ }
37+ need := int64(capacity) * int64(diskMiB)
38+ if have < need {
39+ log.Warn().Str("dir", dir).Int64("free_mib", have).Int64("worst_case_mib", need).
40+ Int("capacity", capacity).Int("job_disk_mib", diskMiB).
41+ Msg("the state directory has less free space than capacity x job disk: jobs that fill their scratch disk will fail with 'no space left on device'")
42+ }
43+}
A internal/sandbox/vm/api.go +208 -0
@@ -0,0 +1,208 @@
1+// SPDX-License-Identifier: MIT
2+
3+package vm
4+
5+import (
6+ "bytes"
7+ "context"
8+ "encoding/json"
9+ "fmt"
10+ "io"
11+ "net"
12+ "net/http"
13+ "time"
14+)
15+
16+// Firecracker is controlled through its HTTP API on a unix socket, with
17+// net/http, rather than through firecracker-go-sdk: the SDK's last release
18+// (v1.0.0, 2022) predates Firecracker 1.17 and drags in CNI, go-openapi and
19+// netlink for what is a handful of PUT requests. The structures below are the
20+// subset of the API this driver uses; Firecracker rejects unknown or invalid
21+// fields with a fault message, which surfaces in the returned error.
22+
23+type machineConfig struct {
24+ VcpuCount int `json:"vcpu_count"`
25+ MemSizeMiB int `json:"mem_size_mib"`
26+ SMT bool `json:"smt"`
27+}
28+
29+type bootSource struct {
30+ KernelImagePath string `json:"kernel_image_path"`
31+ BootArgs string `json:"boot_args"`
32+}
33+
34+type drive struct {
35+ DriveID string `json:"drive_id"`
36+ PathOnHost string `json:"path_on_host"`
37+ IsRootDevice bool `json:"is_root_device"`
38+ IsReadOnly bool `json:"is_read_only"`
39+ RateLimiter *rateLimiter `json:"rate_limiter,omitempty"`
40+}
41+
42+type networkInterface struct {
43+ IfaceID string `json:"iface_id"`
44+ HostDevName string `json:"host_dev_name"`
45+ GuestMAC string `json:"guest_mac"`
46+ RxRateLimiter *rateLimiter `json:"rx_rate_limiter,omitempty"`
47+ TxRateLimiter *rateLimiter `json:"tx_rate_limiter,omitempty"`
48+}
49+
50+// tokenBucket is Firecracker's rate limiter bucket: Size tokens (bytes or
51+// operations) are refilled every RefillTime milliseconds.
52+type tokenBucket struct {
53+ Size int64 `json:"size"`
54+ OneTimeBurst int64 `json:"one_time_burst,omitempty"`
55+ RefillTime int64 `json:"refill_time"`
56+}
57+
58+type rateLimiter struct {
59+ Bandwidth *tokenBucket `json:"bandwidth,omitempty"`
60+ Ops *tokenBucket `json:"ops,omitempty"`
61+}
62+
63+// diskLimiter limits a disk to bandwidthMiB MiB/s and iops operations/s (zero
64+// disables a limit; both zero means no limiter). A one-second burst is allowed
65+// at the start so that short bursts (a git checkout) are not slowed down.
66+func diskLimiter(bandwidthMiB, iops int) *rateLimiter {
67+ var rl rateLimiter
68+ if bandwidthMiB > 0 {
69+ b := int64(bandwidthMiB) << 20
70+ rl.Bandwidth = &tokenBucket{Size: b, OneTimeBurst: b, RefillTime: 1000}
71+ }
72+ if iops > 0 {
73+ rl.Ops = &tokenBucket{Size: int64(iops), OneTimeBurst: int64(iops), RefillTime: 1000}
74+ }
75+ if rl.Bandwidth == nil && rl.Ops == nil {
76+ return nil
77+ }
78+ return &rl
79+}
80+
81+// netLimiter limits one direction of a network interface to mbit Mbit/s.
82+func netLimiter(mbit int) *rateLimiter {
83+ if mbit <= 0 {
84+ return nil
85+ }
86+ b := int64(mbit) * 1_000_000 / 8
87+ return &rateLimiter{Bandwidth: &tokenBucket{Size: b, OneTimeBurst: b, RefillTime: 1000}}
88+}
89+
90+type vsockDevice struct {
91+ GuestCID uint32 `json:"guest_cid"`
92+ UDSPath string `json:"uds_path"`
93+}
94+
95+type action struct {
96+ ActionType string `json:"action_type"`
97+}
98+
99+// apiClient talks to one Firecracker instance.
100+type apiClient struct {
101+ http *http.Client
102+}
103+
104+func newAPIClient(socket string) *apiClient {
105+ return &apiClient{http: &http.Client{
106+ Timeout: 10 * time.Second,
107+ Transport: &http.Transport{
108+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
109+ var d net.Dialer
110+ return d.DialContext(ctx, "unix", socket)
111+ },
112+ DisableKeepAlives: true,
113+ },
114+ }}
115+}
116+
117+// put sends a PUT with a JSON body and expects 204 (or 200).
118+func (c *apiClient) put(ctx context.Context, path string, body any) error {
119+ b, err := json.Marshal(body)
120+ if err != nil {
121+ return err
122+ }
123+ req, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://firecracker"+path, bytes.NewReader(b))
124+ if err != nil {
125+ return err
126+ }
127+ req.Header.Set("Content-Type", "application/json")
128+ resp, err := c.http.Do(req)
129+ if err != nil {
130+ return fmt.Errorf("firecracker PUT %s: %w", path, err)
131+ }
132+ defer func() { _ = resp.Body.Close() }()
133+ if resp.StatusCode/100 != 2 {
134+ msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
135+ return fmt.Errorf("firecracker PUT %s: %s: %s", path, resp.Status, bytes.TrimSpace(msg))
136+ }
137+ return nil
138+}
139+
140+// ready reports whether the API answers GET /.
141+func (c *apiClient) ready(ctx context.Context) error {
142+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://firecracker/", nil)
143+ if err != nil {
144+ return err
145+ }
146+ resp, err := c.http.Do(req)
147+ if err != nil {
148+ return err
149+ }
150+ defer func() { _ = resp.Body.Close() }()
151+ _, _ = io.Copy(io.Discard, resp.Body)
152+ if resp.StatusCode/100 != 2 {
153+ return fmt.Errorf("firecracker GET /: %s", resp.Status)
154+ }
155+ return nil
156+}
157+
158+// vmConfig is everything configured before boot.
159+type vmConfig struct {
160+ Machine machineConfig
161+ Boot bootSource
162+ Drives []drive
163+ Net networkInterface
164+ Vsock vsockDevice
165+}
166+
167+// configure applies the pre-boot configuration and boots the VM. The order is
168+// fixed (and covered by a unit test) because Firecracker rejects some changes
169+// after boot and drive order decides the guest device names vda, vdb, vdc.
170+func (c *apiClient) configure(ctx context.Context, cfg vmConfig) error {
171+ steps := []struct {
172+ path string
173+ body any
174+ }{
175+ {"/machine-config", cfg.Machine},
176+ {"/boot-source", cfg.Boot},
177+ }
178+ for _, d := range cfg.Drives {
179+ steps = append(steps, struct {
180+ path string
181+ body any
182+ }{"/drives/" + d.DriveID, d})
183+ }
184+ steps = append(steps,
185+ struct {
186+ path string
187+ body any
188+ }{"/network-interfaces/" + cfg.Net.IfaceID, cfg.Net},
189+ struct {
190+ path string
191+ body any
192+ }{"/vsock", cfg.Vsock},
193+ struct {
194+ path string
195+ body any
196+ }{"/entropy", struct{}{}},
197+ struct {
198+ path string
199+ body any
200+ }{"/actions", action{ActionType: "InstanceStart"}},
201+ )
202+ for _, s := range steps {
203+ if err := c.put(ctx, s.path, s.body); err != nil {
204+ return err
205+ }
206+ }
207+ return nil
208+}
A internal/sandbox/vm/bootargs.go +42 -0
@@ -0,0 +1,42 @@
1+// SPDX-License-Identifier: MIT
2+
3+package vm
4+
5+import (
6+ "fmt"
7+ "net/netip"
8+ "strconv"
9+ "strings"
10+
11+ "bnema.dev/ermite-runner/internal/sandbox"
12+)
13+
14+// bootArgs builds the guest kernel command line.
15+//
16+// - console=ttyS0 reboot=k panic=1: serial console; a reboot (what init does
17+// at the end) or a panic ends the VMM instead of hanging;
18+// - ipv6.disable=1: the guest has no IPv6 (the host policy drops it too);
19+// - root=/dev/vda ro: the exec disk; init=/ermite-runner -- init: the runner
20+// executable is PID 1 and runs its init subcommand;
21+// - ip=…: static address, gateway and DNS from the kernel, no DHCP client;
22+// - ermite.*: read by init from /proc/cmdline (connect target, gateway, time).
23+func bootArgs(guest, gateway netip.Addr, unixSeconds int64) string {
24+ args := []string{
25+ "console=ttyS0", "reboot=k", "panic=1", "pci=off", "acpi=off", "8250.nr_uarts=1",
26+ "ipv6.disable=1", "random.trust_cpu=on", "i8042.noaux", "i8042.nomux", "i8042.nopnp", "i8042.dumbkbd",
27+ "root=/dev/vda", "ro", "rootfstype=ext4",
28+ "init=/" + sandbox.GuestExecName,
29+ fmt.Sprintf("ip=%s::%s:255.255.255.252:ermite-job:eth0:off:%s", guest, gateway, gateway),
30+ sandbox.CmdlineSandbox + "=" + string(sandbox.VM),
31+ sandbox.CmdlineConnect + "=vsock:" + strconv.Itoa(sandbox.VsockPort),
32+ sandbox.CmdlineGateway + "=" + gateway.String(),
33+ "ermite.time=" + strconv.FormatInt(unixSeconds, 10),
34+ "--", "init",
35+ }
36+ return strings.Join(args, " ")
37+}
38+
39+// macFor derives a locally administered MAC from the slot.
40+func macFor(slot int) string {
41+ return fmt.Sprintf("06:00:ac:10:%02x:%02x", (slot>>8)&0xff, slot&0xff)
42+}
A internal/sandbox/vm/driver.go +663 -0
@@ -0,0 +1,663 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package vm is the micro-VM sandbox: one Firecracker VM per job.
4+//
5+// Per VM, on the host: a jailer chroot (or, without jailer, a plain directory)
6+// holding the per-job sparse scratch disk and the vsock socket; a TAP device
7+// with a /30; rules in the dedicated nftables table; the DNS forwarder bound on
8+// the TAP's host address. In the guest: three virtio disks (read-only runner
9+// executable as the boot root, read-only shared base image, per-job scratch)
10+// and a vsock device. Everything is traced in a state-directory record before it
11+// is created, so that Reap can remove it after a crash.
12+package vm
13+
14+import (
15+ "context"
16+ "errors"
17+ "fmt"
18+ "net"
19+ "net/netip"
20+ "os"
21+ "os/exec"
22+ "path/filepath"
23+ "strconv"
24+ "sync"
25+ "syscall"
26+ "time"
27+
28+ "github.com/bnema/zerowrap"
29+
30+ "bnema.dev/ermite-runner/internal/cmdrun"
31+ "bnema.dev/ermite-runner/internal/netpolicy"
32+ "bnema.dev/ermite-runner/internal/sandbox"
33+ "bnema.dev/ermite-runner/internal/sandbox/disk"
34+ "bnema.dev/ermite-runner/internal/sandbox/netsetup"
35+)
36+
37+// Options are the testable knobs of the driver.
38+type Options struct {
39+ Run cmdrun.Runner
40+ // ProcDir is the /proc used to find orphan VMM processes.
41+ ProcDir string
42+ // CgroupRoot is the cgroup v2 mount, where the jailer creates one cgroup per
43+ // VM (default /sys/fs/cgroup).
44+ CgroupRoot string
45+ // BootTimeout bounds boot + init + executor connection (default 90 s).
46+ BootTimeout time.Duration
47+ // Net overrides the network plumbing (tests).
48+ Net netsetup.Plumbing
49+ // FirecrackerArgs are extra arguments for Firecracker (tests use them to
50+ // select the fake).
51+ FirecrackerArgs []string
52+ // FreeSpace overrides statfs for the free-space warning (tests).
53+ FreeSpace func(string) (int64, error)
54+ // kill overrides killAndWait (tests simulate a VMM that cannot be killed).
55+ kill func(pid int, timeout time.Duration) bool
56+}
57+
58+// vmmOverheadMiB is what the Firecracker process itself may use on top of the
59+// guest memory (device emulation, the API server, the seccomp filter, stacks):
60+// it is added to memory.max so that the guest, not the VMM, is what gets limited.
61+const vmmOverheadMiB = 256
62+
63+// Driver implements sandbox.Driver for micro-VMs.
64+type Driver struct {
65+ cfg sandbox.Config
66+ opt Options
67+ fcPath string
68+ jailer string
69+ execDisk string
70+ store store
71+ net netsetup.Plumbing
72+ lock *sandbox.HostLock
73+
74+ mu sync.Mutex
75+ slots *sandbox.Slots
76+}
77+
78+// New builds the driver: takes the host-wide lock, resolves binaries, builds the
79+// exec disk and enables IPv4 forwarding (a host-wide sysctl that stays on: see
80+// the README). Run Reap before New sandboxes.
81+func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) {
82+ if opt.Run == nil {
83+ opt.Run = cmdrun.Exec{}
84+ }
85+ if opt.ProcDir == "" {
86+ opt.ProcDir = "/proc"
87+ }
88+ if opt.CgroupRoot == "" {
89+ opt.CgroupRoot = "/sys/fs/cgroup"
90+ }
91+ if opt.BootTimeout == 0 {
92+ opt.BootTimeout = 90 * time.Second
93+ }
94+ if opt.kill == nil {
95+ opt.kill = killAndWait
96+ }
97+ d := &Driver{cfg: cfg, opt: opt, slots: sandbox.NewSlots(cfg.Capacity), store: store{dir: filepath.Join(cfg.StateDir, "vms")}}
98+
99+ var err error
100+ if d.fcPath, err = resolveBin(cfg.Firecracker, "firecracker"); err != nil {
101+ return nil, err
102+ }
103+ if !cfg.JailerOff {
104+ if d.jailer, err = resolveBin(cfg.Jailer, "jailer"); err != nil {
105+ return nil, err
106+ }
107+ }
108+ exe, err := cfg.ExecutablePath()
109+ if err != nil {
110+ return nil, err
111+ }
112+ if err := sandbox.CheckStatic(exe); err != nil {
113+ return nil, err
114+ }
115+ if err := os.MkdirAll(cfg.StateDir, 0o755); err != nil {
116+ return nil, fmt.Errorf("state directory: %w", err)
117+ }
118+ if d.lock, err = sandbox.AcquireHostLock(cfg.LockFile); err != nil {
119+ return nil, err
120+ }
121+ fail := func(err error) (*Driver, error) { _ = d.Close(); return nil, err }
122+
123+ if !cfg.JailerOff {
124+ for _, p := range []string{cfg.KernelPath(), cfg.BaseImagePath()} {
125+ if err := checkWorldReadable(p); err != nil {
126+ return fail(err)
127+ }
128+ }
129+ }
130+ sandbox.WarnIfLowSpace(ctx, cfg.StateDir, cfg.Capacity, cfg.JobDiskMiB, opt.FreeSpace)
131+ if err := disk.CheckMkfs(ctx, opt.Run); err != nil {
132+ return fail(err)
133+ }
134+ if d.execDisk, err = ensureExecDisk(ctx, opt.Run, exe, cfg.StateDir); err != nil {
135+ return fail(err)
136+ }
137+ d.net = opt.Net
138+ if d.net == nil {
139+ d.net = netsetup.New(opt.Run, cfg.DNSPort, "")
140+ }
141+ if err := d.net.EnableForwarding(); err != nil {
142+ return fail(err)
143+ }
144+ return d, nil
145+}
146+
147+func resolveBin(configured, name string) (string, error) {
148+ p := configured
149+ if p == "" {
150+ var err error
151+ if p, err = exec.LookPath(name); err != nil {
152+ return "", fmt.Errorf("%s not found in PATH (set ERMITE_RUNNER_%s)", name, map[string]string{"firecracker": "FIRECRACKER", "jailer": "JAILER"}[name])
153+ }
154+ }
155+ abs, err := filepath.Abs(p)
156+ if err != nil {
157+ return "", err
158+ }
159+ if _, err := os.Stat(abs); err != nil {
160+ return "", fmt.Errorf("%s: %w", name, err)
161+ }
162+ return abs, nil
163+}
164+
165+// Mode implements sandbox.Driver.
166+func (d *Driver) Mode() sandbox.Mode { return sandbox.VM }
167+
168+// Isolation implements sandbox.Driver.
169+func (d *Driver) Isolation() sandbox.Isolation { return sandbox.IsolationVM }
170+
171+// Close releases the host lock and the DNS listeners.
172+func (d *Driver) Close() error {
173+ if d.net != nil {
174+ d.net.Close()
175+ }
176+ return d.lock.Release()
177+}
178+
179+// New implements sandbox.Driver.
180+func (d *Driver) New() (sandbox.Sandbox, error) {
181+ id, err := sandbox.NewID()
182+ if err != nil {
183+ return nil, err
184+ }
185+ d.mu.Lock()
186+ defer d.mu.Unlock()
187+ slot, err := d.slots.Acquire()
188+ if err != nil {
189+ return nil, err
190+ }
191+ return &vmSandbox{d: d, slot: slot, id: id}, nil
192+}
193+
194+func (d *Driver) release(slot int) {
195+ d.mu.Lock()
196+ d.slots.Release(slot)
197+ d.mu.Unlock()
198+}
199+
200+func (d *Driver) markLeaked(slot int) {
201+ d.mu.Lock()
202+ d.slots.MarkLeaked(slot)
203+ d.mu.Unlock()
204+}
205+
206+// vmSandbox is one VM, single use.
207+type vmSandbox struct {
208+ d *Driver
209+ slot int
210+ id string
211+ gate sandbox.BootGate
212+
213+ mu sync.Mutex
214+ started bool
215+ stopped bool
216+ // leakErr is the error of a cleanup that left something behind; later Stop
217+ // calls return it again.
218+ leakErr error
219+
220+ rec record
221+ lay layout
222+ cmd *exec.Cmd
223+ exited chan struct{}
224+ console *ring
225+ listener net.Listener
226+ conn net.Conn
227+ tap string
228+ gateway netip.Addr
229+ attached bool
230+}
231+
232+// ID implements sandbox.Sandbox.
233+func (s *vmSandbox) ID() string { return s.id }
234+
235+func (s *vmSandbox) uid() int { return s.d.cfg.UIDBase + s.slot }
236+
237+// Start implements sandbox.Sandbox.
238+func (s *vmSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Conn, error) {
239+ // A Stop from another goroutine aborts the boot through the gate.
240+ bootCtx, err := s.gate.Begin(ctx)
241+ if err != nil {
242+ return nil, fmt.Errorf("vm sandbox: %w", err)
243+ }
244+ defer s.gate.End()
245+
246+ s.mu.Lock()
247+ defer s.mu.Unlock()
248+ if s.started || s.stopped {
249+ return nil, errors.New("vm sandbox: Start called twice or after Stop")
250+ }
251+ s.started = true
252+ log := zerowrap.FromCtx(ctx)
253+ d := s.d
254+ spec = d.cfg.Resolve(spec)
255+
256+ jailed := !d.cfg.JailerOff
257+ runUID := s.uid()
258+ if !jailed {
259+ runUID = os.Getuid()
260+ }
261+ s.lay = newLayout(d.cfg.StateDir, filepath.Base(d.fcPath), s.id, jailed, runUID)
262+ s.rec = record{ID: s.id, Slot: s.slot, Job: spec.JobID, Dir: s.lay.dir}
263+ // The record exists before the first resource, so any crash from here on is
264+ // reapable.
265+ if err := d.store.save(s.rec); err != nil {
266+ s.releaseLocked()
267+ return nil, fmt.Errorf("write state record: %w", err)
268+ }
269+
270+ conn, err := s.boot(bootCtx, spec)
271+ if err != nil {
272+ console := ""
273+ if s.console != nil {
274+ console = s.console.String()
275+ }
276+ cerr := s.cleanupLocked(context.WithoutCancel(ctx))
277+ if console != "" {
278+ err = fmt.Errorf("%w\nVMM console tail:\n%s", err, console)
279+ }
280+ return nil, errors.Join(err, cerr)
281+ }
282+ log.Info().Str("vm", s.id).Int("slot", s.slot).Str("job", spec.JobID).Msg("micro-VM running")
283+ return conn, nil
284+}
285+
286+// boot runs the stages in order; each stage records what it created so that
287+// cleanupLocked can undo exactly that.
288+func (s *vmSandbox) boot(ctx context.Context, spec sandbox.JobSpec) (sandbox.Conn, error) {
289+ netw, err := netpolicy.Subnet(s.d.cfg.Network, s.slot)
290+ if err != nil {
291+ return nil, err
292+ }
293+ if err := s.bootNetwork(ctx, netw); err != nil {
294+ return nil, err
295+ }
296+ api, err := s.bootVMM(ctx, spec)
297+ if err != nil {
298+ return nil, err
299+ }
300+ files, err := s.bootFiles(ctx, spec)
301+ if err != nil {
302+ return nil, err
303+ }
304+ accepted, err := s.bootVsock()
305+ if err != nil {
306+ return nil, err
307+ }
308+ if err := api.configure(ctx, s.vmConfig(spec, netw, files)); err != nil {
309+ return nil, err
310+ }
311+ return s.waitExecutor(ctx, accepted)
312+}
313+
314+// bootNetwork creates the TAP (it must exist when Firecracker opens it) and
315+// installs the firewall policy and the DNS listener.
316+func (s *vmSandbox) bootNetwork(ctx context.Context, netw netpolicy.Net) error {
317+ d := s.d
318+ s.tap, s.gateway = tapName(s.slot), netw.Gateway
319+ s.rec.Tap, s.rec.Gate = s.tap, s.gateway.String()
320+ if err := d.store.save(s.rec); err != nil {
321+ return err
322+ }
323+ if err := createTap(ctx, d.opt.Run, s.tap, s.lay.uid, s.gateway); err != nil {
324+ return err
325+ }
326+ s.attached = true // from here Detach is idempotent and always safe
327+ return d.net.Attach(ctx, netpolicy.Guest{Iface: s.tap, Gateway: netw.Gateway, Addr: netw.Guest})
328+}
329+
330+// bootVMM starts the jailer (or Firecracker) and waits for its API.
331+func (s *vmSandbox) bootVMM(ctx context.Context, spec sandbox.JobSpec) (*apiClient, error) {
332+ apiSock := s.lay.host("api.sock")
333+ if s.lay.jailed {
334+ apiSock = s.lay.host("run/firecracker.socket")
335+ } else if err := os.MkdirAll(s.lay.root, 0o700); err != nil {
336+ return nil, err
337+ }
338+ if err := s.startVMM(spec, apiSock); err != nil {
339+ return nil, err
340+ }
341+ api := newAPIClient(apiSock)
342+ if err := s.waitAPI(ctx, api, apiSock); err != nil {
343+ return nil, err
344+ }
345+ return api, nil
346+}
347+
348+// vmFiles are the paths Firecracker is given.
349+type vmFiles struct{ execDisk, baseDisk, kernel string }
350+
351+// bootFiles makes the shared files available and creates the scratch disk.
352+func (s *vmSandbox) bootFiles(ctx context.Context, spec sandbox.JobSpec) (vmFiles, error) {
353+ d := s.d
354+ var f vmFiles
355+ var err error
356+ if f.execDisk, err = s.lay.share(d.execDisk, "exec.ext4"); err != nil {
357+ return f, err
358+ }
359+ if f.baseDisk, err = s.lay.share(d.cfg.BaseImagePath(), "base.ext4"); err != nil {
360+ return f, err
361+ }
362+ if f.kernel, err = s.lay.share(d.cfg.KernelPath(), "kernel"); err != nil {
363+ return f, err
364+ }
365+ owner := -1
366+ if s.lay.jailed {
367+ owner = s.lay.uid
368+ }
369+ return f, disk.CreateScratch(ctx, d.opt.Run, s.lay.host("scratch.ext4"), spec.DiskMiB, owner)
370+}
371+
372+// bootVsock listens on the host side of the vsock stream: Firecracker connects
373+// to <uds_path>_<port> when the guest connects to the host CID.
374+func (s *vmSandbox) bootVsock() (<-chan acceptResult, error) {
375+ path := s.lay.host(udsName) + fmt.Sprintf("_%d", sandbox.VsockPort)
376+ ln, err := net.Listen("unix", path)
377+ if err != nil {
378+ return nil, fmt.Errorf("listen vsock socket: %w", err)
379+ }
380+ s.listener = ln
381+ if s.lay.jailed {
382+ if err := os.Lchown(path, s.lay.uid, s.lay.uid); err != nil {
383+ return nil, err
384+ }
385+ }
386+ accepted := make(chan acceptResult, 1)
387+ go func() { c, err := ln.Accept(); accepted <- acceptResult{c, err} }()
388+ return accepted, nil
389+}
390+
391+const udsName = "v.sock"
392+
393+func (s *vmSandbox) vmConfig(spec sandbox.JobSpec, netw netpolicy.Net, f vmFiles) vmConfig {
394+ cfg := s.d.cfg
395+ return vmConfig{
396+ Machine: machineConfig{VcpuCount: spec.CPUs, MemSizeMiB: spec.MemoryMiB},
397+ Boot: bootSource{KernelImagePath: f.kernel, BootArgs: bootArgs(netw.Guest, netw.Gateway, time.Now().Unix())},
398+ Drives: []drive{
399+ {DriveID: "rootfs", PathOnHost: f.execDisk, IsRootDevice: true, IsReadOnly: true},
400+ {DriveID: "base", PathOnHost: f.baseDisk, IsReadOnly: true},
401+ {DriveID: "scratch", PathOnHost: s.lay.fc("scratch.ext4"), RateLimiter: diskLimiter(cfg.DiskBandwidthMiB, cfg.DiskIOPS)},
402+ },
403+ Net: networkInterface{
404+ IfaceID: "eth0", HostDevName: s.tap, GuestMAC: macFor(s.slot),
405+ RxRateLimiter: netLimiter(cfg.NetMbit), TxRateLimiter: netLimiter(cfg.NetMbit),
406+ },
407+ Vsock: vsockDevice{GuestCID: 3, UDSPath: s.lay.fc(udsName)},
408+ }
409+}
410+
411+// waitExecutor waits for the guest's executor to connect. Only ctx (the caller's
412+// context, or Stop through the boot gate) and the boot timeout end the wait: a
413+// boot cannot be aborted any other way.
414+func (s *vmSandbox) waitExecutor(ctx context.Context, accepted <-chan acceptResult) (sandbox.Conn, error) {
415+ timer := time.NewTimer(s.d.opt.BootTimeout)
416+ defer timer.Stop()
417+ select {
418+ case r := <-accepted:
419+ if r.err != nil {
420+ return nil, fmt.Errorf("accept executor connection: %w", r.err)
421+ }
422+ s.conn = r.c
423+ return r.c, nil
424+ case <-s.exited:
425+ return nil, errors.New("firecracker exited before the executor connected")
426+ case <-timer.C:
427+ return nil, fmt.Errorf("executor did not connect within %s", s.d.opt.BootTimeout)
428+ case <-ctx.Done():
429+ return nil, context.Cause(ctx)
430+ }
431+}
432+
433+type acceptResult struct {
434+ c net.Conn
435+ err error
436+}
437+
438+// jailerArgs builds the jailer command line (after the binary name).
439+//
440+// - cgroup v2 with memory.max (guest memory + VMM overhead) and pids.max (a
441+// VMM needs a handful of threads per vCPU): a runaway or compromised VMM
442+// cannot take the host's memory or fork-bomb it;
443+// - a new PID namespace: the VMM cannot see or signal host processes;
444+// - no-file: the descriptor budget of the VMM.
445+func jailerArgs(jailer, fc, id string, uid int, chrootBase string, spec sandbox.JobSpec, extra []string) []string {
446+ memBytes := (int64(spec.MemoryMiB) + vmmOverheadMiB) << 20
447+ pids := 32 + 8*spec.CPUs
448+ args := []string{
449+ "--id", id, "--exec-file", fc,
450+ "--uid", strconv.Itoa(uid), "--gid", strconv.Itoa(uid),
451+ "--chroot-base-dir", chrootBase,
452+ "--cgroup-version", "2",
453+ "--cgroup", "memory.max=" + strconv.FormatInt(memBytes, 10),
454+ "--cgroup", "pids.max=" + strconv.Itoa(pids),
455+ "--new-pid-ns",
456+ "--resource-limit", "no-file=4096",
457+ "--",
458+ }
459+ return append(args, extra...)
460+}
461+
462+func (s *vmSandbox) startVMM(spec sandbox.JobSpec, apiSock string) error {
463+ d := s.d
464+ var cmd *exec.Cmd
465+ if s.lay.jailed {
466+ args := jailerArgs(d.jailer, d.fcPath, s.id, s.uid(), jailBase(d.cfg.StateDir), spec, d.opt.FirecrackerArgs)
467+ cmd = exec.Command(d.jailer, args...)
468+ } else {
469+ args := append([]string{"--id", s.id, "--api-sock", apiSock}, d.opt.FirecrackerArgs...)
470+ cmd = exec.Command(d.fcPath, args...)
471+ }
472+ s.console = newRing(16 << 10)
473+ cmd.Stdout, cmd.Stderr = s.console, s.console
474+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
475+ if err := cmd.Start(); err != nil {
476+ return fmt.Errorf("start firecracker: %w", err)
477+ }
478+ s.cmd = cmd
479+ s.exited = make(chan struct{})
480+ jailed := s.lay.jailed
481+ go func() {
482+ _ = cmd.Wait()
483+ // With --new-pid-ns the jailer may exit as soon as it has started the
484+ // VMM in its namespace: the VMM is then gone only when its own pid is.
485+ if jailed {
486+ s.awaitVMMGone()
487+ }
488+ close(s.exited)
489+ }()
490+ s.rec.PID = cmd.Process.Pid
491+ return s.d.store.save(s.rec)
492+}
493+
494+// awaitVMMGone blocks until no process of this VM's id is left.
495+func (s *vmSandbox) awaitVMMGone() {
496+ for {
497+ alive := false
498+ for pid, id := range vmmProcs(s.d.opt.ProcDir) {
499+ if id == s.id && pidAlive(pid) {
500+ alive = true
501+ }
502+ }
503+ if !alive {
504+ return
505+ }
506+ time.Sleep(50 * time.Millisecond)
507+ }
508+}
509+
510+func (s *vmSandbox) waitAPI(ctx context.Context, api *apiClient, sock string) error {
511+ deadline := time.Now().Add(10 * time.Second)
512+ for {
513+ if err := api.ready(ctx); err == nil {
514+ return nil
515+ }
516+ select {
517+ case <-s.exited:
518+ return errors.New("firecracker exited before its API was ready")
519+ case <-ctx.Done():
520+ return context.Cause(ctx)
521+ case <-time.After(25 * time.Millisecond):
522+ }
523+ if time.Now().After(deadline) {
524+ return fmt.Errorf("firecracker API socket %s not ready after 10s", sock)
525+ }
526+ }
527+}
528+
529+// Stop implements sandbox.Sandbox.
530+func (s *vmSandbox) Stop(ctx context.Context) error {
531+ // Abort a boot in progress first: Start holds s.mu for its whole duration.
532+ s.gate.Abort()
533+ s.mu.Lock()
534+ defer s.mu.Unlock()
535+ if s.stopped {
536+ return s.leakErr
537+ }
538+ if !s.started {
539+ s.releaseLocked()
540+ return nil
541+ }
542+ return s.cleanupLocked(context.WithoutCancel(ctx))
543+}
544+
545+func (s *vmSandbox) releaseLocked() {
546+ s.stopped = true
547+ s.d.release(s.slot)
548+}
549+
550+// vmmPIDs returns every host process of this VM: the launched command and
551+// whatever the /proc scan finds with this id.
552+func (s *vmSandbox) vmmPIDs() []int {
553+ set := map[int]bool{}
554+ if s.cmd != nil && s.cmd.Process != nil {
555+ set[s.cmd.Process.Pid] = true
556+ }
557+ for pid, id := range vmmProcs(s.d.opt.ProcDir) {
558+ if id == s.id {
559+ set[pid] = true
560+ }
561+ }
562+ var out []int
563+ for pid := range set {
564+ out = append(out, pid)
565+ }
566+ return out
567+}
568+
569+// cleanupLocked removes everything the VM created, in dependency order. Steps
570+// that need the VMM to be dead (network device, firewall rules, files, cgroup)
571+// are skipped when it could not be killed: removing the TAP or the policy under
572+// a VM that may still run would break the isolation it relies on, so the
573+// sandbox is reported as leaked (sandbox.ErrLeaked), its slot stays taken, and
574+// Reap finishes the job.
575+func (s *vmSandbox) cleanupLocked(ctx context.Context) error {
576+ log := zerowrap.FromCtx(ctx)
577+ d := s.d
578+ var errs []error
579+ if s.conn != nil {
580+ _ = s.conn.Close()
581+ s.conn = nil
582+ }
583+ if s.listener != nil {
584+ _ = s.listener.Close()
585+ s.listener = nil
586+ }
587+ dead := true
588+ if s.cmd != nil && s.cmd.Process != nil {
589+ _ = syscall.Kill(-s.cmd.Process.Pid, syscall.SIGKILL) // the whole group
590+ }
591+ for _, pid := range s.vmmPIDs() {
592+ if !d.opt.kill(pid, 5*time.Second) {
593+ dead = false
594+ errs = append(errs, fmt.Errorf("vmm process %d did not exit", pid))
595+ }
596+ }
597+ if dead && s.exited != nil {
598+ select {
599+ case <-s.exited:
600+ case <-time.After(5 * time.Second):
601+ dead = false
602+ errs = append(errs, errors.New("vmm exit was not observed"))
603+ }
604+ }
605+ if dead {
606+ if s.attached {
607+ if err := d.net.Detach(ctx, s.tap, s.gateway); err != nil {
608+ errs = append(errs, err)
609+ }
610+ if err := deleteTap(ctx, d.opt.Run, s.tap); err != nil {
611+ errs = append(errs, err)
612+ } else {
613+ s.attached = false
614+ }
615+ }
616+ if s.lay.dir != "" {
617+ if err := removeAll(s.lay.dir); err != nil {
618+ errs = append(errs, err)
619+ }
620+ }
621+ if s.lay.jailed {
622+ if err := removeCgroup(d.opt.CgroupRoot, filepath.Base(d.fcPath), s.id); err != nil {
623+ errs = append(errs, err)
624+ }
625+ }
626+ if len(errs) == 0 {
627+ if err := d.store.remove(s.id); err != nil {
628+ errs = append(errs, err)
629+ }
630+ }
631+ }
632+ if len(errs) == 0 {
633+ s.releaseLocked()
634+ return nil
635+ }
636+ s.stopped = true
637+ d.markLeaked(s.slot)
638+ s.leakErr = fmt.Errorf("%w: vm %s: %w", sandbox.ErrLeaked, s.id, errors.Join(errs...))
639+ log.Error().Err(s.leakErr).Str("vm", s.id).Msg("micro-VM cleanup incomplete; call Reap to finish it")
640+ return s.leakErr
641+}
642+
643+// removeAll is os.RemoveAll that also reports a directory that is still there.
644+func removeAll(dir string) error {
645+ if err := os.RemoveAll(dir); err != nil {
646+ return err
647+ }
648+ if _, err := os.Lstat(dir); err == nil {
649+ return fmt.Errorf("%s still exists after removal", dir)
650+ }
651+ return nil
652+}
653+
654+// removeCgroup removes the cgroup the jailer created for a VM
655+// (<root>/<parent>/<id>, parent = the exec file name). The jailer never removes
656+// it. A missing cgroup is fine; a non-empty one fails (rmdir semantics).
657+func removeCgroup(root, parent, id string) error {
658+ err := os.Remove(filepath.Join(root, parent, id))
659+ if err == nil || errors.Is(err, os.ErrNotExist) {
660+ return nil
661+ }
662+ return fmt.Errorf("remove cgroup %s: %w", filepath.Join(root, parent, id), err)
663+}
A internal/sandbox/vm/images.go +97 -0
@@ -0,0 +1,97 @@
1+// SPDX-License-Identifier: MIT
2+
3+package vm
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "encoding/hex"
9+ "fmt"
10+ "io"
11+ "os"
12+ "path/filepath"
13+
14+ "bnema.dev/ermite-runner/internal/cmdrun"
15+ "bnema.dev/ermite-runner/internal/sandbox"
16+)
17+
18+// ensureExecDisk builds (or reuses) the small read-only ext4 disk that carries
19+// the runner's own executable: the guest root during boot, and /opt/ermite
20+// afterwards. It is named after the executable's hash, so a runner upgrade
21+// builds a new one and no image rebuild is needed; older ones are removed.
22+// mkfs.ext4 -d needs no root.
23+func ensureExecDisk(ctx context.Context, run cmdrun.Runner, exe, stateDir string) (string, error) {
24+ sum, size, err := hashFile(exe)
25+ if err != nil {
26+ return "", fmt.Errorf("hash executable: %w", err)
27+ }
28+ cache := filepath.Join(stateDir, "cache")
29+ if err := os.MkdirAll(cache, 0o755); err != nil {
30+ return "", err
31+ }
32+ name := "execdisk-" + sum[:16] + ".ext4"
33+ dest := filepath.Join(cache, name)
34+ if _, err := os.Stat(dest); err == nil {
35+ return dest, nil
36+ }
37+ // Drop disks of other executable versions.
38+ if olds, _ := filepath.Glob(filepath.Join(cache, "execdisk-*")); len(olds) > 0 {
39+ for _, o := range olds {
40+ _ = os.Remove(o)
41+ }
42+ }
43+
44+ tmp, err := os.MkdirTemp(cache, "execroot-")
45+ if err != nil {
46+ return "", err
47+ }
48+ defer func() { _ = os.RemoveAll(tmp) }()
49+ for _, d := range []string{"proc", "sys", "dev", "mnt/base", "mnt/scratch", "mnt/newroot"} {
50+ if err := os.MkdirAll(filepath.Join(tmp, d), 0o755); err != nil {
51+ return "", err
52+ }
53+ }
54+ if err := copyFile(exe, filepath.Join(tmp, sandbox.GuestExecName)); err != nil {
55+ return "", fmt.Errorf("copy executable: %w", err)
56+ }
57+ if err := os.Chmod(filepath.Join(tmp, sandbox.GuestExecName), 0o755); err != nil {
58+ return "", err
59+ }
60+
61+ part := dest + ".part"
62+ _ = os.Remove(part)
63+ f, err := os.OpenFile(part, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
64+ if err != nil {
65+ return "", err
66+ }
67+ // The executable, ext4 overhead and some room; the disk is read-only.
68+ mib := size/(1<<20)*115/100 + 16
69+ if err := f.Truncate(mib << 20); err != nil {
70+ _ = f.Close()
71+ return "", err
72+ }
73+ _ = f.Close()
74+ if _, err := run.Run(ctx, nil, "mkfs.ext4", "-q", "-F", "-L", "ermite-exec",
75+ "-O", "^has_journal", "-E", "root_owner=0:0", "-d", tmp, part); err != nil {
76+ _ = os.Remove(part)
77+ return "", fmt.Errorf("build exec disk: %w", err)
78+ }
79+ if err := os.Rename(part, dest); err != nil {
80+ return "", err
81+ }
82+ return dest, nil
83+}
84+
85+func hashFile(path string) (sum string, size int64, err error) {
86+ f, err := os.Open(path)
87+ if err != nil {
88+ return "", 0, err
89+ }
90+ defer func() { _ = f.Close() }()
91+ h := sha256.New()
92+ n, err := io.Copy(h, f)
93+ if err != nil {
94+ return "", 0, err
95+ }
96+ return hex.EncodeToString(h.Sum(nil)), n, nil
97+}
A internal/sandbox/vm/layout.go +114 -0
@@ -0,0 +1,114 @@
1+// SPDX-License-Identifier: MIT
2+
3+package vm
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "syscall"
12+)
13+
14+// layout is where the files of one VM live and how Firecracker names them.
15+//
16+// With the jailer, Firecracker sees only its chroot: <jailBase>/firecracker/<id>/root,
17+// so shared files are hard-linked into it and named "/name"; per-job files are
18+// created there and handed to the jailed uid. Without the jailer (development
19+// and tests), Firecracker sees the real filesystem: shared files are used in
20+// place and per-job files live in <stateDir>/vm/<id>.
21+type layout struct {
22+ jailed bool
23+ root string // host path of the VM's working directory
24+ dir string // the directory removed whole at cleanup
25+ uid int
26+}
27+
28+// newLayout: execName is the base name of the Firecracker binary, which the
29+// jailer uses as the first directory below its chroot base.
30+func newLayout(stateDir, execName, id string, jailed bool, uid int) layout {
31+ if jailed {
32+ dir := filepath.Join(stateDir, "jail", execName, id)
33+ return layout{jailed: true, root: filepath.Join(dir, "root"), dir: dir, uid: uid}
34+ }
35+ dir := filepath.Join(stateDir, "vm", id)
36+ return layout{root: dir, dir: dir, uid: uid}
37+}
38+
39+// jailBase is the --chroot-base-dir given to the jailer.
40+func jailBase(stateDir string) string { return filepath.Join(stateDir, "jail") }
41+
42+// host returns the host path of a per-job file called name.
43+func (l layout) host(name string) string { return filepath.Join(l.root, name) }
44+
45+// fc returns the path Firecracker must be given for a per-job file.
46+func (l layout) fc(name string) string {
47+ if l.jailed {
48+ return "/" + name
49+ }
50+ return l.host(name)
51+}
52+
53+// share makes the read-only shared file src available to Firecracker and
54+// returns its path for Firecracker. In a jail it is hard-linked, which requires
55+// the same filesystem as the state directory, and src must be readable by the
56+// jailed uid (the link shares the inode: it is never chowned).
57+func (l layout) share(src, name string) (string, error) {
58+ if !l.jailed {
59+ return src, nil
60+ }
61+ dst := l.host(name)
62+ if err := os.Link(src, dst); err != nil {
63+ if errors.Is(err, syscall.EXDEV) {
64+ return "", fmt.Errorf("cannot link %s into the jail: it must be on the same filesystem as the state directory (hard link across filesystems)", src)
65+ }
66+ return "", fmt.Errorf("link %s into the jail: %w", src, err)
67+ }
68+ return "/" + name, nil
69+}
70+
71+// own hands a per-job file in the jail to the jailed uid; no-op without jailer.
72+// It never follows a symlink (Lchown): the jail is writable by the jailed uid
73+// once Firecracker runs, so a link planted there must not redirect the chown.
74+func (l layout) own(name string) error {
75+ if !l.jailed {
76+ return nil
77+ }
78+ return os.Lchown(l.host(name), l.uid, l.uid)
79+}
80+
81+// checkReadable verifies that other users can read the shared file, which the
82+// jailed uid needs. Called once per file at driver start for jailed mode.
83+func checkWorldReadable(path string) error {
84+ st, err := os.Stat(path)
85+ if err != nil {
86+ return err
87+ }
88+ if st.Mode().Perm()&0o004 == 0 {
89+ return fmt.Errorf("%s is not world-readable (mode %v): the jailed uid could not open it", path, st.Mode().Perm())
90+ }
91+ return nil
92+}
93+
94+// copyFile copies src to dst (mode 0600) and fsyncs it.
95+func copyFile(src, dst string) error {
96+ in, err := os.Open(src)
97+ if err != nil {
98+ return err
99+ }
100+ defer func() { _ = in.Close() }()
101+ out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
102+ if err != nil {
103+ return err
104+ }
105+ if _, err := io.Copy(out, in); err != nil {
106+ _ = out.Close()
107+ return err
108+ }
109+ if err := out.Sync(); err != nil {
110+ _ = out.Close()
111+ return err
112+ }
113+ return out.Close()
114+}
A internal/sandbox/vm/proc.go +122 -0
@@ -0,0 +1,122 @@
1+// SPDX-License-Identifier: MIT
2+
3+package vm
4+
5+import (
6+ "bytes"
7+ "os"
8+ "path/filepath"
9+ "strconv"
10+ "strings"
11+ "sync"
12+ "syscall"
13+ "time"
14+
15+ "bnema.dev/ermite-runner/internal/sandbox"
16+)
17+
18+// vmmProcs scans a /proc tree for processes started by this runner for a VM:
19+// the Firecracker process and the jailer. A process is ours when its command
20+// line has "--id <id>" with an id of the form sandbox.NewID produces and also
21+// "--api-sock" (Firecracker) or "--exec-file" (jailer). The program name is not
22+// looked at: the binary may be renamed or versioned (firecracker-v1.17), and a
23+// jailer in a PID namespace leaves a Firecracker whose argv[0] is whatever the
24+// jailer chose. Processes of other users of Firecracker (other ids) are never
25+// matched. It finds orphans even when the state directory was lost.
26+func vmmProcs(procDir string) map[int]string {
27+ out := map[int]string{}
28+ ents, err := os.ReadDir(procDir)
29+ if err != nil {
30+ return out
31+ }
32+ for _, e := range ents {
33+ pid, err := strconv.Atoi(e.Name())
34+ if err != nil || pid <= 1 {
35+ continue
36+ }
37+ b, err := os.ReadFile(filepath.Join(procDir, e.Name(), "cmdline"))
38+ if err != nil || len(b) == 0 {
39+ continue
40+ }
41+ if id := vmIDFromCmdline(strings.Split(strings.TrimRight(string(b), "\x00"), "\x00")); id != "" {
42+ out[pid] = id
43+ }
44+ }
45+ return out
46+}
47+
48+// vmIDFromCmdline returns the VM id of a command line, or "".
49+func vmIDFromCmdline(args []string) string {
50+ var id string
51+ var marker bool
52+ for i := 1; i < len(args); i++ {
53+ switch args[i] {
54+ case "--id":
55+ if i+1 < len(args) && sandbox.ValidID(args[i+1]) {
56+ id = args[i+1]
57+ }
58+ case "--api-sock", "--exec-file":
59+ marker = true
60+ }
61+ }
62+ if marker {
63+ return id
64+ }
65+ return ""
66+}
67+
68+// pidAlive reports whether pid exists and is not a zombie.
69+func pidAlive(pid int) bool {
70+ if err := syscall.Kill(pid, 0); err != nil {
71+ return false
72+ }
73+ if b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat"); err == nil {
74+ if i := bytes.LastIndexByte(b, ')'); i >= 0 && i+2 < len(b) && b[i+2] == 'Z' {
75+ return false
76+ }
77+ }
78+ return true
79+}
80+
81+// ring keeps the last N bytes written to it: the VMM's stdout/stderr, which
82+// carries the guest serial console, for diagnostics when a boot fails.
83+type ring struct {
84+ mu sync.Mutex
85+ max int
86+ buf []byte
87+}
88+
89+func newRing(max int) *ring { return &ring{max: max} }
90+
91+func (r *ring) Write(p []byte) (int, error) {
92+ r.mu.Lock()
93+ defer r.mu.Unlock()
94+ r.buf = append(r.buf, p...)
95+ if len(r.buf) > r.max {
96+ r.buf = append([]byte(nil), r.buf[len(r.buf)-r.max:]...)
97+ }
98+ return len(p), nil
99+}
100+
101+// String returns the retained tail, trimmed.
102+func (r *ring) String() string {
103+ r.mu.Lock()
104+ defer r.mu.Unlock()
105+ return string(bytes.TrimSpace(r.buf))
106+}
107+
108+// killAndWait sends SIGKILL to pid and waits until it is gone (or a zombie that
109+// its parent will reap). It reports whether the process is gone.
110+func killAndWait(pid int, timeout time.Duration) bool {
111+ _ = syscall.Kill(pid, syscall.SIGKILL)
112+ deadline := time.Now().Add(timeout)
113+ for {
114+ if !pidAlive(pid) {
115+ return true
116+ }
117+ if time.Now().After(deadline) {
118+ return false
119+ }
120+ time.Sleep(20 * time.Millisecond)
121+ }
122+}
A internal/sandbox/vm/reap.go +133 -0
@@ -0,0 +1,133 @@
1+// SPDX-License-Identifier: MIT
2+
3+package vm
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "github.com/bnema/zerowrap"
16+
17+ "bnema.dev/ermite-runner/internal/netpolicy"
18+ "bnema.dev/ermite-runner/internal/sandbox"
19+)
20+
21+// Reap implements sandbox.Driver. It removes what a previous run of the runner
22+// left: VMM processes (Firecracker and jailer: matched by "--id <our id>" in
23+// their command line, never by program name), TAP devices named ertap<N>, the
24+// nftables table, VM directories and jailer cgroups. It also finishes the
25+// cleanup of sandboxes that reported ErrLeaked and frees their slots. It relies
26+// on the host-wide lock taken in New: no other runner can own what it removes.
27+// It returns ErrBusy while a sandbox is running.
28+func (d *Driver) Reap(ctx context.Context) error {
29+ log := zerowrap.FromCtx(ctx)
30+ d.mu.Lock()
31+ busy := d.slots.Busy()
32+ d.mu.Unlock()
33+ if busy > 0 {
34+ return fmt.Errorf("%w (%d running)", sandbox.ErrBusy, busy)
35+ }
36+ var errs []error
37+
38+ recs, err := d.store.list()
39+ if err != nil {
40+ errs = append(errs, err)
41+ }
42+ procs := vmmProcs(d.opt.ProcDir)
43+ killed := map[int]bool{}
44+ killProc := func(pid int, id string) {
45+ if killed[pid] {
46+ return
47+ }
48+ killed[pid] = true
49+ log.Warn().Int("pid", pid).Str("vm", id).Msg("reaping leftover VMM process")
50+ if !d.opt.kill(pid, 5*time.Second) {
51+ errs = append(errs, fmt.Errorf("vmm process %d (%s) did not exit", pid, id))
52+ }
53+ }
54+ // 1. Processes. A recorded pid is only trusted when /proc/<pid>/cmdline still
55+ // carries this record's id (pids are reused); then every other process of
56+ // ours found in /proc, which also covers a lost state directory.
57+ for _, r := range recs {
58+ if r.PID > 0 && cmdlineID(d.opt.ProcDir, r.PID) == r.ID {
59+ killProc(r.PID, r.ID)
60+ }
61+ }
62+ for pid, id := range procs {
63+ killProc(pid, id)
64+ }
65+
66+ // 2. Network: the nft table first (it names interfaces), then every TAP.
67+ if err := d.net.Reset(ctx); err != nil {
68+ errs = append(errs, fmt.Errorf("reset nftables table %s: %w", netpolicy.Table, err))
69+ }
70+ taps, err := leftoverTaps(ctx, d.opt.Run)
71+ if err != nil {
72+ errs = append(errs, fmt.Errorf("list interfaces: %w", err))
73+ }
74+ for _, t := range taps {
75+ log.Warn().Str("tap", t).Msg("removing leftover TAP device")
76+ if err := deleteTap(ctx, d.opt.Run, t); err != nil {
77+ errs = append(errs, err)
78+ }
79+ }
80+
81+ // 3. Files and cgroups. The jailer's chroot base has one directory per
82+ // exec-file name.
83+ parents := []string{filepath.Join(d.cfg.StateDir, "vm")}
84+ jails, _ := os.ReadDir(filepath.Join(d.cfg.StateDir, "jail"))
85+ for _, j := range jails {
86+ if j.IsDir() {
87+ parents = append(parents, filepath.Join(d.cfg.StateDir, "jail", j.Name()))
88+ }
89+ }
90+ for _, parent := range parents {
91+ ents, _ := os.ReadDir(parent)
92+ for _, e := range ents {
93+ if sandbox.ValidID(e.Name()) {
94+ log.Warn().Str("dir", filepath.Join(parent, e.Name())).Msg("removing leftover VM directory")
95+ if err := removeAll(filepath.Join(parent, e.Name())); err != nil {
96+ errs = append(errs, err)
97+ }
98+ }
99+ }
100+ }
101+ cg := filepath.Join(d.opt.CgroupRoot, filepath.Base(d.fcPath))
102+ cents, _ := os.ReadDir(cg)
103+ for _, e := range cents {
104+ if e.IsDir() && sandbox.ValidID(e.Name()) {
105+ if err := removeCgroup(d.opt.CgroupRoot, filepath.Base(d.fcPath), e.Name()); err != nil {
106+ errs = append(errs, err)
107+ }
108+ }
109+ }
110+ if len(errs) == 0 {
111+ for _, r := range recs {
112+ if err := d.store.remove(r.ID); err != nil {
113+ errs = append(errs, err)
114+ }
115+ }
116+ }
117+ if len(errs) == 0 {
118+ d.mu.Lock()
119+ d.slots.ReleaseLeaked()
120+ d.mu.Unlock()
121+ }
122+ return errors.Join(errs...)
123+}
124+
125+// cmdlineID returns the VM id in the command line of pid, or "" (not running,
126+// not ours, or recycled for another program).
127+func cmdlineID(procDir string, pid int) string {
128+ b, err := os.ReadFile(filepath.Join(procDir, strconv.Itoa(pid), "cmdline"))
129+ if err != nil || len(b) == 0 {
130+ return ""
131+ }
132+ return vmIDFromCmdline(strings.Split(strings.TrimRight(string(b), "\x00"), "\x00"))
133+}
A internal/sandbox/vm/state.go +82 -0
@@ -0,0 +1,82 @@
1+// SPDX-License-Identifier: MIT
2+
3+package vm
4+
5+import (
6+ "encoding/json"
7+ "errors"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+)
12+
13+// record is the on-disk trace of one VM. It is written before the first
14+// resource is created and rewritten as resources appear, so that Reap can
15+// remove them after a crash of the runner. The record is deleted last.
16+type record struct {
17+ ID string `json:"id"`
18+ Slot int `json:"slot"`
19+ Job string `json:"job,omitempty"`
20+ PID int `json:"pid,omitempty"`
21+ Tap string `json:"tap,omitempty"`
22+ Gate string `json:"gateway,omitempty"`
23+ Dir string `json:"dir,omitempty"` // the jail directory of this VM (removed whole)
24+ Files []string `json:"files,omitempty"`
25+}
26+
27+type store struct{ dir string }
28+
29+func (s store) path(id string) string { return filepath.Join(s.dir, id+".json") }
30+
31+func (s store) save(r record) error {
32+ if err := os.MkdirAll(s.dir, 0o700); err != nil {
33+ return err
34+ }
35+ b, err := json.Marshal(r)
36+ if err != nil {
37+ return err
38+ }
39+ tmp := s.path(r.ID) + ".tmp"
40+ if err := os.WriteFile(tmp, b, 0o600); err != nil {
41+ return err
42+ }
43+ return os.Rename(tmp, s.path(r.ID))
44+}
45+
46+func (s store) remove(id string) error {
47+ if err := os.Remove(s.path(id)); err != nil && !errors.Is(err, os.ErrNotExist) {
48+ return err
49+ }
50+ return nil
51+}
52+
53+// list returns every readable record; unreadable or corrupt files are returned
54+// as ID-only records so that Reap still deletes them.
55+func (s store) list() ([]record, error) {
56+ ents, err := os.ReadDir(s.dir)
57+ if errors.Is(err, os.ErrNotExist) {
58+ return nil, nil
59+ }
60+ if err != nil {
61+ return nil, err
62+ }
63+ var out []record
64+ for _, e := range ents {
65+ name := e.Name()
66+ if e.IsDir() || !strings.HasSuffix(name, ".json") {
67+ // A *.tmp left by a crash mid-write.
68+ if strings.HasSuffix(name, ".tmp") {
69+ _ = os.Remove(filepath.Join(s.dir, name))
70+ }
71+ continue
72+ }
73+ id := strings.TrimSuffix(name, ".json")
74+ var r record
75+ if b, err := os.ReadFile(filepath.Join(s.dir, name)); err == nil && json.Unmarshal(b, &r) == nil && r.ID == id {
76+ out = append(out, r)
77+ continue
78+ }
79+ out = append(out, record{ID: id, Slot: -1})
80+ }
81+ return out, nil
82+}
A internal/sandbox/vm/tap.go +76 -0
@@ -0,0 +1,76 @@
1+// SPDX-License-Identifier: MIT
2+
3+package vm
4+
5+import (
6+ "context"
7+ "fmt"
8+ "net/netip"
9+ "strconv"
10+ "strings"
11+
12+ "bnema.dev/ermite-runner/internal/cmdrun"
13+)
14+
15+// tapPrefix must match the "ertap*" wildcard of the nftables policy.
16+const tapPrefix = "ertap"
17+
18+func tapName(slot int) string { return tapPrefix + strconv.Itoa(slot) }
19+
20+// createTap creates a persistent TAP owned by uid (the user Firecracker runs
21+// as, so it may open it), gives the host side its /30 address and brings it up.
22+// It needs CAP_NET_ADMIN. On error the TAP is removed again.
23+func createTap(ctx context.Context, run cmdrun.Runner, name string, uid int, gateway netip.Addr) error {
24+ u := strconv.Itoa(uid)
25+ if _, err := run.Run(ctx, nil, "ip", "tuntap", "add", "dev", name, "mode", "tap", "user", u, "group", u); err != nil {
26+ return fmt.Errorf("create tap %s: %w", name, err)
27+ }
28+ for _, args := range [][]string{
29+ {"addr", "add", gateway.String() + "/30", "dev", name},
30+ {"link", "set", "dev", name, "up"},
31+ } {
32+ if _, err := run.Run(ctx, nil, "ip", args...); err != nil {
33+ _ = deleteTap(context.WithoutCancel(ctx), run, name)
34+ return fmt.Errorf("configure tap %s: %w", name, err)
35+ }
36+ }
37+ return nil
38+}
39+
40+// deleteTap removes a TAP; a missing device is not an error.
41+func deleteTap(ctx context.Context, run cmdrun.Runner, name string) error {
42+ out, err := run.Run(ctx, nil, "ip", "link", "del", "dev", name)
43+ if err != nil && (strings.Contains(string(out), "Cannot find device") || strings.Contains(err.Error(), "Cannot find device")) {
44+ return nil
45+ }
46+ return err
47+}
48+
49+// leftoverTaps lists interfaces named like ours ("ertap<N>").
50+func leftoverTaps(ctx context.Context, run cmdrun.Runner) ([]string, error) {
51+ out, err := run.Run(ctx, nil, "ip", "-o", "link", "show")
52+ if err != nil {
53+ return nil, err
54+ }
55+ return parseTapNames(string(out)), nil
56+}
57+
58+// parseTapNames extracts "ertap<digits>" interface names from `ip -o link show`
59+// ("3: ertap0: <...>" or "3: ertap0@if2: <...>").
60+func parseTapNames(s string) []string {
61+ var out []string
62+ for _, line := range strings.Split(s, "\n") {
63+ _, rest, ok := strings.Cut(line, ": ")
64+ if !ok {
65+ continue
66+ }
67+ name, _, _ := strings.Cut(rest, ":")
68+ name, _, _ = strings.Cut(name, "@")
69+ if n, ok := strings.CutPrefix(name, tapPrefix); ok && n != "" {
70+ if _, err := strconv.Atoi(n); err == nil {
71+ out = append(out, name)
72+ }
73+ }
74+ }
75+ return out
76+}
A internal/sandbox/vm/vm_test.go +1024 -0
@@ -0,0 +1,1024 @@
1+// SPDX-License-Identifier: MIT
2+
3+package vm
4+
5+import (
6+ "bytes"
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "net"
13+ "net/http"
14+ "net/netip"
15+ "os"
16+ "os/exec"
17+ "path/filepath"
18+ "strings"
19+ "sync"
20+ "syscall"
21+ "testing"
22+ "time"
23+
24+ "github.com/bnema/zerowrap"
25+
26+ "bnema.dev/ermite-runner/internal/cmdrun"
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/disk"
31+ "bnema.dev/ermite-runner/internal/sandbox/sandboxtest"
32+)
33+
34+// The tests run a fake Firecracker: this test binary, re-executed through a
35+// shell script named "firecracker" with FC_HELPER set. It serves the HTTP API on
36+// the unix socket, records every request, and on InstanceStart plays the guest:
37+// it connects to the vsock socket and speaks the executor protocol.
38+func TestMain(m *testing.M) {
39+ if os.Getenv("FC_HELPER") != "" {
40+ fakeFirecracker()
41+ return
42+ }
43+ os.Exit(m.Run())
44+}
45+
46+func fakeFirecracker() {
47+ var sock, id string
48+ args := os.Args[1:]
49+ for i := 0; i+1 < len(args); i++ {
50+ switch args[i] {
51+ case "--api-sock":
52+ sock = args[i+1]
53+ case "--id":
54+ id = args[i+1]
55+ }
56+ }
57+ _ = id
58+ mode := os.Getenv("FC_MODE")
59+ if mode == "die" {
60+ fmt.Fprintln(os.Stderr, "fake firecracker: refusing to start")
61+ os.Exit(3)
62+ }
63+ logf, _ := os.OpenFile(os.Getenv("FC_LOG"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
64+ var vsock string
65+ mux := http.NewServeMux()
66+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
67+ body, _ := io.ReadAll(r.Body)
68+ if r.Method == http.MethodPut {
69+ _, _ = fmt.Fprintf(logf, "%s %s %s\n", r.Method, r.URL.Path, body)
70+ }
71+ if r.URL.Path == "/vsock" {
72+ var v vsockDevice
73+ _ = json.Unmarshal(body, &v)
74+ vsock = v.UDSPath
75+ }
76+ w.WriteHeader(http.StatusNoContent)
77+ if r.URL.Path == "/actions" && mode != "hang" {
78+ go func() {
79+ c, err := net.Dial("unix", fmt.Sprintf("%s_%d", vsock, sandbox.VsockPort))
80+ if err != nil {
81+ _, _ = fmt.Fprintln(logf, "vsock dial:", err)
82+ return
83+ }
84+ defer func() { _ = c.Close() }()
85+ g := execstream.NewConn(c)
86+ _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{Sandbox: "vm"}}})
87+ if _, err := g.Recv(); err != nil {
88+ return
89+ }
90+ _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Output{Output: &execpb.Output{Data: []byte("hello from fake guest\n")}}})
91+ _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{Code: 0}}})
92+ // Like a real VM, stay alive until killed.
93+ select {}
94+ }()
95+ }
96+ })
97+ ln, err := net.Listen("unix", sock)
98+ if err != nil {
99+ fmt.Fprintln(os.Stderr, err)
100+ os.Exit(2)
101+ }
102+ _ = http.Serve(ln, mux)
103+}
104+
105+type env struct {
106+ cfg sandbox.Config
107+ run *cmdrun.Fake
108+ plumb *sandboxtest.Plumbing
109+ log string
110+ taps map[string]bool
111+ mu sync.Mutex
112+}
113+
114+// newEnv prepares a state dir, fake binaries, kernel and images.
115+func newEnv(t *testing.T, mode string) *env { return newEnvNamed(t, mode, "firecracker") }
116+
117+// newEnvNamed runs the fake VMM under another program name (argv[0]).
118+func newEnvNamed(t *testing.T, mode, argv0 string) *env {
119+ t.Helper()
120+ bash, err := exec.LookPath("bash")
121+ if err != nil {
122+ t.Skip("bash needed for the fake firecracker")
123+ }
124+ dir := t.TempDir()
125+ e := &env{taps: map[string]bool{}, plumb: sandboxtest.NewPlumbing(), log: filepath.Join(dir, "fc.log")}
126+ self, _ := os.Executable()
127+ fc := filepath.Join(dir, "firecracker")
128+ script := fmt.Sprintf("#!%s\nexport FC_HELPER=1 FC_MODE=%q FC_LOG=%q\nexec -a %q %q \"$@\"\n", bash, mode, e.log, argv0, self)
129+ if err := os.WriteFile(fc, []byte(script), 0o755); err != nil {
130+ t.Fatal(err)
131+ }
132+ img := filepath.Join(dir, "image")
133+ _ = os.MkdirAll(img, 0o755)
134+ for _, f := range []string{"vmlinux", "base.ext4"} {
135+ _ = os.WriteFile(filepath.Join(img, f), []byte("x"), 0o644)
136+ }
137+ exe := filepath.Join(dir, "runner")
138+ sandboxtest.WriteStaticELF(t, exe)
139+ e.cfg = sandbox.Config{
140+ Isolation: sandbox.VM, StateDir: filepath.Join(dir, "state"), Capacity: 2, Image: img,
141+ JobCPUs: 1, JobMemoryMiB: 512, JobDiskMiB: 1024, UIDBase: 900000, KVMPath: "/dev/null",
142+ Network: mustPrefix("10.200.0.0/16"), Firecracker: fc, JailerOff: true, Executable: exe, DNSPort: 0,
143+ LockFile: filepath.Join(dir, "host.lock"), JobPids: 1000, DiskBandwidthMiB: 400, DiskIOPS: 20000, NetMbit: 1000,
144+ }
145+ e.run = &cmdrun.Fake{Handle: func(c cmdrun.Call) ([]byte, error) {
146+ e.mu.Lock()
147+ defer e.mu.Unlock()
148+ switch {
149+ case c.Name == "mkfs.ext4" && len(c.Args) == 1 && c.Args[0] == "-V":
150+ return []byte("mke2fs 1.47.4 (6-Mar-2025)\n"), nil
151+ case c.Name == "ip" && len(c.Args) > 1 && c.Args[0] == "tuntap":
152+ e.taps[c.Args[3]] = true
153+ case c.Name == "ip" && len(c.Args) > 2 && c.Args[0] == "link" && c.Args[1] == "del":
154+ if !e.taps[c.Args[3]] {
155+ return []byte("Cannot find device"), errors.New("exit status 1")
156+ }
157+ delete(e.taps, c.Args[3])
158+ case c.Name == "ip" && len(c.Args) > 1 && c.Args[0] == "-o":
159+ var b strings.Builder
160+ i := 1
161+ for t := range e.taps {
162+ fmt.Fprintf(&b, "%d: %s: <BROADCAST> mtu 1500\n", i, t)
163+ i++
164+ }
165+ return []byte(b.String()), nil
166+ }
167+ return nil, nil
168+ }}
169+ return e
170+}
171+
172+func (e *env) tapCount() int { e.mu.Lock(); defer e.mu.Unlock(); return len(e.taps) }
173+
174+func (e *env) driver(t *testing.T) *Driver {
175+ t.Helper()
176+ d, err := New(context.Background(), e.cfg, Options{Run: e.run, Net: e.plumb, BootTimeout: 5 * time.Second})
177+ if err != nil {
178+ t.Fatal(err)
179+ }
180+ t.Cleanup(func() { _ = d.Close() })
181+ return d
182+}
183+
184+// leftovers lists everything that must not survive a job.
185+func (e *env) leftovers(t *testing.T) []string {
186+ t.Helper()
187+ var out []string
188+ _ = filepath.WalkDir(e.cfg.StateDir, func(p string, d os.DirEntry, err error) error {
189+ rel, _ := filepath.Rel(e.cfg.StateDir, p)
190+ switch {
191+ case err != nil || rel == ".":
192+ case strings.HasPrefix(rel, "vm/") || strings.HasPrefix(rel, "vms/") || strings.HasPrefix(rel, "jail/"):
193+ out = append(out, rel)
194+ }
195+ return nil
196+ })
197+ if n := e.tapCount(); n != 0 {
198+ out = append(out, fmt.Sprintf("%d TAP devices", n))
199+ }
200+ if n := e.plumb.Live(); n != 0 {
201+ out = append(out, fmt.Sprintf("%d attached guests", n))
202+ }
203+ return out
204+}
205+
206+func runStub(t *testing.T, conn sandbox.Conn) (string, int) {
207+ t.Helper()
208+ var out strings.Builder
209+ _, exit, err := execstream.Drive(conn, &execpb.Run{}, nil, func(o *execpb.Output) { out.Write(o.Data) })
210+ if err != nil {
211+ t.Fatal(err)
212+ }
213+ return out.String(), int(exit.Code)
214+}
215+
216+func TestStartRunStop(t *testing.T) {
217+ e := newEnv(t, "")
218+ d := e.driver(t)
219+ sb, err := d.New()
220+ if err != nil {
221+ t.Fatal(err)
222+ }
223+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "job-1"})
224+ if err != nil {
225+ t.Fatal(err)
226+ }
227+ out, code := runStub(t, conn)
228+ if out != "hello from fake guest\n" || code != 0 {
229+ t.Fatalf("out=%q code=%d", out, code)
230+ }
231+ // While running: record, TAP, attachment, process.
232+ if e.tapCount() != 1 || e.plumb.Live() != 1 {
233+ t.Fatalf("tap=%d attached=%d", e.tapCount(), e.plumb.Live())
234+ }
235+ if recs, _ := d.store.list(); len(recs) != 1 || recs[0].Job != "job-1" || recs[0].PID == 0 || recs[0].Tap != "ertap0" {
236+ t.Fatalf("records: %+v", recs)
237+ }
238+ pid := sb.(*vmSandbox).rec.PID
239+
240+ if err := sb.Stop(context.Background()); err != nil {
241+ t.Fatal(err)
242+ }
243+ if left := e.leftovers(t); len(left) != 0 {
244+ t.Fatalf("leftovers after Stop: %v", left)
245+ }
246+ if err := syscall.Kill(pid, 0); err == nil {
247+ t.Fatalf("firecracker pid %d still alive", pid)
248+ }
249+ if err := sb.Stop(context.Background()); err != nil {
250+ t.Fatalf("Stop must be idempotent: %v", err)
251+ }
252+ if d.slots.InUse() != 0 {
253+ t.Fatal("slot not released")
254+ }
255+}
256+
257+// The API calls must follow the order Firecracker needs and carry the
258+// plan's disk layout: exec disk (boot root, RO), base (RO), scratch (RW).
259+func TestAPICallsAndDiskLayout(t *testing.T) {
260+ e := newEnv(t, "")
261+ d := e.driver(t)
262+ sb, _ := d.New()
263+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j", CPUs: 3, MemoryMiB: 768, DiskMiB: 2048})
264+ if err != nil {
265+ t.Fatal(err)
266+ }
267+ runStub(t, conn)
268+ defer func() { _ = sb.Stop(context.Background()) }()
269+
270+ b, _ := os.ReadFile(e.log)
271+ lines := strings.Split(strings.TrimSpace(string(b)), "\n")
272+ var paths []string
273+ for _, l := range lines {
274+ f := strings.Fields(l)
275+ if len(f) >= 2 && f[0] == "PUT" {
276+ paths = append(paths, f[1])
277+ }
278+ }
279+ want := []string{"/machine-config", "/boot-source", "/drives/rootfs", "/drives/base", "/drives/scratch", "/network-interfaces/eth0", "/vsock", "/entropy", "/actions"}
280+ if strings.Join(paths, ",") != strings.Join(want, ",") {
281+ t.Fatalf("call order:\n got %v\nwant %v", paths, want)
282+ }
283+ log := string(b)
284+ for _, frag := range []string{
285+ `"vcpu_count":3`, `"mem_size_mib":768`, `"smt":false`,
286+ `"drive_id":"rootfs"`, `"is_root_device":true,"is_read_only":true`,
287+ `"drive_id":"base"`, `"is_root_device":false,"is_read_only":true`,
288+ `"drive_id":"scratch"`, `"is_root_device":false,"is_read_only":false`,
289+ `"host_dev_name":"ertap0"`, `"guest_cid":3`, `"action_type":"InstanceStart"`,
290+ "ipv6.disable=1", "ermite.connect=vsock:1024", "ermite.gw=10.200.0.1", "ip=10.200.0.2::10.200.0.1:255.255.255.252",
291+ } {
292+ if !strings.Contains(log, frag) {
293+ t.Errorf("API log lacks %q", frag)
294+ }
295+ }
296+ // The scratch disk was created sparse at the requested size, and formatted.
297+ var mkfs []string
298+ for _, l := range e.run.Lines() {
299+ if strings.HasPrefix(l, "mkfs.ext4") {
300+ mkfs = append(mkfs, l)
301+ }
302+ }
303+ if len(mkfs) == 0 {
304+ t.Fatalf("no mkfs call: %v", e.run.Lines())
305+ }
306+}
307+
308+func TestJobsDoNotShareSlotsOrTaps(t *testing.T) {
309+ e := newEnv(t, "")
310+ d := e.driver(t)
311+ var sbs []sandbox.Sandbox
312+ for i := range 2 {
313+ sb, err := d.New()
314+ if err != nil {
315+ t.Fatal(err)
316+ }
317+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: fmt.Sprint(i)})
318+ if err != nil {
319+ t.Fatal(err)
320+ }
321+ runStub(t, conn)
322+ sbs = append(sbs, sb)
323+ }
324+ if _, err := d.New(); !errors.Is(err, sandbox.ErrCapacity) {
325+ t.Fatalf("capacity not enforced: %v", err)
326+ }
327+ if e.tapCount() != 2 {
328+ t.Fatalf("taps: %v", e.taps)
329+ }
330+ for _, sb := range sbs {
331+ _ = sb.Stop(context.Background())
332+ }
333+ if left := e.leftovers(t); len(left) != 0 {
334+ t.Fatalf("leftovers: %v", left)
335+ }
336+}
337+
338+func TestStartFailureCleansEverything(t *testing.T) {
339+ e := newEnv(t, "die")
340+ d := e.driver(t)
341+ sb, _ := d.New()
342+ _, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
343+ if err == nil || !strings.Contains(err.Error(), "refusing to start") {
344+ t.Fatalf("want the VMM console in the error, got %v", err)
345+ }
346+ if left := e.leftovers(t); len(left) != 0 {
347+ t.Fatalf("leftovers after failed Start: %v", left)
348+ }
349+ if d.slots.InUse() != 0 {
350+ t.Fatal("slot leaked by a failed Start")
351+ }
352+}
353+
354+func TestBootTimeoutCleansEverything(t *testing.T) {
355+ e := newEnv(t, "hang")
356+ d, err := New(context.Background(), e.cfg, Options{Run: e.run, Net: e.plumb, BootTimeout: 300 * time.Millisecond})
357+ if err != nil {
358+ t.Fatal(err)
359+ }
360+ defer func() { _ = d.Close() }()
361+ sb, _ := d.New()
362+ _, err = sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
363+ if err == nil || !strings.Contains(err.Error(), "did not connect") {
364+ t.Fatalf("got %v", err)
365+ }
366+ if left := e.leftovers(t); len(left) != 0 {
367+ t.Fatalf("leftovers: %v", left)
368+ }
369+}
370+
371+func TestStartCancelledByContext(t *testing.T) {
372+ e := newEnv(t, "hang")
373+ d := e.driver(t)
374+ sb, _ := d.New()
375+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
376+ defer cancel()
377+ if _, err := sb.Start(ctx, sandbox.JobSpec{JobID: "j"}); !errors.Is(err, context.DeadlineExceeded) {
378+ t.Fatalf("got %v", err)
379+ }
380+ if left := e.leftovers(t); len(left) != 0 {
381+ t.Fatalf("leftovers: %v", left)
382+ }
383+}
384+
385+func TestNetworkAttachFailureCleans(t *testing.T) {
386+ e := newEnv(t, "")
387+ d := e.driver(t)
388+ e.plumb.FailNext = errors.New("nft broke")
389+ sb, _ := d.New()
390+ if _, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}); err == nil || !strings.Contains(err.Error(), "nft broke") {
391+ t.Fatalf("got %v", err)
392+ }
393+ if left := e.leftovers(t); len(left) != 0 {
394+ t.Fatalf("leftovers: %v", left)
395+ }
396+}
397+
398+func TestStopWithoutStartReleasesSlot(t *testing.T) {
399+ e := newEnv(t, "")
400+ d := e.driver(t)
401+ sb, _ := d.New()
402+ if err := sb.Stop(context.Background()); err != nil {
403+ t.Fatal(err)
404+ }
405+ if d.slots.InUse() != 0 {
406+ t.Fatal("slot leaked")
407+ }
408+ if _, err := sb.Start(context.Background(), sandbox.JobSpec{}); err == nil {
409+ t.Fatal("Start after Stop accepted")
410+ }
411+}
412+
413+// Stop must finish the cleanup even when the caller's context is already done
414+// (a cancelled job).
415+func TestStopRunsWithCancelledContext(t *testing.T) {
416+ e := newEnv(t, "")
417+ d := e.driver(t)
418+ sb, _ := d.New()
419+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
420+ if err != nil {
421+ t.Fatal(err)
422+ }
423+ runStub(t, conn)
424+ ctx, cancel := context.WithCancel(context.Background())
425+ cancel()
426+ if err := sb.Stop(ctx); err != nil {
427+ t.Fatal(err)
428+ }
429+ if left := e.leftovers(t); len(left) != 0 {
430+ t.Fatalf("leftovers: %v", left)
431+ }
432+}
433+
434+// A killed runner leaves a running Firecracker, a TAP, nft rules and files. The
435+// next start must reap all of it (AC4: nothing survives, incl. killed jobs).
436+func TestReapAfterRunnerCrash(t *testing.T) {
437+ e := newEnv(t, "")
438+ d1 := e.driver(t)
439+ sb, _ := d1.New()
440+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
441+ if err != nil {
442+ t.Fatal(err)
443+ }
444+ runStub(t, conn)
445+ pid := sb.(*vmSandbox).rec.PID
446+ if err := syscall.Kill(pid, 0); err != nil {
447+ t.Fatal("fake VM not running")
448+ }
449+ // The runner dies: its lock goes away, nothing else is cleaned.
450+ _ = d1.lock.Release()
451+ _ = conn.Close()
452+ if e.tapCount() != 1 {
453+ t.Fatal("setup: no TAP")
454+ }
455+
456+ d2 := e.driver(t)
457+ if err := d2.Reap(context.Background()); err != nil {
458+ t.Fatal(err)
459+ }
460+ if left := e.leftovers(t); len(left) != 0 {
461+ t.Fatalf("leftovers after Reap: %v", left)
462+ }
463+ deadline := time.Now().Add(3 * time.Second)
464+ for syscall.Kill(pid, 0) == nil && time.Now().Before(deadline) {
465+ // The fake is a child of this test process: it is a zombie until the
466+ // original driver's Wait goroutine collects it.
467+ if b, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err == nil && strings.Contains(string(b), ") Z") {
468+ break
469+ }
470+ time.Sleep(20 * time.Millisecond)
471+ }
472+ if b, _ := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); len(b) > 0 && !strings.Contains(string(b), ") Z") {
473+ t.Fatalf("firecracker %d survived Reap: %s", pid, b)
474+ }
475+ if e.plumb.Resets == 0 {
476+ t.Fatal("the nftables table was not reset")
477+ }
478+}
479+
480+func TestReapRemovesCorruptRecordAndTempFiles(t *testing.T) {
481+ e := newEnv(t, "")
482+ d := e.driver(t)
483+ _ = os.MkdirAll(d.store.dir, 0o700)
484+ _ = os.WriteFile(filepath.Join(d.store.dir, "er00112233.json"), []byte("{garbage"), 0o600)
485+ _ = os.WriteFile(filepath.Join(d.store.dir, "er44556677.json.tmp"), []byte("{"), 0o600)
486+ _ = os.MkdirAll(filepath.Join(e.cfg.StateDir, "vm", "er00112233"), 0o700)
487+ _ = os.MkdirAll(filepath.Join(e.cfg.StateDir, "vm", "not-ours"), 0o700) // must be left alone
488+ if err := d.Reap(context.Background()); err != nil {
489+ t.Fatal(err)
490+ }
491+ if _, err := os.Stat(filepath.Join(e.cfg.StateDir, "vm", "not-ours")); err != nil {
492+ t.Fatal("Reap removed a directory that is not ours")
493+ }
494+ _ = os.Remove(filepath.Join(e.cfg.StateDir, "vm", "not-ours"))
495+ if left := e.leftovers(t); len(left) != 0 {
496+ t.Fatalf("leftovers: %v", left)
497+ }
498+}
499+
500+func TestReapLeavesForeignTapsAlone(t *testing.T) {
501+ e := newEnv(t, "")
502+ e.taps["tap0"] = true
503+ e.taps["ertapx"] = true // not ertap<digits>
504+ e.taps["ertap7"] = true
505+ d := e.driver(t)
506+ if err := d.Reap(context.Background()); err != nil {
507+ t.Fatal(err)
508+ }
509+ if !e.taps["tap0"] || !e.taps["ertapx"] || e.taps["ertap7"] {
510+ t.Fatalf("taps after reap: %v", e.taps)
511+ }
512+}
513+
514+func TestSecondRunnerOnSameStateDirRefused(t *testing.T) {
515+ e := newEnv(t, "")
516+ _ = e.driver(t)
517+ _, err := New(context.Background(), e.cfg, Options{Run: e.run, Net: e.plumb})
518+ if err == nil || !strings.Contains(err.Error(), "another ermite-runner is running on this host") {
519+ t.Fatalf("got %v", err)
520+ }
521+}
522+
523+func TestNewRejectsDynamicExecutable(t *testing.T) {
524+ e := newEnv(t, "")
525+ e.cfg.Executable = "/bin/sh"
526+ if _, err := New(context.Background(), e.cfg, Options{Run: e.run, Net: e.plumb}); err == nil {
527+ t.Skip("/bin/sh is static here")
528+ }
529+}
530+
531+func TestExecDiskIsKeyedByExecutableHash(t *testing.T) {
532+ e := newEnv(t, "")
533+ d1 := e.driver(t)
534+ first := d1.execDisk
535+ _ = d1.Close()
536+ // Same executable: reused, no second mkfs of an exec disk.
537+ n := len(e.run.Calls)
538+ d2 := e.driver(t)
539+ if d2.execDisk != first {
540+ t.Fatal("exec disk rebuilt for the same executable")
541+ }
542+ for _, c := range e.run.Calls[n:] {
543+ if c.Name == "mkfs.ext4" && (len(c.Args) != 1 || c.Args[0] != "-V") {
544+ t.Fatal("mkfs run again for an unchanged executable")
545+ }
546+ }
547+ _ = d2.Close()
548+ // A runner upgrade: new disk, old one removed.
549+ if err := os.WriteFile(e.cfg.Executable, append(readFile(t, e.cfg.Executable), 0), 0o755); err != nil {
550+ t.Fatal(err)
551+ }
552+ d3 := e.driver(t)
553+ if d3.execDisk == first {
554+ t.Fatal("exec disk not rebuilt after the executable changed")
555+ }
556+ if _, err := os.Stat(first); err == nil {
557+ t.Fatal("stale exec disk kept")
558+ }
559+}
560+
561+func readFile(t *testing.T, p string) []byte {
562+ t.Helper()
563+ b, err := os.ReadFile(p)
564+ if err != nil {
565+ t.Fatal(err)
566+ }
567+ return b
568+}
569+
570+func TestJailedLayout(t *testing.T) {
571+ l := newLayout("/var/lib/er", "firecracker", "er00112233", true, 900003)
572+ if l.root != "/var/lib/er/jail/firecracker/er00112233/root" || l.dir != "/var/lib/er/jail/firecracker/er00112233" {
573+ t.Fatalf("%+v", l)
574+ }
575+ if l.fc("scratch.ext4") != "/scratch.ext4" || l.host("scratch.ext4") != l.root+"/scratch.ext4" {
576+ t.Fatal("jailed paths")
577+ }
578+ if jailBase("/var/lib/er") != "/var/lib/er/jail" {
579+ t.Fatal("jail base")
580+ }
581+ if l2 := newLayout("/var/lib/er", "firecracker-v1.17", "er00112233", true, 1); l2.dir != "/var/lib/er/jail/firecracker-v1.17/er00112233" {
582+ t.Fatalf("the jailer names its chroot after the exec file: %s", l2.dir)
583+ }
584+ u := newLayout("/var/lib/er", "firecracker", "er00112233", false, 1000)
585+ if u.fc("api.sock") != "/var/lib/er/vm/er00112233/api.sock" {
586+ t.Fatalf("unjailed path %s", u.fc("api.sock"))
587+ }
588+ if p, _ := u.share("/img/base.ext4", "base.ext4"); p != "/img/base.ext4" {
589+ t.Fatal("unjailed share must use the file in place")
590+ }
591+}
592+
593+func TestJailedShareHardLinks(t *testing.T) {
594+ dir := t.TempDir()
595+ src := filepath.Join(dir, "base.ext4")
596+ _ = os.WriteFile(src, []byte("data"), 0o644)
597+ l := newLayout(dir, "firecracker", "er00112233", true, os.Getuid())
598+ if err := os.MkdirAll(l.root, 0o700); err != nil {
599+ t.Fatal(err)
600+ }
601+ p, err := l.share(src, "base.ext4")
602+ if err != nil || p != "/base.ext4" {
603+ t.Fatalf("%q %v", p, err)
604+ }
605+ a, _ := os.Stat(src)
606+ b, _ := os.Stat(l.host("base.ext4"))
607+ if !os.SameFile(a, b) {
608+ t.Fatal("shared file must be a hard link, never a copy")
609+ }
610+ if err := checkWorldReadable(src); err != nil {
611+ t.Fatal(err)
612+ }
613+ _ = os.Chmod(src, 0o600)
614+ if err := checkWorldReadable(src); err == nil {
615+ t.Fatal("unreadable-by-others file accepted for the jail")
616+ }
617+}
618+
619+func TestBootArgs(t *testing.T) {
620+ a := bootArgs(mustAddr("10.200.0.6"), mustAddr("10.200.0.5"), 1791818522)
621+ for _, want := range []string{
622+ "ipv6.disable=1", "root=/dev/vda ro", "init=/ermite-runner", "reboot=k", "panic=1",
623+ "ip=10.200.0.6::10.200.0.5:255.255.255.252:ermite-job:eth0:off:10.200.0.5",
624+ "ermite.sandbox=vm", "ermite.connect=vsock:1024", "ermite.gw=10.200.0.5", "ermite.time=1791818522",
625+ } {
626+ if !strings.Contains(a, want) {
627+ t.Errorf("boot args lack %q: %s", want, a)
628+ }
629+ }
630+ if !strings.HasSuffix(a, "-- init") {
631+ t.Errorf("init subcommand missing: %s", a)
632+ }
633+ if macFor(0) != "06:00:ac:10:00:00" || macFor(257) != "06:00:ac:10:01:01" {
634+ t.Fatal(macFor(257))
635+ }
636+}
637+
638+func TestParseTapNames(t *testing.T) {
639+ in := "1: lo: <LOOPBACK> mtu 65536\n2: eth0: <UP> mtu 1500\n5: ertap0: <BROADCAST> mtu 1500\n9: ertap12@NONE: <X>\n10: ertapx: <X>\n11: tap0: <X>\n12: ertap: <X>\n"
640+ got := strings.Join(parseTapNames(in), ",")
641+ if got != "ertap0,ertap12" {
642+ t.Fatalf("got %q", got)
643+ }
644+}
645+
646+func TestFirecrackerProcsScan(t *testing.T) {
647+ proc := t.TempDir()
648+ mk := func(pid, cmdline string) {
649+ _ = os.MkdirAll(filepath.Join(proc, pid), 0o755)
650+ _ = os.WriteFile(filepath.Join(proc, pid, "cmdline"), []byte(cmdline), 0o644)
651+ }
652+ mk("100", "firecracker\x00--id\x00er00112233\x00--api-sock\x00/x\x00")
653+ mk("101", "/usr/bin/firecracker\x00--api-sock\x00/x\x00--id\x00er44556677\x00")
654+ mk("102", "firecracker\x00--id\x00other-tool-vm\x00") // someone else's VM
655+ mk("103", "bash\x00--id\x00er00112233\x00") // not firecracker
656+ mk("1", "firecracker\x00--id\x00er99999999\x00") // pid 1 is never touched
657+ mk("abc", "")
658+ got := vmmProcs(proc)
659+ if len(got) != 2 || got[100] != "er00112233" || got[101] != "er44556677" {
660+ t.Fatalf("%v", got)
661+ }
662+}
663+
664+func TestRingKeepsTail(t *testing.T) {
665+ r := newRing(8)
666+ _, _ = r.Write([]byte("0123456789"))
667+ _, _ = r.Write([]byte("ab"))
668+ if r.String() != "23456789ab"[2:] {
669+ t.Fatalf("%q", r.String())
670+ }
671+}
672+
673+func TestAPIErrorsSurfaceFirecrackerFault(t *testing.T) {
674+ sock := filepath.Join(t.TempDir(), "s")
675+ ln, err := net.Listen("unix", sock)
676+ if err != nil {
677+ t.Fatal(err)
678+ }
679+ defer func() { _ = ln.Close() }()
680+ go func() {
681+ _ = http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
682+ w.WriteHeader(400)
683+ _, _ = w.Write([]byte(`{"fault_message":"Invalid request"}`))
684+ }))
685+ }()
686+ err = newAPIClient(sock).put(context.Background(), "/boot-source", bootSource{})
687+ if err == nil || !strings.Contains(err.Error(), "Invalid request") || !strings.Contains(err.Error(), "/boot-source") {
688+ t.Fatalf("got %v", err)
689+ }
690+}
691+
692+func mustPrefix(s string) netip.Prefix { return netip.MustParsePrefix(s) }
693+func mustAddr(s string) netip.Addr { return netip.MustParseAddr(s) }
694+
695+// C3: processes are matched by "--id <our id>" in their command line, not by the
696+// program name: a renamed or versioned Firecracker binary is still found and
697+// reaped, and a recycled pid is never killed on the strength of a record alone.
698+func TestReapFindsRenamedVMMByID(t *testing.T) {
699+ e := newEnvNamed(t, "", "fc-renamed-1.17")
700+ d1 := e.driver(t)
701+ sb, _ := d1.New()
702+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
703+ if err != nil {
704+ t.Fatal(err)
705+ }
706+ runStub(t, conn)
707+ pid := sb.(*vmSandbox).rec.PID
708+ if got := cmdlineID("/proc", pid); got != sb.ID() {
709+ t.Fatalf("cmdlineID=%q want %q (argv[0] is %q)", got, sb.ID(), "fc-renamed-1.17")
710+ }
711+ _ = d1.lock.Release()
712+ _ = conn.Close()
713+ d2 := e.driver(t)
714+ if err := d2.Reap(context.Background()); err != nil {
715+ t.Fatal(err)
716+ }
717+ if pidAlive(pid) {
718+ t.Fatalf("a VMM with a renamed binary survived Reap (pid %d)", pid)
719+ }
720+}
721+
722+func TestVMIDFromCmdline(t *testing.T) {
723+ for name, tc := range map[string]struct {
724+ args []string
725+ want string
726+ }{
727+ "firecracker": {[]string{"firecracker", "--id", "er00112233", "--api-sock", "/x"}, "er00112233"},
728+ "jailer": {[]string{"jailer", "--id", "er00112233", "--exec-file", "/fc", "--uid", "1"}, "er00112233"},
729+ "renamed": {[]string{"anything", "--api-sock", "/x", "--id", "er44556677"}, "er44556677"},
730+ "foreign id": {[]string{"firecracker", "--id", "my-vm", "--api-sock", "/x"}, ""},
731+ "no marker": {[]string{"python", "--id", "er00112233"}, ""},
732+ "id without value": {[]string{"firecracker", "--api-sock", "/x", "--id"}, ""},
733+ "path-like id": {[]string{"firecracker", "--id", "er../../../x", "--api-sock", "/x"}, ""},
734+ } {
735+ if got := vmIDFromCmdline(tc.args); got != tc.want {
736+ t.Errorf("%s: got %q want %q", name, got, tc.want)
737+ }
738+ }
739+}
740+
741+func TestReapDoesNotTrustARecycledPid(t *testing.T) {
742+ e := newEnv(t, "")
743+ d := e.driver(t)
744+ // A record pointing at this very test process: its command line carries no
745+ // VM id, so it must not be killed.
746+ _ = d.store.save(record{ID: "er00112233", Slot: 0, PID: os.Getpid()})
747+ if err := d.Reap(context.Background()); err != nil {
748+ t.Fatal(err)
749+ }
750+ if !pidAlive(os.Getpid()) {
751+ t.Fatal("Reap killed a process on the strength of a stale record")
752+ }
753+}
754+
755+// C5 + C8: when the VMM cannot be killed, the TAP, the firewall policy and the
756+// files are left alone (the VM may still be running), the error wraps ErrLeaked,
757+// the slot stays taken, and Reap (with a working kill) finishes the cleanup and
758+// frees the slot.
759+func TestStopKeepsNetworkWhenTheVMMSurvives(t *testing.T) {
760+ e := newEnv(t, "")
761+ stubborn := true
762+ d, err := New(context.Background(), e.cfg, Options{Run: e.run, Net: e.plumb, BootTimeout: 5 * time.Second,
763+ kill: func(pid int, to time.Duration) bool {
764+ if stubborn {
765+ return false
766+ }
767+ return killAndWait(pid, to)
768+ }})
769+ if err != nil {
770+ t.Fatal(err)
771+ }
772+ defer func() { _ = d.Close() }()
773+ sb, _ := d.New()
774+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
775+ if err != nil {
776+ t.Fatal(err)
777+ }
778+ runStub(t, conn)
779+
780+ err = sb.Stop(context.Background())
781+ if !errors.Is(err, sandbox.ErrLeaked) {
782+ t.Fatalf("Stop must report ErrLeaked, got %v", err)
783+ }
784+ if e.tapCount() != 1 || e.plumb.Live() != 1 {
785+ t.Fatalf("the TAP and the policy must stay while the VMM may run: taps=%d attached=%d", e.tapCount(), e.plumb.Live())
786+ }
787+ if recs, _ := d.store.list(); len(recs) != 1 {
788+ t.Fatalf("the state record must stay for Reap: %v", recs)
789+ }
790+ if err := sb.Stop(context.Background()); !errors.Is(err, sandbox.ErrLeaked) {
791+ t.Fatalf("a second Stop must keep reporting the leak, got %v", err)
792+ }
793+ if d.slots.InUse() != 1 {
794+ t.Fatal("the slot of a leaked sandbox stays taken")
795+ }
796+ // Other jobs keep running on the free slot; the leaked one is never reused.
797+ other, err := d.New()
798+ if err != nil {
799+ t.Fatal(err)
800+ }
801+ if other.(*vmSandbox).slot == sb.(*vmSandbox).slot {
802+ t.Fatal("a leaked slot was handed out again")
803+ }
804+ // Reap refuses while a sandbox is running...
805+ if err := d.Reap(context.Background()); !errors.Is(err, sandbox.ErrBusy) {
806+ t.Fatalf("Reap with a handed-out sandbox: %v", err)
807+ }
808+ _ = other.Stop(context.Background())
809+ // ...and finishes the job once idle.
810+ stubborn = false
811+ if err := d.Reap(context.Background()); err != nil {
812+ t.Fatal(err)
813+ }
814+ if left := e.leftovers(t); len(left) != 0 {
815+ t.Fatalf("leftovers after Reap: %v", left)
816+ }
817+ if d.slots.InUse() != 0 {
818+ t.Fatal("Reap must free the slot of the leaked sandbox")
819+ }
820+}
821+
822+// C7: Stop from another goroutine aborts a boot in progress at once, instead of
823+// waiting for the boot timeout, and leaves nothing behind.
824+func TestStopAbortsABootInProgress(t *testing.T) {
825+ e := newEnv(t, "hang")
826+ d, err := New(context.Background(), e.cfg, Options{Run: e.run, Net: e.plumb, BootTimeout: time.Minute})
827+ if err != nil {
828+ t.Fatal(err)
829+ }
830+ defer func() { _ = d.Close() }()
831+ sb, _ := d.New()
832+ started := make(chan error, 1)
833+ go func() {
834+ _, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
835+ started <- err
836+ }()
837+ time.Sleep(700 * time.Millisecond) // the fake is now waiting for the executor
838+ t0 := time.Now()
839+ if err := sb.Stop(context.Background()); err != nil {
840+ t.Fatal(err)
841+ }
842+ select {
843+ case err := <-started:
844+ if !errors.Is(err, sandbox.ErrAborted) {
845+ t.Fatalf("Start must report that Stop aborted it, got %v", err)
846+ }
847+ case <-time.After(10 * time.Second):
848+ t.Fatal("Start was not aborted by Stop")
849+ }
850+ if time.Since(t0) > 8*time.Second {
851+ t.Fatalf("Stop took %v", time.Since(t0))
852+ }
853+ if left := e.leftovers(t); len(left) != 0 {
854+ t.Fatalf("leftovers: %v", left)
855+ }
856+ // Stop before Start: Start must refuse.
857+ sb2, _ := d.New()
858+ _ = sb2.Stop(context.Background())
859+ if _, err := sb2.Start(context.Background(), sandbox.JobSpec{}); err == nil {
860+ t.Fatal("Start after Stop accepted")
861+ }
862+}
863+
864+// C16: the sandbox exposes its id, which names its files.
865+func TestSandboxIDNamesItsFiles(t *testing.T) {
866+ e := newEnv(t, "")
867+ d := e.driver(t)
868+ sb, _ := d.New()
869+ if !sandbox.ValidID(sb.ID()) {
870+ t.Fatalf("id %q", sb.ID())
871+ }
872+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
873+ if err != nil {
874+ t.Fatal(err)
875+ }
876+ runStub(t, conn)
877+ defer func() { _ = sb.Stop(context.Background()) }()
878+ if _, err := os.Stat(filepath.Join(e.cfg.StateDir, "vm", sb.ID())); err != nil {
879+ t.Fatalf("the VM directory must be named after the sandbox id: %v", err)
880+ }
881+ if d.Isolation() != sandbox.IsolationVM || d.Mode() != sandbox.VM {
882+ t.Fatal("isolation")
883+ }
884+}
885+
886+// S7: cgroup v2 limits, a new PID namespace, and the jailer's own options.
887+func TestJailerArgs(t *testing.T) {
888+ args := jailerArgs("/usr/bin/jailer", "/usr/bin/firecracker", "er00112233", 900003, "/var/lib/er/jail",
889+ sandbox.JobSpec{CPUs: 4, MemoryMiB: 2048}, nil)
890+ line := " " + strings.Join(args, " ") + " "
891+ for _, want := range []string{
892+ " --id er00112233 ", " --exec-file /usr/bin/firecracker ", " --uid 900003 ", " --gid 900003 ",
893+ " --chroot-base-dir /var/lib/er/jail ", " --cgroup-version 2 ", " --new-pid-ns ",
894+ " --cgroup memory.max=" + fmt.Sprint((2048+vmmOverheadMiB)<<20) + " ",
895+ " --cgroup pids.max=64 ", " --resource-limit no-file=4096 ", " -- ",
896+ } {
897+ if !strings.Contains(line, want) {
898+ t.Errorf("jailer args lack %q: %s", want, line)
899+ }
900+ }
901+ if args[len(args)-1] != "--" {
902+ t.Errorf("the jailer separator must come last when there are no extra args: %v", args)
903+ }
904+ more := jailerArgs("j", "f", "er00112233", 1, "/b", sandbox.JobSpec{CPUs: 1, MemoryMiB: 512}, []string{"--x"})
905+ if more[len(more)-2] != "--" || more[len(more)-1] != "--x" {
906+ t.Errorf("extra args follow the separator: %v", more)
907+ }
908+}
909+
910+// S7: sane rate limiters on the scratch disk and both directions of eth0, off
911+// when configured to zero, and only on the writable disk.
912+func TestRateLimitersInTheVMConfig(t *testing.T) {
913+ e := newEnv(t, "")
914+ d := e.driver(t)
915+ sb, _ := d.New()
916+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"})
917+ if err != nil {
918+ t.Fatal(err)
919+ }
920+ runStub(t, conn)
921+ defer func() { _ = sb.Stop(context.Background()) }()
922+ b, _ := os.ReadFile(e.log)
923+ log := string(b)
924+ for _, want := range []string{
925+ `"rate_limiter":{"bandwidth":{"size":419430400,"one_time_burst":419430400,"refill_time":1000},"ops":{"size":20000,"one_time_burst":20000,"refill_time":1000}}`,
926+ `"rx_rate_limiter":{"bandwidth":{"size":125000000,"one_time_burst":125000000,"refill_time":1000}}`,
927+ `"tx_rate_limiter":{"bandwidth":{"size":125000000,"one_time_burst":125000000,"refill_time":1000}}`,
928+ } {
929+ if !strings.Contains(log, want) {
930+ t.Errorf("API log lacks %s\n%s", want, log)
931+ }
932+ }
933+ if n := strings.Count(log, `"rate_limiter"`); n != 1 {
934+ t.Errorf("only the scratch drive is rate limited, got %d rate_limiter fields", n)
935+ }
936+ if diskLimiter(0, 0) != nil || netLimiter(0) != nil {
937+ t.Error("zero must disable the limiters")
938+ }
939+ if l := diskLimiter(0, 100); l.Bandwidth != nil || l.Ops == nil {
940+ t.Errorf("%+v", l)
941+ }
942+}
943+
944+// S8: the free-space check warns, and only warns.
945+func TestNewWarnsOnLowFreeSpace(t *testing.T) {
946+ e := newEnv(t, "")
947+ var buf bytes.Buffer
948+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}))
949+ d, err := New(ctx, e.cfg, Options{Run: e.run, Net: e.plumb, FreeSpace: func(string) (int64, error) { return 1, nil }})
950+ if err != nil {
951+ t.Fatalf("low space must not fail New: %v", err)
952+ }
953+ defer func() { _ = d.Close() }()
954+ if !strings.Contains(buf.String(), "less free space") {
955+ t.Fatalf("no warning: %s", buf.String())
956+ }
957+}
958+
959+// S11: the scratch disk is created without following symlinks and chowned
960+// through its descriptor.
961+func TestMakeScratchRefusesSymlinksAndExistingFiles(t *testing.T) {
962+ dir := t.TempDir()
963+ target := filepath.Join(dir, "victim")
964+ _ = os.WriteFile(target, []byte("precious"), 0o600)
965+ link := filepath.Join(dir, "scratch.ext4")
966+ if err := os.Symlink(target, link); err != nil {
967+ t.Fatal(err)
968+ }
969+ run := &cmdrun.Fake{}
970+ if err := disk.CreateScratch(context.Background(), run, link, 8, -1); err == nil {
971+ t.Fatal("a planted symlink must be refused")
972+ }
973+ if b, _ := os.ReadFile(target); string(b) != "precious" {
974+ t.Fatalf("the symlink target was modified: %q", b)
975+ }
976+ if len(run.Calls) != 0 {
977+ t.Fatalf("mkfs must not run: %v", run.Lines())
978+ }
979+ ok := filepath.Join(dir, "fresh.ext4")
980+ if err := disk.CreateScratch(context.Background(), run, ok, 8, os.Getuid()); err != nil {
981+ t.Fatal(err)
982+ }
983+ if st, err := os.Stat(ok); err != nil || st.Size() != 8<<20 || st.Mode().Perm() != 0o600 {
984+ t.Fatalf("%v %v", st, err)
985+ }
986+ if err := disk.CreateScratch(context.Background(), run, ok, 8, -1); err == nil {
987+ t.Fatal("an existing file must be refused (O_EXCL)")
988+ }
989+}
990+
991+func TestOwnUsesLchown(t *testing.T) {
992+ dir := t.TempDir()
993+ l := newLayout(dir, "firecracker", "er00112233", true, os.Getuid())
994+ _ = os.MkdirAll(l.root, 0o700)
995+ target := filepath.Join(dir, "victim")
996+ _ = os.WriteFile(target, nil, 0o600)
997+ if err := os.Symlink(target, l.host("link")); err != nil {
998+ t.Fatal(err)
999+ }
1000+ if err := l.own("link"); err != nil {
1001+ t.Fatalf("Lchown on a link must act on the link itself: %v", err)
1002+ }
1003+ if err := l.own("missing"); err == nil {
1004+ t.Fatal("a missing file is an error")
1005+ }
1006+ if u := newLayout(dir, "f", "er00112233", false, 1); u.own("anything") != nil {
1007+ t.Fatal("no-op without jailer")
1008+ }
1009+}
1010+
1011+func TestRemoveCgroup(t *testing.T) {
1012+ root := t.TempDir()
1013+ _ = os.MkdirAll(filepath.Join(root, "firecracker", "er00112233"), 0o755)
1014+ if err := removeCgroup(root, "firecracker", "er00112233"); err != nil {
1015+ t.Fatal(err)
1016+ }
1017+ if err := removeCgroup(root, "firecracker", "er00112233"); err != nil {
1018+ t.Fatalf("a missing cgroup is fine: %v", err)
1019+ }
1020+ _ = os.MkdirAll(filepath.Join(root, "firecracker", "er44556677", "child"), 0o755)
1021+ if err := removeCgroup(root, "firecracker", "er44556677"); err == nil {
1022+ t.Fatal("a non-empty cgroup cannot be removed and must be reported")
1023+ }
1024+}
M main.go +66 -4
@@ -3,21 +3,83 @@
33 // Command ermite-runner runs CI jobs for an ermite server. Each job runs in
44 // its own Firecracker micro-VM, or in its own container when the host has no
55 // KVM, and executes workflows compatible with GitHub Actions.
6+//
7+// Subcommands: version; init and exec run inside a sandbox (guest PID 1, and
8+// the executor); sandbox-check runs one stub job through the selected isolation
9+// mode and prints its output (phase D of the CI-1 plan: the host loop that
10+// fetches jobs from ermite comes later).
611 package main
712
813 import (
14+ "context"
915 "fmt"
1016 "os"
17+ "os/signal"
18+ "syscall"
19+
20+ "github.com/bnema/zerowrap"
21+
22+ "bnema.dev/ermite-runner/internal/guest"
1123 )
1224
1325 // version is set at build time with -ldflags "-X main.version=...".
1426 var version = "dev"
1527
1628 func main() {
17- if len(os.Args) > 1 && os.Args[1] == "version" {
29+ os.Exit(run(os.Args[1:]))
30+}
31+
32+func run(args []string) int {
33+ if len(args) == 0 {
34+ usage()
35+ return 2
36+ }
37+ log := zerowrap.New(zerowrap.Config{Level: envOr("ERMITE_RUNNER_LOG_LEVEL", "info"), Format: envOr("ERMITE_RUNNER_LOG_FORMAT", "console")})
38+ ctx := zerowrap.WithCtx(context.Background(), log)
39+ ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
40+ defer stop()
41+
42+ switch args[0] {
43+ case "version":
1844 fmt.Println("ermite-runner", version)
19- return
45+ return 0
46+ case "init":
47+ return guest.Init(ctx, guest.InitConfig{Version: version})
48+ case "exec":
49+ return execCmd(ctx)
50+ case "sandbox-check":
51+ return sandboxCheck(ctx, args[1:])
52+ }
53+ usage()
54+ return 2
55+}
56+
57+func usage() {
58+ fmt.Fprintln(os.Stderr, "usage: ermite-runner version | sandbox-check [script]\n (inside a sandbox: ermite-runner init | exec)")
59+}
60+
61+func envOr(key, def string) string {
62+ if v := os.Getenv(key); v != "" {
63+ return v
64+ }
65+ return def
66+}
67+
68+// execCmd is `ermite-runner exec`, the stub executor.
69+func execCmd(ctx context.Context) int {
70+ log := zerowrap.FromCtx(ctx)
71+ target, err := guest.ParseTarget(os.Getenv("ERMITE_CONNECT"))
72+ if err != nil {
73+ log.Error().Err(err).Msg("exec")
74+ return 1
75+ }
76+ code, err := guest.Exec(ctx, guest.ExecConfig{
77+ Target: target,
78+ Sandbox: envOr("ERMITE_SANDBOX", "container"),
79+ Version: envOr("ERMITE_VERSION", version),
80+ })
81+ if err != nil {
82+ log.Error().Err(err).Msg("exec")
2083 }
21- fmt.Fprintln(os.Stderr, "usage: ermite-runner version")
22- os.Exit(2)
84+ return code
2385 }
A sandbox_check.go +81 -0
@@ -0,0 +1,81 @@
1+// SPDX-License-Identifier: MIT
2+
3+package main
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "strings"
11+ "time"
12+
13+ "github.com/bnema/zerowrap"
14+
15+ "bnema.dev/ermite-runner/internal/execstream"
16+ "bnema.dev/ermite-runner/internal/execstream/execpb"
17+ "bnema.dev/ermite-runner/internal/sandbox"
18+ "bnema.dev/ermite-runner/internal/sandbox/drivers"
19+)
20+
21+// sandboxCheck runs one stub job in the sandbox chosen by
22+// ERMITE_RUNNER_ISOLATION: reap leftovers, start, stream the output to stdout
23+// and stderr, stop. Its exit code is the script's. It is the manual counterpart
24+// of the integration tests and the smoke test of an installation.
25+func sandboxCheck(ctx context.Context, args []string) int {
26+ log := zerowrap.FromCtx(ctx)
27+ cfg, err := sandbox.LoadConfig(os.Getenv)
28+ if err != nil {
29+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
30+ return 2
31+ }
32+ drv, err := sandbox.Select(ctx, cfg, drivers.Factories(), sandbox.Prober{})
33+ if err != nil {
34+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
35+ return 1
36+ }
37+ defer func() { _ = drv.Close() }()
38+ if err := drv.Reap(ctx); err != nil {
39+ log.Warn().Err(err).Msg("reaping leftovers of a previous run")
40+ }
41+ sb, err := drv.New()
42+ if err != nil {
43+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
44+ return 1
45+ }
46+ // Stop runs even when the job context was cancelled.
47+ defer func() {
48+ sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
49+ defer cancel()
50+ if err := sb.Stop(sctx); err != nil {
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+ }
55+ }
56+ }()
57+ conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "sandbox-check"})
58+ if err != nil {
59+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
60+ return 1
61+ }
62+ // Ctrl-C / SIGTERM cancel the job: the executor escalates by itself.
63+ cancel := make(chan *execpb.Cancel, 1)
64+ stop := context.AfterFunc(ctx, func() { cancel <- &execpb.Cancel{Reason: "interrupted"} })
65+ defer stop()
66+ _, exit, err := execstream.Drive(conn, &execpb.Run{Script: strings.Join(args, " ")}, cancel, func(o *execpb.Output) {
67+ f := os.Stdout
68+ if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR {
69+ f = os.Stderr
70+ }
71+ _, _ = f.Write(o.Data)
72+ })
73+ if err != nil {
74+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
75+ return 1
76+ }
77+ if exit.Error != "" {
78+ fmt.Fprintln(os.Stderr, "ermite-runner: executor:", exit.Error)
79+ }
80+ return int(exit.Code)
81+}
A scripts/build-image.sh +66 -0
@@ -0,0 +1,66 @@
1+#!/usr/bin/env bash
2+# SPDX-License-Identifier: MIT
3+#
4+# Build the ermite-runner-ubuntu-24.04 image as an OCI image (container
5+# isolation) and as a sparse ext4 base image (vm isolation). No root needed:
6+# the OCI build uses docker or podman (rootless is fine), the ext4 image is
7+# written by `mkfs.ext4 -d` from the exported rootfs tarball.
8+#
9+# scripts/build-image.sh [OUT_DIR]
10+#
11+# Environment:
12+# ENGINE docker or podman (default: first found)
13+# IMAGE_TAG OCI tag (default ermite-runner-ubuntu-24.04:latest, the
14+# default of ERMITE_RUNNER_IMAGE in container mode)
15+# EXT4_SLACK free space added to the base image in MiB (default 512)
16+#
17+# Output: OUT_DIR/base.ext4 (read-only shared base disk; its UUID and size are
18+# stable per build) and the OCI image IMAGE_TAG in the engine's store.
19+set -euo pipefail
20+
21+root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
22+out=${1:-$root/tmp/image}
23+tag=${IMAGE_TAG:-ermite-runner-ubuntu-24.04:latest}
24+slack=${EXT4_SLACK:-512}
25+engine=${ENGINE:-}
26+
27+if [ -z "$engine" ]; then
28+ for e in docker podman; do
29+ if command -v "$e" >/dev/null 2>&1; then engine=$e; break; fi
30+ done
31+fi
32+[ -n "$engine" ] || { echo "need docker or podman" >&2; exit 1; }
33+command -v mkfs.ext4 >/dev/null || { echo "need mkfs.ext4 (e2fsprogs >= 1.47.1 for tarball input)" >&2; exit 1; }
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+
44+mkdir -p "$out"
45+out=$(cd "$out" && pwd)
46+"$engine" build -t "$tag" "$root/images/ubuntu-24.04"
47+
48+cid=$("$engine" create "$tag")
49+tmp=$(mktemp -d)
50+cleanup() { "$engine" rm -f "$cid" >/dev/null 2>&1 || true; rm -rf "$tmp"; }
51+trap cleanup EXIT
52+
53+"$engine" export "$cid" > "$tmp/rootfs.tar"
54+bytes=$(stat -c %s "$tmp/rootfs.tar")
55+# ext4 metadata overhead is about 5 %; the slack leaves room for the guest's
56+# own writes into the (read-only mounted) base only when someone remounts it.
57+mib=$(( bytes / 1024 / 1024 * 110 / 100 + slack ))
58+
59+img="$out/base.ext4.part"
60+rm -f "$img"
61+truncate -s "${mib}M" "$img"
62+# No journal: the base is mounted read-only and shared between VMs.
63+mkfs.ext4 -q -F -L ermite-base -O ^has_journal -E lazy_itable_init=1,nodiscard \
64+ -d "$tmp/rootfs.tar" "$img"
65+mv "$img" "$out/base.ext4"
66+echo "base image: $out/base.ext4 (${mib} MiB sparse), OCI image: $tag"
A scripts/build-kernel.sh +79 -0
@@ -0,0 +1,79 @@
1+#!/usr/bin/env bash
2+# SPDX-License-Identifier: MIT
3+#
4+# Build the Firecracker guest kernel (Linux 6.18 LTS) from kernel.org.
5+#
6+# scripts/build-kernel.sh [OUT_DIR]
7+#
8+# Environment:
9+# KERNEL_VERSION version to build (default 6.18.56)
10+# ARCH_NAME x86_64 (default: uname -m) or arm64 (aarch64)
11+# WORK_DIR download and build directory (default tmp/kernel, gitignored)
12+# JOBS make parallelism (default: nproc)
13+#
14+# Output: OUT_DIR/vmlinux (x86_64 ELF, accepted by Firecracker) or
15+# OUT_DIR/Image (arm64), plus OUT_DIR/kernel.config. No root needed. The tarball
16+# is verified against the sha256sums.asc published next to it on kernel.org
17+# (integrity over TLS; the PGP signature is not checked, see README).
18+set -euo pipefail
19+
20+root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
21+version=${KERNEL_VERSION:-6.18.56}
22+major=${version%%.*}
23+work=${WORK_DIR:-$root/tmp/kernel}
24+out=${1:-$root/tmp/image}
25+jobs=${JOBS:-$(nproc)}
26+arch_name=${ARCH_NAME:-$(uname -m)}
27+
28+case "$arch_name" in
29+ x86_64|amd64) karch=x86; arch_frag=x86_64; target=vmlinux; artifact=vmlinux ;;
30+ aarch64|arm64) karch=arm64; arch_frag=arm64; target=Image; artifact=arch/arm64/boot/Image ;;
31+ *) echo "unsupported architecture: $arch_name" >&2; exit 2 ;;
32+esac
33+
34+mkdir -p "$work" "$out"
35+work=$(cd "$work" && pwd)
36+out=$(cd "$out" && pwd)
37+tarball="linux-$version.tar.xz"
38+base="https://cdn.kernel.org/pub/linux/kernel/v${major}.x"
39+
40+if [ ! -f "$work/$tarball" ]; then
41+ curl -fsSL -o "$work/$tarball.part" "$base/$tarball"
42+ mv "$work/$tarball.part" "$work/$tarball"
43+fi
44+if [ ! -f "$work/sha256sums.asc" ] || ! grep -qF " $tarball" "$work/sha256sums.asc"; then
45+ curl -fsSL -o "$work/sha256sums.asc" "$base/sha256sums.asc"
46+fi
47+want=$(awk -v f="$tarball" '$2 == f {print $1; exit}' "$work/sha256sums.asc")
48+have=$(sha256sum "$work/$tarball" | awk '{print $1}')
49+if [ -z "$want" ] || [ "$want" != "$have" ]; then
50+ echo "checksum mismatch for $tarball (want '$want', have '$have')" >&2
51+ exit 1
52+fi
53+
54+src="$work/linux-$version"
55+[ -d "$src" ] || tar -C "$work" -xf "$work/$tarball"
56+
57+cd "$src"
58+export ARCH=$karch
59+make tinyconfig >/dev/null
60+scripts/kconfig/merge_config.sh -m .config \
61+ "$root/images/kernel/config-6.18" "$root/images/kernel/config-6.18.$arch_frag" >/dev/null
62+make olddefconfig >/dev/null
63+
64+# Fail if an option of the fragments was dropped by a missing dependency.
65+missing=0
66+while IFS== read -r key val; do
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
71+ echo "kernel option not applied: $key=$val" >&2
72+ missing=1
73+done < <(cat "$root/images/kernel/config-6.18" "$root/images/kernel/config-6.18.$arch_frag" | grep -E '^CONFIG_[A-Z0-9_]+=')
74+[ "$missing" = 0 ] || exit 1
75+
76+make -j"$jobs" "$target"
77+cp "$artifact" "$out/$(basename "$artifact")"
78+cp .config "$out/kernel.config"
79+echo "kernel: $out/$(basename "$artifact")"
A scripts/it-vm-userns.sh +46 -0
@@ -0,0 +1,46 @@
1+#!/usr/bin/env bash
2+# SPDX-License-Identifier: MIT
3+#
4+# Run the VM integration tests WITHOUT root: inside a user + network namespace
5+# the process is "root" with CAP_NET_ADMIN over a private network stack, which is
6+# enough for TAP devices, nftables and the DNS forwarder on the TAP address.
7+# Only /dev/kvm access (group kvm) and the Firecracker binary are needed. The
8+# jailer needs real root, so it is switched off here (see README: this tests the
9+# whole driver except the jailer's chroot, uid drop and cgroups).
10+#
11+# scripts/it-vm-userns.sh [go test args]
12+#
13+# Environment: FIRECRACKER (default: firecracker in PATH), IMAGE_DIR (directory
14+# with vmlinux and base.ext4, default tmp/image). Outbound internet from the
15+# private namespace is not available: the tests do not need it.
16+set -euo pipefail
17+root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
18+cd "$root"
19+fc=${FIRECRACKER:-$(command -v firecracker || true)}
20+[ -x "${fc:-}" ] || { echo "firecracker not found: set FIRECRACKER" >&2; exit 1; }
21+img=${IMAGE_DIR:-$root/tmp/image}
22+# Two separate namespaces: the VM tests leave TAP devices behind when they fail,
23+# and the packet-level firewall test must start from a clean network stack.
24+rc=0
25+unshare --user --map-root-user --net bash -c '
26+ ip link set lo up
27+ # A default route, so that forwarded packets reach the firewall instead of
28+ # being answered "network unreachable" by the routing table: the drops the
29+ # network test sees are then the nftables policy.
30+ ip link add dummy0 type dummy
31+ ip addr add 192.0.2.1/24 dev dummy0
32+ ip link set dummy0 up
33+ ip route add default via 192.0.2.254 dev dummy0
34+ export GOWORK=off ERMITE_RUNNER_IT_VM=1 \
35+ ERMITE_RUNNER_ISOLATION=vm ERMITE_RUNNER_IMAGE="$1" \
36+ ERMITE_RUNNER_FIRECRACKER="$2" ERMITE_RUNNER_JAILER_MODE=off \
37+ ERMITE_RUNNER_LOCK_FILE="${TMPDIR:-/tmp}/ermite-runner-it-$$.lock"
38+ shift 2
39+ exec go test -count=1 -v -timeout 10m ./internal/sandbox/integration "$@"
40+' _ "$img" "$fc" "$@" || rc=$?
41+# The packet-level firewall test needs only a namespace, not Firecracker.
42+unshare --user --map-root-user --net bash -c '
43+ ip link set lo up
44+ GOWORK=off ERMITE_RUNNER_IT_NET=1 exec go test -count=1 -v -timeout 10m -run TwoGuests ./internal/netpolicy
45+' || rc=$?
46+exit $rc

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