Plan step 17: bubblewrap build sandbox for Hostsharing Managed Webspaces

Third build runtime behind BuildRunner: unprivileged user namespace via
bwrap with a prepared Debian rootfs, for hosts without Docker or root.
The step starts with a one-line precondition check to run on the target
webspace; the step-16 git metadata mounts port 1:1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-10 20:42:48 +02:00
co-authored by Claude Fable 5
parent e1f3f5f384
commit 632e4396e4
2 changed files with 94 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
# Step 17: Bubblewrap Build Runtime for Managed Webspaces
Prerequisites: steps 11, 15, 16.
Read `README.md` first.
Motivated by running GitTally on Hostsharing **Managed Webspaces**: no root, no Docker daemon, but `bwrap` (bubblewrap) is available and unprivileged user namespaces are allowed.
Target use case: GitTally builds GitTally itself on a Managed Webspace; builds needing special dependencies get them from a prepared root filesystem instead of the host.
## Precondition Check (run on the target webspace first)
The whole step hinges on one hard precondition: unprivileged user namespaces with uid-0 mapping and read-only root binds must work.
Verify it with a single command line on the target webspace **before** starting implementation:
```bash
bwrap --unshare-user --unshare-pid --die-with-parent --uid 0 --gid 0 \
--ro-bind / / --dev /dev --proc /proc --tmpfs /tmp \
sh -c 'id -u && cat /proc/self/uid_map && (touch /usr/ro-test 2>&1 || true)'
```
Expected output:
- `0` — the build runs as root inside the namespace.
- a uid_map like `0 <webspace-uid> 1` — root maps back to the unprivileged webspace user.
- `touch: cannot touch '/usr/ro-test': Read-only file system` — the read-only root bind is enforced.
If this fails (`bwrap` missing, "setting up uid map: Permission denied", or no user namespace support), the approach is dead on that host — record the result in this file either way.
## Goal
A third build runtime behind the `BuildRunner` interface: `BwrapBuildRunner`, selected per branch via config, sandboxing the build in an unprivileged user namespace with a prepared Debian root filesystem.
No root on the host, no Docker daemon, no changes to the native and Docker runtimes.
## Design
### Prepared root filesystem
`debootstrap`/`mmdebstrap` are not available on the webspace, so the rootfs is **not created on the target system**.
It is built once elsewhere (any machine with Docker or root, e.g. a container VM) and distributed as an archive, e.g. `gittally-buildenv-trixie-java21.tar.zst`, containing Debian plus all build dependencies (JDK 21, git, locales, project-specific tools).
GitTally unpacks it on demand (`tar --no-same-owner`) into `.git/gittally/buildenv/<envKey>/rootfs`**not** into the working tree.
Like the Docker image and the Gradle cache volume, the environment is shared across all branch worktrees and survives worktree pruning; `<envKey>` derives from a hash of the configured archive source, so an environment-version change unpacks a fresh rootfs and stale ones can be pruned.
### Configuration
New `branches.<name>.bwrap` section: `enabled`, `rootfs` (path or URL of the archive), `env` (like `docker.env`).
`bwrap.enabled` and `bwrap.rootfs` join the **pinned sandbox-policy set** (like `docker.enabled`/`docker.network`): a branch must not be able to switch off its sandbox or substitute a foreign rootfs via its committed config.
`docker.enabled` and `bwrap.enabled` are mutually exclusive per branch — reject the config, do not pick silently.
Keep the three config places in sync: `GitTallyConfig`, the `InitCommand` templates, `docs/configuration.md`.
### Invocation
`BwrapBuildRunner` shells out to the `bwrap` CLI (no library, like git and docker):
```
bwrap --unshare-user --unshare-pid --die-with-parent --uid 0 --gid 0 \
--ro-bind <buildenv>/rootfs / \
--bind <workspace> <workspace> \
--bind <buildenv>/home /root \
--ro-bind /etc/resolv.conf /etc/resolv.conf \
--proc /proc --dev /dev --tmpfs /tmp \
--chdir <workspace> \
/bin/sh -c '<buildCommand>'
```
- The workspace is bound at its **host path**, not at `/workspace`: the worktree's `.git` pointer file contains absolute host paths, and the step-16 git metadata mounts (`--ro-bind` of the primary `.git`, `--tmpfs` over `.git/gittally`, `--bind` of `.git/worktrees/<key>`) port 1:1 — reuse that logic, do not duplicate it.
- `<buildenv>/home` bound as `/root` gives Gradle a persistent `$HOME` (wrapper dists, `.gradle` caches) — the bwrap sibling of the Docker runner's Gradle cache volume.
- `--die-with-parent` plus `--unshare-pid`: cancellation kills the returned `bwrap` process tree and nothing survives — same semantics as the other runtimes.
- No ownership repair is needed: files created as uid 0 inside the namespace are owned by the webspace user on the host.
### Known limitations (document, do not solve here)
- Network stays shared with the host (Gradle needs it); isolation is weaker than Docker's per-container network.
- No Docker inside the sandbox, so no Testcontainers-based tests; build commands must select a Docker-free test subset.
For GitTally's own build this means `TestcontainersSmokeTest` must become conditional (`enabledIf` docker present) — that change is part of this step.
## 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)).
## Tests
- `BwrapBuildRunnerTest`: exact argv assertions (mount set, uid mapping, chdir, env), rootfs unpack-on-demand and env-key change, mocked process runner — mirror `DockerBuildRunnerTest`.
- `DispatchingBuildRunnerTest`: routing for `bwrap.enabled`, config rejection when both runtimes are enabled.
- `ConfigLoader` tests: the pinned set strips `bwrap.enabled`/`bwrap.rootfs` from the worktree layer.
## Acceptance Criteria
- The precondition command line above passes on the target webspace; its output is recorded in this file.
- `./gradlew ktlintFormat` then `./gradlew build` is green — also on a machine without Docker (Testcontainers smoke test skipped, not failed).
- On a Managed Webspace: GitTally (from the runtime bundle) builds a real branch of a repo inside the bwrap sandbox; git commands work in the worktree; `.git/gittally/` is not readable from the build; a write to `/usr` fails.
- Docs updated: `docs/configuration.md` (bwrap section), architecture skill (third runtime), ADR 0007.
+5
View File
@@ -78,9 +78,14 @@ Added for the vm2176 → vm4006 migration (2026-08-10):
- [ ] `15-runtime-bundle-distribution.md` — self-contained runtime bundle (jlink JRE + jar) for hosts without a Java runtime - [ ] `15-runtime-bundle-distribution.md` — self-contained runtime bundle (jlink JRE + jar) for hosts without a Java runtime
- [ ] `16-git-in-docker-builds.md` — read-only git metadata inside Docker build containers, with `.git/gittally/` masked - [ ] `16-git-in-docker-builds.md` — read-only git metadata inside Docker build containers, with `.git/gittally/` masked
Added for running GitTally on Hostsharing Managed Webspaces (2026-08-10):
- [ ] `17-bwrap-build-runtime.md` — bubblewrap user-namespace build sandbox with a prepared rootfs, for hosts without Docker (precondition check first — see the step file)
Steps 0103 are independent of each other. Steps 0103 are independent of each other.
Steps 0406 depend on 0103. Steps 0406 depend on 0103.
Steps 0709 depend on 0406. Steps 0709 depend on 0406.
Steps 11 and 12 are optional/deferrable; 10 only needs 0406. Steps 11 and 12 are optional/deferrable; 10 only needs 0406.
Step 13 depends on 07, 11, and 12. 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 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).