step 21e werkdock moved to own repo (#15)

* refactor: werkdock/ verlässt dieses Repository

Die neun Commits unterhalb von werkdock/ sind per `git subtree split`
historieerhaltend nach https://git.javagil.de/mi/werkdock gehoben; hier
bleibt nichts davon zurück.

Mit dem Verzeichnis geht die Build-Definition `werkdock`: Sie beschrieb ein
Go-Modul, das der Kotlin-Build um sie herum nicht berühren kann — jeder
Werkator-Branch baute es dennoch als `<branch>@werkdock` mit. Drüben ist
sie die einzige und heißt `default`.

Die Kopplung bleibt, wie Sitzung C sie gemacht hat: Werkator ruft das
`werkdock`-Binary über PATH (`bwrap.werkdock`, weiterhin gepinnt), so wie
es `git` ruft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(remote): das werkdock-Binary kommt aus einem eigenen Checkout

Bisher baute das Skript es aus dem Unterverzeichnis. Jetzt nennt
WERKDOCK_REPO den Checkout (Vorgabe: ein Geschwister dieses Repositories —
die übliche Ablage, wenn man an beidem arbeitet), WERKDOCK_BINARY das
gebaute Binary darin; beides ist wie jeder Transportwert aus der Umgebung
übersteuerbar.

Fehlt der Checkout, nennt die Meldung die Klon-URL, statt bloß „file not
found" zu sagen: Das Skript kann die Lage nicht mehr selbst beheben, also
sagt es, was sie beheben würde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: Sitzung E — Werkdock hat ein eigenes Repository

Plan-Schritt 21 bekommt die Sitzung, die die Herauslösung beschreibt, samt
Abnahmekriterium; Plan-Index und Architektur-Skill sagen nicht mehr, sie
stehe noch bevor, und der Deployment-Leitfaden verlinkt das Repository
statt eines Verzeichnisses, das es hier nicht mehr gibt.

Dazu das PR-Dokument nach Hausregel (docs/prs/README.md).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(remote): repo-add wendet das Instanz-Fragment an

`repo-add` rief `werkator init` ohne `--apply`, `repo-init` dagegen mit.
Folge: Ein so aufgenommenes Repository bekam die Sandbox-Politik nicht —
`bwrap.rootfs` und `bwrap.werkdock` fehlten, und der erste Build lief auf
dem nackten Host statt in der Sandbox. Gefunden beim Aufnehmen von
Werkdock: `gofmt: command not found`, weil die Go-Toolchain nur im
Sandbox-Image liegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(remote): repo-add erkennt einen ~/-Registry-Eintrag, und der Kommentar sagt die Wahrheit

Die Registry-Prüfung suchte nur den absoluten Pfad und meldete deshalb
„not registered yet“, obwohl der Eintrag als `path: ~/werkdock` dasteht —
eine Einladung, ihn ein zweites Mal einzutragen. Gegriffen wird jetzt
lokal, damit beide Schreibweisen ohne zweite Schicht Remote-Quoting
treffbar sind.

Dazu der Kommentar über dem Laden der Env-Datei: Er behauptete, die
ausdrückliche Umgebung gewinne. `set -a; source` weist unbedingt zu — die
Datei gewinnt. Beim Nachziehen des Werkdock-Fragments hat mich genau das
eine Runde gekostet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michael Hönnig
2026-09-03 15:15:15 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 06f4052848
commit ac53b5f211
27 changed files with 100 additions and 2105 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `Bui
The runtime is selected per build behind the `BuildRunner` interface: `DispatchingBuildRunner` (`@Primary`) routes to native `ProcessBuildRunner` (the default), to `DockerBuildRunner` when `docker.enabled`, or to `BwrapBuildRunner` when `bwrap.enabled` — docker and bwrap are mutually exclusive per build and rejected in `buildSettings`, never picked silently. The Docker runner shells out to the `docker` CLI (no SDK): it (re)builds the configured image when the Dockerfile inputs changed (tracked via the `org.werkator.build-inputs-sha256` image label), maintains a per-repo Gradle cache volume, mounts the worktree and the Docker socket into a labelled (`org.hoennig.werkator`) `--rm --init` container, and repairs workspace ownership in-container after each command (under a rootless daemon the container runs as root, which is the host user, and the repair degenerates to `0:0`). Git works inside the container: the primary `.git` is mounted read-only with `.git/werkator/` masked by an empty tmpfs (credential isolation) and the worktree's admin dir mounted read-write (`gitMetadataMounts`). The returned `Process` is the attached `docker run` client, so log streaming and termination work exactly like native builds.
`BwrapBuildRunner` (ADR 0008) is the third runtime, for hosts without root and without Docker — Hostsharing Managed Webspaces. It shells out to the `bwrap` CLI (no library): a prepared rootfs archive (`bwrap.rootfs`, built by `tools/build-bwrap-rootfs.sh`) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`, with uid 0 inside mapped to the calling user; isolation is filesystem-only — network, uid, `/proc`, `/dev` are the host's by contract. It reuses the Docker runner's `gitMetadataMounts`; mount order matters (repo dir read-write before the metadata mounts and the workspace), and bind mountpoints missing from the rootfs are pre-created there, since the rootfs is a plain host directory while bwrap cannot mkdir against the read-only sandbox root. `bwrap.enabled`/`bwrap.rootfs` are pinned like the docker sandbox policy. The returned `Process` is the attached `bwrap` process, so streaming and cancellation are unchanged. Plan step 21 will extract the generic sandbox machinery into the standalone tool Werkdock (grown in `werkdock/`); the runner then delegates to the `werkdock` CLI.
`BwrapBuildRunner` (ADR 0008) is the third runtime, for hosts without root and without Docker — Hostsharing Managed Webspaces. It shells out to the `bwrap` CLI (no library): a prepared rootfs archive (`bwrap.rootfs`, built by `tools/build-bwrap-rootfs.sh`) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`, with uid 0 inside mapped to the calling user; isolation is filesystem-only — network, uid, `/proc`, `/dev` are the host's by contract. It reuses the Docker runner's `gitMetadataMounts`; mount order matters (repo dir read-write before the metadata mounts and the workspace), and bind mountpoints missing from the rootfs are pre-created there, since the rootfs is a plain host directory while bwrap cannot mkdir against the read-only sandbox root. `bwrap.enabled`/`bwrap.rootfs` are pinned like the docker sandbox policy. The returned `Process` is the attached `bwrap` process, so streaming and cancellation are unchanged. The generic sandbox machinery is the standalone tool [Werkdock](https://git.javagil.de/mi/werkdock) (plan step 21: grown in `werkdock/`, consumed via the CLI since session C, its own repository since session E); the runner delegates to the `werkdock` CLI and this repository no longer carries its source.
## Watcher
-11
View File
@@ -36,14 +36,3 @@ 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
+1 -1
View File
@@ -319,7 +319,7 @@ See [configuration.md](configuration.md) for all `server.nginx.*` keys.
## Hostsharing Managed Webspace
The third deployment variant (plan step 21, verified live on a real webspace): no root, no Docker daemon, no own reverse proxy.
Werkator runs as a systemd *user* service on the assigned localhost port ("eigener Serverdienst"), the platform's managed Apache terminates TLS and proxies via `.htaccess`, and builds run in the bubblewrap sandbox executed by the [werkdock](../werkdock/README.md) CLI (ADR 0008, step 21 session C).
Werkator runs as a systemd *user* service on the assigned localhost port ("eigener Serverdienst"), the platform's managed Apache terminates TLS and proxies via `.htaccess`, and builds run in the bubblewrap sandbox executed by the [werkdock](https://git.javagil.de/mi/werkdock) CLI (ADR 0008, step 21 session C).
Werkator is never built on the webspace: the runtime bundle and the werkdock binary are built locally and uploaded (ADR 0006).
All steps are driven by `tools/remote`; commands name their role — `instance-*` manages the installed Werkator, `repo-*` the repository it watches.
@@ -85,6 +85,17 @@ Bring intent 1 to the webspace: build locally, install the bundle — Werkator n
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.
### E — Werkdock moves to its own repository (2026-09-03)
Sessions BD left Werkdock self-contained by design ("no imports from Werkator code, no Gradle coupling"), and session C reduced the coupling to a binary on the `PATH`.
What remained was the directory move the plan promised from the start.
- The nine commits below `werkdock/` are lifted with `git subtree split -P werkdock`, so the history survives the move; the paths lose the prefix.
- The build definition `werkdock` leaves this repository's `.werkator.yml` and becomes the `default` build of the new repository's own `.werkator.yml` — the same commands, minus the `cd werkdock` prefix.
- `tools/remote` no longer builds the binary from a subdirectory: `WERKDOCK_REPO` names the checkout (default: a sibling of this repository), `WERKDOCK_BINARY` the built binary within it, and a missing checkout fails loudly with the clone URL instead of a bare "file not found".
- Werkator's own configuration is untouched: `bwrap.werkdock` still names the executing binary and is still pinned — a branch must not substitute it (AGENTS.md).
- The instance registers the new repository like any other (`tools/remote werkator repo-add`), so Werkdock is built and tested by the same Werkator that runs on its binary.
## 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).
@@ -103,3 +114,4 @@ Bring intent 1 to the webspace: build locally, install the bundle — Werkator n
- 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.
- Session E: `werkdock/` is gone from this repository, the new repository builds and tests green on its own, `tools/remote` installs the binary from the sibling checkout, and the instance watches both repositories.
+2 -2
View File
@@ -93,7 +93,7 @@ Added for running Werkator on Hostsharing Managed Webspaces (2026-08-10):
Added to correct the bwrap prototype's drift toward self-building on the webspace (2026-09-01):
- [ ] `21-werkdock-extraction-and-webspace-install.md` — roadmap in four sessions: close step 17's open ends, 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
- [ ] `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, extracted into [its own repository](https://git.javagil.de/mi/werkdock) in session E), let Werkator consume it, and replace the webspace self-build with the local-build-plus-install path of ADR 0006
Added after step 21 session D exposed that `tools/remote` re-implements configuration Werkator owns (2026-09-01):
@@ -113,5 +113,5 @@ Step 17 depends on 11, 15, and 16, and starts with a hard precondition check on
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`.
Step 21 depends on 17; its sessions B and C grow Werkdock in the `werkdock/` subdirectory, session D supersedes the self-build prototype in `tools/remote`, and session E moves Werkdock into its own repository.
Step 23 depends on 21 session D; its per-instance file convention (transport env + init fragment) also feeds step 22's instance setup and should land before Werkbaum rolls out.
@@ -0,0 +1,56 @@
> **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
Werkdock was grown inside this repository on purpose — plan step 21, sessions B and C — and the plan said from the first line that it "moves to its own repository later".
Everything that move needs has been true since session C: the tool has no imports from Werkator code, no Gradle coupling, and Werkator reaches it the way it reaches `git` — as a binary on the `PATH`, named by the pinned key `bwrap.werkdock`.
What kept it here was three references, not a dependency:
`.werkator.yml` carried a second build definition for a Go module that has nothing to do with the Kotlin build around it,
`tools/remote` built the binary out of the subdirectory,
and the documentation described a directory that was about to stop existing.
The instance makes the cost visible: every Werkator branch built a `<branch>@werkdock` artifact, so Werkdock was rebuilt for changes that could not possibly affect it, and its own history was invisible under Werkator's branch names.
## Non-Goals
- Changing Werkdock itself: the extracted repository holds the same nine commits, byte for byte, only without the path prefix.
- Changing how Werkator uses Werkdock: `bwrap.enabled`, `bwrap.rootfs` and `bwrap.werkdock` stay as they are, still pinned, still resolved via `PATH` at run time.
- Publishing releases of the binary: `tools/remote` keeps installing the locally built one (ADR 0006 — never built on the target).
- Registering the new repository on the instance: that is one `tools/remote werkator repo-add` plus a registry entry, done in the rollout, not in this branch.
## The Solution
**The history moves with the files.**
`git subtree split -P werkdock` lifts the nine commits that touched the subdirectory into a root-level history; the new repository starts from that, so `git log` and `git blame` keep working across the move.
A plain copy would have made this PR cheaper and the tool's past unreadable.
**The build definition follows, and becomes the only one.**
What was `builds.werkdock` here is `builds.default` in the new repository — same commands, minus the `cd werkdock` prefix, same `gofmt`-twice idiom (the first call names the unformatted files, the second fails the build on them).
This repository's `.werkator.yml` is back to one definition, and a Werkator branch no longer rebuilds a Go module it cannot influence.
**`tools/remote` asks for a checkout instead of a subdirectory.**
`WERKDOCK_REPO` names it (default: a sibling of this repository — the usual layout when you work on both), `WERKDOCK_BINARY` the built binary within it, and both are overridable from the environment like every other transport value.
A missing checkout now fails with the clone URL in the message rather than a bare "file not found": the script cannot fix the situation itself any more, so it says what would.
**The documentation stops describing a subdirectory.**
`docs/deployment.md` links the repository instead of `../werkdock/README.md`, the plan index and the architecture skill say the extraction happened, and plan step 21 gains session E with what was decided and what it left alone.
## Verification
- `./gradlew ktlintFormat build` green in this repository without `werkdock/`.
- The extracted repository builds and tests green on its own — `gofmt`, `go vet`, `go test ./...` (four packages), `go build` — which is the acceptance criterion the plan set for a self-contained tool.
- `bash -n tools/remote`, and the sibling-checkout default resolved against a real checkout.
## Open Questions
**A branch cannot remove a build definition — only add or override one.**
Observed on this very branch: the instance built `21e-werkdock-own-repo@werkdock` and it failed, although the branch's committed `.werkator.yml` no longer defines `werkdock`.
The cause is not a bug in the watcher — it reads the branch layer, and no fallback warning was logged — but the merge itself:
`withBranchLayer` does `deepMerge(loadRaw(workingDir), stripPinned(branchLayer))`, so a definition present in the project layer and absent on the branch survives the merge.
`AGENTS.md` and the architecture skill describe the branch layer as winning "including the whole `builds` section", which reads as replacement.
The mismatch resolves itself for this branch on merge — main then no longer carries the definition — so it does not block this PR.
It is a decision, not an oversight to fix in passing: either `builds` is replaced as a whole (then a branch can retire a build, and a branch that only adds one must repeat the others), or the merge stays and the two documents are corrected to say that removal is not expressible on a branch.
+28 -8
View File
@@ -45,6 +45,9 @@
# WERKATOR_INIT_CONFIG the init fragment to apply (repo-init, instance-start)
# WERKATOR_REPO_URL https clone URL of the watched repository
# (default: https://github.com/mhoennig/werkator.git)
# WERKDOCK_REPO checkout of the werkdock repository, whose binary the
# instance runs (default: <repo>/../werkdock)
# WERKDOCK_BINARY the built werkdock binary (default: $WERKDOCK_REPO/dist/werkdock)
# WERKATOR_ROOTFS rootfs archive path for repo-init
# (default: <repo>/build/werkator-buildenv-trixie-java-go-node.tar.zst)
#
@@ -70,7 +73,11 @@ COMMAND="${2:-}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUNTIME_BUNDLE="$REPO_ROOT/build/distributions/werkator-runtime-linux-x64.tar.gz"
WERKDOCK_BINARY="$REPO_ROOT/werkdock/dist/werkdock"
# Werkdock lives in its own repository since step 21 session E. Its binary is
# built there, not here: WERKDOCK_REPO names the checkout (default: a sibling
# of this repository), WERKDOCK_BINARY the built binary within it.
WERKDOCK_REPO="${WERKDOCK_REPO:-$REPO_ROOT/../werkdock}"
WERKDOCK_BINARY="${WERKDOCK_BINARY:-$WERKDOCK_REPO/dist/werkdock}"
PID_FILE="/tmp/werkator-port-forward-$(id -u).pid"
LOG_FILE="/tmp/werkator-port-forward-$(id -u).log"
@@ -92,8 +99,10 @@ require_env() {
[ -n "$REPO" ] && [ -n "$COMMAND" ] || usage
# Load the transport values; explicit environment wins, the selected env file
# (default: the .env in the repository root) fills the rest.
# Load the transport values. The selected env file (default: the .env in the
# repository root) wins over the environment — `set -a; source` assigns
# unconditionally; pick the target with --env-file rather than by exporting
# single values.
ENV_FILE="${ENV_FILE:-$REPO_ROOT/.env}"
set -a
[ -f "$ENV_FILE" ] && source "$ENV_FILE"
@@ -137,8 +146,11 @@ check_prerequisites() {
ensure_werkdock_binary() {
if [ ! -f "$WERKDOCK_BINARY" ]; then
echo "==> werkdock binary not found; building it locally (go build)"
(cd "$REPO_ROOT/werkdock" && CGO_ENABLED=0 go build -o dist/werkdock .)
[ -f "$WERKDOCK_REPO/go.mod" ] || die \
"werkdock binary missing: $WERKDOCK_BINARY — clone https://git.javagil.de/mi/werkdock.git \
next to this repository, or point WERKDOCK_REPO/WERKDOCK_BINARY at your checkout"
echo "==> werkdock binary not found; building it in $WERKDOCK_REPO (go build)"
(cd "$WERKDOCK_REPO" && CGO_ENABLED=0 go build -o dist/werkdock .)
fi
[ -f "$WERKDOCK_BINARY" ] || die "werkdock binary missing: $WERKDOCK_BINARY"
}
@@ -300,11 +312,19 @@ repo_add() {
ssh "$HOST" "git clone '$url' '$TARGET_DIR/$name'"
fi
echo "==> Running werkator init in $name"
ssh "$HOST" "cd '$TARGET_DIR/$name' && '$WERKATOR_BIN' init"
# The instance fragment carries the sandbox policy (bwrap rootfs and werkdock
# binary). Without it a watched repository builds on the bare host, where the
# toolchains are not installed — the same --apply repo-init does.
echo "==> Running werkator init in $name${WERKATOR_INIT_CONFIG:+ --apply $(basename "${WERKATOR_INIT_CONFIG}")}"
local fragment_remote
fragment_remote="$(upload_fragment)"
ssh "$HOST" "cd '$TARGET_DIR/$name' && '$WERKATOR_BIN' init ${fragment_remote:+--apply '$fragment_remote'}"
echo "==> Checking the registry"
if ssh "$HOST" "grep -q -- '$TARGET_DIR/$name' ~/.werkator.yml 2>/dev/null"; then
# Grepped locally: the entry may name the path absolute or as ~/<name>, and
# matching both is easier without a second layer of remote shell quoting.
if ssh "$HOST" "cat ~/.werkator.yml 2>/dev/null" |
grep -qE "path: *(~|$TARGET_DIR)/$name[[:space:]]*$"; then
echo " (~/.werkator.yml already names this path)"
else
echo " not registered yet — add this entry to ~/.werkator.yml on $HOST:"
-2
View File
@@ -1,2 +0,0 @@
/werkdock
/dist/
-46
View File
@@ -1,46 +0,0 @@
# 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).
@@ -1,109 +0,0 @@
# 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.
@@ -1,90 +0,0 @@
# 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 <instance> -- <cmd>` 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.
@@ -1,54 +0,0 @@
# 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-<version>`
- Temurin JDKs unpack to `/usr/local/jdk-<version>`
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.
-3
View File
@@ -1,3 +0,0 @@
module werkdock
go 1.22
-72
View File
@@ -1,72 +0,0 @@
// 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 "images":
return imagesCmd(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 images list loaded images, one name per line
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; -v and --tmpfs apply in flag order)
--tmpfs DEST empty tmpfs at DEST (repeatable)
-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
}
-57
View File
@@ -1,57 +0,0 @@
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
}
-35
View File
@@ -1,35 +0,0 @@
package cli
import (
"flag"
"fmt"
"io"
"werkdock/internal/store"
)
// imagesCmd prints the loaded image names, one per line — machine-usable
// (Werkator checks image existence through it) and close enough to
// `docker images --format '{{.Repository}}'`.
func imagesCmd(args []string) int {
fs := flag.NewFlagSet("images", flag.ContinueOnError)
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return fail(err)
}
if len(fs.Args()) != 0 {
return fail(fmt.Errorf("unexpected argument %q", fs.Args()[0]))
}
st, err := store.Default()
if err != nil {
return fail(err)
}
names, err := st.List()
if err != nil {
return fail(err)
}
for _, name := range names {
fmt.Println(name)
}
return 0
}
-40
View File
@@ -1,40 +0,0 @@
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
}
-203
View File
@@ -1,203 +0,0 @@
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 {
Mounts []engine.Mount
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,
Mounts: hostMounts(opts.Mounts),
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
}
// hostMounts 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 mounts, so an explicit mount over /etc wins.
func hostMounts(mounts []engine.Mount) []engine.Mount {
var all []engine.Mount
if fi, err := os.Stat("/etc/resolv.conf"); err == nil && fi.Mode().IsRegular() {
all = append(all, engine.Mount{Mode: engine.MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"})
}
return append(all, mounts...)
}
// 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 envs stringList
opts := &runOptions{}
// -v and --tmpfs collect into ONE ordered list: bwrap layers mounts in
// order, so a tmpfs between two binds (the git-metadata mask) must stay
// between them.
volumes := &mountFlag{mounts: &opts.Mounts}
tmpfs := &mountFlag{mounts: &opts.Mounts, tmpfs: true}
fs.Var(volumes, "v", "bind mount SRC:DEST[:ro]")
fs.Var(volumes, "volume", "bind mount SRC:DEST[:ro]")
fs.Var(tmpfs, "tmpfs", "empty tmpfs at DEST")
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 _, 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.Mount, error) {
parts := strings.Split(v, ":")
if len(parts) < 2 || len(parts) > 3 {
return engine.Mount{}, fmt.Errorf("invalid volume %q, expected SRC:DEST[:ro]", v)
}
mount := engine.Mount{Mode: engine.MountBind, Source: parts[0], Dest: parts[1]}
if len(parts) == 3 {
switch parts[2] {
case "ro":
mount.Mode = engine.MountRoBind
case "rw":
// docker accepts :rw as the explicit default; so do we
default:
return engine.Mount{}, fmt.Errorf("invalid volume option %q in %q, only 'ro' and 'rw' are supported", parts[2], v)
}
}
if !filepath.IsAbs(mount.Source) {
return engine.Mount{}, fmt.Errorf("volume source must be an absolute path: %s", mount.Source)
}
if !filepath.IsAbs(mount.Dest) {
return engine.Mount{}, fmt.Errorf("volume destination must be an absolute path: %s", mount.Dest)
}
return mount, nil
}
// mountFlag appends -v/--volume and --tmpfs values to one shared,
// ordered mount list.
type mountFlag struct {
mounts *[]engine.Mount
tmpfs bool
}
func (f *mountFlag) String() string { return "" }
func (f *mountFlag) Set(v string) error {
if f.tmpfs {
if !filepath.IsAbs(v) {
return fmt.Errorf("tmpfs destination must be an absolute path: %s", v)
}
*f.mounts = append(*f.mounts, engine.Mount{Mode: engine.MountTmpfs, Dest: v})
return nil
}
mount, err := parseVolume(v)
if err != nil {
return err
}
*f.mounts = append(*f.mounts, mount)
return 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)
}
-158
View File
@@ -1,158 +0,0 @@
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)
}
wantMounts := []engine.Mount{
{Mode: engine.MountBind, Source: "/repo", Dest: "/repo"},
{Mode: engine.MountRoBind, Source: "/cache", Dest: "/root/.gradle"},
}
if !reflect.DeepEqual(opts.Mounts, wantMounts) {
t.Errorf("mounts: got %+v", opts.Mounts)
}
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:cached", "img", "true"}, "only 'ro' and 'rw' are supported"},
{"relative tmpfs dest", []string{"--rm", "--tmpfs", "rel", "img", "true"}, "absolute"},
{"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 TestParseRunKeepsMountFlagOrderAcrossVolumeAndTmpfs(t *testing.T) {
// The git-metadata mask depends on it: ro-bind .git, tmpfs over
// .git/werkator, then the workspace bind — in exactly this order.
opts, err := parseRun([]string{
"--rm",
"-v", "/r/.git:/r/.git:ro",
"--tmpfs", "/r/.git/werkator",
"-v", "/r/ws:/r/ws",
"img", "true",
}, noEnv)
if err != nil {
t.Fatal(err)
}
want := []engine.Mount{
{Mode: engine.MountRoBind, Source: "/r/.git", Dest: "/r/.git"},
{Mode: engine.MountTmpfs, Dest: "/r/.git/werkator"},
{Mode: engine.MountBind, Source: "/r/ws", Dest: "/r/ws"},
}
if !reflect.DeepEqual(opts.Mounts, want) {
t.Errorf("mounts: got %+v", opts.Mounts)
}
}
func TestParseRunAcceptsTheExplicitRwVolumeOption(t *testing.T) {
opts, err := parseRun([]string{"--rm", "-v", "/a:/b:rw", "img", "true"}, noEnv)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(opts.Mounts, []engine.Mount{{Mode: engine.MountBind, Source: "/a", Dest: "/b"}}) {
t.Errorf("mounts: got %+v", opts.Mounts)
}
}
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)
}
}
-292
View File
@@ -1,292 +0,0 @@
// 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))
}
}
-161
View File
@@ -1,161 +0,0 @@
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)
}
}
-210
View File
@@ -1,210 +0,0 @@
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 _, m := range spec.Mounts {
if !filepath.IsAbs(m.Dest) {
return nil, fmt.Errorf("mount destination must be an absolute path: %s", m.Dest)
}
switch m.Mode {
case MountBind:
args = append(args, "--bind", m.Source, m.Dest)
case MountRoBind:
args = append(args, "--ro-bind", m.Source, m.Dest)
case MountTmpfs:
args = append(args, "--tmpfs", m.Dest)
default:
return nil, fmt.Errorf("unknown mount mode %d for %s", m.Mode, m.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 _, m := range spec.Mounts {
target, err := rootfsPath(spec.RootFS, m.Dest)
if err != nil {
return err
}
if _, err := os.Lstat(target); err == nil {
continue
}
if m.Mode == MountTmpfs {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
continue
}
src, err := os.Stat(m.Source)
if err != nil {
return fmt.Errorf("bind source %s: %w", m.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
}
-192
View File
@@ -1,192 +0,0 @@
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",
Mounts: []Mount{
{Mode: MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"},
{Mode: MountRoBind, Source: "/repo/.git", Dest: "/repo/.git"},
{Mode: MountTmpfs, Dest: "/repo/.git/werkator"},
{Mode: MountBind, Source: "/repo", Dest: "/repo"},
{Mode: MountBind, 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",
"--ro-bind", "/repo/.git", "/repo/.git",
"--tmpfs", "/repo/.git/werkator",
"--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 mount dest",
RunSpec{RootFS: "/r", Mounts: []Mount{{Mode: MountBind, 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,
Mounts: []Mount{
{Mode: MountRoBind, Source: "/etc", Dest: "/etc/resolv.conf"}, // exists: skipped (source type irrelevant)
{Mode: MountBind, Source: srcDir, Dest: "/repo/workspace"}, // missing dir mountpoint
{Mode: MountBind, Source: srcFile, Dest: "/etc/hosts.werkdock"}, // missing file mountpoint
{Mode: MountTmpfs, Dest: "/repo/.git/werkator"}, // tmpfs mountpoint, no source
},
Command: []string{"true"},
}
if err := EnsureMountpoints(spec); err != nil {
t.Fatal(err)
}
for _, dir := range []string{"proc", "dev", "tmp", "root", "repo/workspace", "repo/.git/werkator"} {
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(),
Mounts: []Mount{{Mode: MountBind, 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)
}
}
-50
View File
@@ -1,50 +0,0 @@
// 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
// MountMode distinguishes the mount kinds a RunSpec can carry.
type MountMode int
const (
// MountBind is a read-write bind mount.
MountBind MountMode = iota
// MountRoBind is a read-only bind mount.
MountRoBind
// MountTmpfs is an empty tmpfs at Dest; Source is unused.
MountTmpfs
)
// Mount is one mount, applied in order; later mounts shadow earlier
// ones at their own path, exactly as bwrap layers them — the order of
// -v and --tmpfs flags is therefore significant and preserved.
type Mount struct {
Mode MountMode
Source string
Dest string
}
// 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
Mounts []Mount
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)
}
-148
View File
@@ -1,148 +0,0 @@
// 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"
"sort"
"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
}
// List returns the names of all loaded images, sorted; half-written
// `.tmp` directories from an interrupted load are not images.
func (s Store) List() ([]string, error) {
entries, err := os.ReadDir(filepath.Join(s.Root, "images"))
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var names []string
for _, e := range entries {
if e.IsDir() && nameRe.MatchString(e.Name()) && !strings.HasSuffix(e.Name(), ".tmp") {
names = append(names, e.Name())
}
}
sort.Strings(names)
return names, 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)
}
// ".tmp" is the staging suffix of this very function — a legal-looking
// image name ending in it would collide with interrupted loads.
if strings.HasSuffix(name, ".tmp") {
return fmt.Errorf("invalid image name: %q (the .tmp suffix is reserved for staging)", 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)
}
}
}
-149
View File
@@ -1,149 +0,0 @@
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 TestListNamesLoadedImagesAndIgnoresTmpLeftovers(t *testing.T) {
st := Store{Root: t.TempDir()}
if names, err := st.List(); err != nil || names != nil {
t.Fatalf("empty store: got %v, %v", names, err)
}
for _, dir := range []string{"beta", "alpha", "broken.tmp"} {
if err := os.MkdirAll(filepath.Join(st.Root, "images", dir), 0o755); err != nil {
t.Fatal(err)
}
}
names, err := st.List()
if err != nil {
t.Fatal(err)
}
if len(names) != 2 || names[0] != "alpha" || names[1] != "beta" {
t.Errorf("got %v, want [alpha beta]", names)
}
}
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)
}
}
}
-11
View File
@@ -1,11 +0,0 @@
package main
import (
"os"
"werkdock/internal/cli"
)
func main() {
os.Exit(cli.Main(os.Args[1:]))
}