Step 21 session A: close step 17's paperwork

- PR-docs get their real numbers: bwrap-build-runtime -> PR#4,
  build-current-head-from-the-branches-view -> PR#3 (files and scenario IDs)
- tools/remote: header note marking install's clone step and build as the
  self-build prototype, superseded by session D of step 21 (usage range grown)
- ADR 0008: bubblewrap user-namespace sandbox as the third build runtime;
  step 17 and the plan README now point at 0008 (0007 was taken by build
  definitions before the step landed)
- AGENTS.md: decisions list catches up with ADR 0007 and ADR 0008
- architecture skill: BwrapBuildRunner paragraph (rootfs unpack, uid mapping,
  mount ordering, pinned keys) and the dispatcher's three-way routing
- plan README: step 21 entry now describes the werkdock/ subdirectory path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-01 06:21:29 +02:00
co-authored by Claude Fable 5
parent 3e1085fa52
commit 89de3e0378
8 changed files with 86 additions and 16 deletions
@@ -0,0 +1,61 @@
# Bubblewrap User-Namespace Sandbox as the Third Build Runtime
**Status:**
- proposed: 2026-08-10
- accepted: 2026-09-01
- rejected: -
- superseded: -
**Decision [accepted]:** On hosts without root and without a Docker daemon — Hostsharing Managed Webspaces — builds run inside a `bwrap` (bubblewrap) user-namespace sandbox over a prepared rootfs archive, implemented by `BwrapBuildRunner` as the third runtime behind the `BuildRunner` interface.
Filesystem isolation only: network, uid mapping, `/proc`, `/dev`, and `/tmp` are shared with the host by contract.
Note: plan step 17 announced this decision as "ADR 0007", but 0007 was taken by the build-definitions decision on 2026-08-28; it is recorded here as 0008.
## Context and Problem Statement
Werkator's build sandbox was Docker (ADR 0004ff., plan step 11) or nothing (`native`).
Managed Webspaces provide neither root nor a Docker daemon, so a Werkator instance there could only build with the host's own toolchains — no isolation, and no way to install the toolchain versions a project needs.
The platform does provide unprivileged user namespaces and ships `bubblewrap 0.8.0` (verified on h68, kernel 6.1), which allows mounting a self-prepared root filesystem without any privilege.
### Technical Background
`BwrapBuildRunner` shells out to the `bwrap` CLI — same pattern as git and docker, no library.
The rootfs comes from a project-built archive (`tools/build-bwrap-rootfs.sh`), unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`; uid 0 inside maps to the calling user.
The git metadata mounts of step 16 are reused unchanged: read-only `.git`, tmpfs mask over `.git/werkator/`, read-write worktree admin directory — so secrets stay outside the sandbox exactly as in Docker builds.
`bwrap` creates bind mountpoints against the sandbox view, so every mountpoint must exist in (or be pre-created in) the rootfs; the runner handles that.
Config: `bwrap.enabled`/`bwrap.rootfs` are pinned like the docker sandbox policy — a branch can never turn its sandbox off or swap the rootfs; docker and bwrap are mutually exclusive per build and rejected loudly, never picked silently.
Floor: bubblewrap 0.8.0 has no `--overlay` (added in 0.9.0), so throwaway writable layers are built from tmpfs/bind mounts, not overlays.
## Considered Options
* bwrap user-namespace sandbox (prepared rootfs, filesystem isolation only)
* proot / fakechroot (syscall- or libc-level path rewriting)
* plain native with hand-installed toolchains (no isolation)
### bwrap User-Namespace Sandbox
Good:
- Real kernel-level mount isolation without root; works with what the platform already ships.
- The prepared-rootfs model gives every project its own toolchain versions, like a Docker image does.
- Shelling out to a CLI matches the existing git/docker access pattern; the attached process supports log streaming and cancellation unchanged.
Bad:
- The rootfs archive is project infrastructure that must be built and uploaded (~1.5 GB unpacked, disk/quota checked by `tools/werkator-build-prerequisites.sh`).
- Filesystem-only isolation: network and process view are the host's — acceptable here, and pinned so a branch cannot widen it, but weaker than Docker.
- Mountpoint pre-creation and mount ordering are subtle (hardened on the real webspace; see the fix messages preserved in commit `71f1fc6`).
### proot / fakechroot
Rejected: syscall tracing (proot) is an order of magnitude slower and historically fragile with modern toolchains; fakechroot's `LD_PRELOAD` path rewriting breaks on statically linked tools and does not isolate anything the kernel enforces.
### Plain Native with Hand-Installed Toolchains
Rejected: no isolation, host pollution, and toolchain versions become webspace-global instead of per project — exactly the situation the sandbox exists to end.
## Decision Outcome
bwrap, as implemented in PR #4.
The generic sandbox machinery (rootfs build, prerequisites check, invocation logic) is planned to be extracted into the standalone tool **Werkdock** (plan step 21); `BwrapBuildRunner` will then delegate to the `werkdock` CLI.
That extraction changes the executor behind the config keys, not this decision.
+2 -2
View File
@@ -168,7 +168,7 @@ Two claims could **not** be verified from a Hostsharing primary source; check th
## ADR
Write ADR 0007: bubblewrap user-namespace sandbox as the third build runtime (options considered: bwrap (chosen), proot/fakechroot (slow, fragile), plain native with hand-installed toolchains (no isolation, host pollution)).
Write ADR 0008 (step text originally said 0007, but 0007 was taken by build definitions): bubblewrap user-namespace sandbox as the third build runtime (options considered: bwrap (chosen), proot/fakechroot (slow, fragile), plain native with hand-installed toolchains (no isolation, host pollution)).
## Tests
@@ -182,4 +182,4 @@ Write ADR 0007: bubblewrap user-namespace sandbox as the third build runtime (op
- `./gradlew ktlintFormat` then `./gradlew build` is green — also on a machine without Docker (Testcontainers smoke test skipped, not failed).
- On a Managed Webspace: Werkator (from the runtime bundle) builds a real branch of a repo inside the bwrap sandbox; git commands work in the worktree; `.git/werkator/` is not readable from the build; a write to `/usr` fails.
- On the same webspace: the UI answers over HTTPS under the domain through the Apache `.htaccess` proxy, the service survives a logout and a reboot (systemd lingering), and Gitea statuses carry `publicBaseUrl` links that resolve.
- Docs updated: `docs/configuration.md` (bwrap section), architecture skill (third runtime), ADR 0007, and `docs/deployment.md` gains "Hostsharing Managed Webspace" as a third deployment variant — written only once the setup above is verified on a real webspace, not from this plan.
- Docs updated: `docs/configuration.md` (bwrap section), architecture skill (third runtime), ADR 0008, and `docs/deployment.md` gains "Hostsharing Managed Webspace" as a third deployment variant — written only once the setup above is verified on a real webspace, not from this plan.
+2 -2
View File
@@ -93,7 +93,7 @@ Added for running Werkator on Hostsharing Managed Webspaces (2026-08-10):
Added to correct the bwrap prototype's drift toward self-building on the webspace (2026-09-01):
- [ ] `21-werkdock-extraction-and-webspace-install.md` — roadmap in four sessions: close step 17's open ends, extract the sandbox tooling into a new repository (**Werkdock**, a docker-like filesystem-only sandbox CLI), let Werkator consume it, and replace the webspace self-build with the local-build-plus-install path of ADR 0006
- [ ] `21-werkdock-extraction-and-webspace-install.md` — roadmap in four sessions: close step 17's open ends, grow the sandbox tooling into **Werkdock** (a docker-like filesystem-only sandbox CLI, developed in the `werkdock/` subdirectory, later its own repository), let Werkator consume it, and replace the webspace self-build with the local-build-plus-install path of ADR 0006
Added for surfacing build time as a trend (2026-08-31):
@@ -105,7 +105,7 @@ Steps 0709 depend on 0406.
Steps 11 and 12 are optional/deferrable; 10 only needs 0406.
Step 13 depends on 07, 11, and 12.
Step 15 depends on 12 and 13 and revises the containerized-runtime sketch in `docs/bootstrapping.md` (ADR 0006 is written as part of the step; GraalVM native image was evaluated and rejected there).
Step 17 depends on 11, 15, and 16, and starts with a hard precondition check on the target webspace (ADR 0007 is written as part of the step).
Step 17 depends on 11, 15, and 16, and starts with a hard precondition check on the target webspace (ADR 0008 is written as part of the step; the number 0007 announced in the step file was already taken).
Step 18 depends on nothing in code but on the watched repository having migrated — its precondition check is a hard gate, not a formality.
Step 19 depends on nothing; `WatcherState` and `/api/watcher` already carry everything it needs to render.
Step 20 depends on nothing; the duration is already recorded, and the trend is derived read-only from `repository.history()`.
@@ -31,7 +31,7 @@ Observed in production on 2026-08-31: a restart of master rebuilt a commit from
- A row on `/` (Latest) and `/history` stands for a recorded build.
- A build *name* is the pool: the branch itself for the default build, `<branch>@<build>` for a named one.
#### Scenario#000.01: The Branches view builds the branch's current origin head
#### Scenario#3.01: The Branches view builds the branch's current origin head
So that a restart answers "build this branch as it is", which is what a branch row means.
@@ -45,7 +45,7 @@ So that a restart answers "build this branch as it is", which is what a branch r
- [BuildsApiControllerTest: "restart with atOriginHead builds the branch as it is now, not the recorded commit"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
- [UiControllerTest: "the branches view restarts at the branch's origin head, the latest view repeats the run"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
#### Scenario#000.02: The row keeps its build definition and its real branch
#### Scenario#3.02: The row keeps its build definition and its real branch
So that restarting a named build does not silently turn it into a different build.
@@ -58,7 +58,7 @@ So that restarting a named build does not silently turn it into a different buil
- [BuildsApiControllerTest: "restart with atOriginHead keeps the recorded build definition and its real branch"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
#### Scenario#000.03: A branch that is gone from origin is refused by name
#### Scenario#3.03: A branch that is gone from origin is refused by name
So that a restart cannot quietly fall back to a commit the user did not ask for.
@@ -71,7 +71,7 @@ So that a restart cannot quietly fall back to a commit the user did not ask for.
- [BuildsApiControllerTest: "restart with atOriginHead of a branch gone from origin is refused by name"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
#### Scenario#000.04: Latest and History still repeat the recorded run
#### Scenario#3.04: Latest and History still repeat the recorded run
So that the one view whose rows are runs keeps the behavior that fits them.
@@ -28,7 +28,7 @@ Step 17 (docs/plan/17-bwrap-build-runtime.md) defines a third build runtime behi
- `bwrap.enabled` and `bwrap.rootfs` are pinned (host-set), so a branch's committed config cannot switch its own sandbox off or substitute a foreign rootfs.
- `docker.enabled` and `bwrap.enabled` are mutually exclusive per branch; enabling both is rejected, not silently picked.
#### Scenario#000.01: A bwrap build runs the command inside the sandbox as root
#### Scenario#4.01: A bwrap build runs the command inside the sandbox as root
So that the build is isolated from the host exactly as the native and Docker runtimes intend.
@@ -43,7 +43,7 @@ So that the build is isolated from the host exactly as the native and Docker run
- [BwrapBuildRunnerTest](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
#### Scenario#000.02: Git metadata mounts keep secrets out of the sandbox
#### Scenario#4.02: Git metadata mounts keep secrets out of the sandbox
So that builds can run read-only git commands but never reach the machine config or the control token.
@@ -57,7 +57,7 @@ So that builds can run read-only git commands but never reach the machine config
- [BwrapBuildRunnerTest](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
#### Scenario#000.03: A branch cannot turn its sandbox off or swap its rootfs
#### Scenario#4.03: A branch cannot turn its sandbox off or swap its rootfs
So that the pinned sandbox policy holds for builds a branch invents as well as for ones the host already knows.
@@ -70,7 +70,7 @@ So that the pinned sandbox policy holds for builds a branch invents as well as f
- [ConfigLoaderTest](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
#### Scenario#000.04: The dispatcher routes builds to the bwrap runtime
#### Scenario#4.04: The dispatcher routes builds to the bwrap runtime
So that a bwrap-explicit branch builds inside the sandbox rather than natively.
@@ -89,7 +89,7 @@ So that a bwrap-explicit branch builds inside the sandbox rather than natively.
- Werkator's own build runs the full test suite, which includes `TestcontainersSmokeTest`.
- On a Docker-less host (the webspace, and the bwrap sandbox that builds there), that test must not fail the self-build.
#### Scenario#000.05: The Testcontainers smoke test is skipped, not failed, without Docker
#### Scenario#4.05: The Testcontainers smoke test is skipped, not failed, without Docker
So that a Docker-less build of Werkator itself stays green.