diff --git a/AGENTS.md b/AGENTS.md index ffbbbe1..b2edb5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,9 +39,9 @@ All production code lives under `de.hoennig.werkator`, with sub-packages `comman - Builds run detached in worktrees under `.git/werkator/worktrees/`; the primary checkout is never used for builds; never assume a single running build. - When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`. - Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config. -- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and bubblewrap (`bwrap.enabled`, `bwrap.rootfs`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone. +- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and bubblewrap (`bwrap.enabled`, `bwrap.rootfs`, `bwrap.werkdock`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone. - A build definition carries the complete description of its build, split in two: the `trigger` block (`onPush`, `atTimes`, `branches`, `activeWithin`) says when and for which branches it runs, everything else what it does. `builds.default` is the base every other definition inherits its settings — never its `trigger` — from. The split is structural so that a selector added to `TriggerConfig` later is non-inheritable by construction; writing a trigger key flat is refused, never ignored, because ignoring it leaves a build that silently stops running. A `!` prefix in `trigger.branches` excludes and always wins. -- The inheritance is applied after all layers are merged: that order is what makes a build a branch invents inherit the host's sandbox policy instead of the data-class default, so the pinning also holds for a build the host has never heard of. Pinned are `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, and `bwrap.rootfs`. Docker and bwrap are mutually exclusive per branch — enabling both is rejected at start. +- The inheritance is applied after all layers are merged: that order is what makes a build a branch invents inherit the host's sandbox policy instead of the data-class default, so the pinning also holds for a build the host has never heard of. Pinned are `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, `bwrap.rootfs`, and `bwrap.werkdock`. Docker and bwrap are mutually exclusive per branch — enabling both is rejected at start. - `builds` or the legacy `branches`, never both: `branches` is read only while the merged config defines no build at all (`builds.maxConcurrent` is not one), and ignored with a warning as soon as one exists. The section is deprecated and goes away once the repositories have migrated; then `ConfigVersions.FORMAT_BROKE_IN` gets set and a leftover `branches:` key must be rejected by name — the version check alone cannot catch a file that declares no version. - Web UI: server-rendered Thymeleaf plus one hand-written `static/werkator.js` — no SPA framework, no frontend build pipeline; every fetch has a timeout and an explicit error badge; `UiFormats` and `werkator.js` must produce identical display formats. - Git and Docker access shells out to the CLIs (`GitCommandRunner`, `docker`) — no JGit, no Docker SDK. diff --git a/docs/configuration.md b/docs/configuration.md index ee34b71..97a0bfc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -91,7 +91,7 @@ single branch may decide it: - the repository-side settings: the whole `gitea`, `executor`, and `watcher` sections; - the trust gate: `requirePullRequest`, and the Gitea status context: `statusContext`; - the container sandbox policy: `docker.enabled`/`docker.network` and - `bwrap.enabled`/`bwrap.rootfs` — host-pinned as + `bwrap.enabled`/`bwrap.rootfs`/`bwrap.werkdock` — host-pinned as long as only the host's configuration sets them, master-pinned once the committed configuration does. @@ -353,7 +353,7 @@ Both parts combine as an intersection. Settings: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, `requirePullRequest`, `statusContext`, and `docker` and `bwrap` with all their keys. A definition carries the complete description of its build; unset keys fall back to `builds.default` and then to Werkator's own defaults. -`requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, and `bwrap.rootfs` are pinned (master-pinned, see [the branch layer](#the-branch-layer-a-branch-describes-its-own-ci)): they are read from the repo install/project config even when a branch sets them in its own committed config. +`requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, `bwrap.rootfs`, and `bwrap.werkdock` are pinned (master-pinned, see [the branch layer](#the-branch-layer-a-branch-describes-its-own-ci)): they are read from the repo install/project config even when a branch sets them in its own committed config. Inheritance from `builds.default` covers the settings only — the `trigger` block says when and where *this* build runs and is never inherited. Definitions are part of the branch layer: a branch may add its own and override those from the project config, for its own builds only. Because the inheritance is applied after all layers are merged, a build a branch invents still inherits the host's `builds.default` — its sandbox policy included, which is what keeps the pinning effective for a build the host has never heard of. @@ -411,16 +411,17 @@ All Werkator containers carry `org.hoennig.werkator` labels; stale build contain ### Notes on `builds..bwrap` -With `bwrap.enabled`, Werkator shells out to the `bwrap` CLI (bubblewrap) instead of native execution. -This is the third runtime, for hosts without root and without a Docker daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md` and ADR 0007. -`bwrap` must be on the `PATH`. +With `bwrap.enabled`, Werkator runs the build in a bubblewrap sandbox instead of native execution. +This is the third runtime, for hosts without root and without a Docker daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md` and ADR 0008. +Since step 21 session C the sandbox is executed by the `werkdock` CLI (`bwrap.werkdock`, default: resolved via `PATH`) — Werkator no longer invokes `bwrap` itself; `bwrap` must be installed for werkdock. +`werkdock doctor` checks the host's capability, superseding `tools/werkator-build-prerequisites.sh`. `bwrap.rootfs` names the prepared root filesystem archive — a Debian-base rootfs with the build tools (JDK, git, locales, project-specific tooling) built elsewhere, since `debootstrap` is unavailable on the target. -It is a local path or an `http(s)` URL; a URL is downloaded once. -Build the archive with `tools/build-bwrap-rootfs.sh` on any machine with Docker; verify the host's user-namespace capability first with `tools/werkator-build-prerequisites.sh`. -The archive is unpacked on demand (`tar --no-same-owner`) into `.git/werkator/buildenv//rootfs`, shared across all branch worktrees like the Docker Gradle cache volume; `` derives from a hash of the source, so a changed `rootfs` unpacks a fresh environment and stale ones can be pruned. -Per-branch Gradle caches persist in `.git/werkator/buildenv/home`, bound as `/root`. -`bwrap.env` adds environment variables inside the sandbox. +It is a local path or an `http(s)` URL; a URL is downloaded once into `.git/werkator/buildenv/`. +Build the archive with `tools/build-bwrap-rootfs.sh` on any machine with Docker. +The archive is loaded once per source as the werkdock image `werkator-buildenv-` into werkdock's store (`$WERKDOCK_HOME`, default `~/.werkdock`) — shared by every repository of this OS user; the hash derives from the source string, so a changed `rootfs` loads a fresh image and stale ones can be removed from the store. +Per-repo Gradle caches persist in `.git/werkator/buildenv/home`, bound as `/root`. +`bwrap.env` adds environment variables inside the sandbox; the environment is otherwise cleared (docker semantics) — the server's environment does not leak in. Files created inside the sandbox are owned by the host user, because uid 0 maps back to the unprivileged webspace user. `docker` and `bwrap` are mutually exclusive per branch: enabling both is rejected at start, not silently picked. diff --git a/docs/deployment.md b/docs/deployment.md index 20df304..0e824f9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -261,3 +261,31 @@ All nginx and certificate failures are non-fatal warnings — the plain HTTP ser The nginx container cannot reach `localhost` of the host, so the proxy upstream defaults to `serverName`; set `server.nginx.upstreamHost` if the host is reachable under a different name from inside containers. With the managed nginx, set `server.bindAddress: 0.0.0.0` explicitly (or an address reachable from the Docker network) — the default `127.0.0.1` makes Werkator unreachable for the proxy container. 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 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`, configured through the `.env` file in the repository root; commands name their role — `instance-*` manages the installed Werkator, `repo-*` the repository it watches. + +```bash +tools/remote werkator check-prerequisites # bwrap capability, disk and quota headroom +tools/remote werkator instance-install # upload + unpack the runtime bundle and werkdock +tools/remote werkator repo-init # clone the watched repo, init, rootfs archive, bwrap config +tools/remote werkator instance-start # server config, Apache proxy, systemd user unit +tools/remote port-forward start # browser tunnel while no public domain is set up +``` + +Layout on the host: the watched repository at `$WERKATOR_PATH/werkator/`, the unpacked runtime at `$WERKATOR_PATH/.werkator/werkator/`, the werkdock binary at `$WERKATOR_PATH/.werkator/bin/werkdock`. +The rootfs archive is loaded once per source into werkdock's image store (`~/.werkdock`), shared by every repository of the user. +Fill `git.account`/`git.token` in the machine config when the origin is private, and make the user's services survive logout with `loginctl enable-linger`. + +Updates are one command, refused while a build runs (`FORCE=1` overrides): + +```bash +tools/remote werkator instance-update +``` + +The previous runtime stays as `.werkator/werkator.prev` for one deployment as the rollback asset. diff --git a/docs/plan/21-werkdock-extraction-and-webspace-install.md b/docs/plan/21-werkdock-extraction-and-webspace-install.md index f1a2ab1..b6fae40 100644 --- a/docs/plan/21-werkdock-extraction-and-webspace-install.md +++ b/docs/plan/21-werkdock-extraction-and-webspace-install.md @@ -64,13 +64,16 @@ A docker-like CLI over `bwrap`, filesystem isolation only. - Own docs, plan, and ADRs under `werkdock/` from the start, so the later repository split is a directory move; the Werkator side only keeps what is Werkator-specific (the git-metadata mounts of step 16 and the config pinning). - Keep `werkdock/` self-contained: no imports from Werkator code, no Gradle coupling to the Werkator build — it must build and test on its own. -### C — Werkator consumes Werkdock (this repo, after B) +### C — Werkator consumes Werkdock (this repo, after B; implemented 2026-09-01 on branch `werkator-consumes-werkdock`) - `BwrapBuildRunner` shells out to `werkdock run` instead of assembling the raw `bwrap` argv — same pattern as git and docker: CLI, no library. - Config keys (`bwrap.enabled`, `bwrap.rootfs`) and their pinning stay as they are; only the executor behind them changes. -- Decide in the step: whether the git-metadata mounts stay Werkator-side (passed as extra `--bind`/`--tmpfs` options to `werkdock run`) or become a Werkdock feature; the secrets-masking of `.git/werkator/` must hold either way. + One key was added: `bwrap.werkdock` (the executing binary, default via PATH) — pinned like the rest of the sandbox policy, since a branch must not substitute the executing binary. +- Decided: the git-metadata mounts stay Werkator-side, passed as `-v …:ro` / `--tmpfs` / `-v` options whose flag order werkdock preserves (it grew `--tmpfs` and an ordered mount list for exactly this); the secrets-masking of `.git/werkator/` holds unchanged. +- Decided: the rootfs archive becomes a werkdock *image* (`werkator-buildenv-`, checked via `werkdock images`, loaded via `werkdock load`) in werkdock's own store — shared across every repository of the OS user, which resolves step 22's buildenv-sharing question; only the URL download cache and the persistent `/root` toolchain home stay under `.git/werkator/buildenv/`. +- Consequence of werkdock's `--clearenv`: the server environment no longer leaks into builds, and the runner's TMPDIR workaround is gone. -### D — The Managed-Webspace install path (this repo, independent of B/C) +### D — The Managed-Webspace install path (this repo, independent of B/C; implemented 2026-09-01 on branch `werkator-consumes-werkdock`) Bring intent 1 to the webspace: build locally, install the bundle — Werkator never builds itself on the target. @@ -90,6 +93,9 @@ Bring intent 1 to the webspace: build locally, install the bundle — Werkator n Three defects found and fixed on the way: unanchored tar excludes dropped the Go stdlib's `sys` directory from the archive, pam_tmpdir's `TMPDIR` leaked into the sandbox (Werkator-side fix; Werkdock is immune via `--clearenv`), and non-report artifacts were stored below `reports/` and invisible in the UI. The image was then trimmed (headless JDK, en/de locales only, no man/doc/apt-lists): 351 MB compressed — smaller than the original JDK-only archive despite carrying Go and Node. All rollback assets on mih34 are removed; the PR for this branch is prepared (PR-doc with `PR#000` placeholder) and will be opened later. +- 2026-09-01, session C deployed to mih34: the werkdock binary sits at `.werkator/bin/werkdock`, the machine config names it in `bwrap.werkdock`, the runtime bundle carries the delegating runner, and the TMPDIR workaround left the machine config (obsolete under werkdock's clearenv). +- 2026-09-01, session D done and live-verified on mih34: `tools/remote` reworked to role-named commands (`instance-install`/`instance-update`/`instance-start` for the builder, `repo-init` for the built; the retired `install`/`build`/`start` fail loudly naming their successors); the self-build, the repo clone for it, and the GitHub-key step are gone — the instance installs from locally built artifacts (bundle + werkdock), the watched repo clones anonymously via https. + `instance-update` refuses to swap under a running build, `repo-init` is idempotent (checksum-skipped rootfs upload; the machine-config guard whose indentation mismatch once appended nine duplicate bwrap blocks is fixed); `docs/deployment.md` gained the Managed Webspace as the third variant, written from the verified setup. ## Acceptance Criteria diff --git a/docs/prs/2026-09-02-PR#7-werkator-consumes-werkdock.md b/docs/prs/2026-09-02-PR#7-werkator-consumes-werkdock.md new file mode 100644 index 0000000..aaaa6f6 --- /dev/null +++ b/docs/prs/2026-09-02-PR#7-werkator-consumes-werkdock.md @@ -0,0 +1,127 @@ +> **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 + +PR #6 grew Werkdock as a standalone sandbox CLI, but `BwrapBuildRunner` still assembled its own raw `bwrap` invocation — the extraction was only half done, and the two implementations could drift. +Separately, the webspace deployment path in `tools/remote` still followed the original self-build prototype: clone Werkator's own repository onto the target and build it there, which is exactly the pattern ADR 0006 rejected ("build locally, install the bundle") and step 21 set out to correct. + +## Non-Goals + +- `tools/remote`'s configuration-writing duplication (heredocs/`sed` into the machine config) — that is step 23, PR #8/#9. +- Multi-repository support for one Werkator instance (step 22, PR #10). +- RFC 0002 levels 2/3 and RFC 0003 (composable toolchain mounts) stay deferred/candidate. + +## The Scenarios + +### Feature: `BwrapBuildRunner` delegates to Werkdock + +#### Scenario#7.01: A build runs through `werkdock run` instead of a raw `bwrap` invocation + +So that Werkator and Werkdock never carry two implementations of the same sandbox invocation. + +- **Given** `bwrap.enabled` and a configured rootfs archive +- **When** a build needs the sandbox +- **Then** `BwrapBuildRunner` loads the image via `werkdock images`/`werkdock load` (once, keyed by `imageName(rootfs)` = `werkator-buildenv-`) and runs the build via `werkdock run --rm` + - **and** the configured `bwrap.werkdock` binary (default: `werkdock` via `PATH`) is what gets invoked. + +##### Verified by + +- [assembles the exact werkdock run command for a loaded image](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt) +- [loads the image once when werkdock does not know it yet](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt) +- [does not load an image werkdock already has](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt) +- [uses the configured werkdock binary path](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt) + +#### Scenario#7.02: The git-metadata mask survives the move to ordered `-v`/`--tmpfs` flags + +So that secrets stay outside the sandbox exactly as before, now expressed as flag order instead of an internal mount list. + +- **Given** a worktree build +- **When** the invocation is assembled +- **Then** `.git` is bound read-only, then `.git/werkator/` is masked with `--tmpfs`, then the worktree's admin dir is bound read-write, in that exact order + - **and** Werkdock's `Mount` list (replacing the earlier unordered `Bind` list) preserves the order flags were given in. + +##### Verified by + +- [exposes git metadata read-only with the werkator dir masked, in mount order](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt) +- [mounts no git metadata when the workspace is not a worktree](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt) +- [TestParseRunKeepsMountFlagOrderAcrossVolumeAndTmpfs](../../werkdock/internal/cli/run_test.go) + +#### Scenario#7.03: Werkdock gained what the delegation needed + +So that `images`/`:rw` were built because Werkator's runner needed them, not speculatively. + +- **Given** the new `werkdock images` verb and `:rw` volume option +- **When** the runner checks whether an image is already loaded, or mounts the admin dir read-write +- **Then** `images` lists loaded image names (one per line, `docker images --format` shaped) and `-v src:dst:rw` is accepted alongside the existing `:ro`. + +##### Verified by + +- [TestListNamesLoadedImagesAndIgnoresTmpLeftovers](../../werkdock/internal/store/store_test.go) +- [TestParseRunAcceptsTheExplicitRwVolumeOption](../../werkdock/internal/cli/run_test.go) +- [TestImageNameFromArchive](../../werkdock/internal/store/store_test.go) + +#### Scenario#7.04: The TMPDIR workaround is gone because it is now structurally impossible + +So that the fix and its own workaround do not both linger in the codebase. + +- **Given** Werkdock's `--clearenv` +- **When** a build runs in the sandbox +- **Then** no host `TMPDIR`/`TMP` reaches the sandboxed process at all, so `BwrapBuildRunner`'s earlier explicit `--setenv TMPDIR /tmp` workaround (PR #4) is removed as dead code, not merely redundant. + +##### Verified by + +- [adds bwrap env after the branch environment](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt) +- [TestArgvAssemblesTheHardenedInvocation](../../werkdock/internal/engine/bwrap_test.go) + +### Feature: the webspace install path replaces the self-build prototype + +#### Scenario#7.05: `tools/remote` separates the builder role from the built (watched) repository role + +So that "build Werkator on the webspace" and "Werkator watches a repository on the webspace" are never conflated again. + +- **Given** a Managed Webspace target +- **When** the wrapper manages the Werkator runtime versus a repository Werkator watches +- **Then** `instance-install`/`instance-update`/`instance-start` install and run the Werkator **builder** binary+bundle + - **and** `repo-init` prepares a repository to be **built by** that instance + - **and** the retired `install`/`build`/`start` commands fail loudly, naming their successors, instead of silently doing the old thing. + +##### Verified by + +- manual invocation of the retired commands on mih34 (shell script; no automated test harness for `tools/remote`) + +#### Scenario#7.06: The self-build prototype is gone + +So that Werkator is never again built by checking out its own source onto the target and compiling there. + +- **Given** the old prototype cloned werkator's own repository onto the webspace and built it in place +- **When** an instance is installed or updated now +- **Then** the runtime bundle is built locally and transported (`instance-install`/`instance-update`), never cloned-and-built on the target. + +##### Verified by + +- live run on mih34: `instance-update` against a runtime bundle built locally + +## The Solution + +`BwrapBuildRunner.invocation()` no longer builds a `bwrap` argv; it shells out to the `werkdock` binary named by `bwrap.werkdock` (a new pinned config key, alongside `bwrap.enabled`/`bwrap.rootfs`) for `images`, `load`, and `run --rm`. +`werkdock/internal/engine/engine.go` was rewritten from an unordered `Bind` list to an ordered `Mount` list (`MountBind`/`MountRoBind`/`MountTmpfs`) specifically so the CLI's `-v`/`--tmpfs` flag order — which the git-metadata mask depends on — survives into the sandbox invocation unchanged. +`werkdock/internal/cli/images.go` is new; `run.go`'s volume parsing gained the `:rw` option. +`tools/remote` was reorganized around two roles instead of one flat command list: builder lifecycle (`instance-install`/`instance-update`/`instance-start`) versus watched-repository lifecycle (`repo-init`); the old `install`/`build`/`start` now `die` with the successor's name. +`require_idle()`/`FORCE=1` guards a runtime swap against a build in progress. + +## Additional Changes + +- `docs/deployment.md`: the webspace section now describes the role-separated commands. +- `docs/configuration.md` and `AGENTS.md`: `bwrap.werkdock` documented as a fourth pinned bwrap key. +- Step 21 plan: sessions C and D marked done with live-verification notes. + +## Prerequisite PRs + +- PR #6 (Werkdock bootstrap) — this PR is the consumer of the CLI it built. + +## Follow-up PRs + +- PR #8/#9: `tools/remote`'s remaining configuration-writing duplication with `werkator init` is resolved next (step 23). +- PR #10: multi-repository support for one Werkator instance (step 22). diff --git a/src/main/kotlin/de/hoennig/werkator/build/BwrapBuildRunner.kt b/src/main/kotlin/de/hoennig/werkator/build/BwrapBuildRunner.kt index 63301f8..3735bc5 100644 --- a/src/main/kotlin/de/hoennig/werkator/build/BwrapBuildRunner.kt +++ b/src/main/kotlin/de/hoennig/werkator/build/BwrapBuildRunner.kt @@ -10,20 +10,30 @@ import java.nio.file.Path import java.security.MessageDigest /** - * Runs build commands inside a bubblewrap user-namespace sandbox (Step 17 / ADR 0007), + * Runs build commands inside a bubblewrap user-namespace sandbox (Step 17 / ADR 0008), * for hosts without root and without a Docker daemon (e.g. Hostsharing managed - * webspaces). Shells out to the `bwrap` CLI via the generic [GitCommandRunner] process - * wrapper — no library, consistent with git and docker. + * webspaces). Since step 21 session C it no longer assembles the raw `bwrap` argv: + * it shells out to the `werkdock` CLI (`bwrap.werkdock`, default via PATH) — the same + * pattern as git and docker, CLI, no library. * - * The prepared rootfs (a Debian-base archive built elsewhere, since `debootstrap` is not - * available on the target) is unpacked on demand into `.git/werkator/buildenv//rootfs`, - * shared across all branch worktrees like the Docker gradle cache volume; `` derives - * from a hash of the archive source, so a changed source unpacks a fresh rootfs and stale - * ones can be pruned. The returned [Process] is the attached `bwrap` process, so log - * streaming and cancellation work exactly like native builds (`--die-with-parent` plus - * `--unshare-pid` tear down the whole tree on cancel). Git works inside the sandbox with - * the same layered mounts as the Docker runner: the primary `.git` read-only with - * `.git/werkator/` masked, see [gitMetadataMounts]. + * The rootfs archive becomes a werkdock *image*, loaded once per source + * (`werkator-buildenv-`, the hash over the source string, so a changed source + * loads a fresh image) into werkdock's own store (`$WERKDOCK_HOME`, default + * `~/.werkdock`) — shared by every repository of this OS user, unlike the old + * per-repo unpack. Only the download cache for URL sources and the persistent + * toolchain home (bound to `/root` for Gradle/Go caches) stay under + * `.git/werkator/buildenv/`. + * + * Werkdock clears the environment inside the sandbox (docker semantics), so the + * server's environment no longer leaks in — only the explicit `-e` variables below + * plus werkdock's own `HOME`/`PATH` exist inside; the pam_tmpdir TMPDIR class of + * bugs is gone by construction. Git works inside the sandbox with the same layered + * mounts as the Docker runner, expressed as werkdock flags whose order is + * significant and preserved: read-only `.git`, tmpfs mask over `.git/werkator`, + * read-write worktree admin dir — see [gitMetadataMounts]. The returned [Process] + * is the attached `werkdock run`, whose `bwrap` child dies with it + * (`--die-with-parent`), so log streaming and cancellation work exactly like + * native builds. */ @Component class BwrapBuildRunner( @@ -31,7 +41,7 @@ class BwrapBuildRunner( ) : BuildRunner { private val log = LoggerFactory.getLogger(BwrapBuildRunner::class.java) - /** Replaceable process launcher so unit tests can capture the assembled `bwrap` argv. */ + /** Replaceable process launcher so unit tests can capture the assembled `werkdock` argv. */ internal var processStarter: (List, Path) -> Process = { command, dir -> ProcessBuilder(command).directory(dir.toFile()).start() } @@ -46,76 +56,37 @@ class BwrapBuildRunner( ): Process { val bwrap = branchConfig.bwrap require(bwrap.rootfs.isNotBlank()) { "branches..bwrap.rootfs must be set when bwrap.enabled is true" } - val buildEnvRoot = buildEnvRoot(repoDir) - val envKey = envKey(bwrap.rootfs) - val rootfsDir = buildEnvRoot.resolve(envKey).resolve(ROOTFS_DIR) - ensureRootfs(bwrap, rootfsDir, repoDir, onAuxProcess) - val homeDir = buildEnvRoot.resolve(HOME_DIR) + val werkdock = bwrap.werkdock.ifBlank { "werkdock" } + val image = imageName(bwrap.rootfs) + ensureImage(werkdock, image, bwrap, repoDir, onAuxProcess) + val homeDir = repoDir.resolve(BUILDENV_DIR).resolve(HOME_DIR) Files.createDirectories(homeDir) - val args = - invocation(command, workingDir, environment, repoDir, bwrap, rootfsDir, homeDir) - ensureMountpoints(rootfsDir, args) + val args = invocation(command, workingDir, environment, repoDir, bwrap, werkdock, image, homeDir) return processStarter(args, repoDir) } /** - * bwrap creates mountpoint directories for bind destinations inside the sandbox — - * against the read-only rootfs bind that fails with "Can't mkdir parents ... - * Read-only file system" for every destination that does not exist in the rootfs - * (the workspace under the repo, for example). The rootfs directory itself is a - * plain host directory, so we pre-create the mountpoints there; bwrap then finds - * them and has nothing left to mkdir. + * Loads the rootfs archive into the werkdock image store once per source. + * `werkdock images` answers existence through the CLI, like `docker image + * inspect` does for the Docker runner. */ - private fun ensureMountpoints( - rootfsDir: Path, - args: List, - ) { - var i = 0 - while (i < args.size) { - val arg = args[i] - if (arg == "--bind" || arg == "--ro-bind") { - val dest = args[i + 2] - val mountpoint = rootfsDir.resolve(dest.substring(1)) - // Skip anything that already exists in the rootfs (e.g. /etc/resolv.conf - // is a file the rootfs ships); only missing dirs are created. - if (dest.startsWith("/") && !Files.exists(mountpoint)) { - Files.createDirectories(mountpoint) - } - i += 3 - } else if (arg == "--proc" || arg == "--dev" || arg == "--tmpfs") { - // The rootfs archive ships no /proc, /dev (excluded when packed), so - // these mountpoints must exist too. - val dest = args[i + 1] - if (dest.startsWith("/") && !Files.exists(rootfsDir.resolve(dest.substring(1)))) { - Files.createDirectories(rootfsDir.resolve(dest.substring(1))) - } - i += 2 - } else { - i += 1 - } - } - } - - /** - * Unpacks the configured archive into [rootfsDir] once per environment version - * (identified by [envKey]). Missing means "not yet unpacked"; the environment is a - * cache like the Docker image and the Gradle volume, and stale ones are pruned with - * the rest of `.git/werkator`. - */ - private fun ensureRootfs( + private fun ensureImage( + werkdock: String, + image: String, bwrap: BwrapConfig, - rootfsDir: Path, repoDir: Path, onAuxProcess: (Process) -> Unit, ) { - if (Files.isDirectory(rootfsDir)) { + val loaded = commandRunner.runOrThrow(listOf(werkdock, "images"), repoDir, onProcess = onAuxProcess).lines() + if (image in loaded) { return } - Files.createDirectories(rootfsDir) - val archive = localArchive(bwrap.rootfs, rootfsDir.parent, repoDir, onAuxProcess) - log.info("unpacking build environment {} into {}", bwrap.rootfs, rootfsDir) + val envDir = repoDir.resolve(BUILDENV_DIR).resolve(sourceKey(bwrap.rootfs)) + Files.createDirectories(envDir) + val archive = localArchive(bwrap.rootfs, envDir, repoDir, onAuxProcess) + log.info("loading build environment {} as werkdock image {}", bwrap.rootfs, image) commandRunner.runOrThrow( - listOf("tar", "--no-same-owner", "-xf", archive, "-C", rootfsDir.toString()), + listOf(werkdock, "load", "-i", archive, "--name", image), repoDir, onProcess = onAuxProcess, ) @@ -123,9 +94,7 @@ class BwrapBuildRunner( /** * Resolves [BwrapConfig.rootfs] to a local archive path: a bare or `file:` path is - * used as-is; an `http(s)` URL is downloaded once into the buildenv root. GNU tar - * auto-detects the compression from the archive magic, so a `.tar.gz` or `.tar.zst` - * needs no extra flag. + * used as-is; an `http(s)` URL is downloaded once into the buildenv cache. */ private fun localArchive( rootfs: String, @@ -155,77 +124,48 @@ class BwrapBuildRunner( environment: Map, repoDir: Path, bwrap: BwrapConfig, - rootfsDir: Path, + werkdock: String, + image: String, homeDir: Path, ): List { - // bwrap creates mountpoints for bind destinations inside the sandbox; a - // relative workspace path would resolve there into the read-only rootfs - // ("Can't mkdir parents ...: Read-only file system"). Bind at absolute - // host paths instead — same contract as the Docker runner. Relative - // paths come from the CLI relative to the repo, so resolve them against - // repoDir, not against the process working directory. + // Mounts at absolute host paths — same contract as the Docker runner. + // Relative paths come from the CLI relative to the repo, so resolve them + // against repoDir, not against the process working directory. val repoDirAbs = repoDir.toAbsolutePath().normalize() val workspaceAbs = if (workspace.isAbsolute) workspace.normalize() else repoDirAbs.resolve(workspace).normalize() val homeDirAbs = if (homeDir.isAbsolute) homeDir.normalize() else repoDirAbs.resolve(homeDir).normalize() - val args = - mutableListOf( - "bwrap", - "--unshare-user", - "--unshare-pid", - "--die-with-parent", - "--uid", - "0", - "--gid", - "0", - "--ro-bind", - rootfsDir.toString(), - "/", - ) - // Bind the repo read-write FIRST so bwrap can create the mountpoints of - // the later binds (workspace, worktree admin dir) inside it — creating - // them against the read-only rootfs fails with "Can't mkdir parents ... - // Read-only file system". The git metadata mounts below then layer the - // usual isolation on top: read-only .git, tmpfs mask over .git/werkator, - // read-write worktree admin dir. - args += listOf("--bind", "$repoDirAbs", "$repoDirAbs") - // Git metadata mounts BEFORE the workspace bind: the tmpfs mask over - // .git/werkator must not shadow the workspace, which lives under - // .git/werkator/worktrees — the later workspace bind shadows the mask - // at exactly its own path and nothing else. + val args = mutableListOf(werkdock, "run", "--rm") + // The repo read-write FIRST, as the base the later mountpoints (workspace, + // worktree admin dir) are created in; the git metadata mounts then layer + // the isolation on top, and the workspace bind last shadows the tmpfs mask + // at exactly its own path (it lives under .git/werkator/worktrees). + args += listOf("-v", "$repoDirAbs:$repoDirAbs") args += gitMetadataMounts(workspaceAbs, repoDir) - args += listOf("--bind", "$workspaceAbs", "$workspaceAbs") - args += listOf("--bind", "$homeDirAbs", "/root") - args += listOf("--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf") - args += listOf("--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp") - args += listOf("--setenv", "HOME", "/root") - // The sandbox /tmp is a fresh tmpfs, but bwrap inherits the server's - // environment — on hosts with pam_tmpdir that includes - // TMPDIR=/tmp/user/, which does not exist inside and breaks every - // tool honoring it (go: "creating work dir: stat ...: no such file or - // directory"; the JVM ignores TMPDIR, so Gradle never noticed). Set - // both back to /tmp; explicit env below can still override. - args += listOf("--setenv", "TMPDIR", "/tmp") - args += listOf("--setenv", "TMP", "/tmp") + args += listOf("-v", "$workspaceAbs:$workspaceAbs") + args += listOf("-v", "$homeDirAbs:/root") for ((key, value) in environment) { - args += listOf("--setenv", key, value) + args += listOf("-e", "$key=$value") } for ((key, value) in bwrap.env) { - args += listOf("--setenv", key, value) + args += listOf("-e", "$key=$value") } - args += listOf("--chdir", "$workspaceAbs", "/bin/sh", "-c", command) + args += listOf("-w", "$workspaceAbs") + args += image + args += listOf("/bin/sh", "-c", command) return args } /** * Makes git work inside the sandbox without exposing Werkator's secrets — the same - * three layered mounts as the Docker runner, expressed in `bwrap` flags (bwrap nests - * mounts by target path like Docker): the primary `.git` read-only, an empty tmpfs - * masking `.git/werkator/` (machine config with `git.token`, control token, build - * state), and this worktree's admin directory read-write so index-refreshing commands - * keep working. Object and ref writes stay blocked by the read-only `.git` mount. - * No mounts are added when the workspace is not a worktree of [repoDir]. + * three layered mounts as the Docker runner, expressed as werkdock flags (werkdock + * preserves the -v/--tmpfs flag order, and bwrap nests mounts by target path): the + * primary `.git` read-only, an empty tmpfs masking `.git/werkator/` (machine config + * with `git.token`, control token, build state), and this worktree's admin directory + * read-write so index-refreshing commands keep working. Object and ref writes stay + * blocked by the read-only `.git` mount. No mounts are added when the workspace is + * not a worktree of [repoDir]. */ private fun gitMetadataMounts( workspace: Path, @@ -247,28 +187,27 @@ class BwrapBuildRunner( if (!adminDir.startsWith(gitDir) || !Files.isDirectory(adminDir)) { return emptyList() } - val args = mutableListOf("--ro-bind", "$gitDir", "$gitDir") + val args = mutableListOf("-v", "$gitDir:$gitDir:ro") val werkatorDir = gitDir.resolve("werkator") if (Files.isDirectory(werkatorDir)) { args += listOf("--tmpfs", "$werkatorDir") } - args += listOf("--bind", "$adminDir", "$adminDir") + args += listOf("-v", "$adminDir:$adminDir") return args } - private fun buildEnvRoot(repoDir: Path): Path = repoDir.resolve(BUILDENV_DIR) - - /** A short hash of the archive source, so a changed source unpacks a fresh rootfs. */ - private fun envKey(rootfs: String): String = + /** A short hash of the archive source, so a changed source loads a fresh image. */ + private fun sourceKey(rootfs: String): String = MessageDigest .getInstance("SHA-256") .digest(rootfs.toByteArray()) .joinToString("") { "%02x".format(it) } .take(12) + private fun imageName(rootfs: String): String = "werkator-buildenv-${sourceKey(rootfs)}" + companion object { const val BUILDENV_DIR = ".git/werkator/buildenv" - const val ROOTFS_DIR = "rootfs" const val HOME_DIR = "home" } } diff --git a/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt index ac78d34..139490b 100644 --- a/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt @@ -226,6 +226,7 @@ class InitCommand( bwrap: enabled: false # run clean/build in a bwrap sandbox instead of natively (pinned) rootfs: "" # prepared rootfs archive (path or URL); required when enabled (pinned) + werkdock: werkdock # the werkdock CLI executing the sandbox; default resolves via PATH (pinned) env: {} # additional environment variables set inside the sandbox # Gitea check this build reports as; empty uses gitea.statusContext. # Two builds of one commit under the same context overwrite each other. diff --git a/src/main/kotlin/de/hoennig/werkator/config/BuildDefinition.kt b/src/main/kotlin/de/hoennig/werkator/config/BuildDefinition.kt index 0c64dad..b02da40 100644 --- a/src/main/kotlin/de/hoennig/werkator/config/BuildDefinition.kt +++ b/src/main/kotlin/de/hoennig/werkator/config/BuildDefinition.kt @@ -68,6 +68,7 @@ data class BuildDefinition( branchConfig.bwrap.copy( enabled = bwrap?.enabled ?: branchConfig.bwrap.enabled, rootfs = bwrap?.rootfs ?: branchConfig.bwrap.rootfs, + werkdock = bwrap?.werkdock ?: branchConfig.bwrap.werkdock, env = bwrap?.env ?: branchConfig.bwrap.env, ), ) @@ -174,5 +175,7 @@ data class BwrapOverrides( val enabled: Boolean? = null, /** Rootfs archive source. Pinned — a branch must not substitute a foreign rootfs. */ val rootfs: String? = null, + /** The werkdock CLI executing the sandbox. Pinned — a branch must not substitute the executing binary. */ + val werkdock: String? = null, val env: Map? = null, ) diff --git a/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt b/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt index ca59590..88c909f 100644 --- a/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt +++ b/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt @@ -403,8 +403,8 @@ class ConfigLoader( /** `docker` keys a branch must never override: the sandbox policy. */ private val PINNED_DOCKER_KEYS = setOf("enabled", "network") - /** `bwrap` keys a branch must never override: the sandbox policy (Step 17). */ - private val PINNED_BWRAP_KEYS = setOf("enabled", "rootfs") + /** `bwrap` keys a branch must never override: the sandbox policy (Step 17) and its executing binary. */ + private val PINNED_BWRAP_KEYS = setOf("enabled", "rootfs", "werkdock") /** * The one key of a build definition that says *when* and *for which branches* it diff --git a/src/main/kotlin/de/hoennig/werkator/config/WerkatorConfig.kt b/src/main/kotlin/de/hoennig/werkator/config/WerkatorConfig.kt index 38eb09c..9045828 100644 --- a/src/main/kotlin/de/hoennig/werkator/config/WerkatorConfig.kt +++ b/src/main/kotlin/de/hoennig/werkator/config/WerkatorConfig.kt @@ -197,6 +197,11 @@ data class BwrapConfig( * Pinned — a branch must not substitute a foreign rootfs via its committed config. */ val rootfs: String = "", + /** + * The werkdock CLI executing the sandbox (step 21 session C); empty or the default + * resolves via PATH. Pinned — a branch must not substitute the executing binary. + */ + val werkdock: String = "werkdock", /** Additional environment variables set inside the sandbox. */ val env: Map = emptyMap(), ) diff --git a/src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt b/src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt index 3e8b988..7a77516 100644 --- a/src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt @@ -35,11 +35,18 @@ class BwrapBuildRunnerTest : FunSpec() { ), ) - private fun rootfsUnpacked(rootfs: String = "/srv/buildenv.tar.zst"): Path = - repoDir - .resolve(BwrapBuildRunner.BUILDENV_DIR) - .resolve(rootfs.sha12()) - .resolve(BwrapBuildRunner.ROOTFS_DIR) + private fun imageName(rootfs: String = "/srv/buildenv.tar.zst"): String = "werkator-buildenv-${rootfs.sha12()}" + + /** The image is already loaded: `werkdock images` lists it, so no load runs. */ + private fun givenImageLoaded(rootfs: String = "/srv/buildenv.tar.zst") { + every { commandRunner.runOrThrow(listOf("werkdock", "images"), repoDir, any(), any()) } returns + GitCommandResult(0, imageName(rootfs) + "\n", "") + } + + private fun givenImageMissing() { + every { commandRunner.runOrThrow(listOf("werkdock", "images"), repoDir, any(), any()) } returns + GitCommandResult(0, "some-other-image\n", "") + } init { beforeEach { @@ -54,110 +61,92 @@ class BwrapBuildRunnerTest : FunSpec() { } } - test("unpacks the rootfs on demand and assembles the exact bwrap command") { - every { - commandRunner.runOrThrow( - listOf("tar", "--no-same-owner", "-xf", "/srv/buildenv.tar.zst", "-C", rootfsUnpacked().toString()), - repoDir, - any(), - any(), - ) - } returns - GitCommandResult(0, "", "") + test("assembles the exact werkdock run command for a loaded image") { + givenImageLoaded() runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig()) - val args = captured.single() - val rootfsDir = args[args.indexOf("--ro-bind") + 1] - args shouldBe + captured.single() shouldBe listOf( - "bwrap", - "--unshare-user", - "--unshare-pid", - "--die-with-parent", - "--uid", - "0", - "--gid", - "0", - "--ro-bind", - rootfsUnpacked().toString(), - "/", - "--bind", - repoDir.toString(), - repoDir.toString(), - "--bind", - workspace.toString(), - workspace.toString(), - "--bind", - repoDir.resolve(".git/werkator/buildenv/home").toString(), - "/root", - "--ro-bind", - "/etc/resolv.conf", - "/etc/resolv.conf", - "--proc", - "/proc", - "--dev", - "/dev", - "--tmpfs", - "/tmp", - "--setenv", - "HOME", - "/root", - "--setenv", - "TMPDIR", - "/tmp", - "--setenv", - "TMP", - "/tmp", - "--setenv", - "branch", - "main", - "--chdir", + "werkdock", + "run", + "--rm", + "-v", + "$repoDir:$repoDir", + "-v", + "$workspace:$workspace", + "-v", + "${repoDir.resolve(".git/werkator/buildenv/home")}:/root", + "-e", + "branch=main", + "-w", workspace.toString(), + imageName(), "/bin/sh", "-c", "./gradlew test", ) - Files.isDirectory(rootfsUnpacked()) shouldBe true } - test("binds a relative workspace path at its absolute location") { - // bwrap creates mountpoints for bind destinations inside the sandbox; - // a relative path would land in the read-only rootfs and fail with - // "Can't mkdir parents ...: Read-only file system" (seen on the webspace). + test("loads the image once when werkdock does not know it yet") { + givenImageMissing() every { commandRunner.runOrThrow( - listOf("tar", "--no-same-owner", "-xf", "/srv/buildenv.tar.zst", "-C", rootfsUnpacked().toString()), + listOf("werkdock", "load", "-i", "/srv/buildenv.tar.zst", "--name", imageName()), repoDir, any(), any(), ) - } returns - GitCommandResult(0, "", "") + } returns GitCommandResult(0, "", "") + runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig()) + + verify { + commandRunner.runOrThrow( + listOf("werkdock", "load", "-i", "/srv/buildenv.tar.zst", "--name", imageName()), + repoDir, + any(), + any(), + ) + } + } + + test("does not load an image werkdock already has") { + givenImageLoaded() + + runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig()) + + verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) } + } + + test("uses the configured werkdock binary path") { + every { commandRunner.runOrThrow(listOf("/opt/bin/werkdock", "images"), repoDir, any(), any()) } returns + GitCommandResult(0, imageName() + "\n", "") + val branchConfig = + BranchConfig( + bwrap = BwrapConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst", werkdock = "/opt/bin/werkdock"), + ) + + runner.start("./gradlew test", workspace, emptyMap(), repoDir, branchConfig) + + captured.single().first() shouldBe "/opt/bin/werkdock" + } + + test("mounts a relative workspace path at its absolute location") { + givenImageLoaded() val relativeWorkspace = repoDir.relativize(workspace) runner.start("./gradlew test", relativeWorkspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig()) val args = captured.single() val absolute = workspace.toAbsolutePath().normalize().toString() - val bindIdx = args.withIndex().filter { it.value == "--bind" }.map { it.index } - // first bind is the repo dir (mountpoint base), second is the workspace - args[bindIdx[1] + 1] shouldBe absolute - args[bindIdx[1] + 2] shouldBe absolute - args[args.indexOf("--chdir") + 1] shouldBe absolute + args shouldContainElement "-v" + args[args.indexOf("-w") + 1] shouldBe absolute + args.count { it == "$absolute:$absolute" } shouldBe 1 } - test("does not re-unpack an already prepared rootfs") { - Files.createDirectories(rootfsUnpacked()) - - runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig()) - - verify(exactly = 0) { commandRunner.runOrThrow(match { it.first() == "tar" }, any(), any(), any()) } - } - - test("adds bwrap env and passes the branch environment through") { - Files.createDirectories(rootfsUnpacked()) + test("adds bwrap env after the branch environment") { + givenImageLoaded() runner.start( "./gradlew test", @@ -168,39 +157,43 @@ class BwrapBuildRunnerTest : FunSpec() { ) val args = captured.single() - args[args.indexOf("branch") - 1] shouldBe "--setenv" - args[args.indexOf("branch") + 1] shouldBe "main" - args[args.indexOf("FOO") - 1] shouldBe "--setenv" - args[args.indexOf("FOO") + 1] shouldBe "bar" + args[args.indexOf("branch=main") - 1] shouldBe "-e" + args[args.indexOf("FOO=bar") - 1] shouldBe "-e" + args.indexOf("branch=main") shouldBe args.indexOf("FOO=bar") - 2 } - test("exposes git metadata read-only with the werkator dir masked for a worktree workspace") { + test("exposes git metadata read-only with the werkator dir masked, in mount order") { val gitDir = repoDir.resolve(".git") val adminDir = gitDir.resolve("worktrees/workspace") Files.createDirectories(adminDir) Files.createDirectories(gitDir.resolve("werkator")) Files.createDirectories(workspace) Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n") - Files.createDirectories(rootfsUnpacked()) + givenImageLoaded() runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig()) val args = captured.single() - args[args.indexOf(gitDir.toString()) - 1] shouldBe "--ro-bind" + args[args.indexOf("$gitDir:$gitDir:ro") - 1] shouldBe "-v" args[args.indexOf("$gitDir/werkator") - 1] shouldBe "--tmpfs" - args[args.indexOf(adminDir.toString()) - 1] shouldBe "--bind" + args[args.indexOf("$adminDir:$adminDir") - 1] shouldBe "-v" + // order: ro .git, tmpfs mask, admin dir, then the workspace bind that + // shadows the mask at its own path + val roGit = args.indexOf("$gitDir:$gitDir:ro") + val mask = args.indexOf("$gitDir/werkator") + val admin = args.indexOf("$adminDir:$adminDir") + val workspaceBind = args.indexOf("$workspace:$workspace") + (roGit < mask && mask < admin && admin < workspaceBind) shouldBe true } test("mounts no git metadata when the workspace is not a worktree") { - Files.createDirectories(rootfsUnpacked()) + givenImageLoaded() Files.createDirectories(workspace) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig()) val args = captured.single() val gitDir = repoDir.resolve(".git") - // the sandbox's own /tmp tmpfs is always present; the point is that no tmpfs - // masks .git/werkator and no worktree admin dir is bound args.none { it == "$gitDir/werkator" } shouldBe true args.none { it.contains("worktrees/") } shouldBe true } @@ -216,16 +209,17 @@ class BwrapBuildRunnerTest : FunSpec() { exception.message shouldContain "bwrap.rootfs" } - test("downloads a URL rootfs once before unpacking") { + test("downloads a URL rootfs once before loading it") { val url = "https://example.test/buildenv.tar.zst" val downloadTarget = repoDir .resolve(BwrapBuildRunner.BUILDENV_DIR) .resolve(url.sha12()) .resolve("buildenv.tar.zst") + givenImageMissing() every { commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any()) } returns GitCommandResult(0, "", "") - every { commandRunner.runOrThrow(match { it.first() == "tar" }, any(), any(), any()) } returns + every { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) } returns GitCommandResult(0, "", "") runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig(rootfs = url)) @@ -233,10 +227,19 @@ class BwrapBuildRunnerTest : FunSpec() { verify { commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any()) } - verify { commandRunner.runOrThrow(match { it.first() == "tar" }, repoDir, any(), any()) } + verify { + commandRunner.runOrThrow( + listOf("werkdock", "load", "-i", downloadTarget.toString(), "--name", "werkator-buildenv-${url.sha12()}"), + repoDir, + any(), + any(), + ) + } } } + private infix fun List.shouldContainElement(element: String) = (element in this) shouldBe true + private fun String.sha12(): String = java.security.MessageDigest .getInstance("SHA-256") diff --git a/tools/remote b/tools/remote index 016214c..7ce3faf 100755 --- a/tools/remote +++ b/tools/remote @@ -5,63 +5,65 @@ # the second the command. All connection and deployment values come from the # `.env` file in the repository root — never as command line parameters. # -# NOTE: `install` (its clone step) and `build` are a prototype of the webspace -# self-build workflow. They proved the bwrap sandbox, but as a deployment path -# they invert ADR 0006 (build locally, install the bundle) and will be replaced -# by session D of docs/plan/21-werkdock-extraction-and-webspace-install.md. +# Commands name their role (step 21 session D): `instance-*` manages the +# BUILDER — the installed Werkator instance and its werkdock sandbox tool — +# while `repo-*` acts on the BUILT, the repository the instance watches. +# Werkator is never built on the target: the instance is installed from the +# locally built runtime bundle (ADR 0006), and builds of the watched +# repository are the running instance's job (or `bin/werkator build` on the +# host — the werkator CLI, not this script). # # Usage: -# tools/remote werkator check-prerequisites -# tools/remote werkator install -# tools/remote werkator build # WERKATOR_BRANCH to override, default main -# tools/remote werkator start -# tools/remote port-forward start # background tunnel to the Werkator UI -# tools/remote port-forward stop +# tools/remote werkator check-prerequisites bwrap capability + disk/quota on the host +# tools/remote werkator instance-install first-time: upload + unpack bundle and werkdock +# tools/remote werkator instance-update redeploy bundle + werkdock, restart the service +# tools/remote werkator instance-start server config, Apache proxy, systemd unit +# tools/remote werkator repo-init clone the watched repo, init, rootfs, bwrap config # tools/remote werkator control-token +# tools/remote port-forward start background tunnel to the Werkator UI +# tools/remote port-forward stop # # Required in .env: # WERKATOR_REMOTE user@host to operate on, e.g. mih34-werkator@mih34.hostsharing.net # WERKATOR_PATH target directory on that host, e.g. /home/storage/mih34/users/werkator # -# Required for `start`: +# Required for `instance-start`: # WERKATOR_PORT the localhost port assigned by Hostsharing (eigener Serverdienst) # WERKATOR_DOMAIN the domain served by the managed Apache, e.g. ci.example.de # # Required for `port-forward`: # WERKATOR_LOCAL_PORT the local port the browser uses # Optional in .env: -# WERKATOR_BRANCH branch for `build` (default: main) -# WERKATOR_MEMORY_MAX systemd MemoryMax for the unit, e.g. 1G (start) -# WERKATOR_TASKS_MAX systemd TasksMax for the unit, e.g. 512 (start) -# WERKATOR_ROOTFS rootfs archive path -# (default: /build/werkator-buildenv-trixie.tar.zst) +# WERKATOR_REPO_URL https clone URL of the watched repository +# (default: https://github.com/mhoennig/werkator.git) +# WERKATOR_MEMORY_MAX systemd MemoryMax for the unit, e.g. 1G (instance-start) +# WERKATOR_TASKS_MAX systemd TasksMax for the unit, e.g. 512 (instance-start) +# WERKATOR_ROOTFS rootfs archive path for repo-init +# (default: /build/werkator-buildenv-trixie-java-go-node.tar.zst) # # Install layout on the host: -# $WERKATOR_PATH/werkator/ the repository clone -# $WERKATOR_PATH/.werkator/ runtime bundle + rootfs archive -# -# `install` performs, in order: -# 1. check-prerequisites (bwrap capability + disk/quota, aborts on FAIL) -# 2. ensure SSH access (ssh-copy-id on first use; asks for the password) -# 3. upload artifacts (runtime bundle, built locally if missing, + rootfs) -# 4. clone the repository (needs the host SSH key registered at GitHub once — -# the script prints the key and waits) -# 5. `werkator init` + machine-local bwrap configuration +# $WERKATOR_PATH/werkator/ the watched repository (clone) +# $WERKATOR_PATH/.werkator/werkator/ the unpacked runtime bundle +# $WERKATOR_PATH/.werkator/bin/ the werkdock binary +# $WERKATOR_PATH/.werkator/*.tar.* uploaded bundle and rootfs archives # set -euo pipefail +die() { echo "ERROR: $*" >&2; exit 1; } + REPO="${1:-}" COMMAND="${2:-}" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PREREQ_SCRIPT="$REPO_ROOT/tools/werkator-build-prerequisites.sh" RUNTIME_BUNDLE="$REPO_ROOT/build/distributions/werkator-runtime-linux-x64.tar.gz" +WERKDOCK_BINARY="$REPO_ROOT/werkdock/dist/werkdock" PID_FILE="/tmp/werkator-port-forward-$(id -u).pid" LOG_FILE="/tmp/werkator-port-forward-$(id -u).log" usage() { - sed -n '3,38p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + awk 'NR > 2 && !/^#/ { exit } NR > 2 { sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}" exit 2 } @@ -87,7 +89,11 @@ set +a require_env WERKATOR_REMOTE WERKATOR_PATH HOST="$WERKATOR_REMOTE" TARGET_DIR="$WERKATOR_PATH" -ROOTFS="${WERKATOR_ROOTFS:-$REPO_ROOT/build/werkator-buildenv-trixie.tar.zst}" +ROOTFS="${WERKATOR_ROOTFS:-$REPO_ROOT/build/werkator-buildenv-trixie-java-go-node.tar.zst}" +REPO_URL="${WERKATOR_REPO_URL:-https://github.com/mhoennig/werkator.git}" +MACHINE_CONFIG="$TARGET_DIR/werkator/.git/werkator/.werkator.yml" +WERKATOR_BIN="$TARGET_DIR/.werkator/werkator/bin/werkator" +UNIT="werkator-werkator.service" ssh_present() { ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" true 2>/dev/null @@ -99,7 +105,7 @@ ensure_ssh() { else echo "==> No key-based SSH access yet; running ssh-copy-id (password prompt expected)" ssh-copy-id "$HOST" - ssh_present || { echo "ERROR: SSH access still not working after ssh-copy-id" >&2; exit 1; } + ssh_present || die "SSH access still not working after ssh-copy-id" fi } @@ -109,118 +115,167 @@ check_prerequisites() { echo "==> Checking prerequisites on $HOST (target dir: $TARGET_DIR)" local rootfs_remote="$TARGET_DIR/.werkator/$(basename "$ROOTFS")" if ! ssh "$HOST" "WERKATOR_SSH_TARGET='$HOST' bash -s -- '$TARGET_DIR' '$rootfs_remote'" < "$PREREQ_SCRIPT"; then - echo "ERROR: prerequisites failed on $HOST — install aborted" >&2 - exit 1 + die "prerequisites failed on $HOST — install aborted" fi } -ensure_local_artifacts() { +# The instance artifacts are built locally (ADR 0006): the runtime bundle via +# Gradle, the werkdock binary via the Go toolchain. Both are rebuilt when +# missing, never on the target. +ensure_instance_artifacts() { if [ ! -f "$RUNTIME_BUNDLE" ]; then echo "==> Runtime bundle not found; building it locally (./gradlew runtimeBundle)" (cd "$REPO_ROOT" && ./gradlew runtimeBundle --console=plain -q) fi - [ -f "$RUNTIME_BUNDLE" ] || { echo "ERROR: runtime bundle missing: $RUNTIME_BUNDLE" >&2; exit 1; } - [ -f "$ROOTFS" ] || { - echo "ERROR: rootfs archive missing: $ROOTFS" >&2 - echo " build it with tools/build-bwrap-rootfs.sh or set WERKATOR_ROOTFS" >&2 - exit 1 - } -} - -ensure_github_access() { - # `ssh -T git@github.com` exits 1 even on success ("does not provide shell - # access") — neutralize remotely, then match on the greeting text. - if ssh "$HOST" 'ssh -o BatchMode=yes -o ConnectTimeout=10 -T git@github.com 2>&1 || true' | grep -q "successfully authenticated"; then - echo "==> GitHub SSH access from $HOST: ok" - return 0 + [ -f "$RUNTIME_BUNDLE" ] || die "runtime bundle missing: $RUNTIME_BUNDLE" + 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 .) fi - echo - echo "==> The host cannot reach GitHub via SSH yet." - echo " Add THIS public key to GitHub (Settings > SSH and GPG keys > New SSH key):" - ssh "$HOST" 'cat ~/.ssh/id_*.pub 2>/dev/null' || { - echo "ERROR: no public key on the host; create one with ssh-keygen -t ed25519" >&2 - exit 1 - } - read -r -p " Press Enter once the key is registered at GitHub... " - ssh "$HOST" 'ssh -o BatchMode=yes -T git@github.com 2>&1 || true' | grep -q "successfully authenticated" || { - echo "ERROR: GitHub authentication from $HOST still failing" >&2 - exit 1 - } - echo "==> GitHub SSH access from $HOST: ok" + [ -f "$WERKDOCK_BINARY" ] || die "werkdock binary missing: $WERKDOCK_BINARY" } -install() { +# Uploads and unpacks the instance artifacts. The previous runtime stays as +# werkator.prev for one deployment as the rollback asset. +deploy_instance() { + echo "==> Uploading runtime bundle and werkdock binary" + ssh "$HOST" "mkdir -p '$TARGET_DIR/.werkator/bin'" + scp -q "$RUNTIME_BUNDLE" "$HOST:$TARGET_DIR/.werkator/" + scp -q "$WERKDOCK_BINARY" "$HOST:$TARGET_DIR/.werkator/bin/werkdock.new" + echo "==> Unpacking" + ssh "$HOST" "set -e + cd '$TARGET_DIR/.werkator' + mv bin/werkdock.new bin/werkdock && chmod 755 bin/werkdock + rm -rf werkator.prev + [ ! -d werkator ] || mv werkator werkator.prev + tar xzf '$(basename "$RUNTIME_BUNDLE")' + './werkator/bin/werkator' --version + './bin/werkdock' version" +} + +instance_install() { ensure_ssh check_prerequisites - ensure_local_artifacts + ensure_instance_artifacts + deploy_instance + echo + echo "==> Instance installed." + echo " Runtime: $WERKATOR_BIN" + echo " werkdock: $TARGET_DIR/.werkator/bin/werkdock" + echo " Next: tools/remote werkator repo-init, then instance-start" +} - echo "==> Uploading runtime bundle and rootfs archive" - ssh "$HOST" "mkdir -p '$TARGET_DIR/.werkator'" - scp -q "$RUNTIME_BUNDLE" "$HOST:$TARGET_DIR/.werkator/" - scp -q "$ROOTFS" "$HOST:$TARGET_DIR/.werkator/" +# Refuse to swap the runtime under a running build; FORCE=1 overrides. +require_idle() { + local port + port="$(ssh "$HOST" "awk '/^server:/{f=1;next} f && /^ port:/{print \$2; exit}' '$MACHINE_CONFIG' 2>/dev/null" || true)" + [ -n "$port" ] || return 0 + local current + current="$(ssh "$HOST" "curl -s --max-time 5 http://127.0.0.1:$port/api/builds/current" || true)" + if [ -n "$current" ] && [ "$current" != "[]" ]; then + [ "${FORCE:-}" = "1" ] || die "a build is running on $HOST — retry when idle, or FORCE=1 to override" + echo "==> WARNING: deploying although a build is running (FORCE=1)" + fi +} - echo "==> Unpacking runtime bundle" - ssh "$HOST" "tar xzf '$TARGET_DIR/.werkator/$(basename "$RUNTIME_BUNDLE")' -C '$TARGET_DIR/.werkator'" - ssh "$HOST" "'$TARGET_DIR/.werkator/werkator/bin/werkator' --version" +instance_update() { + ensure_ssh + ensure_instance_artifacts + require_idle + local was_active=0 + if ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user is-active --quiet '$UNIT'"; then + was_active=1 + fi + if [ "$was_active" = "1" ]; then + echo "==> Stopping $UNIT" + ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user stop '$UNIT'" + fi + deploy_instance + if [ "$was_active" = "1" ]; then + echo "==> Starting $UNIT" + ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user start '$UNIT' && sleep 3 && systemctl --user is-active '$UNIT'" + else + echo "==> Service was not running; not started (use instance-start for the first start)" + fi + echo "==> Instance updated." +} - ensure_github_access +# Sets up the WATCHED repository: an anonymous https clone (a private origin +# gets its credentials via git.account/git.token in the machine config that +# `werkator init` creates), the werkator init, the rootfs archive for the +# sandbox builds, and the machine-local bwrap configuration. +repo_init() { + ensure_ssh + [ -f "$ROOTFS" ] || die "rootfs archive missing: $ROOTFS — build it with tools/build-bwrap-rootfs.sh or set WERKATOR_ROOTFS" + ssh "$HOST" "test -x '$WERKATOR_BIN'" || die "no instance on $HOST — run instance-install first" - echo "==> Cloning the repository" + echo "==> Cloning the watched repository" if ssh "$HOST" "test -d '$TARGET_DIR/werkator/.git'"; then echo " (already cloned, skipping)" else - ssh "$HOST" "git clone git@github.com:mhoennig/werkator.git '$TARGET_DIR/werkator'" + ssh "$HOST" "git clone '$REPO_URL' '$TARGET_DIR/werkator'" fi echo "==> Running werkator init" - ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$TARGET_DIR/.werkator/werkator/bin/werkator' init" + ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' init" - echo "==> Writing machine-local bwrap configuration" - ssh "$HOST" "grep -q '^ bwrap:' '$TARGET_DIR/werkator/.git/werkator/.werkator.yml' 2>/dev/null" || ssh "$HOST" "cat >> '$TARGET_DIR/werkator/.git/werkator/.werkator.yml' <<'CFG' + echo "==> Uploading the rootfs archive (skipped when unchanged)" + local rootfs_remote="$TARGET_DIR/.werkator/$(basename "$ROOTFS")" + local local_sha remote_sha + local_sha="$(sha256sum "$ROOTFS" | cut -d' ' -f1)" + remote_sha="$(ssh "$HOST" "sha256sum '$rootfs_remote' 2>/dev/null | cut -d' ' -f1" || true)" + if [ "$local_sha" = "$remote_sha" ]; then + echo " (already on the host, skipping)" + else + scp -q "$ROOTFS" "$HOST:$rootfs_remote" + remote_sha="$(ssh "$HOST" "sha256sum '$rootfs_remote' | cut -d' ' -f1")" + [ "$local_sha" = "$remote_sha" ] || die "rootfs upload checksum mismatch" + fi -# Build in the bubblewrap sandbox instead of natively (Step 17 / ADR 0007). -# Both keys are pinned: read from this machine config even if a branch sets -# its own values in a committed .werkator.yml. + echo "==> Writing the machine-local bwrap configuration" + # NOTE: the guard must match the block's real indentation — a mismatch here + # once appended the block on every run. + if ssh "$HOST" "grep -q '^ bwrap:' '$MACHINE_CONFIG' 2>/dev/null"; then + echo " (bwrap block present, skipping)" + else + ssh "$HOST" "cat >> '$MACHINE_CONFIG' <<'CFG' + +# Build in the bubblewrap sandbox instead of natively (ADR 0008), executed by +# the werkdock CLI (step 21 session C). All three keys are pinned: read from +# this machine config even if a branch sets its own values. builds: default: bwrap: enabled: true - rootfs: $TARGET_DIR/.werkator/$(basename "$ROOTFS") + rootfs: $rootfs_remote + werkdock: $TARGET_DIR/.werkator/bin/werkdock CFG" + fi echo "==> Verifying the effective configuration" - ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$TARGET_DIR/.werkator/werkator/bin/werkator' config:print 2>/dev/null | grep -A3 'bwrap:' | head -4" + ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' config:print 2>/dev/null | grep -A4 'bwrap:' | head -5" echo - echo "==> Install complete." - echo " Repo: $TARGET_DIR/werkator" - echo " Runtime: $TARGET_DIR/.werkator/werkator/bin/werkator" - echo " Next: tools/remote werkator build" -} - -build() { - ensure_ssh - local branch="${WERKATOR_BRANCH:-main}" - echo "==> Running one initial build of branch '$branch' on $HOST (in the bwrap sandbox)" - ssh -t "$HOST" "cd '$TARGET_DIR/werkator' && '$TARGET_DIR/.werkator/werkator/bin/werkator' build '$branch'" + echo "==> Repository ready." + echo " Repo: $TARGET_DIR/werkator" + echo " Next: fill git.account/git.token in $MACHINE_CONFIG if the origin is private," + echo " then tools/remote werkator instance-start" } # Start the server as a systemd user unit behind the managed Apache. # WERKATOR_MEMORY_MAX / WERKATOR_TASKS_MAX (optional) are written into the # machine config so `init --systemd` bakes them into the unit. -start() { +instance_start() { ensure_ssh require_env WERKATOR_PORT WERKATOR_DOMAIN - local machine="$TARGET_DIR/werkator/.git/werkator/.werkator.yml" - local unit="werkator-$(basename "$TARGET_DIR/werkator").service" local htaccess="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/.htaccess" echo "==> Writing server settings to the machine config" - if ssh "$HOST" "grep -q '^server:' '$machine' 2>/dev/null"; then + if ssh "$HOST" "grep -q '^server:' '$MACHINE_CONFIG' 2>/dev/null"; then # re-run: update port and publicBaseUrl in place (systemd limits stay as written) - ssh "$HOST" "sed -i 's/^ port: .*/ port: $WERKATOR_PORT/; s|^ publicBaseUrl: .*| publicBaseUrl: \"https://$WERKATOR_DOMAIN/\"|' '$machine'" + ssh "$HOST" "sed -i 's/^ port: .*/ port: $WERKATOR_PORT/; s|^ publicBaseUrl: .*| publicBaseUrl: \"https://$WERKATOR_DOMAIN/\"|' '$MACHINE_CONFIG'" else - ssh "$HOST" "cat >> '$machine' <<'CFG' + ssh "$HOST" "cat >> '$MACHINE_CONFIG' <<'CFG' # Web access: the managed Apache terminates TLS and proxies to the localhost # port assigned by Hostsharing (eigener Serverdienst); TLS is the domain's @@ -246,18 +301,20 @@ RewriteRule .* http://127.0.0.1:$WERKATOR_PORT%{REQUEST_URI} [proxy] HT" echo "==> Generating the systemd user unit (init --systemd)" - ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$TARGET_DIR/.werkator/werkator/bin/werkator' init --systemd" + ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' init --systemd" echo "==> Linking the units into ~/.config/systemd/user and enabling the service" ssh "$HOST" "mkdir -p ~/.config/systemd/user && \ - ln -sf '$TARGET_DIR/werkator/.git/werkator/$unit' ~/.config/systemd/user/ && \ + ln -sf '$TARGET_DIR/werkator/.git/werkator/$UNIT' ~/.config/systemd/user/ && \ ln -sf '$TARGET_DIR/werkator/.git/werkator/werkator-docker-prune.service' ~/.config/systemd/user/ && \ ln -sf '$TARGET_DIR/werkator/.git/werkator/werkator-docker-prune.timer' ~/.config/systemd/user/ && \ - systemctl --user daemon-reload && systemctl --user restart '$unit' && systemctl --user status '$unit' --no-pager -l | head -12" + XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user daemon-reload && \ + XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user restart '$UNIT' && \ + XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user status '$UNIT' --no-pager -l | head -12" echo echo "==> Server started. Verify: https://$WERKATOR_DOMAIN/" - echo " Logs: ssh $HOST -- systemctl --user status '$unit'" + echo " Logs: ssh $HOST -- systemctl --user status '$UNIT'" } # Background SSH tunnel to the Werkator server, so the browser reaches the UI @@ -266,8 +323,8 @@ HT" port_forward() { require_env WERKATOR_LOCAL_PORT local remote_port - remote_port="$(ssh "$HOST" "awk '/^server:/{f=1;next} f && /^ port:/{print \$2; exit}' '$TARGET_DIR/werkator/.git/werkator/.werkator.yml'")" - [ -n "$remote_port" ] || { echo "ERROR: no server.port in the machine config — run 'tools/remote werkator start' first" >&2; exit 1; } + remote_port="$(ssh "$HOST" "awk '/^server:/{f=1;next} f && /^ port:/{print \$2; exit}' '$MACHINE_CONFIG'")" + [ -n "$remote_port" ] || die "no server.port in the machine config — run 'tools/remote werkator instance-start' first" case "$COMMAND" in start) @@ -324,18 +381,30 @@ case "$REPO" in ensure_ssh check_prerequisites ;; - install) - install + instance-install) + instance_install ;; - build) - build + instance-update) + instance_update ;; - start) - start + instance-start) + instance_start + ;; + repo-init) + repo_init ;; control-token) control_token ;; + install) + die "'install' was the self-build prototype; use instance-install + repo-init (step 21 session D)" + ;; + build) + die "'build' (the self-build) is retired; the instance builds pushes itself, or run '$WERKATOR_BIN build ' on the host" + ;; + start) + die "'start' is now 'instance-start' — commands name their role (builder vs built)" + ;; *) echo "ERROR: unknown command: $COMMAND" >&2 usage diff --git a/werkdock/internal/cli/cli.go b/werkdock/internal/cli/cli.go index 903e898..e668f9d 100644 --- a/werkdock/internal/cli/cli.go +++ b/werkdock/internal/cli/cli.go @@ -27,6 +27,8 @@ func Main(args []string) int { 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": @@ -49,11 +51,13 @@ 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, applied in order) + -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) diff --git a/werkdock/internal/cli/images.go b/werkdock/internal/cli/images.go new file mode 100644 index 0000000..9d8b944 --- /dev/null +++ b/werkdock/internal/cli/images.go @@ -0,0 +1,35 @@ +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/run.go b/werkdock/internal/cli/run.go index 8168db6..50cb441 100644 --- a/werkdock/internal/cli/run.go +++ b/werkdock/internal/cli/run.go @@ -16,7 +16,7 @@ import ( // runOptions is the parsed form of `werkdock run` flags, separated from // execution so the parsing is testable and a later daemon can reuse it. type runOptions struct { - Volumes []engine.Bind + Mounts []engine.Mount Env []engine.EnvVar Workdir string Remove bool @@ -39,7 +39,7 @@ func runCmd(args []string) int { } spec := engine.RunSpec{ RootFS: rootfs, - Binds: hostBinds(opts.Volumes), + Mounts: hostMounts(opts.Mounts), Env: opts.Env, Workdir: opts.Workdir, Command: opts.Command, @@ -52,15 +52,15 @@ func runCmd(args []string) int { return code } -// hostBinds prepends the host mounts the contract prescribes: DNS comes +// 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 binds, so an explicit bind over /etc wins. -func hostBinds(volumes []engine.Bind) []engine.Bind { - var binds []engine.Bind +// 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() { - binds = append(binds, engine.Bind{Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf", ReadOnly: true}) + all = append(all, engine.Mount{Mode: engine.MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"}) } - return append(binds, volumes...) + return append(all, mounts...) } // parseRun parses the docker-shaped run flags. Docker flags whose @@ -69,10 +69,16 @@ func hostBinds(volumes []engine.Bind) []engine.Bind { func parseRun(args []string, getenv func(string) string) (*runOptions, error) { fs := flag.NewFlagSet("run", flag.ContinueOnError) fs.SetOutput(io.Discard) - var volumes, envs stringList + var envs stringList opts := &runOptions{} - fs.Var(&volumes, "v", "bind mount SRC:DEST[:ro]") - fs.Var(&volumes, "volume", "bind mount SRC:DEST[:ro]") + // -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") @@ -101,13 +107,6 @@ func parseRun(args []string, getenv func(string) string) (*runOptions, error) { } opts.Image = rest[0] opts.Command = rest[1:] - for _, v := range volumes { - bind, err := parseVolume(v) - if err != nil { - return nil, err - } - opts.Volumes = append(opts.Volumes, bind) - } for _, e := range envs { opts.Env = append(opts.Env, parseEnv(e, getenv)) } @@ -117,25 +116,54 @@ func parseRun(args []string, getenv func(string) string) (*runOptions, error) { return opts, nil } -func parseVolume(v string) (engine.Bind, error) { +func parseVolume(v string) (engine.Mount, error) { parts := strings.Split(v, ":") if len(parts) < 2 || len(parts) > 3 { - return engine.Bind{}, fmt.Errorf("invalid volume %q, expected SRC:DEST[:ro]", v) + return engine.Mount{}, fmt.Errorf("invalid volume %q, expected SRC:DEST[:ro]", v) } - bind := engine.Bind{Source: parts[0], Dest: parts[1]} + mount := engine.Mount{Mode: engine.MountBind, Source: parts[0], Dest: parts[1]} if len(parts) == 3 { - if parts[2] != "ro" { - return engine.Bind{}, fmt.Errorf("invalid volume option %q in %q, only 'ro' is supported", parts[2], v) + 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) } - bind.ReadOnly = true } - if !filepath.IsAbs(bind.Source) { - return engine.Bind{}, fmt.Errorf("volume source must be an absolute path: %s", bind.Source) + if !filepath.IsAbs(mount.Source) { + return engine.Mount{}, fmt.Errorf("volume source must be an absolute path: %s", mount.Source) } - if !filepath.IsAbs(bind.Dest) { - return engine.Bind{}, fmt.Errorf("volume destination must be an absolute path: %s", bind.Dest) + if !filepath.IsAbs(mount.Dest) { + return engine.Mount{}, fmt.Errorf("volume destination must be an absolute path: %s", mount.Dest) } - return bind, nil + 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 { diff --git a/werkdock/internal/cli/run_test.go b/werkdock/internal/cli/run_test.go index d4364cd..d8d26b6 100644 --- a/werkdock/internal/cli/run_test.go +++ b/werkdock/internal/cli/run_test.go @@ -28,12 +28,12 @@ func TestParseRunSupportedFlags(t *testing.T) { if !reflect.DeepEqual(opts.Command, []string{"sh", "-c", "./gradlew build"}) { t.Errorf("command: got %q", opts.Command) } - wantVolumes := []engine.Bind{ - {Source: "/repo", Dest: "/repo"}, - {Source: "/cache", Dest: "/root/.gradle", ReadOnly: true}, + wantMounts := []engine.Mount{ + {Mode: engine.MountBind, Source: "/repo", Dest: "/repo"}, + {Mode: engine.MountRoBind, Source: "/cache", Dest: "/root/.gradle"}, } - if !reflect.DeepEqual(opts.Volumes, wantVolumes) { - t.Errorf("volumes: got %+v", opts.Volumes) + 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) @@ -96,7 +96,8 @@ func TestParseRunValidation(t *testing.T) { {"no image", []string{"--rm"}, "no image specified"}, {"no command", []string{"--rm", "img"}, "no command specified"}, {"volume without dest", []string{"--rm", "-v", "/only-src", "img", "true"}, "expected SRC:DEST"}, - {"volume with bad option", []string{"--rm", "-v", "/a:/b:rw", "img", "true"}, "only 'ro' is supported"}, + {"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"}, @@ -111,6 +112,39 @@ func TestParseRunValidation(t *testing.T) { } } +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. diff --git a/werkdock/internal/engine/bwrap.go b/werkdock/internal/engine/bwrap.go index 2f278f5..7d47d0e 100644 --- a/werkdock/internal/engine/bwrap.go +++ b/werkdock/internal/engine/bwrap.go @@ -65,15 +65,20 @@ func (b *Bwrap) Argv(spec RunSpec) ([]string, error) { "--tmpfs", "/tmp", "--tmpfs", "/root", } - for _, bd := range spec.Binds { - if !filepath.IsAbs(bd.Dest) { - return nil, fmt.Errorf("bind destination must be an absolute path: %s", bd.Dest) + for _, m := range spec.Mounts { + if !filepath.IsAbs(m.Dest) { + return nil, fmt.Errorf("mount destination must be an absolute path: %s", m.Dest) } - flag := "--bind" - if bd.ReadOnly { - flag = "--ro-bind" + 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, flag, bd.Source, bd.Dest) } args = append(args, "--clearenv", @@ -108,17 +113,23 @@ func EnsureMountpoints(spec RunSpec) error { return err } } - for _, bd := range spec.Binds { - target, err := rootfsPath(spec.RootFS, bd.Dest) + 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 } - src, err := os.Stat(bd.Source) + 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", bd.Source, err) + return fmt.Errorf("bind source %s: %w", m.Source, err) } if src.Mode().IsRegular() { if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { diff --git a/werkdock/internal/engine/bwrap_test.go b/werkdock/internal/engine/bwrap_test.go index 0a18d91..256230a 100644 --- a/werkdock/internal/engine/bwrap_test.go +++ b/werkdock/internal/engine/bwrap_test.go @@ -14,10 +14,12 @@ func TestArgvAssemblesTheHardenedInvocation(t *testing.T) { b := &Bwrap{} spec := RunSpec{ RootFS: "/store/images/buildenv/rootfs", - Binds: []Bind{ - {Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf", ReadOnly: true}, - {Source: "/repo", Dest: "/repo"}, - {Source: "/cache", Dest: "/root/.gradle"}, + 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", @@ -34,6 +36,8 @@ func TestArgvAssemblesTheHardenedInvocation(t *testing.T) { "--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", @@ -59,8 +63,8 @@ func TestArgvValidation(t *testing.T) { {"relative rootfs", RunSpec{RootFS: "rootfs", Command: []string{"true"}}, "absolute"}, {"missing command", RunSpec{RootFS: "/r"}, "no command specified"}, { - "relative bind dest", - RunSpec{RootFS: "/r", Binds: []Bind{{Source: "/s", Dest: "work"}}, Command: []string{"true"}}, + "relative mount dest", + RunSpec{RootFS: "/r", Mounts: []Mount{{Mode: MountBind, Source: "/s", Dest: "work"}}, Command: []string{"true"}}, "absolute", }, } @@ -103,17 +107,18 @@ func TestEnsureMountpointsCreatesMissingAndSkipsExisting(t *testing.T) { } spec := RunSpec{ RootFS: rootfs, - Binds: []Bind{ - {Source: "/etc", Dest: "/etc/resolv.conf", ReadOnly: true}, // exists: skipped (source type irrelevant) - {Source: srcDir, Dest: "/repo/workspace"}, // missing dir mountpoint - {Source: srcFile, Dest: "/etc/hosts.werkdock"}, // missing file mountpoint + 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"} { + 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) @@ -132,7 +137,7 @@ func TestEnsureMountpointsCreatesMissingAndSkipsExisting(t *testing.T) { func TestEnsureMountpointsRefusesEscapingDestinations(t *testing.T) { spec := RunSpec{ RootFS: t.TempDir(), - Binds: []Bind{{Source: "/tmp", Dest: "/../outside"}}, + Mounts: []Mount{{Mode: MountBind, Source: "/tmp", Dest: "/../outside"}}, Command: []string{"true"}, } err := EnsureMountpoints(spec) diff --git a/werkdock/internal/engine/engine.go b/werkdock/internal/engine/engine.go index 5763509..323f0f6 100644 --- a/werkdock/internal/engine/engine.go +++ b/werkdock/internal/engine/engine.go @@ -3,12 +3,25 @@ // logic without duplicating it (RFC 0002). package engine -// Bind is one bind mount, applied in order; later mounts shadow earlier -// ones at their own path, exactly as bwrap layers them. -type Bind struct { - Source string - Dest string - ReadOnly bool +// 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. @@ -23,7 +36,7 @@ type RunSpec struct { // RootFS is the absolute path to the unpacked image rootfs, // bound read-only at /. RootFS string - Binds []Bind + Mounts []Mount Env []EnvVar Workdir string Command []string diff --git a/werkdock/internal/store/store.go b/werkdock/internal/store/store.go index 871afa2..77efa66 100644 --- a/werkdock/internal/store/store.go +++ b/werkdock/internal/store/store.go @@ -11,6 +11,7 @@ import ( "os/exec" "path/filepath" "regexp" + "sort" "strings" "time" ) @@ -57,6 +58,26 @@ func (s Store) RootFS(name string) (string, error) { 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 @@ -65,6 +86,11 @@ 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 diff --git a/werkdock/internal/store/store_test.go b/werkdock/internal/store/store_test.go index 4ff871e..759d493 100644 --- a/werkdock/internal/store/store_test.go +++ b/werkdock/internal/store/store_test.go @@ -115,6 +115,25 @@ func TestRootFSValidation(t *testing.T) { } } +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"},