diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index dbfde90..b498f85 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -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//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//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 diff --git a/.werkator.yml b/.werkator.yml index 95e79ae..e13c322 100644 --- a/.werkator.yml +++ b/.werkator.yml @@ -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 diff --git a/docs/deployment.md b/docs/deployment.md index 98b949d..d1c4191 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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. diff --git a/docs/plan/21-werkdock-extraction-and-webspace-install.md b/docs/plan/21-werkdock-extraction-and-webspace-install.md index b6fae40..467489f 100644 --- a/docs/plan/21-werkdock-extraction-and-webspace-install.md +++ b/docs/plan/21-werkdock-extraction-and-webspace-install.md @@ -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 B–D 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. diff --git a/docs/plan/README.md b/docs/plan/README.md index 4cfadd0..e943c4d 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -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. diff --git a/docs/prs/2026-09-03-PR#14-werkdock-own-repository.md b/docs/prs/2026-09-03-PR#14-werkdock-own-repository.md new file mode 100644 index 0000000..844fd2c --- /dev/null +++ b/docs/prs/2026-09-03-PR#14-werkdock-own-repository.md @@ -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 `@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. diff --git a/tools/remote b/tools/remote index bd0083c..070c32c 100755 --- a/tools/remote +++ b/tools/remote @@ -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: /../werkdock) +# WERKDOCK_BINARY the built werkdock binary (default: $WERKDOCK_REPO/dist/werkdock) # WERKATOR_ROOTFS rootfs archive path for repo-init # (default: /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 ~/, 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:" diff --git a/werkdock/.gitignore b/werkdock/.gitignore deleted file mode 100644 index e273528..0000000 --- a/werkdock/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/werkdock -/dist/ diff --git a/werkdock/README.md b/werkdock/README.md deleted file mode 100644 index fc220d5..0000000 --- a/werkdock/README.md +++ /dev/null @@ -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). diff --git a/werkdock/docs/rfcs/0001-implementation-language.md b/werkdock/docs/rfcs/0001-implementation-language.md deleted file mode 100644 index 26951fe..0000000 --- a/werkdock/docs/rfcs/0001-implementation-language.md +++ /dev/null @@ -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. diff --git a/werkdock/docs/rfcs/0002-docker-compatible-surface.md b/werkdock/docs/rfcs/0002-docker-compatible-surface.md deleted file mode 100644 index f02bd5d..0000000 --- a/werkdock/docs/rfcs/0002-docker-compatible-surface.md +++ /dev/null @@ -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 -- ` as sketched in plan step 21), compatibility later — rejected: retrofitting docker semantics onto a shipped CLI breaks users; the compatibility must shape the surface from day one. -* Docker-compatible CLI now, API-ready architecture, levels 2 and 3 deferred — chosen. - -## Concrete Proposal - -### Level 1 — CLI (session B) - -Verbs and flags follow docker; unsupported docker flags fail loudly with a reason, never silently no-op: - -| Werkdock | Docker equivalent | Notes | -|---|---|---| -| `werkdock run [flags] IMAGE [CMD...]` | `docker run` | creates an instance from the image, runs CMD | -| `werkdock create` / `start` / `stop` / `rm` | same | instance lifecycle | -| `werkdock ps [-a]` | same | running/all instances | -| `werkdock images` / `rmi` | same | local image store | -| `werkdock load -i FILE` | `docker load` | imports a rootfs archive as an image | -| `werkdock exec INSTANCE CMD...` | `docker exec` | additional process in a running sandbox | -| `werkdock logs [-f] INSTANCE` | `docker logs` | | -| `werkdock inspect NAME` | `docker inspect` | JSON, docker-shaped where fields apply | -| `werkdock doctor` | *(none)* | host capability and quota check; `info` aliases the summary | - -Supported `run` flags from the start: `-v/--volume` (bind mounts), `-e/--env`, `-w/--workdir`, `--rm`, `--name`, `-d/--detach`, `--entrypoint`. -Refused with explanation: everything that promises isolation Werkdock does not provide (`-p/--publish`, `--network`, `--memory`, `--cpus`, `--user` beyond the fixed uid-0 mapping). - -Semantic shift against the step-21 sketch: `run` takes an **image** (docker semantics), not a pre-unpacked instance; instances are created per run and correspond to docker containers. -`--rm` deletes the instance tree afterwards; without it, `ps -a`/`start` see it again. - -### Level 2 — OCI images (deferred, designed for) - -`werkdock pull IMAGE[:TAG]` fetches from an OCI registry (Docker Hub et al.) and flattens the layers into a rootfs. -This is HTTP + JSON + tar with whiteout handling — implementable within the stdlib-only policy (RFC 0001), but a substantial work package (registry auth token dance included). -Until then, `werkdock load` and the self-built rootfs archives carry the image store. - -### Level 3 — daemon API (deferred, designed for) - -`werkdock daemon` serves the Docker Engine API subset from "What Testcontainers Actually Needs" on a unix socket; consumers set `DOCKER_HOST=unix://$XDG_RUNTIME_DIR/werkdock.sock`. -Architecture consequence now: the CLI must not own the lifecycle logic — verbs are thin frontends over the same internal service the daemon would expose, and instance state lives on disk in a format both can read. -Ryuk stays disabled in documentation until proven. - -## Consequences - -- Plan step 21 session B and the README change their CLI sketch to the docker-shaped surface above. -- The engine interface from RFC 0001 is unaffected — compatibility shapes the surface, engines stay swappable behind it. -- Testcontainers remains a stated goal, not a claim: it is validated the day level 3 exists, and the port-collision limitation is documented until a network-namespace RFC changes it. - -## Decision Outcome - -Decided 2026-09-01: level 1 shapes the CLI — verbs and flags follow docker, unsupported flags fail loudly. -Levels 2 and 3 (OCI pull, daemon API, Testcontainers) are deferred indefinitely; nothing in the code may make them harder, nothing is built for them now. -The immediate goal is narrower than level 1's full verb list: `doctor`, `load`, and `run` — enough for the sandbox builds of Werkator, Werkbaum, and Werkdock itself; the remaining verbs follow with need. diff --git a/werkdock/docs/rfcs/0003-composable-toolchain-mounts.md b/werkdock/docs/rfcs/0003-composable-toolchain-mounts.md deleted file mode 100644 index e2a9da4..0000000 --- a/werkdock/docs/rfcs/0003-composable-toolchain-mounts.md +++ /dev/null @@ -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-` -- Temurin JDKs unpack to `/usr/local/jdk-` - -So composition is plain bind mounts, available in every bwrap version, no root, no overlayfs: - -``` -werkdock run --rm --with jdk-21 --with go-1.24 --with node-20 base sh -c '...' -``` - -## Sketch - -- The base image shrinks to what apt must provide (debootstrap minbase, git, ca-certificates, locales — roughly 300 MB unpacked). -- A *toolchain* is a store artifact beside images: an unpacked tarball plus a small manifest naming its mount prefix and the environment it needs (`PATH` entries, `JAVA_HOME`, `GOROOT`, ...). -- `--with NAME` adds a read-only bind of the toolchain at its prefix and applies its manifest environment; order follows the flags, like `-v`. -- Deduplication falls out for free: each toolchain is stored once, every combination costs zero additional disk. - -## Limits - -- Only tarball-distributed toolchains fit; apt-installed ones spread across `/usr` and cannot be prefix-mounted. - For JDK, Go, and Node the official tarballs exist; toolchains without one stay in the base image. -- `--with` is a werkdock extension beyond the docker-compatible surface (RFC 0002) — docker has no counterpart. - It is additive: level-1 compatibility of the remaining CLI is untouched. - -## Considered Alternatives - -- On-target image building (apt/mmdebstrap on the webspace, unprivileged): technically possible via user namespaces, but slow, network-bound per build, and a relapse into the self-build drift step 21 corrects — build locally, install artifacts. -- Letting package managers fill a persistent home cache (Gradle toolchains, Go modules): works today as a side effect, but unhermetic and network-dependent on cold caches. -- Overlayfs layers or hardlink dedup between image versions: the general solutions, still worthwhile later, but blocked on bwrap 0.9+ (overlay) or more store machinery (dedup) — composition needs neither. - -## Decision Outcome - -Pending — to be decided when a second toolchain combination is actually needed (for example Werkbaum pinning its own Node version). -Until then the one fat image (RFC 0002 outcome, plan step 21) stays the deliberate choice. diff --git a/werkdock/go.mod b/werkdock/go.mod deleted file mode 100644 index 560098c..0000000 --- a/werkdock/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module werkdock - -go 1.22 diff --git a/werkdock/internal/cli/cli.go b/werkdock/internal/cli/cli.go deleted file mode 100644 index e668f9d..0000000 --- a/werkdock/internal/cli/cli.go +++ /dev/null @@ -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 -} diff --git a/werkdock/internal/cli/doctor.go b/werkdock/internal/cli/doctor.go deleted file mode 100644 index e8286cc..0000000 --- a/werkdock/internal/cli/doctor.go +++ /dev/null @@ -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 -} diff --git a/werkdock/internal/cli/images.go b/werkdock/internal/cli/images.go deleted file mode 100644 index 9d8b944..0000000 --- a/werkdock/internal/cli/images.go +++ /dev/null @@ -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 -} diff --git a/werkdock/internal/cli/load.go b/werkdock/internal/cli/load.go deleted file mode 100644 index b6be2a0..0000000 --- a/werkdock/internal/cli/load.go +++ /dev/null @@ -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 -} diff --git a/werkdock/internal/cli/run.go b/werkdock/internal/cli/run.go deleted file mode 100644 index 50cb441..0000000 --- a/werkdock/internal/cli/run.go +++ /dev/null @@ -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) -} diff --git a/werkdock/internal/cli/run_test.go b/werkdock/internal/cli/run_test.go deleted file mode 100644 index d8d26b6..0000000 --- a/werkdock/internal/cli/run_test.go +++ /dev/null @@ -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) - } -} diff --git a/werkdock/internal/doctor/doctor.go b/werkdock/internal/doctor/doctor.go deleted file mode 100644 index 910b5b7..0000000 --- a/werkdock/internal/doctor/doctor.go +++ /dev/null @@ -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)) - } -} diff --git a/werkdock/internal/doctor/doctor_test.go b/werkdock/internal/doctor/doctor_test.go deleted file mode 100644 index 0524467..0000000 --- a/werkdock/internal/doctor/doctor_test.go +++ /dev/null @@ -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) - } -} diff --git a/werkdock/internal/engine/bwrap.go b/werkdock/internal/engine/bwrap.go deleted file mode 100644 index 7d47d0e..0000000 --- a/werkdock/internal/engine/bwrap.go +++ /dev/null @@ -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 -} diff --git a/werkdock/internal/engine/bwrap_test.go b/werkdock/internal/engine/bwrap_test.go deleted file mode 100644 index 256230a..0000000 --- a/werkdock/internal/engine/bwrap_test.go +++ /dev/null @@ -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) - } -} diff --git a/werkdock/internal/engine/engine.go b/werkdock/internal/engine/engine.go deleted file mode 100644 index 323f0f6..0000000 --- a/werkdock/internal/engine/engine.go +++ /dev/null @@ -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) -} diff --git a/werkdock/internal/store/store.go b/werkdock/internal/store/store.go deleted file mode 100644 index 77efa66..0000000 --- a/werkdock/internal/store/store.go +++ /dev/null @@ -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) - } - } -} diff --git a/werkdock/internal/store/store_test.go b/werkdock/internal/store/store_test.go deleted file mode 100644 index 759d493..0000000 --- a/werkdock/internal/store/store_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/werkdock/main.go b/werkdock/main.go deleted file mode 100644 index 9e0d884..0000000 --- a/werkdock/main.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - "os" - - "werkdock/internal/cli" -) - -func main() { - os.Exit(cli.Main(os.Args[1:])) -}