diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index a2df19d..0ed169d 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -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..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//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 diff --git a/.werkator.yml b/.werkator.yml index e13c322..95e79ae 100644 --- a/.werkator.yml +++ b/.werkator.yml @@ -36,3 +36,14 @@ builds: - build/reports stdoutLog: build.stdout.log # filename for captured stdout stderrLog: build.stderr.log # filename for captured stderr + + # Werkdock builds itself: the Go module in werkdock/ (plan step 21). + # The first gofmt call prints any unformatted files, the second fails + # the build on them. Needs the go toolchain in the build environment. + werkdock: + trigger: + onPush: true + cleanCommand: rm -rf werkdock/dist + buildCommand: cd werkdock && gofmt -l . && test -z "$(gofmt -l .)" && go vet ./... && go test ./... && CGO_ENABLED=0 go build -o dist/werkdock . + artifactDirs: + - werkdock/dist diff --git a/AGENTS.md b/AGENTS.md index 254d830..ffbbbe1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/adrs/0008-2026-09-01.bwrap-build-runtime.md b/docs/adrs/0008-2026-09-01.bwrap-build-runtime.md new file mode 100644 index 0000000..8f55dde --- /dev/null +++ b/docs/adrs/0008-2026-09-01.bwrap-build-runtime.md @@ -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//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. diff --git a/docs/configuration.md b/docs/configuration.md index 7cb34b0..ee34b71 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -201,7 +201,9 @@ builds: cleanCommand: rm -rf build # shell command for each build buildCommand: ./gradlew --console=plain --no-daemon test - # directories copied as build artifacts + # directories copied as build artifacts; each is archived at its own + # workspace-relative path, except build/reports, which archives as reports/ + # and is browsed by the artifact page's report index artifactDirs: - build/reports - build/doc diff --git a/docs/plan/17-bwrap-build-runtime.md b/docs/plan/17-bwrap-build-runtime.md index 42a2960..74af77b 100644 --- a/docs/plan/17-bwrap-build-runtime.md +++ b/docs/plan/17-bwrap-build-runtime.md @@ -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. diff --git a/docs/plan/21-werkdock-extraction-and-webspace-install.md b/docs/plan/21-werkdock-extraction-and-webspace-install.md new file mode 100644 index 0000000..f1a2ab1 --- /dev/null +++ b/docs/plan/21-werkdock-extraction-and-webspace-install.md @@ -0,0 +1,99 @@ +# Step 21: Werkdock Extraction and the Managed-Webspace Install Path + +Prerequisites: step 17 (merged to `main` as PR #4, commit `71f1fc6`). +Read `README.md` first. +This step is a roadmap: it records where the bwrap work drifted from the original intent, and breaks the correction into sessions (A–D below). +Each session is sized like a normal step. +Werkdock is developed in the `werkdock/` subdirectory of this repository first and moves to its own repository later; the whole effort runs on the branch `werkdock-extraction`. + +## What Was Planned, and What the Branch Built Instead + +Two intents from the existing documentation ended up competing in the bwrap work merged as PR #4: + +1. **ADR 0006 / step 15**: Werkator is *built locally* and distributed as a self-contained runtime bundle; the target host only unpacks and runs it. + That install path exists and is documented — but only for Hostsharing **container servers** (`docs/deployment.md`, "Hosts Without a Java Runtime", verified on vm4006). +2. **Step 17**: bubblewrap as the third build runtime, with the stated target use case "Werkator builds Werkator itself on a Managed Webspace". + +Step 17 itself proved the self-build unnecessary for deployment: the precondition section records that the runtime bundle runs on the webspace unchanged (glibc floor `GLIBC_2.15`, checked on h68), "so no container build and no second build machine are needed for this platform". +The merged code nevertheless implements the self-build end to end — `tools/remote install` clones the repository onto the webspace and `tools/remote build` builds Werkator there inside the bwrap sandbox. +That is a working prototype and a good proof of the sandbox, but as a *deployment* path it inverts intent 1: the webspace should receive a locally built bundle, exactly like vm4006 does. + +Independently, the bwrap machinery itself (rootfs archive build, prerequisites check, the mount/uid-mapping invocation in `BwrapBuildRunner`) is generic filesystem isolation, not Werkator-specific. +The plan is to extract it as a small docker-like tool: filesystem isolation only, everything else (network, uid, `/proc`, `/dev`) shared with the host — usable on Managed Webspaces to install one's own program versions, with Werkator as its first consumer. +It grows in the `werkdock/` subdirectory of this repository and moves to its own repository once it stands on its own. + +## Naming the Extracted Tool + +**Werkdock** — decided 2026-09-01. +A dock is the enclosed basin in which ships are built, so the name carries both halves of the tool at once: the closed-off area (filesystem isolation) and the docker-light ambition. +The metaphor extends to the contract: the dock gate controls what passes, while the water outside is shared with the whole harbor — network, uid, `/proc`, `/dev` from the host. +The audible nearness to Docker is read as an honest genre label, not as an accident. +"Dock" is the same word in German and English — the only candidate that needed no translation in either direction. +As of 2026-09-01 there is no GitHub repository, product, or company of that name. + +Considered and dropped, over three naming rounds: + +- *Werkwrap* (the working title): names the mechanism — a wrapper over `bwrap` — rather than the result; one abandoned zero-star GitHub repo of that name also exists. +- *Werkroot*: technically the most precise (the isolated artifact is a root filesystem; lineage chroot → fakeroot), completely free — the runner-up. +- *Werkgrund*: "own ground to build on", free; but "Grund" also reads as "reason" and signals neither isolation nor containers. +- German root-words: *Wurzelwerk* (the finest word, but a well-known German gardening brand, and it inverts the Werk-family order), *Werkwurzel* (family-true but botanical), *Stammwerk*, *Wurzelraum*. +- Enclosed-area words: *Werkkammer* (sober engineering chamber), *Werkinsel* (isolation literally from *insula*), *Werkgehege* (best tagline — „damit sich Programmversionen nicht ins Gehege kommen" — but zoo overtones), *Werkklause*, *Werkzone*, *Werkhof* (the Swiss municipal works yard), *Werkgarten* (walled-garden connotation). +- *Werkbank* (taken on GitHub at least twice, and a common German word), *Werkbox* (crowded `*box` sandbox namespace), *Kapsel* (SAP's Kapsel framework). + +## The Sessions + +### A — Close step 17's open ends (this repo) + +The `BuildRunner` half is a keeper regardless of the extraction; it is merged (PR #4), but its paperwork is not finished. + +- Rename `docs/prs/2026-08-31-PR#000-bwrap-build-runtime.md` and its scenario IDs to the real number, #4. +- Mark `tools/remote install`/`build` in the script header as a prototype of the self-build workflow, superseded by session D. +- Write the bwrap-runtime ADR — step 17 says "ADR 0007", but 0007 is taken by build definitions since 2026-08-28; the ADR becomes **0008**. +- Update the architecture skill: it does not mention the third runtime yet. + +### B — Bootstrap Werkdock (subdirectory `werkdock/`, later its own repo) + +A docker-like CLI over `bwrap`, filesystem isolation only. + +- Semantics: an *image* is a rootfs archive; an *instance* is an unpacked, writable directory tree and corresponds to a docker container; `werkdock run [flags] IMAGE [CMD...]` executes in the sandbox with uid 0 mapped to the calling user. +- The surface is docker-compatible as far as the filesystem-only contract allows — verbs, flags, and (deferred) a Docker-Engine-API daemon for Testcontainers; levels and limits in Werkdock RFC 0002. +- Decided 2026-09-01: RFC 0002 levels 2 and 3 are deferred indefinitely; the immediate goal of this session is the minimal build-capable CLI — `doctor`, `load`, `run` — sufficient for the sandbox builds of Werkator, Werkbaum (Kotlin/Gradle backend plus Node frontend), and Werkdock itself (Go); while Werkdock lives in this repository, its own CI is just a build definition in this repository's `.werkator.yml`. +- Host-shared by design, not by omission: network, uid mapping, `/proc`, `/dev`, `/tmp` come from the host; document this as the contract, since it is what makes the tool work without root on a Managed Webspace. +- Moves in from Werkator: `tools/build-bwrap-rootfs.sh` (becomes the image build), the generic half of `tools/werkator-build-prerequisites.sh` (becomes `werkdock doctor`: userns capability, quota headroom), and the invocation logic of `BwrapBuildRunner` (mount ordering, mountpoint pre-creation, uid mapping — the parts hardened on the real webspace; the squash commit `71f1fc6` preserves the individual fix messages). +- Known floor: bubblewrap 0.8.0 on the webspaces has no `--overlay`; writable spots are tmpfs/bind mounts until the platform reaches 0.9. +- Own docs, plan, and ADRs under `werkdock/` from the start, so the later repository split is a directory move; the Werkator side only keeps what is Werkator-specific (the git-metadata mounts of step 16 and the config pinning). +- Keep `werkdock/` self-contained: no imports from Werkator code, no Gradle coupling to the Werkator build — it must build and test on its own. + +### C — Werkator consumes Werkdock (this repo, after B) + +- `BwrapBuildRunner` shells out to `werkdock run` instead of assembling the raw `bwrap` argv — same pattern as git and docker: CLI, no library. +- Config keys (`bwrap.enabled`, `bwrap.rootfs`) and their pinning stay as they are; only the executor behind them changes. +- Decide in the step: whether the git-metadata mounts stay Werkator-side (passed as extra `--bind`/`--tmpfs` options to `werkdock run`) or become a Werkdock feature; the secrets-masking of `.git/werkator/` must hold either way. + +### D — The Managed-Webspace install path (this repo, independent of B/C) + +Bring intent 1 to the webspace: build locally, install the bundle — Werkator never builds itself on the target. + +- `tools/remote install` loses the repository clone and the GitHub-key step; it uploads the locally built runtime bundle (built on demand, as today) and runs `init`. +- The rootfs upload stays, but for its real purpose: the sandbox for the repositories this instance *watches*, not for building Werkator. +- `tools/remote build` (the self-build) is retired with session A's prototype marker. +- Untangle the two roles `tools/remote` mixes (noted 2026-09-01): some of its commands manage the *builder* (the installed Werkator instance: install, start, control-token, the runtime bundle, the rootfs it builds others in), others act on the *built* (the watched repository: build, branch selection) — and Werkator itself overlaps with both (the instance IS a Werkator, and status/build/retry exist as `werkator` CLI commands too). + On the self-building instance both roles coincide in one product, which misleads: "updating werkator" can mean swapping the builder's bundle or building the repo's head, and they are different operations with different risks. + Session D's replacement must name the role in every command and in the script's vocabulary (e.g. `instance install`/`instance update` vs `repo build`), and prefer delegating built-side operations to the `werkator` CLI instead of reimplementing them. +- `docs/deployment.md` gains "Hostsharing Managed Webspace" as the third deployment variant — step 17 required this to be written from a verified setup, and the branch's live run provides exactly that. + +## Session Notes + +- 2026-09-01: The fat build image exists and is live on mih34: `tools/build-bwrap-rootfs.sh` gained `--pkgs-extra`, the archive `werkator-buildenv-trixie-java-go-node.tar.zst` (515 MB, JDK 21 + Go + Node/npm) was built locally, uploaded checksum-verified, and the machine config switched to it (deduplicating nine identical bwrap blocks the install prototype had appended). + The old archive and its unpacked environment stay as rollback until the `werkdock` build pool is green. +- 2026-09-01, later: sessions A and B are done and live-verified on mih34 — the skeleton (`doctor`, `load`, `run` over the bwrap engine) builds itself there as pool `@werkdock`, and the CI-built static binary runs. + Three defects found and fixed on the way: unanchored tar excludes dropped the Go stdlib's `sys` directory from the archive, pam_tmpdir's `TMPDIR` leaked into the sandbox (Werkator-side fix; Werkdock is immune via `--clearenv`), and non-report artifacts were stored below `reports/` and invisible in the UI. + The image was then trimmed (headless JDK, en/de locales only, no man/doc/apt-lists): 351 MB compressed — smaller than the original JDK-only archive despite carrying Go and Node. + All rollback assets on mih34 are removed; the PR for this branch is prepared (PR-doc with `PR#000` placeholder) and will be opened later. + +## Acceptance Criteria + +- Session A: PR-doc renamed to #4, ADR 0008 written, architecture skill mentions the third runtime, `tools/remote` header carries the prototype note. +- Session B: the `werkdock/` subdirectory holds a self-contained tool in which `werkdock doctor`, an image build, and `werkdock run` work on a Managed Webspace without any Werkator involvement. +- Session C: `./gradlew build` green with `BwrapBuildRunner` delegating to `werkdock`; the pinned-key tests and the metadata-masking tests unchanged and green. +- Session D: a fresh Managed Webspace reaches a running, HTTPS-reachable Werkator via `tools/remote werkator install` + `start` without ever compiling on the target; `docs/deployment.md` documents it. diff --git a/docs/plan/README.md b/docs/plan/README.md index 41a1c6c..a6a0d00 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -91,6 +91,10 @@ Added for running Werkator on Hostsharing Managed Webspaces (2026-08-10): - [ ] `17-bwrap-build-runtime.md` — Werkator on a Managed Webspace: bubblewrap user-namespace build sandbox with a prepared rootfs (precondition check first — see the step file), plus web access under a domain via the platform's Apache proxy and Let's Encrypt +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, 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): - [ ] `20-build-duration-tracking.md` — a per-name duration trend over the existing history, derived on read in the History view: series, window average/min/max, and a visible marker when the latest build is slower than its window average (grouped by the history's own `name`, so branch builds and named jobs stay separate — complements Step 14, which owns phase timing) @@ -101,7 +105,8 @@ Steps 07–09 depend on 04–06. Steps 11 and 12 are optional/deferrable; 10 only needs 04–06. 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()`. +Step 21 depends on 17; its sessions B and C grow Werkdock in the `werkdock/` subdirectory (later its own repository), and session D supersedes the self-build prototype in `tools/remote`. diff --git a/docs/prs/2026-08-31-PR#000-build-current-head-from-the-branches-view.md b/docs/prs/2026-08-31-PR#3-build-current-head-from-the-branches-view.md similarity index 94% rename from docs/prs/2026-08-31-PR#000-build-current-head-from-the-branches-view.md rename to docs/prs/2026-08-31-PR#3-build-current-head-from-the-branches-view.md index 2c58e45..436464e 100644 --- a/docs/prs/2026-08-31-PR#000-build-current-head-from-the-branches-view.md +++ b/docs/prs/2026-08-31-PR#3-build-current-head-from-the-branches-view.md @@ -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, `@` 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. diff --git a/docs/prs/2026-08-31-PR#000-bwrap-build-runtime.md b/docs/prs/2026-08-31-PR#4-bwrap-build-runtime.md similarity index 94% rename from docs/prs/2026-08-31-PR#000-bwrap-build-runtime.md rename to docs/prs/2026-08-31-PR#4-bwrap-build-runtime.md index a13cd8e..6eee50e 100644 --- a/docs/prs/2026-08-31-PR#000-bwrap-build-runtime.md +++ b/docs/prs/2026-08-31-PR#4-bwrap-build-runtime.md @@ -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. diff --git a/docs/prs/2026-09-01-PR#6-werkdock-bootstrap.md b/docs/prs/2026-09-01-PR#6-werkdock-bootstrap.md new file mode 100644 index 0000000..469d84a --- /dev/null +++ b/docs/prs/2026-09-01-PR#6-werkdock-bootstrap.md @@ -0,0 +1,173 @@ +> **WARNING:** This document describes only the change applied in this PR. +> It may already be outdated once the next PR is merged. +> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation. + +## The Problem + +The bwrap work merged as PR #4 drifted from the original intent (plan step 21): it built Werkator *on* the webspace instead of extracting the generic sandbox machinery into a reusable tool. +This PR starts that extraction: **Werkdock**, a docker-like sandbox CLI over `bwrap` — filesystem isolation only — grown in the `werkdock/` subdirectory until it stands on its own. +The concrete goal of the session (decided 2026-09-01): the sandbox builds of Werkator, Werkbaum, and Werkdock itself must work on a Managed Webspace. +Getting there surfaced and fixed three real defects: the rootfs archive silently lost every directory named `sys`/`proc`/`dev` (tar exclude patterns are unanchored by default), the sandbox inherited the host's pam_tmpdir `TMPDIR` and broke every tool honoring it, and non-report build artifacts were stored mislabeled under `reports/` and never shown in the UI. + +## Non-Goals + +- OCI image pull, the Docker Engine API daemon, and Testcontainers support (RFC 0002 levels 2 and 3, deferred indefinitely). +- Persistent Werkdock instances (`run` currently requires `--rm`) and the verbs beyond `doctor`/`load`/`run`. +- Session C (Werkator's `BwrapBuildRunner` delegating to the `werkdock` CLI) and session D (the webspace install path replacing the self-build prototype). +- Composable toolchain mounts (RFC 0003 stays a candidate). +- Multi-repository support for one Werkator instance. + +## The Scenarios + +### Feature: Werkdock, a docker-shaped sandbox CLI + +#### Background + +- An *image* is a rootfs archive, unpacked into the store at `$WERKDOCK_HOME` (default `~/.werkdock`); an *instance* corresponds to a docker container. +- The contract is filesystem isolation only: network, uid mapping target, `/proc`, `/dev`, `/tmp` come from the host. +- RFC 0001 decided Go (stdlib-only, one static binary); RFC 0002 decided the docker-compatible surface. + +#### Scenario#6.01: A command runs inside the sandbox as root with a clean environment + +So that builds are reproducible and docker knowledge transfers. + +- **Given** a loaded image and the bwrap CLI on the host +- **When** `werkdock run --rm -v /repo:/repo -e CI=true -w /repo IMAGE sh -c '...'` is invoked +- **Then** the command runs with uid 0 mapped to the calling user, the rootfs read-only at `/`, tmpfs on `/tmp` and `/root` + - **and** the environment is cleared (`--clearenv`) with `HOME`/`PATH` set explicitly and the `-e` variables applied + - **and** the command's exit code is passed through (werkdock's own errors exit 125, like docker). + +##### Verified by + +- [TestArgvAssemblesTheHardenedInvocation](../../werkdock/internal/engine/bwrap_test.go) +- [TestRunInsideRealSandbox](../../werkdock/internal/engine/bwrap_test.go) (gated: skips without bwrap/userns) +- [TestRunPassesTheExitCodeThrough](../../werkdock/internal/engine/bwrap_test.go) + +#### Scenario#6.02: Docker flags whose promise cannot be kept are refused loudly + +So that a docker user is never silently under-isolated. + +- **Given** the docker-shaped `run` flag surface +- **When** `-p`, `--network`, `--memory`, `--cpus`, `--user`, or `-d` is passed +- **Then** the invocation fails with the reason, never a silent no-op + - **and** `run` without `--rm` fails with "persistent instances are not implemented yet". + +##### Verified by + +- [TestParseRunRefusesDockerFlagsLoudly](../../werkdock/internal/cli/run_test.go) +- [TestParseRunRequiresRmForNow](../../werkdock/internal/cli/run_test.go) + +#### Scenario#6.03: Images load atomically and mountpoints are pre-created + +So that a failed load leaves no half image and bind targets missing from a read-only rootfs cannot fail the run. + +- **Given** a rootfs archive +- **When** `werkdock load -i ARCHIVE` imports it and a later `run` binds paths the rootfs does not ship +- **Then** the image is unpacked to a temp directory and renamed into place (a broken archive leaves nothing behind) + - **and** missing bind mountpoints are pre-created inside the rootfs — directories for directory sources, files for file sources + - **and** a bind destination escaping the rootfs is refused. + +##### Verified by + +- [TestLoadUnpacksArchiveIntoTheStore, TestLoadLeavesNoHalfImageOnFailure](../../werkdock/internal/store/store_test.go) +- [TestEnsureMountpointsCreatesMissingAndSkipsExisting, TestEnsureMountpointsRefusesEscapingDestinations](../../werkdock/internal/engine/bwrap_test.go) + +#### Scenario#6.04: `werkdock doctor` decides whether a host can run sandboxes + +So that a broken host fails loudly before the first build, in the PASS/FAIL format of the prerequisites script it ports. + +- **Given** a target host +- **When** `werkdock doctor [TARGET_DIR]` runs +- **Then** it checks the userns probe (uid 0 inside, uid_map back to the caller, read-only root enforced), tar/zstd, free space, and group-quota headroom + - **and** exits non-zero when any check fails. + +##### Verified by + +- [doctor_test.go](../../werkdock/internal/doctor/doctor_test.go) (probe evaluation, df/quota parsers incl. wrapped quota lines) + +### Feature: Werkdock builds itself on the webspace + +#### Scenario#6.05: The `werkdock` build definition compiles the Go module in the sandbox + +So that "Werkdock builds itself" is CI reality while it lives in this repository. + +- **Given** the `builds.werkdock` definition in `.werkator.yml` and a build image containing the go toolchain +- **When** a commit is pushed +- **Then** the pool `@werkdock` runs gofmt gate, `go vet`, `go test`, and a static `go build` + - **and** the binary is stored as a build artifact (verified live on mih34: the CI-built binary downloads and runs). + +##### Verified by + +- live run on mih34 (config change; the definition mechanics are covered by the existing build-definition tests) + +#### Scenario#6.06: The rootfs archive contains everything its packages installed + +So that a directory of the Go stdlib named `sys` is never again silently missing (seen live: "package internal/runtime/sys is not in std"). + +- **Given** `tools/build-bwrap-rootfs.sh` +- **When** the archive is packed +- **Then** only the top-level `/proc`, `/sys`, `/dev` mountpoints are excluded (anchored patterns), not every path component of that name. + +##### Verified by + +- live rebuild + archive listing (script change; asserted by the green go build in Scenario#6.05) + +#### Scenario#6.07: The sandbox resets TMPDIR to its own /tmp + +So that hosts with pam_tmpdir (`TMPDIR=/tmp/user/`) cannot break tools honoring TMPDIR inside the sandbox. + +- **Given** a server environment carrying `TMPDIR`/`TMP` +- **When** `BwrapBuildRunner` assembles the invocation +- **Then** both are set back to `/tmp` before the configured environment, which can still override them. + +##### Verified by + +- [BwrapBuildRunnerTest](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt) + +### Feature: honest artifact paths + +#### Scenario#6.08: Non-report artifact directories keep their own paths and appear on the artifact page + +So that a built binary is neither mislabeled below `reports/` nor invisible. + +- **Given** a build with `artifactDirs` beyond `build/reports` +- **When** its artifacts are persisted and the artifact page is rendered +- **Then** `build/reports` still archives as `reports/` (the browsable report anchor and every existing link) + - **and** every other directory archives at its workspace-relative path + - **and** the page lists those files (capped at 200) with download links, logs staying in their own section. + +##### Verified by + +- [FileArtifactStoreTest](../../src/test/kotlin/de/hoennig/werkator/artifacts/FileArtifactStoreTest.kt) +- [UiControllerTest."artifact index lists plain files outside reports/…"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt) + +## The Solution + +Werkdock is its own Go module in `werkdock/` — stdlib-only, no Gradle coupling, `CGO_ENABLED=0 go build` yields one ~3.5 MB static binary. +The layering anticipates RFC 0002 level 3: CLI verbs are thin frontends over `internal/engine` (a `RunSpec` behind an `Engine` interface, bwrap first, native namespaces possible later per RFC 0001), `internal/store` (images on disk), and `internal/doctor`. +The bwrap invocation is the port of the runner hardened live in PR #4 — mount ordering, mountpoint pre-creation, uid mapping — plus `--clearenv` (which makes Werkdock immune to the TMPDIR class of bugs by construction). +The decisions are recorded as RFCs in `werkdock/docs/rfcs/`: 0001 language (Go, over Rust/Python/Kotlin-Native/bash, ten-criteria scoring), 0002 docker-compatible surface (level 1 now, OCI and daemon deferred), 0003 composable toolchain mounts (candidate). +The build image grew into one fat trixie rootfs (JDK 21 headless + Go + Node/npm) and then shrank below the original JDK-only archive: 351 MB vs 375 MB, after trimming X11 (headless JDK), non-en/de locales, man/doc, and apt lists. + +## Open Questions + +- The version werkdock reports (`0.1.0-dev`) has no release process yet; it gets one when the repository split nears. + +## Additional Changes + +- Step 21 plan: session notes, the deferral decisions, and the builder-vs-built role tangle in `tools/remote` noted for session D. +- ADR 0008 (bwrap runtime) written; step 17 and the plan README now point at 0008 (0007 was already taken). +- PR-docs of PR #3 and PR #4 renamed from their `PR#000` placeholders. +- Architecture skill: the third runtime documented; AGENTS.md decision list caught up with ADR 0007/0008. +- `tools/build-bwrap-rootfs.sh` gained `--pkgs-extra`. +- `docs/configuration.md`: artifactDirs archiving described. + +## Prerequisite PRs + +- PR #4 (bwrap build runtime) — Werkdock ports its hardened invocation. + +## Follow-up PRs + +- PR #7: session C (`BwrapBuildRunner` delegates to the `werkdock` CLI) and session D (the webspace install path replaces the self-build prototype in `tools/remote`). +- PR #8/#9: `tools/remote` and `werkator init` stop duplicating each other's configuration writing. +- PR #10: multi-repository support for one Werkator instance (step 22). diff --git a/src/main/kotlin/de/hoennig/werkator/artifacts/FileArtifactStore.kt b/src/main/kotlin/de/hoennig/werkator/artifacts/FileArtifactStore.kt index 1ac2129..a08f50e 100644 --- a/src/main/kotlin/de/hoennig/werkator/artifacts/FileArtifactStore.kt +++ b/src/main/kotlin/de/hoennig/werkator/artifacts/FileArtifactStore.kt @@ -151,12 +151,18 @@ class FileArtifactStore( } } - /** Legacy `archived_artefact_dir_path`: `build/reports` archives as `reports/`, everything else below `reports/`. */ + /** + * `build/reports` archives as `reports/` — the browsable-reports anchor of the + * artifact page and the legacy `archived_artefact_dir_path` layout. Every other + * directory archives at its own workspace-relative path: it is not a report, + * and hiding e.g. a built binary below `reports/` made it both mislabeled and + * invisible (the report index only scans for HTML pages). + */ private fun archivedPath(artifactDir: String): String = if (artifactDir == "build/reports") { "reports" } else { - "reports/$artifactDir" + artifactDir } /** diff --git a/src/main/kotlin/de/hoennig/werkator/build/BwrapBuildRunner.kt b/src/main/kotlin/de/hoennig/werkator/build/BwrapBuildRunner.kt index f41cb69..63301f8 100644 --- a/src/main/kotlin/de/hoennig/werkator/build/BwrapBuildRunner.kt +++ b/src/main/kotlin/de/hoennig/werkator/build/BwrapBuildRunner.kt @@ -200,6 +200,14 @@ class BwrapBuildRunner( args += listOf("--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf") args += listOf("--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp") args += listOf("--setenv", "HOME", "/root") + // The sandbox /tmp is a fresh tmpfs, but bwrap inherits the server's + // environment — on hosts with pam_tmpdir that includes + // TMPDIR=/tmp/user/, which does not exist inside and breaks every + // tool honoring it (go: "creating work dir: stat ...: no such file or + // directory"; the JVM ignores TMPDIR, so Gradle never noticed). Set + // both back to /tmp; explicit env below can still override. + args += listOf("--setenv", "TMPDIR", "/tmp") + args += listOf("--setenv", "TMP", "/tmp") for ((key, value) in environment) { args += listOf("--setenv", key, value) } diff --git a/src/main/kotlin/de/hoennig/werkator/server/UiController.kt b/src/main/kotlin/de/hoennig/werkator/server/UiController.kt index 925ed1d..4121bf9 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/UiController.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/UiController.kt @@ -203,9 +203,27 @@ class UiController( ?: emptyList(), ) model.addAttribute("reportIndexes", artifactDir?.let { reportIndexes(it) } ?: emptyList()) + model.addAttribute("fileArtifacts", artifactDir?.let { fileArtifacts(it) } ?: emptyList()) return "artifact" } + /** + * Plain artifact files outside `reports/` — build outputs like binaries or + * jars, archived at their workspace-relative paths. The top-level log files + * have their own section. Capped so a huge output tree cannot flood the page. + */ + private fun fileArtifacts(artifactDir: Path): List = + Files.walk(artifactDir).use { paths -> + paths + .asSequence() + .filter { Files.isRegularFile(it) } + .map { artifactDir.relativize(it).toString() } + .filterNot { it.startsWith("reports/") || (!it.contains('/') && it.endsWith(".log")) } + .sorted() + .take(MAX_FILE_ARTIFACTS) + .toList() + } + /** Adds the attributes every page needs and returns the Gitea link helper for row building. */ private fun baseModel( model: Model, @@ -366,6 +384,8 @@ class UiController( } companion object { + private const val MAX_FILE_ARTIFACTS = 200 + private val FAILURES_COUNTER = Regex("""id="failures">\s*
(\d+)""") /** diff --git a/src/main/resources/templates/artifact.html b/src/main/resources/templates/artifact.html index ac8ad76..1cd3eb7 100644 --- a/src/main/resources/templates/artifact.html +++ b/src/main/resources/templates/artifact.html @@ -70,7 +70,13 @@ th:text="${report.failures} + ' failed'">2 failed -

+

+

No artifact directories were produced by this build.

diff --git a/src/test/kotlin/de/hoennig/werkator/artifacts/FileArtifactStoreTest.kt b/src/test/kotlin/de/hoennig/werkator/artifacts/FileArtifactStoreTest.kt index 7f72fc0..ca11788 100644 --- a/src/test/kotlin/de/hoennig/werkator/artifacts/FileArtifactStoreTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/artifacts/FileArtifactStoreTest.kt @@ -90,9 +90,11 @@ class FileArtifactStoreTest : FunSpec() { Files.readString(artifactDir.resolve("build.stdout.log")) shouldBe "out" Files.readString(artifactDir.resolve("build.stderr.log")) shouldBe "err" Files.readString(artifactDir.resolve("build.log")) shouldBe "live" - // legacy layout: build/reports archives as reports/, other dirs below reports/ + // build/reports archives as reports/ (the artifact page's browsable + // anchor), every other dir at its own workspace-relative path Files.exists(artifactDir.resolve("reports/tests/index.html")) shouldBe true - Files.exists(artifactDir.resolve("reports/build/doc/readme.txt")) shouldBe true + Files.exists(artifactDir.resolve("build/doc/readme.txt")) shouldBe true + Files.exists(artifactDir.resolve("reports/build")) shouldBe false Files.exists(staging) shouldBe false } @@ -104,8 +106,8 @@ class FileArtifactStoreTest : FunSpec() { h.store.persist(build, stagingDir(), workspace) val artifactDir = h.branchesDir().resolve(build.artifactKey) - Files.exists(artifactDir.resolve("reports/build/doc/readme.txt")) shouldBe true - Files.exists(artifactDir.resolve("reports/tests")) shouldBe false + Files.exists(artifactDir.resolve("build/doc/readme.txt")) shouldBe true + Files.exists(artifactDir.resolve("reports")) shouldBe false } test("persist without a workspace stores only the logs") { diff --git a/src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt b/src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt index 65c8932..3e8b988 100644 --- a/src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt @@ -104,6 +104,12 @@ class BwrapBuildRunnerTest : FunSpec() { "HOME", "/root", "--setenv", + "TMPDIR", + "/tmp", + "--setenv", + "TMP", + "/tmp", + "--setenv", "branch", "main", "--chdir", diff --git a/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt b/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt index f35baa6..83bcff8 100644 --- a/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt @@ -291,6 +291,30 @@ class UiControllerTest : FunSpec() { ).andExpect(content().string(not(containsString("reports/tests/test/packages/index.html")))) } + test("artifact index lists plain files outside reports/ and keeps logs and report files out of that list") { + val artifactDir = Files.createDirectories(tempDir.resolve("files-view-key")) + Files.writeString(artifactDir.resolve("build.stdout.log"), "out") + Files.createDirectories(artifactDir.resolve("werkdock/dist")) + Files.writeString(artifactDir.resolve("werkdock/dist/werkdock"), "elf") + Files.createDirectories(artifactDir.resolve("reports/tests")) + Files.writeString(artifactDir.resolve("reports/tests/index.html"), "") + every { repository.history() } returns listOf(successResult) + every { artifactStore.artifactDir("files-view-key") } returns artifactDir + + val page = + mockMvc + .perform(get("/builds/files-view-key")) + .andExpect(status().isOk) + .andExpect( + content().string(containsString("""/artifacts/files-view-key/werkdock/dist/werkdock" target="_blank"""")), + ).andReturn() + .response.contentAsString + // stored at its own path, not below reports/; the log stays in the + // logs section and is not repeated in the files list + page shouldNotContain "reports/werkdock" + (page.split("/artifacts/files-view-key/build.stdout.log").size - 1) shouldBe 1 + } + test("the artifact page shows the command of the build's own definition, not the plain branch command") { val pitestResult = successResult.copy( diff --git a/tools/build-bwrap-rootfs.sh b/tools/build-bwrap-rootfs.sh index 5b1a5fe..b4ed45a 100755 --- a/tools/build-bwrap-rootfs.sh +++ b/tools/build-bwrap-rootfs.sh @@ -25,10 +25,15 @@ # - Only the final `tar --zstd` writes to stdout; every build step is # redirected to stderr, so the archive coming out of `docker run` is pure. # -# Usage: build-bwrap-rootfs.sh [--release trixie] [--mirror URL] [--out path] -# --release Debian release/architecture tail, default "trixie" -# --mirror apt mirror for debootstrap, default http://deb.debian.org/debian -# --out output archive path, default ./werkator-buildenv-.tar.zst +# Usage: build-bwrap-rootfs.sh [--release trixie] [--mirror URL] [--out path] [--pkgs-extra "PKG..."] +# --release Debian release/architecture tail, default "trixie" +# --mirror apt mirror for debootstrap, default http://deb.debian.org/debian +# --out output archive path, default ./werkator-buildenv-.tar.zst +# --pkgs-extra additional apt packages on top of the base list, e.g. +# "golang-go nodejs npm" for Go and Node builds. Name the +# archive after its content (--out): the bwrap runtime keys the +# unpacked environment by the archive SOURCE PATH, so a changed +# content needs a changed name to take effect. # set -euo pipefail @@ -45,11 +50,13 @@ usage() { release="trixie" out="" +pkgs_extra="" while [ $# -gt 0 ]; do case "$1" in --release) release="${2:?missing value for --release}"; shift 2 ;; --mirror) mirror="${2:?missing value for --mirror}"; shift 2 ;; --out) out="${2:?missing value for --out}"; shift 2 ;; + --pkgs-extra) pkgs_extra="${2:?missing value for --pkgs-extra}"; shift 2 ;; -*) die "unknown option: $1" ;; *) usage ;; esac @@ -62,8 +69,11 @@ command -v docker >/dev/null 2>&1 || die "docker is required to build the rootfs # Rootfs content: Werkator's own build needs a JDK 21 toolchain (Gradle # toolchain resolution), git, ca-certificates for HTTPS, locales for git, and # curl/unzip/xz-utils/zstd for the Gradle wrapper and general build hygiene. +# The headless JDK on purpose: it skips the X11/fontconfig library stack +# (~200 MB) and still supports headless AWT, which is all a CI build needs. # Keep this list additive — project-specific tooling goes on top of this base. -PKGS="openjdk-21-jdk git ca-certificates locales procps file curl unzip xz-utils zstd" +PKGS="openjdk-21-jdk-headless git ca-certificates locales procps file curl unzip xz-utils zstd" +[ -z "$pkgs_extra" ] || PKGS="$PKGS $pkgs_extra" # The chroot step runs inside the freshly debootstrapped rootfs; passed into # the container as base64 so no nested heredoc corrupts the piped script. @@ -76,6 +86,12 @@ apt-get clean rm -f /etc/localtime locale-gen en_US.UTF-8 de_DE.UTF-8 >/dev/null 2>&1 || true update-locale LANG=en_US.UTF-8 >/dev/null 2>&1 || true +# Trim what a build environment never reads: translated message catalogs +# except en/de (the generated locales in /usr/lib/locale stay untouched), +# man pages, package docs, and the apt package lists (apt still works after +# an apt-get update, should anyone ever need it inside the sandbox). +find /usr/share/locale -mindepth 1 -maxdepth 1 ! -name "en*" ! -name "de*" -exec rm -rf {} + +rm -rf /usr/share/man/* /usr/share/doc/* /var/lib/apt/lists/* /var/cache/apt ' | base64 -w0)" # The outer script runs inside the Debian container as root. Build noise goes @@ -98,7 +114,7 @@ chmod +x /b/rootfs/inner.sh chroot /b/rootfs /bin/bash /inner.sh umount /b/rootfs/proc; umount /b/rootfs/sys; umount /b/rootfs/dev exec 1>&3 -tar --zstd --exclude=proc --exclude=sys --exclude=dev -C /b/rootfs -cf - . +tar --zstd --anchored --exclude=./proc --exclude=./sys --exclude=./dev -C /b/rootfs -cf - . ' | base64 -w0)" echo "building ${release} rootfs (downloads packages, takes a while; log below)..." diff --git a/tools/remote b/tools/remote index 2fc45ea..016214c 100755 --- a/tools/remote +++ b/tools/remote @@ -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 } diff --git a/werkdock/.gitignore b/werkdock/.gitignore new file mode 100644 index 0000000..e273528 --- /dev/null +++ b/werkdock/.gitignore @@ -0,0 +1,2 @@ +/werkdock +/dist/ diff --git a/werkdock/README.md b/werkdock/README.md new file mode 100644 index 0000000..fc220d5 --- /dev/null +++ b/werkdock/README.md @@ -0,0 +1,46 @@ +# Werkdock + +A docker-like sandbox CLI over `bwrap` — filesystem isolation only. +A dock is the enclosed basin in which ships are built: the dock gate controls what passes, the water outside is shared with the whole harbor. +Accordingly, network, uid, `/proc`, `/dev`, and `/tmp` come from the host by contract; that is what makes Werkdock work without root on a Hostsharing Managed Webspace. + +Semantics — docker-compatible as far as the filesystem-only contract allows (see [RFC 0002](docs/rfcs/0002-docker-compatible-surface.md)): + +- An *image* is a rootfs archive; an *instance* is an unpacked, writable directory tree and corresponds to a docker container. +- `werkdock run [flags] IMAGE [CMD...]` creates an instance and executes in the sandbox with uid 0 mapped to the calling user; verbs and flags follow docker, unsupported docker flags fail loudly. +- `werkdock doctor` checks the host: user-namespace capability, disk and quota headroom. +- A daemon speaking the Docker Engine API subset (for Testcontainers) is designed for but deferred. + +## Disk Footprint + +Werkdock's storage model is coarser than Docker's on the image side and cheaper on the instance side: + +- An image is a flat, complete directory tree — there are no layers, and nothing is shared between images. + A JDK+Go+Node build image is roughly 2 GiB unpacked, plus its compressed archive (~0.5 GiB) as long as that is kept around. +- An instance costs (almost) nothing: the rootfs is bound read-only into every sandbox, writable are only tmpfs (`/tmp`, `/root`) and the caller's binds. + Ten parallel runs in one image add zero filesystem copies; what grows per project are its own caches in bound volumes. +- Consequence: prefer ONE fat image shared by all projects over per-project images. +- Watch out for orphans: consumers that key an unpacked environment by the archive's source path (Werkator's bwrap runtime does) leave the old tree behind on every path change; pruning is manual until `rmi`/`prune` verbs exist. +- Future options that would remove the flat-tree cost, in their own RFCs when they come due: composable toolchain mounts — a slim base plus per-toolchain prefix binds, no overlayfs needed ([RFC 0003](docs/rfcs/0003-composable-toolchain-mounts.md), candidate) — overlayfs layers (the kernel allows it unprivileged in a user namespace since 5.11; the webspaces' bwrap 0.8.0 cannot yet), or hardlink deduplication between image versions in the store (the ostree principle, no root needed). + +## Build and Test + +```bash +go test ./... # all tests; sandbox integration tests skip without bwrap/userns +go vet ./... && gofmt -l . # quality gates (gofmt must print nothing) +CGO_ENABLED=0 go build . # one static linux binary, ~3 MB +``` + +First steps on a host: + +```bash +werkdock doctor # can this host run sandboxes? +werkdock load -i rootfs.tar.zst # import a rootfs archive as an image +werkdock run --rm -v /repo:/repo -w /repo IMAGE sh -c './gradlew build' +``` + +Status: bootstrap. +The implementation language is Go, decided in [RFC 0001](docs/rfcs/0001-implementation-language.md). +Werkdock grows in this subdirectory of the Werkator repository and moves to its own repository once it stands on its own. +It must stay self-contained: no imports from Werkator code, no Gradle coupling to the Werkator build. +The roadmap is session B of [docs/plan/21-werkdock-extraction-and-webspace-install.md](../docs/plan/21-werkdock-extraction-and-webspace-install.md). diff --git a/werkdock/docs/rfcs/0001-implementation-language.md b/werkdock/docs/rfcs/0001-implementation-language.md new file mode 100644 index 0000000..26951fe --- /dev/null +++ b/werkdock/docs/rfcs/0001-implementation-language.md @@ -0,0 +1,109 @@ +# RFC 0001: Implementation Language for Werkdock + +**Status:** +- proposed: 2026-09-01 +- accepted: 2026-09-01 +- rejected: - + +**Proposal:** Werkdock is implemented in **Go** — as a single static binary, stdlib-only, with the sandbox engine behind an interface so bwrap can later be replaced by native namespaces. + +## Context and Problem Statement + +Werkdock is a docker-like sandbox CLI over `bwrap`, filesystem isolation only (see [README](../../README.md) and Werkator plan step 21). +Three hard requirements drive the language choice: + +1. **Distribution to a Managed Webspace without root** — the tool must arrive and run with no package installation and no runtime dependency on the host. +2. **The work is process and filesystem orchestration** — spawning `bwrap`/`tar`/`zstd` with streamed logs and forwarded signals, assembling mount arguments, `doctor` checks. +3. **Self-contained and testable** — no code sharing and no build coupling with Werkator; the integration is `werkdock run` as a CLI call, like git and docker. + +Two further criteria matter in this project: + +- **AI-generated code quality** — the tool is developed AI-assisted; languages where generated code is reliably correct and idiomatic reduce review load. +- **Security** — Werkdock assembles mount arguments and uid mappings from user input; language safety and a small supply chain count. + +## Considered Options + +bash, Python 3, Kotlin Native, Rust, Go. + +Scoring: −2 (unsuitable) to +2 (ideal), unweighted sum. + +| Criterion | bash | Python 3 | Kotlin Native | Rust | Go | +|---|---:|---:|---:|---:|---:| +| Distribution to webspace (no root) | +2 | +1 | −1 | +2 | +2 | +| Fit for process/FS orchestration | +1 | +2 | 0 | +2 | +2 | +| Testability | −2 | +2 | +1 | +2 | +2 | +| Robustness/maintainability as it grows | −2 | +1 | +1 | +2 | +2 | +| Closeness to the maintainer's stack (Kotlin dev) | 0 | +1 | +2 | −1 | +1 | +| Genre references to learn from | −1 | 0 | −1 | +1 | +2 | +| Future: own namespaces instead of bwrap | −2 | −1 | 0 | +2 | +1 | +| Toolchain/build effort | +2 | +2 | −2 | 0 | +2 | +| AI-generated code quality | −1 | +2 | 0 | +1 | +2 | +| Security | −2 | +1 | +1 | +2 | +2 | +| **Sum** | **−5** | **+11** | **+1** | **+13** | **+18** | + +The ranking is robust against re-weighting: Go scores below +1 in no criterion — it wins by absence of weaknesses, not by one outlier. + +### bash + +Out on principle: the Werkator repository exists because a grown bash CI script became unmaintainable. +A tool with subcommands, image/instance state, and doctor checks starts beyond the bash comfort zone. +AI generates bash fluently but with the classic silent defects (quoting, word splitting, unchecked exit codes), and the missing test story means nobody notices. +Security −2 is earned: injection via word splitting in exactly the kind of code Werkdock writes — user-supplied paths assembled into mount arguments. +The existing scripts serve as specification, not as foundation. + +### Python 3 + +The best "no new compiler" candidate: present on every Debian webspace, the stdlib suffices (unpacking `tar.zst` shells out to `zstd` anyway), excellent testability, excellent AI generation. +Weaknesses: version drift across hosts (3.11/3.13), no static type check at runtime, and the tool runs as a tamperable source file on the host interpreter instead of as a binary. + +### Kotlin Native + +Loses despite maximum stack closeness, and not narrowly — the weakness sits exactly where Werkdock lives: + +- **The stdlib gap hits the tool's core.** Kotlin never had its own system libraries; on the JVM it delegates file, process, and IO work to the JDK. On Native that platform library is gone and only `platform.posix` remains. Werkdock's central operation — spawning processes with log streaming, signal forwarding, and exit codes — means hand-written `fork`/`execvp`/`waitpid` over cinterop. +- **Kotlin Native was built for iOS, not for CLI tools.** The driver was Kotlin Multiplatform (no JVM allowed on iPhone); the kotlinx ecosystem grew what mobile apps need. Mobile apps never spawn child processes, so no official process API exists. +- **AI drifts to the JVM.** The Kotlin training corpus is overwhelmingly JVM/Android; models reliably propose `ProcessBuilder` and `java.nio`, which do not exist on Native. +- **Distribution is build-machine-bound.** Unlike the jlink bundle (which copies Temurin's prebuilt binaries, glibc floor 2.15, measured in Werkator ADR 0006), Kotlin Native compiles locally, so the binary's glibc floor is the build machine's. +- **The expected payoff never materializes.** There is no shared code and no shared build graph with Werkator by design; "same language" buys only developer familiarity — and JVM-library-free Native Kotlin feels more foreign than Go does after a week. + +The honest variant of language consistency — Kotlin/JVM plus a jlink bundle like Werkator itself — was not on the ballot and would be disproportionate: a ~66 MB bundle for a sandbox helper copied to foreign webspaces, against one static Go binary. + +### Rust + +Technically the strongest language for the genre and the best if Werkdock one day opens namespaces itself (direct syscalls, `youki` as a memory-safe sandbox reference). +Price: the steepest learning curve for a Kotlin developer and the slowest progress; AI-generated Rust needs iterations at the borrow checker, which the compiler at least enforces loudly. + +### Go + +The sweet spot: + +- The container world Werkdock imitates is written in Go — docker CLI, podman, runc — so every subproblem has a proven, readable reference. +- One static binary (`CGO_ENABLED=0`) is the perfect webspace distribution; cross-compilation is a `GOOS`/`GOARCH` pair; builds take seconds. +- Testing is built in; `gofmt` knows exactly one style, which makes AI-generated Go above-average correct on the first attempt. +- The stdlib covers everything the tool does (`os/exec`, `os`, `io`, `archive/tar`), keeping the dependency list near zero — the smallest supply chain in the field. +- Coming from Kotlin, Go is productive within days: garbage collector, familiar concepts, deliberately small language. + +## The Namespace Future, Concretely + +Own namespaces instead of shelling out to `bwrap` are a real option, and Go keeps it open: + +- The webspace kernel provably allows unprivileged user namespaces — Debian's `bwrap` has not been setuid since bookworm and uses nothing else. +- Go needs no cgo for it: namespaces are created when spawning the child via `SysProcAttr` (`Cloneflags`, `UidMappings`/`GidMappings`), with the usual re-exec pattern (`werkdock run` starts itself as a hidden init subcommand inside the fresh namespaces, sets up mounts, then execs the payload). +- The concrete payoff: since kernel 5.11, overlayfs mounts are allowed inside a user namespace unprivileged — the webspace runs 6.1, but its `bubblewrap 0.8.0` has no `--overlay` (added in 0.9.0). Own namespace code could provide the throwaway writable layer per build today. +- The counterweight: `bwrap` is hardened, Flatpak-tested code, and if the platform ever adopts an AppArmor userns restriction (as Ubuntu 24.04 did), the distribution's `bwrap` would likely stay permitted while a brought-along binary gets its `clone()` refused. + +Consequence for the design, independent of the engine question's outcome: the sandbox engine sits behind an interface from the start — engine 1 is `bwrap` (present, proven, invocation logic exists), engine 2 can later be native namespaces. + +## Concrete Proposal + +1. **Language**: Go, current stable toolchain, pinned in `go.mod` (`toolchain` directive). +2. **Module**: `werkdock` as its own Go module in this subdirectory — no Gradle involvement, `go build` / `go test` / `go vet` are the whole toolchain. +3. **Dependency policy**: stdlib-only; any third-party dependency needs an RFC. +4. **Distribution**: one static linux/amd64 binary, built with `CGO_ENABLED=0`; other architectures are a build-matrix entry away if ever needed. +5. **Style and quality gates**: `gofmt` (enforced), `go vet`, table-driven tests with the built-in `testing` package. +6. **Architecture**: CLI semantics (`run`, images, instances, `doctor`) decoupled from a sandbox engine interface; `bwrap` is the first engine, native namespaces a possible second. +7. **External processes**: `bwrap`, `tar`, `zstd` are called as CLIs via `os/exec` — the same pattern Werkator uses for git and docker. + +## Decision Outcome + +Accepted on 2026-09-01: Werkdock is implemented in Go, under the terms of the concrete proposal above. diff --git a/werkdock/docs/rfcs/0002-docker-compatible-surface.md b/werkdock/docs/rfcs/0002-docker-compatible-surface.md new file mode 100644 index 0000000..f02bd5d --- /dev/null +++ b/werkdock/docs/rfcs/0002-docker-compatible-surface.md @@ -0,0 +1,90 @@ +# RFC 0002: Docker-Compatible Surface + +**Status:** +- proposed: 2026-09-01 +- accepted: 2026-09-01 (level 1 as the shape of the CLI; levels 2 and 3 deferred indefinitely) +- rejected: - + +**Proposal:** Werkdock's user-facing surface follows Docker wherever the filesystem-only contract allows: level 1 is a docker-compatible CLI (verbs, flags, exit codes), level 2 is pulling OCI images from registries, level 3 is a daemon offering the Docker Engine REST API subset that Testcontainers needs. +Level 1 is built in session B; levels 2 and 3 are designed for but deferred. + +## Context and Problem Statement + +The requirement (2026-09-01): the CLI — and a daemon API, if one is needed — shall be docker-compatible as far as possible, also to enable integrating Testcontainers later. + +Docker compatibility is not one thing; it comes in three separable levels, and Testcontainers forces a position on each: + +1. **CLI compatibility** — `werkdock run` takes the flags a docker user already knows. Cheap, pure design discipline, and it makes every docker tutorial partially applicable. +2. **Image compatibility** — a werkdock image today is a self-built rootfs archive; docker images are OCI images from registries. Pulling and flattening OCI images makes the world's images usable. +3. **API compatibility** — Testcontainers never invokes the CLI; it speaks the Docker Engine REST API over a unix socket (`DOCKER_HOST`). Podman achieves Testcontainers support exactly this way (`podman system service`). Without this level there is no Testcontainers, regardless of the CLI. + +## What Testcontainers Actually Needs + +From observing docker-java/testcontainers-java against real daemons: + +- `/version` and `/info` handshakes; then image pull (level 2 is a prerequisite), container create/start/inspect/logs/wait/remove. +- Port mapping: create requests an exposed container port with an empty host port, inspect must answer with the mapped ephemeral host port (`NetworkSettings.Ports`). +- The Ryuk reaper container (disableable via `TESTCONTAINERS_RYUK_DISABLED=true`). + +The port mapping is the crux for Werkdock: with filesystem-only isolation there is no network namespace, the payload binds host ports directly. +Two consequences: + +- "Mapping" degenerates to identity — inspect reports the port the service actually bound. Workable for sequential CI use. +- Two containers wanting the same fixed port collide, exactly as with docker's `--network=host`. + +The honest way out, if Testcontainers support ever becomes serious: unprivileged network namespaces are available inside a user namespace (rootless podman does networking this way, via a userspace stack — pasta/slirp4netns). +That would be a deliberate, opt-in extension of the filesystem-only contract, decided in its own RFC — not implied by this one. + +## Considered Options + +* Docker-compatible from the start on all three levels — rejected: level 3 without a consumer is speculation, and the Ryuk/port semantics need real Testcontainers runs to validate against. +* Own CLI idioms (`werkdock run -- ` as sketched in plan step 21), compatibility later — rejected: retrofitting docker semantics onto a shipped CLI breaks users; the compatibility must shape the surface from day one. +* Docker-compatible CLI now, API-ready architecture, levels 2 and 3 deferred — chosen. + +## Concrete Proposal + +### Level 1 — CLI (session B) + +Verbs and flags follow docker; unsupported docker flags fail loudly with a reason, never silently no-op: + +| Werkdock | Docker equivalent | Notes | +|---|---|---| +| `werkdock run [flags] IMAGE [CMD...]` | `docker run` | creates an instance from the image, runs CMD | +| `werkdock create` / `start` / `stop` / `rm` | same | instance lifecycle | +| `werkdock ps [-a]` | same | running/all instances | +| `werkdock images` / `rmi` | same | local image store | +| `werkdock load -i FILE` | `docker load` | imports a rootfs archive as an image | +| `werkdock exec INSTANCE CMD...` | `docker exec` | additional process in a running sandbox | +| `werkdock logs [-f] INSTANCE` | `docker logs` | | +| `werkdock inspect NAME` | `docker inspect` | JSON, docker-shaped where fields apply | +| `werkdock doctor` | *(none)* | host capability and quota check; `info` aliases the summary | + +Supported `run` flags from the start: `-v/--volume` (bind mounts), `-e/--env`, `-w/--workdir`, `--rm`, `--name`, `-d/--detach`, `--entrypoint`. +Refused with explanation: everything that promises isolation Werkdock does not provide (`-p/--publish`, `--network`, `--memory`, `--cpus`, `--user` beyond the fixed uid-0 mapping). + +Semantic shift against the step-21 sketch: `run` takes an **image** (docker semantics), not a pre-unpacked instance; instances are created per run and correspond to docker containers. +`--rm` deletes the instance tree afterwards; without it, `ps -a`/`start` see it again. + +### Level 2 — OCI images (deferred, designed for) + +`werkdock pull IMAGE[:TAG]` fetches from an OCI registry (Docker Hub et al.) and flattens the layers into a rootfs. +This is HTTP + JSON + tar with whiteout handling — implementable within the stdlib-only policy (RFC 0001), but a substantial work package (registry auth token dance included). +Until then, `werkdock load` and the self-built rootfs archives carry the image store. + +### Level 3 — daemon API (deferred, designed for) + +`werkdock daemon` serves the Docker Engine API subset from "What Testcontainers Actually Needs" on a unix socket; consumers set `DOCKER_HOST=unix://$XDG_RUNTIME_DIR/werkdock.sock`. +Architecture consequence now: the CLI must not own the lifecycle logic — verbs are thin frontends over the same internal service the daemon would expose, and instance state lives on disk in a format both can read. +Ryuk stays disabled in documentation until proven. + +## Consequences + +- Plan step 21 session B and the README change their CLI sketch to the docker-shaped surface above. +- The engine interface from RFC 0001 is unaffected — compatibility shapes the surface, engines stay swappable behind it. +- Testcontainers remains a stated goal, not a claim: it is validated the day level 3 exists, and the port-collision limitation is documented until a network-namespace RFC changes it. + +## Decision Outcome + +Decided 2026-09-01: level 1 shapes the CLI — verbs and flags follow docker, unsupported flags fail loudly. +Levels 2 and 3 (OCI pull, daemon API, Testcontainers) are deferred indefinitely; nothing in the code may make them harder, nothing is built for them now. +The immediate goal is narrower than level 1's full verb list: `doctor`, `load`, and `run` — enough for the sandbox builds of Werkator, Werkbaum, and Werkdock itself; the remaining verbs follow with need. diff --git a/werkdock/docs/rfcs/0003-composable-toolchain-mounts.md b/werkdock/docs/rfcs/0003-composable-toolchain-mounts.md new file mode 100644 index 0000000..e2a9da4 --- /dev/null +++ b/werkdock/docs/rfcs/0003-composable-toolchain-mounts.md @@ -0,0 +1,54 @@ +# RFC 0003: Composable Toolchain Mounts + +**Status:** +- proposed: 2026-09-01 (as a candidate — comes due when more than one toolchain combination is needed) +- accepted: - +- rejected: - + +**Proposal:** Instead of baking every toolchain combination into its own flat image, werkdock composes a sandbox at run time: a slim base image plus per-toolchain artifacts from the store, mounted read-only under their own prefixes. + +## Context and Problem Statement + +Werkdock images are flat trees without layers (see the Disk Footprint section of the README): every toolchain combination is a full archive, built, uploaded, and unpacked as a whole. +The pain is concrete: adding Go and Node to the JDK build environment meant rebuilding and re-uploading a ~600 MB archive whose JDK half did not change. +Docker solves this with content-addressed layers over overlayfs — which needs either root or an overlay-capable bwrap (0.9+), neither available on the target webspaces today. + +## The Key Insight + +Overlayfs is only needed when trees must merge *at the same paths*. +Toolchains that live under their own prefix need no merging at all — and the official tarball distributions do exactly that: + +- Go unpacks to `/usr/local/go` +- Node unpacks to `/usr/local/node-` +- Temurin JDKs unpack to `/usr/local/jdk-` + +So composition is plain bind mounts, available in every bwrap version, no root, no overlayfs: + +``` +werkdock run --rm --with jdk-21 --with go-1.24 --with node-20 base sh -c '...' +``` + +## Sketch + +- The base image shrinks to what apt must provide (debootstrap minbase, git, ca-certificates, locales — roughly 300 MB unpacked). +- A *toolchain* is a store artifact beside images: an unpacked tarball plus a small manifest naming its mount prefix and the environment it needs (`PATH` entries, `JAVA_HOME`, `GOROOT`, ...). +- `--with NAME` adds a read-only bind of the toolchain at its prefix and applies its manifest environment; order follows the flags, like `-v`. +- Deduplication falls out for free: each toolchain is stored once, every combination costs zero additional disk. + +## Limits + +- Only tarball-distributed toolchains fit; apt-installed ones spread across `/usr` and cannot be prefix-mounted. + For JDK, Go, and Node the official tarballs exist; toolchains without one stay in the base image. +- `--with` is a werkdock extension beyond the docker-compatible surface (RFC 0002) — docker has no counterpart. + It is additive: level-1 compatibility of the remaining CLI is untouched. + +## Considered Alternatives + +- On-target image building (apt/mmdebstrap on the webspace, unprivileged): technically possible via user namespaces, but slow, network-bound per build, and a relapse into the self-build drift step 21 corrects — build locally, install artifacts. +- Letting package managers fill a persistent home cache (Gradle toolchains, Go modules): works today as a side effect, but unhermetic and network-dependent on cold caches. +- Overlayfs layers or hardlink dedup between image versions: the general solutions, still worthwhile later, but blocked on bwrap 0.9+ (overlay) or more store machinery (dedup) — composition needs neither. + +## Decision Outcome + +Pending — to be decided when a second toolchain combination is actually needed (for example Werkbaum pinning its own Node version). +Until then the one fat image (RFC 0002 outcome, plan step 21) stays the deliberate choice. diff --git a/werkdock/go.mod b/werkdock/go.mod new file mode 100644 index 0000000..560098c --- /dev/null +++ b/werkdock/go.mod @@ -0,0 +1,3 @@ +module werkdock + +go 1.22 diff --git a/werkdock/internal/cli/cli.go b/werkdock/internal/cli/cli.go new file mode 100644 index 0000000..903e898 --- /dev/null +++ b/werkdock/internal/cli/cli.go @@ -0,0 +1,68 @@ +// Package cli parses werkdock's docker-shaped command line (RFC 0002) +// and dispatches to the internal packages. Exit codes follow docker: +// 125 for werkdock's own errors, otherwise the sandboxed command's code +// is passed through. +package cli + +import ( + "fmt" + "io" + "os" +) + +// Version is replaced at release time; the dev default marks unreleased +// builds. +var Version = "0.1.0-dev" + +const exitCLIError = 125 + +// Main runs the CLI and returns the process exit code. +func Main(args []string) int { + if len(args) == 0 { + usage(os.Stderr) + return exitCLIError + } + switch args[0] { + case "run": + return runCmd(args[1:]) + case "load": + return loadCmd(args[1:]) + case "doctor": + return doctorCmd(args[1:]) + case "version", "--version": + fmt.Printf("werkdock %s\n", Version) + return 0 + case "help", "--help", "-h": + usage(os.Stdout) + return 0 + default: + fmt.Fprintf(os.Stderr, "werkdock: unknown command %q\n\n", args[0]) + usage(os.Stderr) + return exitCLIError + } +} + +func usage(w io.Writer) { + fmt.Fprint(w, `werkdock — a docker-like sandbox CLI over bwrap, filesystem isolation only. +Network, uid, /proc, /dev, and /tmp come from the host by contract. + +Usage: + werkdock run [flags] IMAGE COMMAND [ARG...] run a command in a sandbox + werkdock load -i ARCHIVE [--name NAME] import a rootfs archive as an image + werkdock doctor [TARGET_DIR] check whether this host can run sandboxes + werkdock version print the version + +Run flags: + -v, --volume SRC:DEST[:ro] bind mount (repeatable, applied in order) + -e, --env KEY=VALUE set an environment variable (KEY alone copies it from the host) + -w, --workdir DIR working directory inside the sandbox (default /) + --rm remove the instance afterwards (currently required) + +The store lives in $WERKDOCK_HOME (default ~/.werkdock). +`) +} + +func fail(err error) int { + fmt.Fprintf(os.Stderr, "werkdock: %v\n", err) + return exitCLIError +} diff --git a/werkdock/internal/cli/doctor.go b/werkdock/internal/cli/doctor.go new file mode 100644 index 0000000..e8286cc --- /dev/null +++ b/werkdock/internal/cli/doctor.go @@ -0,0 +1,57 @@ +package cli + +import ( + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + + "werkdock/internal/doctor" + "werkdock/internal/store" +) + +func doctorCmd(args []string) int { + fs := flag.NewFlagSet("doctor", flag.ContinueOnError) + fs.SetOutput(io.Discard) + if err := fs.Parse(args); err != nil { + return fail(err) + } + targetDir := "" + switch len(fs.Args()) { + case 0: + st, err := store.Default() + if err != nil { + return fail(err) + } + targetDir = st.Root + // The store may not exist yet; measure its closest existing + // ancestor, which sits on the same filesystem. + for { + if _, err := os.Stat(targetDir); err == nil { + break + } + parent := filepath.Dir(targetDir) + if parent == targetDir { + break + } + targetDir = parent + } + case 1: + targetDir = fs.Args()[0] + default: + return fail(fmt.Errorf("unexpected argument %q", fs.Args()[1])) + } + report := doctor.Run(targetDir, os.Getuid(), runCombined) + report.Render(os.Stdout) + if report.OK() { + return 0 + } + return 1 +} + +func runCombined(name string, args ...string) (string, error) { + out, err := exec.Command(name, args...).CombinedOutput() + return string(out), err +} diff --git a/werkdock/internal/cli/load.go b/werkdock/internal/cli/load.go new file mode 100644 index 0000000..b6be2a0 --- /dev/null +++ b/werkdock/internal/cli/load.go @@ -0,0 +1,40 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + + "werkdock/internal/store" +) + +func loadCmd(args []string) int { + fs := flag.NewFlagSet("load", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var input, name string + fs.StringVar(&input, "i", "", "rootfs archive to import") + fs.StringVar(&input, "input", "", "rootfs archive to import") + fs.StringVar(&name, "name", "", "image name (default: derived from the archive file name)") + if err := fs.Parse(args); err != nil { + return fail(err) + } + if input == "" { + return fail(errors.New("load needs -i ARCHIVE")) + } + if len(fs.Args()) != 0 { + return fail(fmt.Errorf("unexpected argument %q", fs.Args()[0])) + } + if name == "" { + name = store.ImageNameFromArchive(input) + } + st, err := store.Default() + if err != nil { + return fail(err) + } + if err := st.Load(input, name); err != nil { + return fail(err) + } + fmt.Printf("Loaded image: %s\n", name) + return 0 +} diff --git a/werkdock/internal/cli/run.go b/werkdock/internal/cli/run.go new file mode 100644 index 0000000..8168db6 --- /dev/null +++ b/werkdock/internal/cli/run.go @@ -0,0 +1,175 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "werkdock/internal/engine" + "werkdock/internal/store" +) + +// runOptions is the parsed form of `werkdock run` flags, separated from +// execution so the parsing is testable and a later daemon can reuse it. +type runOptions struct { + Volumes []engine.Bind + Env []engine.EnvVar + Workdir string + Remove bool + Image string + Command []string +} + +func runCmd(args []string) int { + opts, err := parseRun(args, os.Getenv) + if err != nil { + return fail(err) + } + st, err := store.Default() + if err != nil { + return fail(err) + } + rootfs, err := st.RootFS(opts.Image) + if err != nil { + return fail(err) + } + spec := engine.RunSpec{ + RootFS: rootfs, + Binds: hostBinds(opts.Volumes), + Env: opts.Env, + Workdir: opts.Workdir, + Command: opts.Command, + } + eng := &engine.Bwrap{} + code, err := eng.Run(spec) + if err != nil { + return fail(err) + } + return code +} + +// hostBinds prepends the host mounts the contract prescribes: DNS comes +// from the host, so /etc/resolv.conf is bound read-only when it exists — +// before the user binds, so an explicit bind over /etc wins. +func hostBinds(volumes []engine.Bind) []engine.Bind { + var binds []engine.Bind + if fi, err := os.Stat("/etc/resolv.conf"); err == nil && fi.Mode().IsRegular() { + binds = append(binds, engine.Bind{Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf", ReadOnly: true}) + } + return append(binds, volumes...) +} + +// parseRun parses the docker-shaped run flags. Docker flags whose +// promise werkdock cannot keep are registered and refused with a +// reason — never silently ignored (RFC 0002). +func parseRun(args []string, getenv func(string) string) (*runOptions, error) { + fs := flag.NewFlagSet("run", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var volumes, envs stringList + opts := &runOptions{} + fs.Var(&volumes, "v", "bind mount SRC:DEST[:ro]") + fs.Var(&volumes, "volume", "bind mount SRC:DEST[:ro]") + fs.Var(&envs, "e", "environment variable KEY=VALUE") + fs.Var(&envs, "env", "environment variable KEY=VALUE") + fs.StringVar(&opts.Workdir, "w", "", "working directory inside the sandbox") + fs.StringVar(&opts.Workdir, "workdir", "", "working directory inside the sandbox") + fs.BoolVar(&opts.Remove, "rm", false, "remove the instance afterwards") + refuse(fs, "p", "werkdock has no network isolation; the sandbox binds host ports directly") + refuse(fs, "publish", "werkdock has no network isolation; the sandbox binds host ports directly") + refuse(fs, "network", "the network is the host's by contract; there is nothing to configure") + refuse(fs, "memory", "werkdock does not manage resources; use the host's limits (e.g. systemd)") + refuse(fs, "cpus", "werkdock does not manage resources; use the host's limits (e.g. systemd)") + refuse(fs, "user", "the sandbox always runs uid 0 mapped to the calling user") + refuse(fs, "d", "detached instances are not implemented yet") + refuse(fs, "detach", "detached instances are not implemented yet") + if err := fs.Parse(args); err != nil { + return nil, err + } + if !opts.Remove { + return nil, errors.New("persistent instances are not implemented yet; run with --rm") + } + rest := fs.Args() + if len(rest) == 0 { + return nil, errors.New("no image specified") + } + if len(rest) == 1 { + return nil, errors.New("no command specified (werkdock images carry no default command yet)") + } + opts.Image = rest[0] + opts.Command = rest[1:] + for _, v := range volumes { + bind, err := parseVolume(v) + if err != nil { + return nil, err + } + opts.Volumes = append(opts.Volumes, bind) + } + for _, e := range envs { + opts.Env = append(opts.Env, parseEnv(e, getenv)) + } + if opts.Workdir != "" && !filepath.IsAbs(opts.Workdir) { + return nil, fmt.Errorf("workdir must be an absolute path: %s", opts.Workdir) + } + return opts, nil +} + +func parseVolume(v string) (engine.Bind, error) { + parts := strings.Split(v, ":") + if len(parts) < 2 || len(parts) > 3 { + return engine.Bind{}, fmt.Errorf("invalid volume %q, expected SRC:DEST[:ro]", v) + } + bind := engine.Bind{Source: parts[0], Dest: parts[1]} + if len(parts) == 3 { + if parts[2] != "ro" { + return engine.Bind{}, fmt.Errorf("invalid volume option %q in %q, only 'ro' is supported", parts[2], v) + } + bind.ReadOnly = true + } + if !filepath.IsAbs(bind.Source) { + return engine.Bind{}, fmt.Errorf("volume source must be an absolute path: %s", bind.Source) + } + if !filepath.IsAbs(bind.Dest) { + return engine.Bind{}, fmt.Errorf("volume destination must be an absolute path: %s", bind.Dest) + } + return bind, nil +} + +func parseEnv(e string, getenv func(string) string) engine.EnvVar { + if key, value, found := strings.Cut(e, "="); found { + return engine.EnvVar{Key: key, Value: value} + } + return engine.EnvVar{Key: e, Value: getenv(e)} +} + +// stringList collects a repeatable flag's values in order. +type stringList []string + +func (s *stringList) String() string { return strings.Join(*s, ",") } + +func (s *stringList) Set(v string) error { + *s = append(*s, v) + return nil +} + +// refusedFlag rejects a known docker flag with the reason werkdock +// cannot honor it. +type refusedFlag struct { + name string + reason string +} + +func (f *refusedFlag) String() string { return "" } + +func (f *refusedFlag) Set(string) error { + return fmt.Errorf("flag -%s is not supported: %s", f.name, f.reason) +} + +func (f *refusedFlag) IsBoolFlag() bool { return true } + +func refuse(fs *flag.FlagSet, name, reason string) { + fs.Var(&refusedFlag{name: name, reason: reason}, name, reason) +} diff --git a/werkdock/internal/cli/run_test.go b/werkdock/internal/cli/run_test.go new file mode 100644 index 0000000..d4364cd --- /dev/null +++ b/werkdock/internal/cli/run_test.go @@ -0,0 +1,124 @@ +package cli + +import ( + "reflect" + "strings" + "testing" + + "werkdock/internal/engine" +) + +func noEnv(string) string { return "" } + +func TestParseRunSupportedFlags(t *testing.T) { + opts, err := parseRun([]string{ + "--rm", + "-v", "/repo:/repo", + "--volume", "/cache:/root/.gradle:ro", + "-e", "CI=true", + "-w", "/repo", + "buildenv", "sh", "-c", "./gradlew build", + }, noEnv) + if err != nil { + t.Fatal(err) + } + if opts.Image != "buildenv" { + t.Errorf("image: got %q", opts.Image) + } + if !reflect.DeepEqual(opts.Command, []string{"sh", "-c", "./gradlew build"}) { + t.Errorf("command: got %q", opts.Command) + } + wantVolumes := []engine.Bind{ + {Source: "/repo", Dest: "/repo"}, + {Source: "/cache", Dest: "/root/.gradle", ReadOnly: true}, + } + if !reflect.DeepEqual(opts.Volumes, wantVolumes) { + t.Errorf("volumes: got %+v", opts.Volumes) + } + if !reflect.DeepEqual(opts.Env, []engine.EnvVar{{Key: "CI", Value: "true"}}) { + t.Errorf("env: got %+v", opts.Env) + } + if opts.Workdir != "/repo" { + t.Errorf("workdir: got %q", opts.Workdir) + } +} + +func TestParseRunCopiesBareEnvKeysFromTheHost(t *testing.T) { + getenv := func(key string) string { + if key == "LANG" { + return "C.UTF-8" + } + return "" + } + opts, err := parseRun([]string{"--rm", "-e", "LANG", "img", "true"}, getenv) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(opts.Env, []engine.EnvVar{{Key: "LANG", Value: "C.UTF-8"}}) { + t.Errorf("env: got %+v", opts.Env) + } +} + +func TestParseRunRefusesDockerFlagsLoudly(t *testing.T) { + tests := []struct { + args []string + wantReason string + }{ + {[]string{"--rm", "-p", "8080:80", "img", "true"}, "no network isolation"}, + {[]string{"--rm", "--network", "host", "img", "true"}, "network is the host's"}, + {[]string{"--rm", "--memory", "1g", "img", "true"}, "does not manage resources"}, + {[]string{"--rm", "--user", "1000", "img", "true"}, "uid 0 mapped to the calling user"}, + {[]string{"--rm", "-d", "img", "true"}, "not implemented yet"}, + } + for _, tt := range tests { + t.Run(strings.Join(tt.args, " "), func(t *testing.T) { + _, err := parseRun(tt.args, noEnv) + if err == nil || !strings.Contains(err.Error(), tt.wantReason) { + t.Errorf("got %v, want refusal containing %q", err, tt.wantReason) + } + }) + } +} + +func TestParseRunRequiresRmForNow(t *testing.T) { + _, err := parseRun([]string{"img", "true"}, noEnv) + if err == nil || !strings.Contains(err.Error(), "--rm") { + t.Errorf("got %v, want the --rm requirement", err) + } +} + +func TestParseRunValidation(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + {"no image", []string{"--rm"}, "no image specified"}, + {"no command", []string{"--rm", "img"}, "no command specified"}, + {"volume without dest", []string{"--rm", "-v", "/only-src", "img", "true"}, "expected SRC:DEST"}, + {"volume with bad option", []string{"--rm", "-v", "/a:/b:rw", "img", "true"}, "only 'ro' is supported"}, + {"relative volume source", []string{"--rm", "-v", "rel:/b", "img", "true"}, "absolute"}, + {"relative volume dest", []string{"--rm", "-v", "/a:rel", "img", "true"}, "absolute"}, + {"relative workdir", []string{"--rm", "-w", "rel", "img", "true"}, "absolute"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseRun(tt.args, noEnv) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("got %v, want it to contain %q", err, tt.wantErr) + } + }) + } +} + +func TestParseRunStopsFlagParsingAtTheImage(t *testing.T) { + // Docker semantics: everything after the image belongs to the + // command, even if it looks like a flag. + opts, err := parseRun([]string{"--rm", "img", "ls", "-la", "/tmp"}, noEnv) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(opts.Command, []string{"ls", "-la", "/tmp"}) { + t.Errorf("command: got %q", opts.Command) + } +} diff --git a/werkdock/internal/doctor/doctor.go b/werkdock/internal/doctor/doctor.go new file mode 100644 index 0000000..910b5b7 --- /dev/null +++ b/werkdock/internal/doctor/doctor.go @@ -0,0 +1,292 @@ +// Package doctor checks whether this host can run werkdock sandboxes: +// unprivileged user namespaces with a uid-0 mapping and enforced +// read-only root binds, the required CLI tools, and disk/quota headroom +// for the build footprint. It is a port of Werkator's +// werkator-build-prerequisites.sh, with the same PASS/FAIL output. +package doctor + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// MinFreeKiB is the disk footprint a sandbox build needs headroom for: +// unpacked rootfs (zstd expands roughly 3-4x), toolchain caches, build +// output. ~5 GiB, in KiB. +const MinFreeKiB = 5 * 1024 * 1024 + +// Runner executes a command and returns its combined output; injected +// so the evaluation logic is testable against captured fixtures. +type Runner func(name string, args ...string) (string, error) + +// Report is the outcome of all checks. +type Report struct { + Checks []Check + Warnings []string +} + +// Check is one PASS/FAIL line. +type Check struct { + OK bool + Msg string +} + +func (r *Report) pass(format string, a ...any) { + r.Checks = append(r.Checks, Check{OK: true, Msg: fmt.Sprintf(format, a...)}) +} + +func (r *Report) fail(format string, a ...any) { + r.Checks = append(r.Checks, Check{OK: false, Msg: fmt.Sprintf(format, a...)}) +} + +func (r *Report) warn(format string, a ...any) { + r.Warnings = append(r.Warnings, fmt.Sprintf(format, a...)) +} + +// OK reports whether no check failed. +func (r *Report) OK() bool { + for _, c := range r.Checks { + if !c.OK { + return false + } + } + return true +} + +// Run executes all checks against targetDir (where images and build +// workspaces will live). +func Run(targetDir string, selfUID int, run Runner) *Report { + r := &Report{} + sandboxChecks(r, selfUID, run) + toolChecks(r) + diskChecks(r, targetDir, run) + return r +} + +// sandboxProbe is the command run inside the sandbox; its three output +// lines are the signals evaluated below. +const sandboxProbe = "id -u && cat /proc/self/uid_map && (touch /usr/ro-test 2>&1 || true)" + +func sandboxChecks(r *Report, selfUID int, run Runner) { + if _, err := exec.LookPath("bwrap"); err != nil { + r.fail("bwrap is not installed on this host") + return + } + version, err := run("bwrap", "--version") + if err != nil { + r.fail("bwrap --version failed: %v", err) + return + } + r.pass("bwrap version: %s", strings.TrimSpace(version)) + out, err := run("bwrap", + "--unshare-user", "--unshare-pid", "--die-with-parent", + "--uid", "0", "--gid", "0", + "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", + "sh", "-c", sandboxProbe) + if err != nil { + r.fail("bwrap invocation failed (no user namespace support?): %s", strings.TrimSpace(out)) + return + } + EvaluateSandbox(r, out, selfUID) +} + +// EvaluateSandbox checks the three signals of the sandbox probe output: +// uid 0 inside, a uid_map back to the unprivileged user, and an +// enforced read-only root bind. +func EvaluateSandbox(r *Report, output string, selfUID int) { + lines := strings.Split(strings.TrimRight(output, "\n"), "\n") + line := func(i int) string { + if i < len(lines) { + return strings.TrimSpace(lines[i]) + } + return "" + } + if line(0) == "0" { + r.pass("build runs as root inside the namespace (uid 0)") + } else { + r.fail("expected uid 0 inside the namespace, got: %s", line(0)) + } + mapRe := regexp.MustCompile(`^\s*0\s+` + strconv.Itoa(selfUID) + `\s+1`) + if mapRe.MatchString(line(1)) { + r.pass("uid_map maps root back to the unprivileged user (uid %d)", selfUID) + } else { + r.fail("expected uid_map '0 %d 1', got: %s", selfUID, line(1)) + } + if strings.Contains(strings.ToLower(output), "read-only file system") { + r.pass("read-only root bind is enforced") + } else { + r.fail("the read-only root bind did not reject a write to /usr") + } +} + +func toolChecks(r *Report) { + if _, err := exec.LookPath("tar"); err != nil { + r.fail("tar is not installed — required to unpack images") + } else { + r.pass("tar is available") + } + if _, err := exec.LookPath("zstd"); err != nil { + r.warn("zstd is not installed — .tar.zst images cannot be unpacked") + } +} + +func diskChecks(r *Report, targetDir string, run Runner) { + minGiB := MinFreeKiB / 1024 / 1024 + homeFS := "" + if home, err := os.UserHomeDir(); err == nil { + if out, err := run("df", "-Pk", home); err == nil { + homeFS, _, _ = ParseDF(out) + } + } + out, err := run("df", "-Pk", targetDir) + if err != nil { + r.warn("could not measure free space on %s — only the quota check applies", targetDir) + return + } + device, availKiB, mount := ParseDF(out) + if device == "" { + r.warn("could not measure free space on %s — only the quota check applies", targetDir) + } else { + if homeFS != "" && device != homeFS { + r.warn("target dir is on %s (mounted at %s), not the home filesystem (%s) — builds will run on slower storage", device, mount, homeFS) + } + if availKiB < MinFreeKiB { + r.fail("less than %d GiB free space on the build working filesystem (%s)", minGiB, mount) + } else { + r.pass("at least %d GiB free space on the build working filesystem (%s, device %s)", minGiB, mount, device) + } + } + quotaOut, err := run("quota", "-g") + if err != nil || strings.TrimSpace(quotaOut) == "" { + r.warn("no readable group quota tooling on this host — only free space was checked") + return + } + lines := ParseQuota(quotaOut) + if len(lines) == 0 { + r.warn("quota tooling present but no group quota lines could be parsed — only free space was checked") + return + } + ok := true + detail := "" + for _, q := range lines { + // Only the quota of the target filesystem counts — other + // volumes may legitimately be full without affecting builds. + if device != "" && filepath.Base(q.FS) != filepath.Base(device) && q.FS != device { + continue + } + headroom := q.Limit - q.Blocks + if headroom < MinFreeKiB { + ok = false + detail += fmt.Sprintf(" %s: %.1f GiB free of quota;", filepath.Base(q.FS), float64(headroom)/1024/1024) + } + } + if ok { + r.pass("group quota headroom covers the %d GiB build footprint", minGiB) + } else { + r.fail("group quota headroom below the %d GiB build footprint; raise the quota before building.%s", minGiB, detail) + } +} + +// ParseDF extracts device, available KiB, and mount point from +// `df -Pk DIR` output. +func ParseDF(output string) (device string, availKiB int64, mount string) { + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) < 2 { + return "", 0, "" + } + fields := strings.Fields(lines[1]) + if len(fields) < 6 { + return "", 0, "" + } + avail, err := strconv.ParseInt(fields[3], 10, 64) + if err != nil { + return "", 0, "" + } + return fields[0], avail, fields[5] +} + +// QuotaLine is one filesystem's group quota: used blocks and the hard +// limit, both in KiB. +type QuotaLine struct { + FS string + Blocks int64 + Limit int64 +} + +// ParseQuota parses `quota -g` output, including the wrapped form where +// a long device name stands alone on its own line and the numbers +// follow on the next. A '*' suffix on the blocks value (over soft +// quota) is ignored. +func ParseQuota(output string) []QuotaLine { + var result []QuotaLine + pendingFS := "" + for _, raw := range strings.Split(output, "\n") { + fields := strings.Fields(raw) + if len(fields) == 0 { + continue + } + if len(fields) == 1 && strings.HasPrefix(fields[0], "/") { + pendingFS = fields[0] + continue + } + if strings.HasPrefix(fields[0], "/") && len(fields) >= 4 { + if blocks, limit, ok := quotaNumbers(fields[1], fields[3]); ok { + result = append(result, QuotaLine{FS: fields[0], Blocks: blocks, Limit: limit}) + pendingFS = "" + } + continue + } + if pendingFS != "" && len(fields) >= 3 { + if blocks, limit, ok := quotaNumbers(fields[0], fields[2]); ok { + result = append(result, QuotaLine{FS: pendingFS, Blocks: blocks, Limit: limit}) + pendingFS = "" + } + } + } + return result +} + +func quotaNumbers(blocksField, limitField string) (int64, int64, bool) { + blocks, err := strconv.ParseInt(strings.TrimSuffix(blocksField, "*"), 10, 64) + if err != nil { + return 0, 0, false + } + limit, err := strconv.ParseInt(limitField, 10, 64) + if err != nil { + return 0, 0, false + } + return blocks, limit, true +} + +// Render writes the report in the PASS/FAIL format of the original +// prerequisites script, ending with a RESULT line. +func (r *Report) Render(w io.Writer) { + for _, c := range r.Checks { + status := "PASS" + if !c.OK { + status = "FAIL" + } + fmt.Fprintf(w, "%s: %s\n", status, c.Msg) + } + for _, warning := range r.Warnings { + fmt.Fprintf(w, "WARNING: %s\n", warning) + } + passed := 0 + for _, c := range r.Checks { + if c.OK { + passed++ + } + } + fmt.Fprintln(w) + if r.OK() { + fmt.Fprintf(w, "RESULT: PASS (%d/%d) — werkdock sandboxes are usable on this host.\n", passed, len(r.Checks)) + } else { + fmt.Fprintf(w, "RESULT: FAIL (%d/%d) — werkdock sandboxes are not usable on this host.\n", passed, len(r.Checks)) + } +} diff --git a/werkdock/internal/doctor/doctor_test.go b/werkdock/internal/doctor/doctor_test.go new file mode 100644 index 0000000..0524467 --- /dev/null +++ b/werkdock/internal/doctor/doctor_test.go @@ -0,0 +1,161 @@ +package doctor + +import ( + "reflect" + "strings" + "testing" +) + +func TestEvaluateSandboxAllSignalsPass(t *testing.T) { + r := &Report{} + output := "0\n 0 120957 1\ntouch: cannot touch '/usr/ro-test': Read-only file system\n" + EvaluateSandbox(r, output, 120957) + if !r.OK() { + t.Errorf("expected all signals to pass, got %+v", r.Checks) + } + if len(r.Checks) != 3 { + t.Errorf("expected 3 checks, got %d", len(r.Checks)) + } +} + +func TestEvaluateSandboxFailures(t *testing.T) { + tests := []struct { + name string + output string + selfUID int + wantFail string + }{ + { + "not root inside", + "1000\n 0 120957 1\nRead-only file system\n", + 120957, + "expected uid 0", + }, + { + "uid_map maps someone else", + "0\n 0 999999 1\nRead-only file system\n", + 120957, + "expected uid_map", + }, + { + "writable root bind", + "0\n 0 120957 1\n", + 120957, + "did not reject a write", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Report{} + EvaluateSandbox(r, tt.output, tt.selfUID) + found := false + for _, c := range r.Checks { + if !c.OK && strings.Contains(c.Msg, tt.wantFail) { + found = true + } + } + if !found { + t.Errorf("expected a failing check containing %q, got %+v", tt.wantFail, r.Checks) + } + }) + } +} + +func TestParseDF(t *testing.T) { + output := "Filesystem 1024-blocks Used Available Capacity Mounted on\n" + + "/dev/mapper/vg0-home 959786032 447013936 463941300 50% /home\n" + device, avail, mount := ParseDF(output) + if device != "/dev/mapper/vg0-home" || avail != 463941300 || mount != "/home" { + t.Errorf("got %q %d %q", device, avail, mount) + } + if d, a, m := ParseDF("garbage"); d != "" || a != 0 || m != "" { + t.Errorf("expected empty result for garbage, got %q %d %q", d, a, m) + } +} + +func TestParseQuotaPlainAndWrappedLines(t *testing.T) { + output := `Disk quotas for group g123456 (gid 123456): + Filesystem blocks quota limit grace files quota limit grace +/dev/vdb1 123456 900000 1000000 1234 0 0 +/dev/mapper/very-long-device-name-that-wraps + 654321* 4500000 5000000 4321 0 0 +` + want := []QuotaLine{ + {FS: "/dev/vdb1", Blocks: 123456, Limit: 1000000}, + {FS: "/dev/mapper/very-long-device-name-that-wraps", Blocks: 654321, Limit: 5000000}, + } + if got := ParseQuota(output); !reflect.DeepEqual(got, want) { + t.Errorf("got %+v\nwant %+v", got, want) + } +} + +func TestParseQuotaIgnoresUnparsableOutput(t *testing.T) { + if got := ParseQuota("no quotas here\n"); len(got) != 0 { + t.Errorf("expected no lines, got %+v", got) + } +} + +// fakeRunner serves canned outputs keyed by command name. +func fakeRunner(outputs map[string]string) Runner { + return func(name string, args ...string) (string, error) { + return outputs[name], nil + } +} + +func TestDiskChecksFailOnQuotaHeadroomOfTheTargetFilesystem(t *testing.T) { + r := &Report{} + // 1 GiB quota headroom on the home device, plenty on another one. + outputs := map[string]string{ + "df": "Filesystem 1024-blocks Used Available Capacity Mounted on\n" + + "/dev/vdb1 100000000 10000000 90000000 10% /home\n", + "quota": "Disk quotas for group g1 (gid 1):\n" + + " Filesystem blocks quota limit grace\n" + + "/dev/vdb1 4000000 5000000 5048576 - - -\n" + + "/dev/other 0 0 99999999 - - -\n", + } + diskChecks(r, "/home/user", fakeRunner(outputs)) + if r.OK() { + t.Fatalf("expected the quota check to fail, got %+v", r.Checks) + } + failing := "" + for _, c := range r.Checks { + if !c.OK { + failing = c.Msg + } + } + if !strings.Contains(failing, "quota headroom below") || !strings.Contains(failing, "vdb1") { + t.Errorf("unexpected failure message: %s", failing) + } +} + +func TestDiskChecksPassWithSpaceAndQuota(t *testing.T) { + r := &Report{} + outputs := map[string]string{ + "df": "Filesystem 1024-blocks Used Available Capacity Mounted on\n" + + "/dev/vdb1 100000000 10000000 90000000 10% /home\n", + "quota": "Disk quotas for group g1 (gid 1):\n" + + " Filesystem blocks quota limit grace\n" + + "/dev/vdb1 1000000 90000000 99000000 - - -\n", + } + diskChecks(r, "/home/user", fakeRunner(outputs)) + if !r.OK() { + t.Errorf("expected disk checks to pass, got %+v", r.Checks) + } + if len(r.Checks) != 2 { + t.Errorf("expected free-space and quota checks, got %+v", r.Checks) + } +} + +func TestRenderEndsWithTheResultLine(t *testing.T) { + r := &Report{} + r.pass("all good") + r.warn("just saying") + var out strings.Builder + r.Render(&out) + rendered := out.String() + if !strings.Contains(rendered, "PASS: all good\n") || + !strings.Contains(rendered, "WARNING: just saying\n") || + !strings.Contains(rendered, "RESULT: PASS (1/1)") { + t.Errorf("unexpected rendering:\n%s", rendered) + } +} diff --git a/werkdock/internal/engine/bwrap.go b/werkdock/internal/engine/bwrap.go new file mode 100644 index 0000000..2f278f5 --- /dev/null +++ b/werkdock/internal/engine/bwrap.go @@ -0,0 +1,199 @@ +package engine + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Bwrap runs a RunSpec through the bwrap CLI — filesystem isolation +// only; network, uid mapping target, /proc, /dev, and /tmp come from +// the host by contract. +// +// The invocation is a port of Werkator's BwrapBuildRunner, including +// the parts hardened on a real Hostsharing webspace: bind mountpoints +// are pre-created inside the rootfs (a plain host directory), because +// bwrap cannot mkdir them against the read-only root bind. +type Bwrap struct { + // Path of the bwrap binary; empty means "bwrap" via PATH. + Path string + // Stdio of the sandboxed command; nil fields default to the + // werkdock process's own. + Stdout io.Writer + Stderr io.Writer + Stdin io.Reader +} + +// DefaultPATH is the PATH inside the sandbox; the environment is +// cleared (docker semantics), so a sane default must be set explicitly. +const DefaultPATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +// Argv assembles the full bwrap command line for spec. +// +// Mount order: the rootfs first; then /proc, /dev, and the tmpfs +// mounts for /tmp and /root, BEFORE the user binds, so a bind whose +// destination lies below them lands inside instead of being shadowed; +// then the user binds in the given order. +func (b *Bwrap) Argv(spec RunSpec) ([]string, error) { + if spec.RootFS == "" { + return nil, errors.New("rootfs must be set") + } + if !filepath.IsAbs(spec.RootFS) { + return nil, fmt.Errorf("rootfs must be an absolute path: %s", spec.RootFS) + } + if len(spec.Command) == 0 { + return nil, errors.New("no command specified") + } + bin := b.Path + if bin == "" { + bin = "bwrap" + } + args := []string{ + bin, + "--unshare-user", + "--unshare-pid", + "--die-with-parent", + "--uid", "0", + "--gid", "0", + "--ro-bind", spec.RootFS, "/", + "--proc", "/proc", + "--dev", "/dev", + "--tmpfs", "/tmp", + "--tmpfs", "/root", + } + for _, bd := range spec.Binds { + if !filepath.IsAbs(bd.Dest) { + return nil, fmt.Errorf("bind destination must be an absolute path: %s", bd.Dest) + } + flag := "--bind" + if bd.ReadOnly { + flag = "--ro-bind" + } + args = append(args, flag, bd.Source, bd.Dest) + } + args = append(args, + "--clearenv", + "--setenv", "HOME", "/root", + "--setenv", "PATH", DefaultPATH, + ) + for _, e := range spec.Env { + args = append(args, "--setenv", e.Key, e.Value) + } + workdir := spec.Workdir + if workdir == "" { + workdir = "/" + } + args = append(args, "--chdir", workdir, "--") + args = append(args, spec.Command...) + return args, nil +} + +// EnsureMountpoints pre-creates the mountpoints of spec inside the +// rootfs directory. bwrap creates mountpoints against the sandbox view, +// which is the read-only rootfs bind — every destination missing from +// the rootfs fails with "Read-only file system". The rootfs directory +// itself is a plain host directory, so the mountpoints are created +// there; bwrap then finds them and has nothing left to mkdir. +// +// Anything that already exists in the rootfs is left alone (e.g. +// /etc/resolv.conf is a file many rootfs archives ship). A bind whose +// source is a regular file gets a file mountpoint, not a directory. +func EnsureMountpoints(spec RunSpec) error { + for _, dest := range []string{"/proc", "/dev", "/tmp", "/root"} { + if err := ensureDir(spec.RootFS, dest); err != nil { + return err + } + } + for _, bd := range spec.Binds { + target, err := rootfsPath(spec.RootFS, bd.Dest) + if err != nil { + return err + } + if _, err := os.Lstat(target); err == nil { + continue + } + src, err := os.Stat(bd.Source) + if err != nil { + return fmt.Errorf("bind source %s: %w", bd.Source, err) + } + if src.Mode().IsRegular() { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o644) + if err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + continue + } + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + } + return nil +} + +func ensureDir(rootfs, dest string) error { + target, err := rootfsPath(rootfs, dest) + if err != nil { + return err + } + if _, statErr := os.Lstat(target); statErr == nil { + return nil + } + return os.MkdirAll(target, 0o755) +} + +// rootfsPath resolves dest inside rootfs and refuses destinations that +// escape it — werkdock assembles mounts from user input, so this must +// hold even for hostile paths. +func rootfsPath(rootfs, dest string) (string, error) { + root := filepath.Clean(rootfs) + target := filepath.Join(root, dest) + prefix := root + if !strings.HasSuffix(prefix, string(filepath.Separator)) { + prefix += string(filepath.Separator) + } + if target != root && !strings.HasPrefix(target, prefix) { + return "", fmt.Errorf("bind destination escapes the rootfs: %s", dest) + } + return target, nil +} + +// Run executes spec and returns the command's exit code; bwrap +// propagates the child's code, so the caller can pass it through. +func (b *Bwrap) Run(spec RunSpec) (int, error) { + argv, err := b.Argv(spec) + if err != nil { + return 0, err + } + if err := EnsureMountpoints(spec); err != nil { + return 0, err + } + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Stdout = b.Stdout + if cmd.Stdout == nil { + cmd.Stdout = os.Stdout + } + cmd.Stderr = b.Stderr + if cmd.Stderr == nil { + cmd.Stderr = os.Stderr + } + cmd.Stdin = b.Stdin + err = cmd.Run() + if err == nil { + return 0, nil + } + var exit *exec.ExitError + if errors.As(err, &exit) { + return exit.ExitCode(), nil + } + return 0, err +} diff --git a/werkdock/internal/engine/bwrap_test.go b/werkdock/internal/engine/bwrap_test.go new file mode 100644 index 0000000..0a18d91 --- /dev/null +++ b/werkdock/internal/engine/bwrap_test.go @@ -0,0 +1,187 @@ +package engine + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestArgvAssemblesTheHardenedInvocation(t *testing.T) { + b := &Bwrap{} + spec := RunSpec{ + RootFS: "/store/images/buildenv/rootfs", + Binds: []Bind{ + {Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf", ReadOnly: true}, + {Source: "/repo", Dest: "/repo"}, + {Source: "/cache", Dest: "/root/.gradle"}, + }, + Env: []EnvVar{{Key: "CI", Value: "true"}, {Key: "TERM", Value: "dumb"}}, + Workdir: "/repo", + Command: []string{"/bin/sh", "-c", "./gradlew build"}, + } + argv, err := b.Argv(spec) + if err != nil { + t.Fatal(err) + } + want := []string{ + "bwrap", + "--unshare-user", "--unshare-pid", "--die-with-parent", + "--uid", "0", "--gid", "0", + "--ro-bind", "/store/images/buildenv/rootfs", "/", + "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", "/root", + "--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf", + "--bind", "/repo", "/repo", + "--bind", "/cache", "/root/.gradle", + "--clearenv", + "--setenv", "HOME", "/root", + "--setenv", "PATH", DefaultPATH, + "--setenv", "CI", "true", + "--setenv", "TERM", "dumb", + "--chdir", "/repo", "--", + "/bin/sh", "-c", "./gradlew build", + } + if !reflect.DeepEqual(argv, want) { + t.Errorf("argv mismatch:\n got %q\nwant %q", argv, want) + } +} + +func TestArgvValidation(t *testing.T) { + tests := []struct { + name string + spec RunSpec + wantErr string + }{ + {"missing rootfs", RunSpec{Command: []string{"true"}}, "rootfs must be set"}, + {"relative rootfs", RunSpec{RootFS: "rootfs", Command: []string{"true"}}, "absolute"}, + {"missing command", RunSpec{RootFS: "/r"}, "no command specified"}, + { + "relative bind dest", + RunSpec{RootFS: "/r", Binds: []Bind{{Source: "/s", Dest: "work"}}, Command: []string{"true"}}, + "absolute", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := (&Bwrap{}).Argv(tt.spec) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("got error %v, want it to contain %q", err, tt.wantErr) + } + }) + } +} + +func TestArgvDefaultsWorkdirToRoot(t *testing.T) { + argv, err := (&Bwrap{}).Argv(RunSpec{RootFS: "/r", Command: []string{"true"}}) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(argv, " ") + if !strings.Contains(joined, "--chdir / --") { + t.Errorf("expected default workdir /, got: %s", joined) + } +} + +func TestEnsureMountpointsCreatesMissingAndSkipsExisting(t *testing.T) { + rootfs := t.TempDir() + // The rootfs ships /etc/resolv.conf as a file with content — it + // must be left alone. + if err := os.MkdirAll(filepath.Join(rootfs, "etc"), 0o755); err != nil { + t.Fatal(err) + } + shipped := filepath.Join(rootfs, "etc", "resolv.conf") + if err := os.WriteFile(shipped, []byte("nameserver 127.0.0.53\n"), 0o644); err != nil { + t.Fatal(err) + } + srcDir := t.TempDir() + srcFile := filepath.Join(srcDir, "hosts") + if err := os.WriteFile(srcFile, []byte("127.0.0.1 localhost\n"), 0o644); err != nil { + t.Fatal(err) + } + spec := RunSpec{ + RootFS: rootfs, + Binds: []Bind{ + {Source: "/etc", Dest: "/etc/resolv.conf", ReadOnly: true}, // exists: skipped (source type irrelevant) + {Source: srcDir, Dest: "/repo/workspace"}, // missing dir mountpoint + {Source: srcFile, Dest: "/etc/hosts.werkdock"}, // missing file mountpoint + }, + Command: []string{"true"}, + } + if err := EnsureMountpoints(spec); err != nil { + t.Fatal(err) + } + for _, dir := range []string{"proc", "dev", "tmp", "root", "repo/workspace"} { + fi, err := os.Stat(filepath.Join(rootfs, dir)) + if err != nil || !fi.IsDir() { + t.Errorf("expected directory mountpoint %s in the rootfs: %v", dir, err) + } + } + fi, err := os.Stat(filepath.Join(rootfs, "etc", "hosts.werkdock")) + if err != nil || !fi.Mode().IsRegular() { + t.Errorf("expected file mountpoint etc/hosts.werkdock in the rootfs: %v", err) + } + content, err := os.ReadFile(shipped) + if err != nil || string(content) != "nameserver 127.0.0.53\n" { + t.Errorf("shipped rootfs file was modified: %q, %v", content, err) + } +} + +func TestEnsureMountpointsRefusesEscapingDestinations(t *testing.T) { + spec := RunSpec{ + RootFS: t.TempDir(), + Binds: []Bind{{Source: "/tmp", Dest: "/../outside"}}, + Command: []string{"true"}, + } + err := EnsureMountpoints(spec) + if err == nil || !strings.Contains(err.Error(), "escapes the rootfs") { + t.Errorf("got %v, want an escape refusal", err) + } +} + +// TestRunInsideRealSandbox is the gated integration test: it runs only +// where bwrap and unprivileged user namespaces actually work. The host +// / serves as the read-only rootfs, so nothing is unpacked and (all +// mountpoints existing) nothing is written. +func TestRunInsideRealSandbox(t *testing.T) { + if _, err := exec.LookPath("bwrap"); err != nil { + t.Skip("bwrap not installed") + } + if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil { + t.Skipf("unprivileged user namespaces not usable here: %v", err) + } + var stdout, stderr bytes.Buffer + b := &Bwrap{Stdout: &stdout, Stderr: &stderr} + code, err := b.Run(RunSpec{ + RootFS: "/", + Command: []string{"id", "-u"}, + }) + if err != nil { + t.Fatalf("run failed: %v (stderr: %s)", err, stderr.String()) + } + if code != 0 { + t.Fatalf("exit code %d, stderr: %s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != "0" { + t.Errorf("expected uid 0 inside the sandbox, got %q", got) + } +} + +func TestRunPassesTheExitCodeThrough(t *testing.T) { + if _, err := exec.LookPath("bwrap"); err != nil { + t.Skip("bwrap not installed") + } + if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil { + t.Skipf("unprivileged user namespaces not usable here: %v", err) + } + b := &Bwrap{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} + code, err := b.Run(RunSpec{RootFS: "/", Command: []string{"sh", "-c", "exit 42"}}) + if err != nil { + t.Fatal(err) + } + if code != 42 { + t.Errorf("expected exit code 42, got %d", code) + } +} diff --git a/werkdock/internal/engine/engine.go b/werkdock/internal/engine/engine.go new file mode 100644 index 0000000..5763509 --- /dev/null +++ b/werkdock/internal/engine/engine.go @@ -0,0 +1,37 @@ +// Package engine executes sandboxed commands. The CLI verbs are thin +// frontends over this package, so a later daemon can expose the same +// logic without duplicating it (RFC 0002). +package engine + +// Bind is one bind mount, applied in order; later mounts shadow earlier +// ones at their own path, exactly as bwrap layers them. +type Bind struct { + Source string + Dest string + ReadOnly bool +} + +// EnvVar is one environment variable; order is preserved. +type EnvVar struct { + Key string + Value string +} + +// RunSpec describes one sandboxed command, independent of the engine +// that executes it. +type RunSpec struct { + // RootFS is the absolute path to the unpacked image rootfs, + // bound read-only at /. + RootFS string + Binds []Bind + Env []EnvVar + Workdir string + Command []string +} + +// Engine runs a RunSpec and reports the command's exit code. +// bwrap is the first engine; native namespaces may become a second +// (RFC 0001). +type Engine interface { + Run(spec RunSpec) (int, error) +} diff --git a/werkdock/internal/store/store.go b/werkdock/internal/store/store.go new file mode 100644 index 0000000..871afa2 --- /dev/null +++ b/werkdock/internal/store/store.go @@ -0,0 +1,122 @@ +// Package store is the on-disk image store. An image is a rootfs +// archive unpacked under the store root; instance state will live here +// too once persistent instances exist, in a format both the CLI and a +// later daemon can read (RFC 0002). +package store + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" +) + +// Store is rooted at $WERKDOCK_HOME, defaulting to ~/.werkdock. +type Store struct { + Root string +} + +// ImageMeta is written as image.json beside each image's rootfs. +type ImageMeta struct { + Name string `json:"name"` + Source string `json:"source"` + CreatedAt time.Time `json:"createdAt"` +} + +var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + +// Default resolves the store root from the environment. +func Default() (Store, error) { + if root := os.Getenv("WERKDOCK_HOME"); root != "" { + return Store{Root: root}, nil + } + home, err := os.UserHomeDir() + if err != nil { + return Store{}, fmt.Errorf("cannot resolve the store root: %w", err) + } + return Store{Root: filepath.Join(home, ".werkdock")}, nil +} + +func (s Store) imageDir(name string) string { + return filepath.Join(s.Root, "images", name) +} + +// RootFS resolves an image name to its unpacked rootfs directory. +func (s Store) RootFS(name string) (string, error) { + if !nameRe.MatchString(name) { + return "", fmt.Errorf("invalid image name: %q", name) + } + rootfs := filepath.Join(s.imageDir(name), "rootfs") + if fi, err := os.Stat(rootfs); err != nil || !fi.IsDir() { + return "", fmt.Errorf("no such image: %s (load it with: werkdock load -i ARCHIVE --name %s)", name, name) + } + return rootfs, nil +} + +// Load imports a rootfs archive as an image. The archive is unpacked +// with the tar CLI (compression auto-detected; .tar.zst needs the zstd +// binary, which doctor checks) into a temporary directory and renamed +// into place, so a failed load leaves no half image behind. +func (s Store) Load(archive, name string) error { + if !nameRe.MatchString(name) { + return fmt.Errorf("invalid image name: %q (allowed: lowercase letters, digits, '.', '_', '-')", name) + } + archiveAbs, err := filepath.Abs(archive) + if err != nil { + return err + } + if _, err := os.Stat(archiveAbs); err != nil { + return fmt.Errorf("archive: %w", err) + } + dir := s.imageDir(name) + if _, err := os.Stat(dir); err == nil { + return fmt.Errorf("image %q already exists (remove %s to replace it)", name, dir) + } + tmp := dir + ".tmp" + if err := os.RemoveAll(tmp); err != nil { + return err + } + rootfs := filepath.Join(tmp, "rootfs") + if err := os.MkdirAll(rootfs, 0o755); err != nil { + return err + } + cmd := exec.Command("tar", "--no-same-owner", "-xf", archiveAbs, "-C", rootfs) + if out, err := cmd.CombinedOutput(); err != nil { + _ = os.RemoveAll(tmp) + return fmt.Errorf("unpacking %s failed: %w\n%s", archiveAbs, err, strings.TrimSpace(string(out))) + } + meta, err := json.MarshalIndent(ImageMeta{Name: name, Source: archiveAbs, CreatedAt: time.Now().UTC()}, "", " ") + if err != nil { + _ = os.RemoveAll(tmp) + return err + } + if err := os.WriteFile(filepath.Join(tmp, "image.json"), append(meta, '\n'), 0o644); err != nil { + _ = os.RemoveAll(tmp) + return err + } + if err := os.Rename(tmp, dir); err != nil { + _ = os.RemoveAll(tmp) + return err + } + return nil +} + +// ImageNameFromArchive derives a default image name from an archive +// file name by stripping the compression and tar extensions: +// "werkator-buildenv-trixie.tar.zst" becomes "werkator-buildenv-trixie". +func ImageNameFromArchive(archive string) string { + name := filepath.Base(archive) + for { + ext := filepath.Ext(name) + switch strings.ToLower(ext) { + case ".tar", ".gz", ".tgz", ".zst", ".xz", ".bz2": + name = strings.TrimSuffix(name, ext) + default: + return strings.ToLower(name) + } + } +} diff --git a/werkdock/internal/store/store_test.go b/werkdock/internal/store/store_test.go new file mode 100644 index 0000000..4ff871e --- /dev/null +++ b/werkdock/internal/store/store_test.go @@ -0,0 +1,130 @@ +package store + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// writeTestArchive builds a minimal rootfs .tar.gz with the stdlib, so +// the tests need no zstd; Load unpacks it with the system tar. +func writeTestArchive(t *testing.T, path string) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{Name: "etc/", Mode: 0o755, Typeflag: tar.TypeDir}); err != nil { + t.Fatal(err) + } + content := []byte("hello from the rootfs\n") + if err := tw.WriteHeader(&tar.Header{Name: "etc/hello", Mode: 0o644, Size: int64(len(content))}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + for _, c := range []interface{ Close() error }{tw, gz, f} { + if err := c.Close(); err != nil { + t.Fatal(err) + } + } +} + +func TestLoadUnpacksArchiveIntoTheStore(t *testing.T) { + if _, err := exec.LookPath("tar"); err != nil { + t.Skip("tar not installed") + } + st := Store{Root: t.TempDir()} + archive := filepath.Join(t.TempDir(), "mini-rootfs.tar.gz") + writeTestArchive(t, archive) + if err := st.Load(archive, "mini"); err != nil { + t.Fatal(err) + } + rootfs, err := st.RootFS("mini") + if err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(rootfs, "etc", "hello")) + if err != nil || string(content) != "hello from the rootfs\n" { + t.Errorf("unpacked file: %q, %v", content, err) + } + metaRaw, err := os.ReadFile(filepath.Join(st.Root, "images", "mini", "image.json")) + if err != nil { + t.Fatal(err) + } + var meta ImageMeta + if err := json.Unmarshal(metaRaw, &meta); err != nil { + t.Fatal(err) + } + if meta.Name != "mini" || meta.Source == "" || meta.CreatedAt.IsZero() { + t.Errorf("image.json incomplete: %+v", meta) + } +} + +func TestLoadRefusesAnExistingImageName(t *testing.T) { + if _, err := exec.LookPath("tar"); err != nil { + t.Skip("tar not installed") + } + st := Store{Root: t.TempDir()} + archive := filepath.Join(t.TempDir(), "mini.tar.gz") + writeTestArchive(t, archive) + if err := st.Load(archive, "mini"); err != nil { + t.Fatal(err) + } + err := st.Load(archive, "mini") + if err == nil || !strings.Contains(err.Error(), "already exists") { + t.Errorf("got %v, want an already-exists refusal", err) + } +} + +func TestLoadLeavesNoHalfImageOnFailure(t *testing.T) { + if _, err := exec.LookPath("tar"); err != nil { + t.Skip("tar not installed") + } + st := Store{Root: t.TempDir()} + broken := filepath.Join(t.TempDir(), "broken.tar.gz") + if err := os.WriteFile(broken, []byte("this is not a tar archive"), 0o644); err != nil { + t.Fatal(err) + } + if err := st.Load(broken, "broken"); err == nil { + t.Fatal("expected the load to fail") + } + if _, err := os.Stat(filepath.Join(st.Root, "images", "broken")); !os.IsNotExist(err) { + t.Errorf("expected no image directory, got %v", err) + } + if _, err := os.Stat(filepath.Join(st.Root, "images", "broken.tmp")); !os.IsNotExist(err) { + t.Errorf("expected no leftover tmp directory, got %v", err) + } +} + +func TestRootFSValidation(t *testing.T) { + st := Store{Root: t.TempDir()} + if _, err := st.RootFS("no-such-image"); err == nil || !strings.Contains(err.Error(), "no such image") { + t.Errorf("got %v, want a no-such-image error", err) + } + if _, err := st.RootFS("../escape"); err == nil || !strings.Contains(err.Error(), "invalid image name") { + t.Errorf("got %v, want an invalid-name error", err) + } +} + +func TestImageNameFromArchive(t *testing.T) { + tests := []struct{ in, want string }{ + {"werkator-buildenv-trixie.tar.zst", "werkator-buildenv-trixie"}, + {"/path/to/Base.TAR.GZ", "base"}, + {"rootfs.tgz", "rootfs"}, + {"plain", "plain"}, + } + for _, tt := range tests { + if got := ImageNameFromArchive(tt.in); got != tt.want { + t.Errorf("ImageNameFromArchive(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/werkdock/main.go b/werkdock/main.go new file mode 100644 index 0000000..9e0d884 --- /dev/null +++ b/werkdock/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "werkdock/internal/cli" +) + +func main() { + os.Exit(cli.Main(os.Args[1:])) +}