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