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
+4 -2
View File
@@ -1,6 +1,6 @@
---
name: architecture
description: Detailed Werkator subsystem architecture — CLI wiring and exit codes, server mode, web UI, configuration system, git access, build execution (native and Docker), watcher poll cycle, and system metrics. Use when designing or modifying code in the commands, config, git, gitea, build, artifacts, watcher, metrics, or server packages, or when a question goes beyond the overview in AGENTS.md.
description: Detailed Werkator subsystem architecture — CLI wiring and exit codes, server mode, web UI, configuration system, git access, build execution (native, Docker, and bwrap), watcher poll cycle, and system metrics. Use when designing or modifying code in the commands, config, git, gitea, build, artifacts, watcher, metrics, or server packages, or when a question goes beyond the overview in AGENTS.md.
---
# Werkator Architecture
@@ -65,7 +65,9 @@ Three places must stay in sync when config keys change: the `WerkatorConfig` dat
On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`).
The runtime is selected per branch behind the `BuildRunner` interface: `DispatchingBuildRunner` (`@Primary`) routes to native `ProcessBuildRunner` (the default) or to `DockerBuildRunner` when `branches.<name>.docker.enabled`. The Docker runner shells out to the `docker` CLI (no SDK): it (re)builds the configured image when the Dockerfile inputs changed (tracked via the `org.werkator.build-inputs-sha256` image label), maintains a per-repo Gradle cache volume, mounts the worktree and the Docker socket into a labelled (`org.hoennig.werkator`) `--rm --init` container, and repairs workspace ownership in-container after each command (under a rootless daemon the container runs as root, which is the host user, and the repair degenerates to `0:0`). Git works inside the container: the primary `.git` is mounted read-only with `.git/werkator/` masked by an empty tmpfs (credential isolation) and the worktree's admin dir mounted read-write (`gitMetadataMounts`). The returned `Process` is the attached `docker run` client, so log streaming and termination work exactly like native builds.
The runtime is selected per build behind the `BuildRunner` interface: `DispatchingBuildRunner` (`@Primary`) routes to native `ProcessBuildRunner` (the default), to `DockerBuildRunner` when `docker.enabled`, or to `BwrapBuildRunner` when `bwrap.enabled` — docker and bwrap are mutually exclusive per build and rejected in `buildSettings`, never picked silently. The Docker runner shells out to the `docker` CLI (no SDK): it (re)builds the configured image when the Dockerfile inputs changed (tracked via the `org.werkator.build-inputs-sha256` image label), maintains a per-repo Gradle cache volume, mounts the worktree and the Docker socket into a labelled (`org.hoennig.werkator`) `--rm --init` container, and repairs workspace ownership in-container after each command (under a rootless daemon the container runs as root, which is the host user, and the repair degenerates to `0:0`). Git works inside the container: the primary `.git` is mounted read-only with `.git/werkator/` masked by an empty tmpfs (credential isolation) and the worktree's admin dir mounted read-write (`gitMetadataMounts`). The returned `Process` is the attached `docker run` client, so log streaming and termination work exactly like native builds.
`BwrapBuildRunner` (ADR 0008) is the third runtime, for hosts without root and without Docker — Hostsharing Managed Webspaces. It shells out to the `bwrap` CLI (no library): a prepared rootfs archive (`bwrap.rootfs`, built by `tools/build-bwrap-rootfs.sh`) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`, with uid 0 inside mapped to the calling user; isolation is filesystem-only — network, uid, `/proc`, `/dev` are the host's by contract. It reuses the Docker runner's `gitMetadataMounts`; mount order matters (repo dir read-write before the metadata mounts and the workspace), and bind mountpoints missing from the rootfs are pre-created there, since the rootfs is a plain host directory while bwrap cannot mkdir against the read-only sandbox root. `bwrap.enabled`/`bwrap.rootfs` are pinned like the docker sandbox policy. The returned `Process` is the attached `bwrap` process, so streaming and cancellation are unchanged. Plan step 21 will extract the generic sandbox machinery into the standalone tool Werkdock (grown in `werkdock/`); the runner then delegates to the `werkdock` CLI.
## Watcher
+2
View File
@@ -79,6 +79,8 @@ All major decisions are in `docs/adrs/`. Run `adr-status` (after `source .envrc`
- **Rewrite architecture**: JSON-file persistence behind a repository interface, server-rendered UI with JSON polling, no managed nginx — systemd unit behind the host's reverse proxy (ADR 0004)
- **Managed nginx/TLS**: revises ADR 0004 — an opt-in nginx+certbot container for hosts without a reverse proxy (e.g. Hostsharing), planned as `docs/plan/13-nginx-tls.md` (ADR 0005)
- **Runtime bundle distribution**: `./gradlew runtimeBundle` builds a jlink-trimmed JRE + jar tarball for hosts without a Java runtime; GraalVM native image and a containerized runtime were rejected (ADR 0006)
- **Build definitions**: a top-level `builds` section of named builds with `trigger` blocks replaces the branch-owned `autoBuild` schedules (ADR 0007)
- **bwrap build runtime**: on hosts without root and without Docker (Hostsharing Managed Webspaces), builds run in a `bwrap` user-namespace sandbox over a prepared rootfs — filesystem isolation only, network and uid shared with the host; proot/fakechroot and unisolated native builds were rejected (ADR 0008)
## Skills
@@ -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.
+6 -1
View File
@@ -5,6 +5,11 @@
# the second the command. All connection and deployment values come from the
# `.env` file in the repository root — never as command line parameters.
#
# NOTE: `install` (its clone step) and `build` are a prototype of the webspace
# self-build workflow. They proved the bwrap sandbox, but as a deployment path
# they invert ADR 0006 (build locally, install the bundle) and will be replaced
# by session D of docs/plan/21-werkdock-extraction-and-webspace-install.md.
#
# Usage:
# tools/remote werkator check-prerequisites
# tools/remote werkator install
@@ -56,7 +61,7 @@ PID_FILE="/tmp/werkator-port-forward-$(id -u).pid"
LOG_FILE="/tmp/werkator-port-forward-$(id -u).log"
usage() {
sed -n '3,32p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
sed -n '3,38p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 2
}