Author SHA1 Message Date
mhoennigandClaude Fable 5.1 ec07269cca docs(prs): refer to werkdock's change, not its branch
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:29:55 +02:00
mhoennigandClaude Fable 5.1 b8600bb556 werkdock: import the rootfs archive, fall back to load -i on an older werkdock
Werkdock's docker-compat branch names the verb for rootfs archives
'import' (docker import semantics) and keeps 'load -i --name' only as
a compatibility path. Werkator now runs 'werkdock import ARCHIVE IMAGE'
and falls back to 'load -i --name' on exactly the unknown-verb
signature (exit 125, 'unknown command' on stderr), so Werkator and
werkdock can be updated in either order. Any other import failure
propagates as before. The image name stays untagged: it equals
werkator-buildenv-<hash>:latest in werkdock's naming, and the existing
store needs no re-import. PR-doc under docs/prs/ with the PR#000
placeholder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:49:46 +02:00
mhoennig 0f9f119687 init: keep dots in detected repository names 2026-09-05 15:15:12 +02:00
miandmhoennig 5c64391f5f Feature/remote authenticated clone (#24)
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #24
2026-09-05 14:06:58 +02:00
mhoennigandClaude Opus 5 cd6f915231 init reads its configuration from the repository root, and says so when it cannot (#22)
`init --systemd` generated the systemd unit, the .htaccess and the maintenance
page from a configuration it never actually read reliably: the load swallowed
every exception and fell back to a default `ServerConfig`, and it resolved the
layers from the process's current directory instead of the git top level every
other file of the command goes through.

A broken `.werkator.yml` was therefore indistinguishable from an unconfigured
`publicBaseUrl` — the host integration was skipped without a word — and running
`init` from a subdirectory read a foreign configuration or none, which also
defeated `--apply`'s promise that the fragment reaches the generated unit.

The fallback stays, but the exception message is printed, once per run, and the
repository root is passed down like every other caller of `ConfigLoader.load`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:38:41 +02:00
092183ca30 Clone the watched repository from Gitea by default (#21)
The repository moved to git.javagil.de, but repo-init still defaulted to
the GitHub mirror, which is no longer updated.
A freshly cloned instance therefore watched a stale main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #21
2026-09-04 13:33:08 +02:00
mhoennig 0761a274db Merge main into the deflake-maxconcurrent-test branch 2026-09-04 12:46:25 +02:00
mhoennigandClaude Opus 5 5c73c4cc21 docs(prs): the deflake-maxconcurrent-test PR-doc
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 08:53:00 +02:00
16543f038b the sandbox config section is werkdock, not bwrap (#19)
`bwrap` named the mechanism one layer below the tool that actually runs it: since
v1.0.0 Werkator does not invoke bwrap at all, it shells out to the werkdock CLI —
which made `bwrap.werkdock` a key naming its own executor.

The section is `werkdock` now and that key is `werkdock.binary`; BwrapConfig,
BwrapOverrides and BwrapBuildRunner follow the name. A file still writing `bwrap`
is read as before and warned about once per file, in `renameLegacySandbox` on the
raw map of every layer before merging — so nothing downstream knows two names, and
the old name is not a way around the pinning either. Renaming rather than refusing,
because the section lives in the machine configuration of every webspace instance,
which no repository tracks; the hard refusal belongs to the release that sets
ConfigVersions.FORMAT_BROKE_IN, where a file declaring no version can be caught
by name at all.

WERKATOR_SANDBOX in tools/remote follows, and still accepts `bwrap`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #19
2026-09-03 20:41:18 +02:00
3ccc901d1b tools/remote drives any host layout (#18)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #18
2026-09-03 20:37:58 +02:00
mhoennigandClaude Fable 5.1 11f6f9bd26 docs(rfcs): RFC 0001 proposes the Instrument Panel web UI redesign
Six explored directions, the chosen one (E) worked out in a light and a dark
teal palette, with a repository strip previewing every served repository,
the ten-repository and the phone case, and the backend it needs (/api/repos).
Renderings of all artboards live next to the RFC; AGENTS.md registers docs/rfcs/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:15:04 +02:00
mhoennigandClaude Opus 5 9221550a2a test(build): gate the maxConcurrent-1 test instead of racing a sleep
The build of branch-a slept one second while the test asserted, without
any synchronization, that branch-b was still PENDING.
Under CPU contention on the mih09 host the sleep could elapse first, so
branch-b was already RUNNING or SUCCESS when the assertion ran.
branch-a now blocks until the test creates a gate file, and the test
first waits for branch-a to be RUNNING; the PENDING assertion no longer
depends on timing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:32:22 +02:00
38 changed files with 1356 additions and 225 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
---
name: architecture
description: Detailed Werkator subsystem architecture — CLI wiring and exit codes, server mode, web UI, configuration system, git access, build execution (native, Docker, and bwrap), watcher poll cycle, and system metrics. Use when designing or modifying code in the commands, config, git, gitea, build, artifacts, watcher, metrics, or server packages, or when a question goes beyond the overview in AGENTS.md.
description: Detailed Werkator subsystem architecture — CLI wiring and exit codes, server mode, web UI, configuration system, git access, build execution (native, Docker, and the werkdock sandbox), watcher poll cycle, and system metrics. Use when designing or modifying code in the commands, config, git, gitea, build, artifacts, watcher, metrics, or server packages, or when a question goes beyond the overview in AGENTS.md.
---
# Werkator Architecture
@@ -49,7 +49,7 @@ Werkator is configured by three YAML files, deep-merged by `ConfigLoader` (later
Every lookup falls back to the pre-rename name (`ConfigFiles`): `.gittally.yml`, and `.git/gittally/.gittally.yml` for the machine layer. Current name first, and where both exist the old one is ignored rather than merged — a missing config is not an error, so an un-renamed installation would otherwise start on defaults without a single failure.
On top of those comes the **branch layer**: the `.werkator.yml` committed on a branch, applied by `loadWithBranchLayer` (the watcher passes the content read via `git show`, `loadForWorktree` the file in the build worktree). A branch describes its own CI and wins over both layers — the whole `builds` section — so a configuration can be tried out on a branch without touching other branches' builds. `stripPinned` removes what is not a description of this branch's build: `git`, `server`, `gitea`, `executor`, `watcher`, and — inside every `builds` definition as well as every legacy `branches` entry — `requirePullRequest`, `statusContext`, `docker.enabled`/`docker.network`, and `bwrap.enabled`/`bwrap.rootfs`/`bwrap.werkdock`.
On top of those comes the **branch layer**: the `.werkator.yml` committed on a branch, applied by `loadWithBranchLayer` (the watcher passes the content read via `git show`, `loadForWorktree` the file in the build worktree). A branch describes its own CI and wins over both layers — the whole `builds` section — so a configuration can be tried out on a branch without touching other branches' builds. `stripPinned` removes what is not a description of this branch's build: `git`, `server`, `gitea`, `executor`, `watcher`, and — inside every `builds` definition as well as every legacy `branches` entry — `requirePullRequest`, `statusContext`, `docker.enabled`/`docker.network`, and `werkdock.enabled`/`werkdock.rootfs`/`werkdock.binary` (the section was called `bwrap` until v1.2.0; `renameLegacySandbox` maps the old name onto the new one on every raw layer, before merging).
Each file is version-checked before merging (`werkator.version.since`/`below`, `ConfigVersions.verdict`), so the message can name the file to fix: `since` is hard in both directions — too old a Werkator, or a file written before `ConfigVersions.FORMAT_BROKE_IN` and read after it — while `below` only warns. There is no format version (`apiVersion`) on purpose: only one configuration generation is supported, and the declared version exists to make the incompatibility nameable.
@@ -71,9 +71,9 @@ Everything repository-scoped goes through a `RepoContext` (`repo` package, ADR 0
On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`).
The runtime is selected per 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.
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 `WerkdockBuildRunner` when `werkdock.enabled` — docker and werkdock are mutually exclusive per build and rejected in `buildSettings`, never picked silently. The Docker runner shells out to the `docker` CLI (no SDK): it (re)builds the configured image when the Dockerfile inputs changed (tracked via the `org.werkator.build-inputs-sha256` image label), maintains a per-repo Gradle cache volume, mounts the worktree and the Docker socket into a labelled (`org.hoennig.werkator`) `--rm --init` container, and repairs workspace ownership in-container after each command (under a rootless daemon the container runs as root, which is the host user, and the repair degenerates to `0:0`). Git works inside the container: the primary `.git` is mounted read-only with `.git/werkator/` masked by an empty tmpfs (credential isolation) and the worktree's admin dir mounted read-write (`gitMetadataMounts`). The returned `Process` is the attached `docker run` client, so log streaming and termination work exactly like native builds.
`BwrapBuildRunner` (ADR 0008) is the third runtime, for hosts without root and without Docker — Hostsharing Managed Webspaces. It shells out to the `bwrap` CLI (no library): a prepared rootfs archive (`bwrap.rootfs`, built by `tools/build-bwrap-rootfs.sh`) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`, with uid 0 inside mapped to the calling user; isolation is filesystem-only — network, uid, `/proc`, `/dev` are the host's by contract. It reuses the Docker runner's `gitMetadataMounts`; mount order matters (repo dir read-write before the metadata mounts and the workspace), and bind mountpoints missing from the rootfs are pre-created there, since the rootfs is a plain host directory while bwrap cannot mkdir against the read-only sandbox root. `bwrap.enabled`/`bwrap.rootfs` are pinned like the docker sandbox policy. The returned `Process` is the attached `bwrap` process, so streaming and cancellation are unchanged. 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.
`WerkdockBuildRunner` (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 (`werkdock.rootfs`, built by `tools/build-bwrap-rootfs.sh`) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`, with uid 0 inside mapped to the calling user; isolation is filesystem-only — network, uid, `/proc`, `/dev` are the host's by contract. It reuses the Docker runner's `gitMetadataMounts`; mount order matters (repo dir read-write before the metadata mounts and the workspace), and bind mountpoints missing from the rootfs are pre-created there, since the rootfs is a plain host directory while bwrap cannot mkdir against the read-only sandbox root. `werkdock.enabled`/`werkdock.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
+3 -2
View File
@@ -40,9 +40,9 @@ All production code lives under `de.hoennig.werkator`, with sub-packages `comman
- Everything repository-scoped (results, artifacts, worktrees, git and config access) goes through a `RepoContext`, never through an implicit current directory: the executor serializes per (context, branch) under one global `maxConcurrent`, the watcher polls every context in its own guard. `RepoRegistry` opens one context per entry of the instance configuration `~/.werkator.yml` (ADR 0009), or the current directory without one; the instance-level keys (`server`, `executor`, `watcher.pollInterval`) and the `defaults` block are folded into every repository's effective config by `ConfigLoader` itself, so no consumer reads the home file directly. Server routes carry the repository as `/repos/<name>/…` and `/api/repos/<name>/…`, with the unscoped form permanently meaning the served repository; the pages stay per repository and a drop-down in the page title switches between them.
- 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`, `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 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 werkdock (`werkdock.enabled`, `werkdock.rootfs`, `werkdock.binary`) 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`, `bwrap.rootfs`, and `bwrap.werkdock`. 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`, `werkdock.enabled`, `werkdock.rootfs`, and `werkdock.binary`. Docker and werkdock are mutually exclusive per branch — enabling both is rejected at start. The section was called `bwrap` until v1.2.0 and is still read under that name, with a warning; the hard refusal waits for the release that sets `ConfigVersions.FORMAT_BROKE_IN`.
- `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.
@@ -68,6 +68,7 @@ Keep sentences short.
- `docs/deployment.md` — running Werkator as a systemd user service behind an existing reverse proxy (`init --systemd` generates the unit).
- `docs/werkator-migrationsplan.md` — renaming a running installation from GitTally to Werkator: what the name fallback covers and what has to be moved by hand.
- `docs/plan/` — the step-by-step rewrite plan; `docs/plan/README.md` explains how to execute a step, `docs/plan/00-legacy-analysis.md` summarizes the legacy bash script.
- `docs/rfcs/` — requests for comments: proposals that are larger than one PR and not yet a decision (an accepted RFC becomes an ADR or a plan step).
- `docs/prs/` — one document per pull request; every PR needs one. IMPORTANT: Before opening or finishing a pull request, load the [pr-doc skill](.claude/skills/pr-doc/SKILL.md) and write the PR-doc.
## Key Architectural Decisions
+22
View File
@@ -8,6 +8,28 @@ Lightweight, declarative and highly opinionated software build system (CI/CD).
- [docs/bootstrapping.md](docs/bootstrapping.md) — initializing a repository with `init`
- [docs/deployment.md](docs/deployment.md) — running Werkator as a systemd service behind a reverse proxy
## Adding a Gitea Repository
One instance serves several repositories (`docs/deployment.md`, ADR 0009).
From the workstation, clone and initialise, then register:
```bash
tools/remote --env-file .env.<instance> werkator repo-add https://gitea.example.org/<owner>/<repo>.git [<name>]
```
It prints the registry entry: add it to `~/.werkator.yml` under `repositories:`, then restart the service.
The optional `[<name>]` overrides the directory basename: it becomes the route segment (`/repos/<name>/…`) and the UI switcher entry, so it must be unique.
Needed only when the clone directory name is wrong or collides — e.g. `michael.hoennig.de.git` checked out as `michael.hoennig.de`, or two forges serving a repo of the same name.
A **public** repository needs nothing else: the clone runs anonymously.
A **private** repository needs shared credentials once on the host, in `~/.werkator.yml` of the service user, before cloning:
```yaml
defaults:
git:
account: <gitea-user>
token: <token> # Gitea → Settings → Applications → Generate Token, scope read:repository
```
## Developer Setup
Source `.envrc` to add `tools/` to your `PATH`, or install [direnv](#direnv) to have this done automatically on `cd`:
+1 -1
View File
@@ -13,7 +13,7 @@ group = "de.hoennig"
// so the UI footer (BuildProperties), --version and the release notes identify what is
// actually running; a deployment bundles whatever was committed since the last one.
// ReleaseVersionConsistencyTest fails the build if this and the top releases.html entry disagree.
version = "1.1.2"
version = "1.2.0"
java {
toolchain {
+14 -11
View File
@@ -71,7 +71,7 @@ above, giving the precedence **branch > repo install > project**. It takes prece
everything that describes how this branch is built: the whole `builds` section — its own
definitions and its overrides of the definitions from the project config, with
`buildCommand`, `cleanCommand`, `artifactDirs`, log file names, and
`docker.image`/`dockerfile`/`context`/`env` and `bwrap.env` inside them. That is how a new configuration is tried out: change it on a branch, and
`docker.image`/`dockerfile`/`context`/`env` and `werkdock.env` inside them. That is how a new configuration is tried out: change it on a branch, and
no other branch's builds are affected.
The branch layer is used in both places where it matters: the watcher reads the committed
@@ -99,7 +99,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`/`bwrap.werkdock` — host-pinned as
`werkdock.enabled`/`werkdock.rootfs`/`werkdock.binary` — host-pinned as
long as only the host's configuration sets them, master-pinned once the committed
configuration does.
@@ -406,9 +406,9 @@ That is how a branch gets a build of its own without being built by the default
`activeWithin` (e.g. `24h`) additionally keeps only branches whose origin head commit is younger than the duration — useful to run a nightly deep check over all recently active branches.
Both parts combine as an intersection.
Settings: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, `requirePullRequest`, `statusContext`, and `docker` and `bwrap` with all their keys.
Settings: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, `requirePullRequest`, `statusContext`, and `docker` and `werkdock` 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`, `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.
`requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `werkdock.enabled`, `werkdock.rootfs`, and `werkdock.binary` 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.
@@ -464,22 +464,25 @@ Note that the rest of `.git` — including `.git/config` — is visible to build
The Docker socket is mounted into the container and `DOCKER_HOST`/`TESTCONTAINERS_*` variables are set, so Testcontainers-based builds work inside the container.
All Werkator containers carry `org.hoennig.werkator` labels; stale build containers of the repository are removed before the first Docker build after a restart.
### Notes on `builds.<name>.bwrap`
### Notes on `builds.<name>.werkdock`
With `bwrap.enabled`, Werkator runs the build in a bubblewrap sandbox instead of native execution.
With `werkdock.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.
Since step 21 session C the sandbox is executed by the `werkdock` CLI (`werkdock.binary`, default: resolved via `PATH`) — Werkator no longer invokes `bwrap` itself; `bwrap` must be installed for werkdock.
The section was called `bwrap` and its binary key `bwrap.werkdock` until v1.2.0; both are still read, with a warning naming the file, so an installation can be migrated at its next configuration edit rather than at the next update.
`werkdock doctor` checks the host's capability (it replaced the retired `tools/werkator-build-prerequisites.sh` in step 23).
`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.
`werkdock.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 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-<hash>` 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.
The archive is imported once per source as the werkdock image `werkator-buildenv-<hash>` 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` imports a fresh image and stale ones can be removed from the store.
Werkator uses `werkdock import ARCHIVE IMAGE` (docker import semantics) and falls back to the older `werkdock load -i ARCHIVE --name IMAGE` when the installed werkdock does not know the verb yet, so Werkator and werkdock can be updated in either order.
The image keeps its untagged name (it equals `werkator-buildenv-<hash>:latest` in werkdock's docker-style naming), so an existing store needs no re-import.
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.
`werkdock.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.
`docker` and `werkdock` are mutually exclusive per branch: enabling both is rejected at start, not silently picked.
Git works inside the sandbox exactly as inside the Docker container: the primary `.git` is mounted read-only with `.git/werkator/` masked, so builds can run read-only git commands but never reach the machine config or the control token.
## `.git/werkator/.werkator.yml` (not committed)
+12 -5
View File
@@ -120,9 +120,10 @@ Adding a repository is editing a registry entry — never a data migration, beca
tools/remote --env-file .env.<instance> werkator repo-add https://github.com/<owner>/<repo>.git [<name>]
```
It clones the repository next to the ones already served, runs `init` in it, and **prints** the registry entry.
It does not write `~/.werkator.yml`: that file is the instance's own — port, global concurrency, possibly shared credentials — and a script editing it in place would rewrite the operator's configuration behind their back.
Cloning and initialising is mechanical; registering is a decision.
It clones the repository next to the ones already served, runs `init` in it, and **prints** the registry entry.
It does not write `~/.werkator.yml`: that file is the instance's own — port, global concurrency, possibly shared credentials — and a script editing it in place would rewrite the operator's configuration behind their back.
Cloning and initialising is mechanical; registering is a decision.
A private `https` origin authenticates with the shared `defaults.git.account`/`defaults.git.token` of `~/.werkator.yml` (the token travels via a one-shot `GIT_ASKPASS` on the host, never in a URL or process list); enter those once before cloning a private repository — without them only public origins clone.
4. **Restart** the service; startup recovery re-enqueues what was in flight:
@@ -184,6 +185,11 @@ ssh <user>@<host>
The tarball unpacks to a `werkator/` directory, so it must not be extracted over `~/opt` directly — unpack it in `/tmp` and move it into place, as above.
Rollback is the reverse: stop, remove the new directory (or jar), move `.bak` back, start.
`tools/remote --env-file .env.<instance> werkator instance-update` does the same sequence for any host, not only the webspace layout it was written for.
Three optional keys in the env file name what differs (see the script's header): `WERKATOR_REPO_DIR` (directory of the watched repository, which also names the systemd unit), `WERKATOR_INSTALL_DIR` (where the runtime bundle is unpacked), and `WERKATOR_SANDBOX` (`werkdock`, the default, or `docker` — a Docker host has no werkdock binary and no rootfs archive to upload).
Their defaults are the layout `instance-install` creates, so an env file that names none of them behaves exactly as before.
The upload happens before the service is stopped and every artifact is checksum-verified after the transfer, so a dropped connection costs the transfer and not the running service.
Then check `https://<public-url>/` for the new version in the footer, and `journalctl --user -u werkator-<repo-name>.service -n 50` for a clean start.
Config file changes are not needed for an update; new keys take their defaults.
@@ -324,7 +330,7 @@ Werkator runs as a systemd *user* service on the assigned localhost port ("eigen
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.
Each instance is a pair of files (step 23): a transport env file selected with `--env-file` (default `.env`), and a YAML fragment in the configuration schema, named by its `WERKATOR_INIT_CONFIG` key and installed remotely via `werkator init --apply` — e.g. `.env.mih34` + `.env.mih34.yml`, both gitignored.
The fragment carries the Werkator configuration (`server.port`, `publicBaseUrl`, systemd limits, `builds.default.bwrap.*`); the env file only says where and how to reach the host.
The fragment carries the Werkator configuration (`server.port`, `publicBaseUrl`, systemd limits, `builds.default.werkdock.*`); the env file only says where and how to reach the host.
```bash
tools/remote --env-file .env.mih34 werkator check-prerequisites # uploads werkdock, runs its doctor
@@ -335,7 +341,8 @@ tools/remote --env-file .env.mih34 port-forward start # browser tunne
```
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.
That is the default, not a requirement: `WERKATOR_REPO_DIR`, `WERKATOR_INSTALL_DIR` and `WERKATOR_SANDBOX` bend it to an installation that predates the script, see [Updating an Existing Installation](#updating-an-existing-installation).
The rootfs archive is imported 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):
@@ -0,0 +1,98 @@
> **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
`tools/remote` grew with the Hostsharing Managed Webspace rollout (steps 21 and 23) and encoded that rollout's layout as if it were the only one.
The watched repository had to be `$WERKATOR_PATH/werkator`, the runtime `$WERKATOR_PATH/.werkator/werkator`, and the systemd unit was spelled out as `werkator-werkator.service`; a werkdock binary and a bwrap rootfs archive were built and uploaded unconditionally.
`vm4006`, the Docker host that has been running Werkator since long before the script existed, matches none of that: its watched repository is `~/hs.hsadmin.ng` (so its unit is `werkator-hs.hsadmin.ng.service`), its runtime lives in `~/opt/werkator`, and it has neither werkdock nor a rootfs because its builds run in Docker.
So that host could only be deployed by hand, and had drifted nine releases behind — the question that started this PR was whether the Werkdock and multi-repo work had broken it, which it had not: what was broken was the deployment tooling.
The second problem surfaced while deploying: `instance-update` stopped the systemd unit and *then* started the upload.
The 66 MB transfer to `vm4006` died with `scp: Connection closed`, leaving the host with no running Werkator and nothing new to start.
## Non-Goals
- `instance-start` for a Docker host: it places a Hostsharing `.htaccess` into a `doms/<domain>/` docroot, which only exists on a Managed Webspace. `vm4006` uses the managed nginx container instead, and keeps its existing units.
- `repo-init` for `vm4006` — the repository has been cloned and configured there for months; only `instance-update` was needed.
- Making the machine configuration of `vm4006` current: it still carries the pre-rename `gitTally:` meta key, which is read by nothing today. Harmless while `ConfigVersions.FORMAT_BROKE_IN` is empty, a trap on the day it is not.
## The Scenarios
### Feature: one deployment command for every host layout
#### Background
- The env file carries transport values only; the three new keys describe *where* things are on the host, not what Werkator does.
- The defaults are exactly the layout `instance-install` creates, so an env file naming none of them resolves as before.
#### Scenario#18.01: A host that predates the script can be deployed with it
So that an installation is not condemned to hand-typed `scp` sequences because it was set up before the tooling existed.
- **Given** an env file with `WERKATOR_REPO_DIR=hs.hsadmin.ng`, `WERKATOR_INSTALL_DIR=/home/tallyman/opt` and `WERKATOR_SANDBOX=docker`
- **When** `tools/remote --env-file .env.vm4006 werkator instance-update` runs
- **Then** it addresses `werkator-hs.hsadmin.ng.service`, unpacks into `~/opt`, and uploads neither a werkdock binary nor a rootfs archive
##### Verified by
- Live on `vm4006` (2026-09-03): `check-prerequisites` reported the Docker daemon instead of running `werkdock doctor`; the update swapped `~/opt/werkator` from v1.0.1 to **v1.1.2**, the unit came up `active`, `/api/watcher` polls without errors, and `/api/system` still reports real disk figures (`diskSource.kind: volume`) rather than the quota shape from PR#16.
#### Scenario#18.02: The webspace hosts are unaffected
So that making the script layout-aware does not break the deployment path that is actually in production.
- **Given** `.env.mih09` and `.env.mih34`, neither naming any of the new keys
- **When** the layout is resolved
- **Then** repository directory, install directory, unit name and sandbox are identical to the hardcoded values they replace
##### Verified by
- Resolution measured for both env files (2026-09-03): `REPO_DIR=$WERKATOR_PATH/werkator`, `INSTALL_DIR=$WERKATOR_PATH/.werkator`, `UNIT=werkator-werkator.service`, `SANDBOX=bwrap`.
- Live on `mih09` (2026-09-03): a full `instance-update` ran through the bwrap path — werkdock uploaded, `werkdock 0.1.0-dev` reported after the swap, service `active`, `https://werkator.javagil.de/` answering 200.
#### Scenario#18.03: A failed transfer does not take the service down
So that a dropped connection costs the upload and nothing else.
- **Given** an instance whose service is running
- **When** the runtime bundle cannot be transferred
- **Then** the service is still running, because the upload happens before the stop, and a partially transferred file is never moved into place
##### Verified by
- The failure itself on `vm4006` (2026-09-03), which is what this scenario is written from: with the old order, `scp: Connection closed` left the unit stopped and the host without Werkator.
- Live on `mih09` (2026-09-03): the bundle already on the host was recognised by its sha256 and skipped, so the upload step cost nothing and the stop followed only after it.
## The Solution
Three optional env keys replace three hardcoded assumptions.
`WERKATOR_REPO_DIR` and `WERKATOR_INSTALL_DIR` are resolved absolute-or-relative-to-`WERKATOR_PATH`, and the unit name is now *derived* from the repository directory the way `SystemdServiceFiles.unitName` derives it (basename, every character outside `[A-Za-z0-9_.-]` replaced by a dash) instead of being spelled out — one rule, in two places, with the script naming the Kotlin function it mirrors.
`WERKATOR_SANDBOX=docker` skips everything bwrap-shaped: no werkdock build, no werkdock upload, no rootfs archive, and `check-prerequisites` asks the Docker daemon instead of running `werkdock doctor`.
`repo-add` clones beside the watched repository rather than into `WERKATOR_PATH`, which is the same directory whenever the default layout is used.
`deploy_instance` is split into `upload_instance_artifacts` and `swap_instance_runtime`, and `instance_update` calls the first *before* stopping the unit.
Every artifact is uploaded to `<name>.part`, compared by sha256 with the local file, and only then moved into place; three attempts, and an unchanged artifact is skipped entirely.
The checksum is not belt-and-braces: a truncated tarball would unpack into a runtime that starts and misbehaves, which is far worse than the failed transfer it came from.
## Open Questions
- **Should `instance-start` learn the Docker-host shape too?** Not answered here. `vm4006` keeps its existing units and its managed nginx; the day it needs regenerating, `init --systemd` on the host is the documented path.
## Additional Changes
- None beyond the feature itself.
## Follow-up work discovered while deploying this
- `https://vm4006.hostsharing.net:8443/` is not reachable from outside the host, while `https://127.0.0.1:8443/` answers 200 and the `werkator-nginx-hs.hsadmin.ng` container publishes both ports. Pre-existing and unrelated to this PR — the host's own firewall, not Werkator.
- The machine configuration on `vm4006` still declares `gitTally: version: since: "0.9.20"`; the current code reads only `werkator:`, so the file's version claim is silently ignored (`werkator.version.since` prints empty). It costs nothing while `ConfigVersions.FORMAT_BROKE_IN` is `""`, and stops protecting that host the moment plan step 18 sets it.
## Prerequisite PRs
- None; it changes only `tools/remote` and the documentation.
## Follow-up PRs
- None planned.
@@ -0,0 +1,98 @@
> **WARNING:** This document describes only the change applied in this PR.
> It may already be outdated once the next PR is merged.
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
## The Problem
The build sandbox for hosts without Docker was configured as `bwrap`, named after the mechanism rather than after the thing Werkator runs.
Since step 21 session C (v1.0.0) Werkator does not invoke `bwrap` at all: it shells out to the [werkdock](https://git.javagil.de/mi/werkdock) CLI, which assembles the bubblewrap invocation and owns the image store.
The name outlived its truth, and the clearest symptom was the key `bwrap.werkdock` — a section naming its own executor.
It also leaked outward.
`tools/remote` gained a `WERKATOR_SANDBOX` key in PR#18 whose value had to be `bwrap` while the very thing it switches on is *uploading the werkdock binary*, and the question that prompted this PR — "is there also `WERKATOR_SANDBOX=werkdock`?" — is one nobody would ask about a name that matched.
## Non-Goals
- Refusing the old name. That belongs to the release which sets `ConfigVersions.FORMAT_BROKE_IN` (plan step 18): only there can a file that declares no version be caught by name at all, and only there is one migration asked of the operator instead of two.
- Renaming `tools/build-bwrap-rootfs.sh` or `docs/plan/17-bwrap-build-runtime.md`. The script really does build a bubblewrap rootfs, and plan documents are historic records.
- Touching ADR 0008, which decided the *runtime* and is a snapshot of that decision.
## The Scenarios
### Feature: the sandbox is named after the tool that runs it
#### Background
- `builds.<name>.werkdock` replaces `builds.<name>.bwrap`, and `werkdock.binary` replaces `bwrap.werkdock`.
- The pinned set is unchanged in meaning: `enabled`, `rootfs` and the binary stay host-pinned, under their new names.
#### Scenario#19.01: A configuration written for the old name keeps working
So that no installation has to be edited before it can be updated — the section lives in machine configurations that no repository tracks.
- **Given** a configuration writing `builds.default.bwrap` with `enabled`, `rootfs`, `werkdock` and `env`
- **When** the configuration is loaded
- **Then** the settings appear as `werkdock.enabled`, `werkdock.rootfs`, `werkdock.binary` and `werkdock.env`, and the file is named once in a warning
##### Verified by
- [ConfigLoaderTest — "the legacy bwrap section is read as werkdock, its werkdock key as binary"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
#### Scenario#19.02: The old name is not a way around the pinning
So that a branch cannot escape its sandbox by writing the section a branch is not allowed to write under its previous name.
- **Given** a host configuration with `werkdock.enabled: true` and a rootfs
- **When** a branch's committed config sets `bwrap.enabled: false` with a foreign rootfs
- **Then** the sandbox stays enabled and the host's rootfs is used
##### Verified by
- [ConfigLoaderTest — "a legacy bwrap section on a branch is pinned exactly like the new name"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
#### Scenario#19.03: The new name behaves exactly as the old one did
So that the rename is a rename, not a change of behavior.
- **Given** configurations using `werkdock` throughout
- **When** builds are dispatched, pinned keys stripped, and both sandboxes enabled at once
- **Then** the werkdock runner is selected, the branch cannot override the pinned keys, and enabling docker and werkdock together is rejected naming both
##### Verified by
- [DispatchingBuildRunnerTest — "runs in the werkdock sandbox when the branch enables it (and not Docker)"](../../src/test/kotlin/de/hoennig/werkator/build/DispatchingBuildRunnerTest.kt)
- [ConfigLoaderTest — "a branch cannot disable its werkdock sandbox …"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt) and "enabling both docker and werkdock on a build is rejected, not picked silently"
- [WerkdockBuildRunnerTest](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerTest.kt), unchanged in substance and renamed with the runner
## The Solution
`BwrapConfig``WerkdockConfig` (field `werkdock``binary`), `BwrapOverrides``WerkdockOverrides`, `BranchConfig.bwrap``.werkdock`, `BwrapBuildRunner``WerkdockBuildRunner`, and `PINNED_BWRAP_KEYS``PINNED_WERKDOCK_KEYS` with `binary` in place of `werkdock`.
The compatibility lives in exactly one function, `ConfigLoader.renameLegacySandbox`, applied in `loadFile` and `parseYaml` — the two places a raw layer enters — so it runs before merging, before pinning and before binding, and every consumer downstream knows one name.
That placement is what makes Scenario#19.02 hold without a second thought: the branch layer is normalised *before* `stripPinned` reads it, so the old name cannot smuggle a pinned key past a check that looks for the new one.
Where a file writes both sections, the explicit `werkdock` one wins, because it is the name that is meant.
The warning is emitted once per file (`warnedSections`), like the other section-level warnings, since the config is re-read on every poll cycle.
The version is bumped to 1.2.0 with a release note: the minor, because this changes the configuration schema, and a deployment must be identifiable as the one that introduced it.
## Open Questions
- **Should `WERKATOR_SANDBOX` keep accepting `bwrap`?** It does today, normalised on read and documented as the former name. The env files are local and gitignored, so this alias costs one line and can go whenever the config alias does.
## Additional Changes
- None beyond the rename and its documentation.
## Deployment note
The order matters, and only in one direction: deploy v1.2.0 to a host *before* rewriting its init fragment (`.env.<instance>.yml`) to the new key names.
A fragment carrying `werkdock:` applied by an older Werkator fails the fragment's strict schema validation — which is the safe outcome, but a failed `repo-init` nonetheless.
The reverse never breaks: v1.2.0 reads every existing `bwrap:` fragment and machine config as before.
## Prerequisite PRs
- [PR#18](2026-09-03-PR%2318-remote-host-layout.md) introduced `WERKATOR_SANDBOX`, whose value this PR renames.
## Follow-up PRs
- Plan step 18 (removing the legacy `branches` section) sets `ConfigVersions.FORMAT_BROKE_IN`; the `bwrap` alias should be dropped in the same release, refusing the key by name.
@@ -0,0 +1,42 @@
> **WARNING:** This document describes only the change applied in this PR.
> It may already be outdated once the next PR is merged.
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
## The Problem
The build for commit `7028ca8` on `main` failed on the mih09 production instance with
`BuildExecutorTest > with maxConcurrent 1 a second branch stays PENDING until the first finished`,
while a retry of the very same commit passed, and the test passes locally.
The test was timing-dependent.
It started a build for `branch-a` whose build command was `sleep 1`, immediately started a second build for `branch-b`,
and then asserted — without any synchronization at all — that `branch-b` was still `PENDING`.
That assertion only held as long as the test thread reached it within the one second `branch-a` slept.
On a shared host under CPU contention the executor can get through `branch-a` entirely (queued, running, slept, succeeded) first,
and `branch-b` is then already `RUNNING` or `SUCCESS` when the assertion runs.
The failure therefore says nothing about the executor; it is pure scheduling noise that costs a build and a retry every time it hits.
## Non-Goals
- No change to production code — `BuildExecutor` is not touched, its queueing behaviour is unchanged.
- No sweep of the other timing-sensitive tests in the suite; only the one that actually flaked is fixed.
## The Solution
`branch-a` no longer sleeps for a fixed time, it blocks until the test says so:
its build command is `until [ -f gate ]; do sleep 0.05; done`, and the build workspace is the test's working directory.
The test now
1. waits (via `eventually`) until `branch-a` is `RUNNING`, so the single executor slot is provably occupied,
2. asserts that `branch-b` is `PENDING` — which cannot race anything, because `branch-a` cannot finish before the gate file exists,
3. creates the gate file, and only then awaits both builds' `SUCCESS`.
The assertion on the event transitions (`branch-b` goes `RUNNING` only after `branch-a` reached `SUCCESS`) is unchanged.
A blocking gate was chosen over a mocked `BuildRunner` because it keeps the test on the real `ProcessBuildRunner`,
so it still covers the actual process handling rather than only the executor's bookkeeping.
Verified by running `BuildExecutorTest` five times on an idle machine and three more times with twice `nproc` busy-loops saturating the CPU,
which is the condition that produced the original failure.
- [BuildExecutorTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt)
@@ -0,0 +1,30 @@
> **WARNING:** This document describes only the change applied in this PR.
> It may already be outdated once the next PR is merged.
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
## The Problem
The repository's home is `https://git.javagil.de/mi/werkator.git` since the move to the own Gitea instance,
but `tools/remote` still defaulted `WERKATOR_REPO_URL` to the GitHub mirror.
The mih09 production instance was cloned from that mirror and consequently watched a `main` that nobody pushes to any more:
it kept reporting the last GitHub state as green while three merged pull requests sat unbuilt on the real `main`.
The failure that started this — a flaky test already fixed on Gitea's `main` — could not be re-verified live,
because the instance had no way to see the fix.
## Non-Goals
- The existing clone on mih09 is not touched by this change; its remote was repointed by hand (`git remote set-url`), and `repo-init` skips an existing clone.
- The generic placeholder URLs in `docs/deployment.md` stay as they are — they describe cloning *any* watched repository, not werkator's own.
- No decision about mirroring to GitHub; the mirror simply stops being the source an instance builds from.
## The Solution
`REPO_URL` in [tools/remote](../../tools/remote) defaults to the Gitea URL, and the usage comment says so.
The value stays overridable via `WERKATOR_REPO_URL` in the instance's env file,
so an installation that deliberately watches a different remote is unaffected.
Anonymous HTTPS works against Gitea exactly as it did against GitHub, so no deploy key or token is involved.
Note that pull requests opened via AGit-Flow create no branch in Gitea,
so an instance watching this remote sees `main` only — branch builds require pushing real branches.
@@ -0,0 +1,90 @@
> **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
`init --systemd` generates the host integration — the systemd unit's resource limits, the Apache `.htaccess` and the maintenance page — from the effective configuration.
It read that configuration through a helper with two independent defects, both of which fail silently.
**It swallowed every error.**
The load sat in `try { … } catch (_: Exception) { ServerConfig() }`.
Any configuration error at all — a missing required field, a malformed layer, a version floor violation — produced a default `ServerConfig` with a blank `publicBaseUrl`.
A repository with a broken `.werkator.yml` then looked exactly like one that simply has no public base URL configured:
the `.htaccess` and the maintenance page were skipped without a word.
This surfaced while verifying [PR#17](2026-09-03-PR%2317-maintenance-page.md) on mih09, where the missing files looked like an unconfigured `publicBaseUrl` and were in fact an unrelated validation error.
**It read from the wrong directory.**
The helper called `configLoader.load(Paths.get("."))` — the process's current directory — while everything else in the command works off the git top level resolved by `GitService.getTopLevel`.
`ConfigLoader.loadRaw` resolves the layers directly under the directory it is given and does not walk up to the repository root, so the two agree only when `init` happens to be invoked from the root itself.
From a subdirectory the command read another repository's configuration, or none.
That also broke `--apply`: the fragment is installed into the repository root deliberately before the systemd files are written, so that its port and limits reach the generated unit, and a current-directory read does not see it.
`init --systemd` runs during initial deployment setup, which is exactly when a silent wrong answer is most expensive.
## Non-Goals
- The fallback itself is kept: a configuration that cannot be loaded is not fatal for `init`, the units are still generated with the defaults.
During the very first bootstrap there is legitimately nothing to load yet.
- No change to `ConfigLoader`, to the configuration schema, or to any other command.
- No sweep for catch-all exception handlers elsewhere in the code base;
the two other `catch` blocks in `InitCommand` already print an `Error:` and abort, so they were only checked, not changed.
## The Scenarios
### Feature: init reports what it read and where it read it from
#### Background
- The *repository root* is the git top level as resolved by `GitService.getTopLevel`, the directory holding `.werkator.yml`, `.git/werkator/.werkator.yml` and an applied fragment.
- The *current directory* is the process working directory, which is the repository root only when `init` is invoked there.
#### Scenario#22.01: A broken configuration is named, not defaulted over
So that a validation error during deployment setup is not mistaken for an unconfigured installation.
- **Given** a repository whose effective configuration cannot be loaded
- **When** `init --systemd` runs
- **Then** the exception message is printed as a warning
- **and** the unit files are still generated with the default settings
- **and** the warning appears exactly once, although three settings are read from the configuration
##### Verified by
- [InitCommandTest: `--systemd warns once when the effective configuration cannot be loaded`](../../src/test/kotlin/de/hoennig/werkator/commands/InitCommandTest.kt)
#### Scenario#22.02: The configuration is read from the repository root
So that the generated host integration reflects the repository being initialized, whatever directory `init` was invoked from.
- **Given** a repository whose root configuration sets `server.publicBaseUrl` and `server.port`
- **and** a current directory that is not that repository root
- **When** `init --systemd` runs
- **Then** the `.htaccess` and the maintenance page are generated
- **and** the `.htaccess` proxies to the port from the root configuration
##### Verified by
- [InitCommandTest: `--systemd reads the configuration from the repository root, not the current directory`](../../src/test/kotlin/de/hoennig/werkator/commands/InitCommandTest.kt)
## The Solution
The catch-all now prints the exception message before falling back:
```
Warning: the effective configuration could not be loaded (<message>)
continuing with default server settings — check the generated unit and host files
```
The configuration is read three times while the systemd files are written (`memoryMax`, `tasksMax`, `publicBaseUrl`), which would repeat the warning three times.
It is therefore loaded once per run and cached in the command, and the cache is reset at the top of `run()` so a reused instance — the command is a Spring singleton — re-reads.
The repository root is passed down into the two accessors instead of `Paths.get(".")`.
This matches every other caller of `ConfigLoader.load` in the code base, all of which pass an explicit working directory;
`InitCommand` was the only one relying on the process's current directory.
Both fixes are the same failure in two forms — the command answered from a configuration it never actually read — which is why they are in one PR.
## Additional Changes
- None.
@@ -0,0 +1,92 @@
> **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.
## Related Links
- ADR 0009 — multi-repo instance: the `defaults` block carries shared repository-level keys, read by `ConfigLoader`, never directly by consumers.
- `docs/deployment.md` — registry setup: `repo-add` clones, initialises, and prints the registry entry.
## The Problem
`tools/remote werkator repo-add <private-https-url>` fails on the host with `could not read Username`.
The clone runs anonymously, but the credentials exist only in the instance file's `defaults.git` block — which the script never consults.
Registering a private repository therefore needs a manual SSH session today.
## Non-Goals
The script still does not write `~/.werkator.yml`: registering stays the operator's decision.
No SSH-URL support: the forge is reached over `https` from the host.
No new config keys: `defaults.git.account`/`defaults.git.token` already exist.
## The Scenarios
### Feature: authenticated clone for private origins
#### Background
- The shared credentials live in `~/.werkator.yml` under `defaults.git` (ADR 0009).
- Public origins and instances without shared credentials must keep cloning anonymously.
#### Scenario#000.01: Private https origin clones with shared credentials
- **Given** `defaults.git.account`/`defaults.git.token` in `~/.werkator.yml` on the host
- **When** `tools/remote werkator repo-add <private-https-url>` runs
- **Then** the clone authenticates with those credentials and succeeds.
##### Verified by
- Manual stub-`git` test: URL carries `account@`, askpass answers the token, `GIT_TERMINAL_PROMPT=0`.
#### Scenario#000.02: Public origin clones anonymously
- **Given** no shared credentials (or a public repository)
- **When** `repo-add` or `repo-init` runs
- **Then** the clone runs exactly as before, without authentication.
##### Verified by
- Local helper test against a `file://` origin with and without an instance file.
#### Scenario#000.03: Token never leaks locally
- **Given** an authenticated clone
- **When** the command runs from the workstation
- **Then** the token appears in neither the local process list nor a repository config.
##### Verified by
- Code inspection: the token is read on the host and passed via a one-shot `GIT_ASKPASS` script.
## The Solution
`tools/remote` gained a `clone_repo` helper used by both `repo-init` and `repo-add`.
For `https://` URLs it ships a small Python helper (base64-encoded, so no `$` is expanded locally) to the host.
The helper reads `defaults.git.account`/`defaults.git.token` from `~/.werkator.yml`, puts the account into the URL, and hands the token to git via a one-shot `0700` `GIT_ASKPASS` script deleted in `finally`.
Without credentials it falls back to the plain anonymous clone; non-`https` URLs clone unchanged.
`docs/deployment.md` documents that the shared credentials must exist before cloning a private repository.
## Open Questions
- None.
## Attachments
### Adding a Gitea repository — example
From the workstation, clone and initialise, then register:
```bash
tools/remote --env-file .env.<instance> werkator repo-add https://gitea.example.org/<owner>/<repo>.git [<name>]
```
Add the printed entry to `~/.werkator.yml` under `repositories:`, then restart the service.
A public repository needs nothing else: the clone runs anonymously.
A private repository needs shared credentials once on the host, in `~/.werkator.yml` of the service user, before cloning (token: Gitea → Settings → Applications → Generate Token, scope `read:repository`):
```yaml
defaults:
git:
account: <gitea-user>
token: <token>
```
@@ -0,0 +1,102 @@
> **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.
## Related Links
- Werkdock `CHANGELOG.md` (repository `mi/werkdock`, Unreleased section) — the release notes this PR adapts to: `import` replaces `load -i --name` for rootfs archives, `load` is reserved for docker/OCI image archives, image names gained docker-style tags.
- ADR 0008 — the bwrap build runtime; `docs/configuration.md`, notes on `builds.<name>.werkdock`.
## The Problem
Werkdock moves its CLI closer to `docker run --rm`.
For the rootfs archives Werkator uses, the verb is now `werkdock import ARCHIVE IMAGE` (docker import semantics).
`werkdock load -i ARCHIVE --name IMAGE` still works but is a compatibility path with a note on stderr, and is announced to go away once Werkator has switched.
Werkator ships the werkdock binary with its deployment, but nothing forces the two to be updated together: an installation may run a new Werkator against an older werkdock for a while, or the other way round.
## Non-Goals
- No switch of the existence check from `werkdock images` to `werkdock inspect`: `images` prints the bare name for untagged images on every werkdock version, the exact-line match keeps working, and `inspect` would tie Werkator to the new werkdock.
- No tagged image name (`werkator-buildenv:<hash>`): it would re-import every build environment on the webspace and orphan the old image, for no functional gain today.
- None of the new `run` flags (`--mount`, `--entrypoint`, `--network host`): Werkator's invocation needs none of them.
## The Scenarios
### Feature: rootfs archives are imported with werkdock's docker-shaped verb
#### Background
- The build environment is a rootfs archive named by `builds.<name>.werkdock.rootfs`.
- Werkator creates the werkdock image `werkator-buildenv-<hash>` from it once per source.
- Werkdock answers an unknown verb with `werkdock: unknown command "import"` and exit code 125, its code for its own errors.
#### Scenario#000.01: A missing image is imported with `werkdock import`
So that Werkator uses the verb werkdock names for rootfs archives, and the deprecated path can be removed on werkdock's side.
- **Given** `werkdock images` does not list the image
- **When** a build starts
- **Then** Werkator runs `werkdock import ARCHIVE werkator-buildenv-<hash>`
- **and** runs no `werkdock load`.
##### Verified by
- [WerkdockBuildRunnerTest "imports the image once when werkdock does not know it yet"](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerTest.kt)
- [WerkdockBuildRunnerTest "downloads a URL rootfs once before importing it"](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerTest.kt)
#### Scenario#000.02: An older werkdock without the verb still works
So that Werkator and werkdock can be updated in either order.
- **Given** the installed werkdock answers `import` with `unknown command` and exit 125
- **When** a build starts with a missing image
- **Then** Werkator falls back to `werkdock load -i ARCHIVE --name werkator-buildenv-<hash>`
- **and** logs the fallback.
##### Verified by
- [WerkdockBuildRunnerTest "falls back to load -i --name on a werkdock without the import verb"](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerTest.kt)
#### Scenario#000.03: A real import failure is not masked by the fallback
So that a broken archive fails the build with werkdock's message, as before.
- **Given** `werkdock import` fails for any other reason (any other exit code, or exit 125 without `unknown command`)
- **When** a build starts with a missing image
- **Then** the build fails with that command's output
- **and** no `werkdock load` runs.
##### Verified by
- [WerkdockBuildRunnerTest "propagates an import failure that is not a missing verb"](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerTest.kt)
#### Scenario#000.04: An existing image is neither imported nor loaded
- **Given** `werkdock images` lists the image
- **When** a build starts
- **Then** neither `import` nor `load` runs.
##### Verified by
- [WerkdockBuildRunnerTest "does not import an image werkdock already has"](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerTest.kt)
## The Solution
`WerkdockBuildRunner.ensureImage` calls the new `importImage`: `werkdock import ARCHIVE IMAGE` through the non-throwing `run`, then either returns, falls back to `load -i --name` on exactly the unknown-verb signature (exit 125 plus `unknown command` on stderr), or rethrows the import's result as a `GitCommandException` — the same exception and message the old code produced.
The fallback is keyed on werkdock's own error signature rather than on a version string, because werkdock's version output does not yet distinguish the two builds.
## Open Questions
- When to drop the fallback: once every installation runs a werkdock with `import`, the `load -i --name` branch and its test go, and werkdock can remove the compatibility path.
## Additional Changes
- `docs/configuration.md` and `docs/deployment.md` say "imported" where they said "loaded", and name the fallback and the reason the image name stays untagged.
## Prerequisite PRs
- The werkdock change that introduces `import` (repository `mi/werkdock`, CHANGELOG Unreleased); against an older werkdock the fallback path runs.
## Follow-up PRs
- Remove the fallback (see Open Questions).
+151
View File
@@ -0,0 +1,151 @@
# RFC 0001: Web UI Redesign — the Instrument Panel
**Status:**
- proposed: 2026-09-03
- accepted: -
- rejected: -
**Proposal:** The Werkator web UI adopts the **Instrument Panel** direction: a teal palette in a light and a dark mode, IBM Plex typography, a repository strip that previews the state of every served repository, a title hierarchy that names the view first and explains it second, and a tab bar at the foot that becomes the mobile navigation.
The architecture does not change: server-rendered Thymeleaf, one `werkator.css`, one hand-written `werkator.js`, JSON polling, no framework, no frontend build pipeline.
## Context and Problem Statement
The current UI is a functional port of the legacy generated pages: a table per view, pill badges, system font, blue links.
It is correct and calm, but it looks like every other CI page and gives no hint of the other repositories an instance serves (ADR 0009).
The brief for this RFC was "fancy, but serious and trustworthy", with two references from the same author for visual kinship:
- [werkbaum.javagil.de](https://werkbaum.javagil.de/) — light paper with a fine grid, IBM Plex, a petrol accent, panel labels in small caps.
- [javagil.de/vibe-engineering](https://javagil.de/vibe-engineering) — a dark instrument panel: ink and petrol, clay for warnings, monospaced spaced labels, a tab bar at the foot.
A hard constraint of this RFC is honesty towards the data.
The mockups show only what the API delivers today; nothing is invented to make a screen look richer.
### What the UI Has to Work With
Per build row (`BuildRowView`, `BuildResultDto`): status, branch name, commit (12-character abbreviation, full id for copying), started at (`yyyy-MM-dd HH:mm`), duration (`m:ss`; a pending build shows its wait time in italics), artifact key with the artifact, permalink and live-log links, and the actions restart and delete (history has no restart).
Statuses: `pending`, `running`, `success`, `failed`, `interrupted`, `cancelled`, plus `unknown` for a never-built branch and the client-side `finished` on a card whose build has left the current list.
Views: Latest (one build per name), Branches (every origin branch and its latest build), History (all stored builds), Current (running builds with their live log), System (seven metric rows with current/min/max/avg, warn from 80 %, critical from 90 %), the artifact page, and the release notes.
Live state: the indicator is `static`, `live` or `error`; the watcher banner reports `watcher stopped`, `origin unreachable` or `poll cycle failed`.
Multi-repo: the repository switcher is a server-rendered `<select>` of names; `WatcherState.repositories` already carries a per-repository watcher state that the UI does not show.
What does **not** exist, and therefore appears in no mockup: a commit subject line, a typical or expected duration, an ETA, a per-branch build history, test counts on a row, and any cross-repository status summary in the API.
## Considered Options
Six directions were sketched on a shared design canvas, two rounds of three, all with the same sample rows.
| Option | Idea | Why | Tradeoff |
|---|---|---|---|
| A · Quiet Console | Today's design refined: top bar, dot-plus-word statuses, hover actions | Smallest step, everything stays valid | Least distinctive |
| B · Mission Board | Health tiles, one card per branch with a history strip, running build with progress | Answers "is everything fine?" at a glance | Needs data the API does not have (history, typical duration) |
| C · Ledger | Warm paper, serif masthead, hairline rules, typographic status marks | The most "serious"; reads like a signed record | Leaves the system font, needs its own dark theme |
| D · Paper Rail | Werkbaum's paper and grid, a repository rail on the left with per-branch dots | Family resemblance to Werkbaum; other repositories visible | 250 px of table width lost; empty with one repository |
| **E · Instrument Panel** | Vibe-Engineering's dark panel, repositories as tiles, tab bar at the foot | Reads like a control room; failures in clay stay serious without alarm | Dark-only as drawn; needs a light palette |
| F · Fleet Overview | A new landing page with one panel per repository, ledger typography on paper | One page answers the question for the whole instance | Becomes a list beyond five repositories |
Round one (AC) still contained invented data; it is kept on the canvas for the visual ideas only.
**E was chosen**, and round three worked out what it lacked: the light mode, the ten-repository case, the title hierarchy, and the phone layout.
## The Design
### Palette
Both modes are CSS custom properties on `:root`, switched by `prefers-color-scheme` as today (`color-scheme: light dark`).
Failures use clay, not red, so they stay serious without shouting; the accent is teal in both modes.
| Token | Dark | Light | Used for |
|---|---|---|---|
| bg | `#061C1F` | `#EAF4F2` | page ground |
| panel | `#0A2A2E` | `#FFFFFF` | tables, cards, chips |
| panel-2 | `#0F3A3D` | `#D6ECE8` | the current repository, the active tab |
| line | `#17474B` | `#C9DFDB` | borders and rules |
| text | `#E4EEEC` | `#0B2B2E` | body text |
| text-2 | `#B4CBC8` | `#35595B` | timestamps |
| muted | `#7DA19E` | `#5E8583` | labels, footers |
| accent | `#5FD3C7` | `#0E8079` | links, success, running, the live indicator |
| accent-2 | `#1E9A93` | `#149A90` | underlines, the current repository's border |
| clay | `#E09070` | `#B0563B` | failed, error, delete, watcher warnings |
| clay-2 | `#C4664A` | `#C4664A` | the border of a failing repository chip |
| ghost | `#4A7370` | `#BFD4D1` | cancelled, interrupted, unknown |
Tinted rows: a running row gets 16 % (dark) or 10 % (light) of accent-2 as background, a failed row 12 % or 10 % of clay-2.
The reference's background grid was tried and dropped: it competes with the table, especially in light mode.
### Typography
IBM Plex Sans for text, IBM Plex Mono for commits, timestamps, durations and every label.
Labels are 10 px Mono, uppercase, letter-spaced 0.12 em, in `muted`; statuses are 11 px Mono uppercase in their status color, each preceded by an 8 px dot (outlined for pending, pulsing for running).
Fallback stacks: `"IBM Plex Sans", "Segoe UI", system-ui, sans-serif` and `"IBM Plex Mono", ui-monospace, Consolas, monospace`.
Whether Plex is bundled under `static/` or the fallback stack is accepted is an open question below.
### Anatomy of a Page (desktop)
1. **Header**, 52 px: logo, `Werkator` with the Gitea repository name in accent, a small label `updated HH:mm:ss`; right: the live indicator as an outlined chip with a pulsing dot, the reload button.
2. **Repository strip**: see below.
3. **Panel** with the view's title: the view name at 22 px semibold with a 2 px accent-2 underline, followed by a one-line label that explains it (`Latest``one build per branch, newest first`; `Branches``every origin branch and its latest build`; `History``all stored builds, newest first`; `System``instance metrics since first start`); on the right a Mono line with the row count, the last poll and the watcher state.
4. **Table**, columns as today (Status, Branch, Commit, Started, Duration, Artifacts, Actions), rows 9 px padding on a 1 px `line` rule; copy buttons as outlined 13 px icons; artifact links and actions as stroke icons (no emoji).
5. **Footer**: version and copyright left, the navigation as a Mono tab bar in the middle (Latest, Branches, History, System with icons; the active tab in panel-2 with an accent underline), Impressum and Privacy right.
### The Repository Strip
The `<select>` switcher is replaced by a strip below the header that shows every served repository with its state, so a failure elsewhere is visible without leaving the page.
- Up to about three repositories: **tiles** (220 px), each with `current` or `repo` label, the name, one dot per branch in the branch's latest status, a summary line (`6 builds · 1 failed · 1 running`), and the watcher warning in clay when that repository's watcher reports an error.
- More repositories: **chips** (30 px), each with one dot for the worst status in the repository, the name, an optional short finding (`1 failed`, `main`, `never built`), and a warning triangle when the watcher reports an error; the current repository has an accent-2 border on panel-2, a failing one a clay-2 border on the clay tint.
- Order is *failing first*: the current repository, then failing, running, then green; a summary line above (`10 served · 2 failing · 1 unreachable · 2 running`) and a sort control on the right.
- The strip **scrolls**: horizontally on the phone, and on the desktop it wraps to a second row up to about ten repositories and becomes a horizontally scrollable band beyond that, with the failing chips pinned at the front so they never scroll out of view.
- Beyond roughly twenty repositories the strip shows only the conspicuous chips (failing, running, unreachable) plus a search field for the rest.
- With a single served repository the strip is omitted, as the switcher is today.
### Phone (below 680 px)
The existing breakpoint behaviour is kept and restyled: rows become cards with the `data-label` captions, the live indicator collapses to a dot.
The header stacks `Werkator` over the repository name; the repository strip scrolls horizontally under its summary line; the panel title keeps its hierarchy; each card carries the status line with the branch, then commit, started and duration, then the artifact icons and the actions as 44 px targets.
The footer tab bar becomes a fixed bottom tab bar with icons — the same four entries as on the desktop.
No painted status bar or keyboard; the device provides those.
### What Is Deliberately Not in the Proposal
- The `DE` language button in the mockups is a leftover of the reference; the UI stays English-only.
- No commit subjects, typical durations, ETAs or history strips: they need data the server does not have, and each would be its own RFC with its own storage.
- No manual theme toggle; `prefers-color-scheme` decides, as today.
## Consequences
### Backend
- One new endpoint, `GET /api/repos`: for every served repository its name, its UI root (`/repos/<name>`), whether it is the current one, the latest status per build name (the Latest view's `latestPerName` reduced to counts, plus the worst status), and its `RepoWatcherState` (`lastFetchError`, `lastPollError`, `lastPollAt`).
With one served repository the endpoint returns a list of one and the strip stays hidden.
- `werkator.js` polls it on the table interval (10 s) and renders the strip; every fetch keeps the timeout and the explicit error badge.
- `UiFormats` and `werkator.js` keep producing identical formats; the palette and the title labels are template and CSS only.
### Rollout, One Concern per Pull Request
1. Palette, typography and the title hierarchy in `werkator.css` and the fragments — no data change, both modes.
2. Header and footer tab bar, including the phone tab bar.
3. `GET /api/repos` and the repository strip, replacing the `<select>`.
4. Card refinements on the phone and the System and artifact pages in the new vocabulary.
Each step leaves the UI usable, and the tests in `server` that assert on markup are adjusted with the step that changes it.
## Open Questions
- **Fonts:** bundle IBM Plex Sans and Mono under `static/fonts/` (about 100150 KB in WOFF2 for the four faces), or accept the fallback stack on hosts without the font; the reference sites load Plex from a CDN, which the deployment behind a strict reverse proxy may not want.
- **Current view:** it is reachable today only from a running row's live icon; the tab bar has room for it as a fifth entry with a count badge, or it stays a link from the row.
- **Instance pages:** `/system` and `/releases` are instance-level; in the tab bar they sit next to the per-repository views, which the strip makes visible enough, or they move to the footer's right side.
## Design Sources
The design canvas with all eleven artboards (rounds one to three, desktop and phone) is a private Claude artifact of the author; its renderings live next to this RFC under `0001-web-ui-instrument-panel/`.
The sample rows are real field shapes with invented values; the repositories other than `werkator` are invented.
The proposal:
- [E · dark, desktop](0001-web-ui-instrument-panel/e-dark-desktop.png) · [E · light, desktop](0001-web-ui-instrument-panel/e-light-desktop.png)
- [E · dark, ten repositories](0001-web-ui-instrument-panel/e-dark-10-repos.png) · [E · light, ten repositories](0001-web-ui-instrument-panel/e-light-10-repos.png)
- [E · dark, phone](0001-web-ui-instrument-panel/e-dark-phone.png) · [E · light, phone](0001-web-ui-instrument-panel/e-light-phone.png)
The alternatives, for the record:
- [A · Quiet Console](0001-web-ui-instrument-panel/a-quiet-console.png), [B · Mission Board](0001-web-ui-instrument-panel/b-mission-board.png), [C · Ledger](0001-web-ui-instrument-panel/c-ledger.png) — round one, still with invented data.
- [D · Paper Rail](0001-web-ui-instrument-panel/d-paper-rail.png), [F · Fleet Overview](0001-web-ui-instrument-panel/f-fleet-overview.png) — round two.
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

@@ -43,17 +43,17 @@ class ProcessBuildRunner : BuildRunner {
}
/**
* Selects the runtime per branch: Docker when `branches.<name>.docker.enabled`,
* bubblewrap when `branches.<name>.bwrap.enabled`, native shell execution otherwise
* (the unchanged default). Docker and bwrap are mutually exclusive per branch and are
* rejected together at config load, so the branch order here never has to "pick".
* Selects the runtime per branch: Docker when `builds.<name>.docker.enabled`, the
* werkdock sandbox when `builds.<name>.werkdock.enabled`, native shell execution
* otherwise (the unchanged default). Docker and werkdock are mutually exclusive per
* branch and are rejected together at config load, so the order here never has to "pick".
*/
@Primary
@Component
class DispatchingBuildRunner(
private val processBuildRunner: ProcessBuildRunner,
private val dockerBuildRunner: DockerBuildRunner,
private val bwrapBuildRunner: BwrapBuildRunner,
private val werkdockBuildRunner: WerkdockBuildRunner,
) : BuildRunner {
override fun start(
command: String,
@@ -66,7 +66,7 @@ class DispatchingBuildRunner(
val runner =
when {
branchConfig.docker.enabled -> dockerBuildRunner
branchConfig.bwrap.enabled -> bwrapBuildRunner
branchConfig.werkdock.enabled -> werkdockBuildRunner
else -> processBuildRunner
}
return runner.start(command, workingDir, environment, repoDir, branchConfig, onAuxProcess)
@@ -1,7 +1,8 @@
package de.hoennig.werkator.build
import de.hoennig.werkator.config.BranchConfig
import de.hoennig.werkator.config.BwrapConfig
import de.hoennig.werkator.config.WerkdockConfig
import de.hoennig.werkator.git.GitCommandException
import de.hoennig.werkator.git.GitCommandRunner
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
@@ -13,12 +14,12 @@ import java.security.MessageDigest
* 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). 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
* it shells out to the `werkdock` CLI (`werkdock.binary`, default via PATH) the same
* pattern as git and docker, CLI, no library.
*
* The rootfs archive becomes a werkdock *image*, loaded once per source
* The rootfs archive becomes a werkdock *image*, imported once per source
* (`werkator-buildenv-<hash>`, the hash over the source string, so a changed source
* loads a fresh image) into werkdock's own store (`$WERKDOCK_HOME`, default
* imports 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
@@ -36,10 +37,10 @@ import java.security.MessageDigest
* native builds.
*/
@Component
class BwrapBuildRunner(
class WerkdockBuildRunner(
private val commandRunner: GitCommandRunner,
) : BuildRunner {
private val log = LoggerFactory.getLogger(BwrapBuildRunner::class.java)
private val log = LoggerFactory.getLogger(WerkdockBuildRunner::class.java)
/** Replaceable process launcher so unit tests can capture the assembled `werkdock` argv. */
internal var processStarter: (List<String>, Path) -> Process = { command, dir ->
@@ -54,26 +55,28 @@ class BwrapBuildRunner(
branchConfig: BranchConfig,
onAuxProcess: (Process) -> Unit,
): Process {
val bwrap = branchConfig.bwrap
require(bwrap.rootfs.isNotBlank()) { "branches.<name>.bwrap.rootfs must be set when bwrap.enabled is true" }
val werkdock = bwrap.werkdock.ifBlank { "werkdock" }
val image = imageName(bwrap.rootfs)
ensureImage(werkdock, image, bwrap, repoDir, onAuxProcess)
val sandbox = branchConfig.werkdock
require(sandbox.rootfs.isNotBlank()) { "builds.<name>.werkdock.rootfs must be set when werkdock.enabled is true" }
val werkdock = sandbox.binary.ifBlank { "werkdock" }
val image = imageName(sandbox.rootfs)
ensureImage(werkdock, image, sandbox, repoDir, onAuxProcess)
val homeDir = repoDir.resolve(BUILDENV_DIR).resolve(HOME_DIR)
Files.createDirectories(homeDir)
val args = invocation(command, workingDir, environment, repoDir, bwrap, werkdock, image, homeDir)
val args = invocation(command, workingDir, environment, repoDir, sandbox, werkdock, image, homeDir)
return processStarter(args, repoDir)
}
/**
* Loads the rootfs archive into the werkdock image store once per source.
* Imports 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.
* inspect` does for the Docker runner; it prints the bare name for an untagged
* image on every werkdock version, so the exact-line match holds across the
* werkdock upgrade that introduced tags.
*/
private fun ensureImage(
werkdock: String,
image: String,
bwrap: BwrapConfig,
sandbox: WerkdockConfig,
repoDir: Path,
onAuxProcess: (Process) -> Unit,
) {
@@ -81,10 +84,36 @@ class BwrapBuildRunner(
if (image in loaded) {
return
}
val envDir = repoDir.resolve(BUILDENV_DIR).resolve(sourceKey(bwrap.rootfs))
val envDir = repoDir.resolve(BUILDENV_DIR).resolve(sourceKey(sandbox.rootfs))
Files.createDirectories(envDir)
val archive = localArchive(bwrap.rootfs, envDir, repoDir, onAuxProcess)
log.info("loading build environment {} as werkdock image {}", bwrap.rootfs, image)
val archive = localArchive(sandbox.rootfs, envDir, repoDir, onAuxProcess)
log.info("importing build environment {} as werkdock image {}", sandbox.rootfs, image)
importImage(werkdock, archive, image, repoDir, onAuxProcess)
}
/**
* `werkdock import ARCHIVE IMAGE` (docker import semantics) creates the image from a
* rootfs archive. A werkdock that predates the verb answers `unknown command` with
* exit 125; then the older `load -i ARCHIVE --name IMAGE` does the same job, so an
* installation can update Werkator and werkdock in either order. Any other failure
* is the import's own and propagates as it did before.
*/
private fun importImage(
werkdock: String,
archive: String,
image: String,
repoDir: Path,
onAuxProcess: (Process) -> Unit,
) {
val importCommand = listOf(werkdock, "import", archive, image)
val imported = commandRunner.run(importCommand, repoDir, onProcess = onAuxProcess)
if (imported.isSuccess) {
return
}
if (imported.exitCode != WERKDOCK_CLI_ERROR || "unknown command" !in imported.stderr) {
throw GitCommandException(importCommand, imported)
}
log.info("this werkdock has no import verb yet, falling back to load -i --name")
commandRunner.runOrThrow(
listOf(werkdock, "load", "-i", archive, "--name", image),
repoDir,
@@ -93,7 +122,7 @@ class BwrapBuildRunner(
}
/**
* Resolves [BwrapConfig.rootfs] to a local archive path: a bare or `file:` path is
* Resolves [WerkdockConfig.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 cache.
*/
private fun localArchive(
@@ -123,7 +152,7 @@ class BwrapBuildRunner(
workspace: Path,
environment: Map<String, String>,
repoDir: Path,
bwrap: BwrapConfig,
sandbox: WerkdockConfig,
werkdock: String,
image: String,
homeDir: Path,
@@ -148,7 +177,7 @@ class BwrapBuildRunner(
for ((key, value) in environment) {
args += listOf("-e", "$key=$value")
}
for ((key, value) in bwrap.env) {
for ((key, value) in sandbox.env) {
args += listOf("-e", "$key=$value")
}
args += listOf("-w", "$workspaceAbs")
@@ -209,5 +238,8 @@ class BwrapBuildRunner(
companion object {
const val BUILDENV_DIR = ".git/werkator/buildenv"
const val HOME_DIR = "home"
/** werkdock's exit code for its own errors (docker's 125), as opposed to the sandboxed command's. */
const val WERKDOCK_CLI_ERROR = 125
}
}
@@ -48,6 +48,7 @@ class InitCommand(
internal var javaExecutableResolver: () -> Path = { Paths.get(System.getProperty("java.home"), "bin", "java") }
override fun run() {
cachedServerConfig = null
val normalizedWorkingDir = workingDir.toAbsolutePath().normalize()
val root =
try {
@@ -88,7 +89,7 @@ class InitCommand(
if (url == null) return DetectedValues()
if (url.startsWith("http")) {
val regex = Regex("""https?://(?:([^@]+)@)?([^/]+)/([^/]+)/([^/.]+)(?:\.git)?""")
val regex = Regex("""https?://(?:([^@]+)@)?([^/]+)/([^/]+)/(.+?)(?:\.git)?$""")
val match = regex.find(url)
if (match != null) {
val (user, host, owner, repo) = match.destructured
@@ -101,7 +102,7 @@ class InitCommand(
}
} else if (url.contains("@") && url.contains(":")) {
// Assume SSH: git@host:owner/repo.git
val regex = Regex("""([^@]+)@([^:]+):([^/]+)/([^/.]+)(?:\.git)?""")
val regex = Regex("""([^@]+)@([^:]+):([^/]+)/(.+?)(?:\.git)?$""")
val match = regex.find(url)
if (match != null) {
val (_, host, owner, repo) = match.destructured
@@ -242,12 +243,13 @@ class InitCommand(
context: "." # Docker build context used with dockerfile
network: "" # Docker network mode for the build container; empty = Docker default (pinned)
env: {} # additional environment variables set inside the build container
# bubblewrap user-namespace sandbox for hosts without root and without a
# Docker daemon (e.g. Hostsharing managed webspaces). Mutually exclusive with docker.
bwrap:
enabled: false # run clean/build in a bwrap sandbox instead of natively (pinned)
# werkdock sandbox (bubblewrap user namespace) for hosts without root and
# without a Docker daemon (e.g. Hostsharing managed webspaces). Mutually
# exclusive with docker. Called bwrap before v1.2.0, still read under that name.
werkdock:
enabled: false # run clean/build in the 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)
binary: 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.
@@ -295,14 +297,31 @@ class InitCommand(
* already loadable (re-running `init --systemd` on an installed instance); during
* the very first bootstrap they stay unset and the defaults (no directives) apply.
*/
private fun loadedSystemdConfig(): de.hoennig.werkator.config.SystemdConfig = loadedServerConfig().systemd
private fun loadedSystemdConfig(root: Path): de.hoennig.werkator.config.SystemdConfig = loadedServerConfig(root).systemd
private fun loadedServerConfig(): de.hoennig.werkator.config.ServerConfig =
try {
configLoader.load(Paths.get(".")).server
} catch (_: Exception) {
de.hoennig.werkator.config
.ServerConfig()
/** Loaded once per run, so a broken configuration is reported once and not per caller. */
private var cachedServerConfig: de.hoennig.werkator.config.ServerConfig? = null
/**
* Read from the repository root like every other file this command touches the
* layers sit there, not in whatever directory the process happens to run in, and
* an applied fragment must reach the generated unit even when `init` is invoked
* from a subdirectory.
*
* A configuration error here is not fatal the units are still generated with defaults
* but it must not pass for "nothing configured": without the warning a broken `.werkator.yml`
* looks exactly like an unset `publicBaseUrl` and the host integration is skipped silently.
*/
private fun loadedServerConfig(root: Path): de.hoennig.werkator.config.ServerConfig =
cachedServerConfig ?: run {
try {
configLoader.load(root).server
} catch (e: Exception) {
println("Warning: the effective configuration could not be loaded (${e.message})")
println(" continuing with default server settings — check the generated unit and host files")
de.hoennig.werkator.config
.ServerConfig()
}.also { cachedServerConfig = it }
}
private fun createSystemdFiles(
@@ -326,8 +345,8 @@ class InitCommand(
javaExecutable = javaExecutableResolver(),
jarPath = jarPath,
envFile = envFile,
memoryMax = loadedSystemdConfig().memoryMax,
tasksMax = loadedSystemdConfig().tasksMax,
memoryMax = loadedSystemdConfig(root).memoryMax,
tasksMax = loadedSystemdConfig(root).tasksMax,
),
)
println("created ${unitFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
@@ -350,7 +369,7 @@ class InitCommand(
// generated host integration like the units: only meaningful behind a web
// frontend, so it needs a public base URL; unused elsewhere and harmless
val server = loadedServerConfig()
val server = loadedServerConfig(root)
if (server.publicBaseUrl.isNotBlank()) {
val htaccessFile = werkatorDir.resolve(SystemdServiceFiles.HTACCESS_NAME)
htaccessFile.toFile().writeText(SystemdServiceFiles.htaccessContent(server.port))
@@ -42,8 +42,8 @@ data class BuildDefinition(
val statusContext: String? = null,
/** Overrides of the docker settings; null inherits them. */
val docker: DockerOverrides? = null,
/** Overrides of the bwrap settings; null inherits them. */
val bwrap: BwrapOverrides? = null,
/** Overrides of the werkdock settings; null inherits them. */
val werkdock: WerkdockOverrides? = null,
) {
/** The settings this build runs with: [branchConfig] with this definition applied; unset values fall through. */
fun applyTo(branchConfig: BranchConfig): BranchConfig =
@@ -64,12 +64,12 @@ data class BuildDefinition(
network = docker?.network ?: branchConfig.docker.network,
env = docker?.env ?: branchConfig.docker.env,
),
bwrap =
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,
werkdock =
branchConfig.werkdock.copy(
enabled = werkdock?.enabled ?: branchConfig.werkdock.enabled,
rootfs = werkdock?.rootfs ?: branchConfig.werkdock.rootfs,
binary = werkdock?.binary ?: branchConfig.werkdock.binary,
env = werkdock?.env ?: branchConfig.werkdock.env,
),
)
@@ -169,13 +169,13 @@ data class DockerOverrides(
val env: Map<String, String>? = null,
)
/** Nullable bubblewrap overrides of a [BuildDefinition]; null values inherit the branch's setting. */
data class BwrapOverrides(
/** Run the build in the bwrap sandbox instead of natively. Pinned — a branch must not escape its sandbox. */
/** Nullable werkdock overrides of a [BuildDefinition]; null values inherit the branch's setting. */
data class WerkdockOverrides(
/** Run the build in the sandbox instead of natively. Pinned — a branch must not escape its sandbox. */
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 binary: String? = null,
val env: Map<String, String>? = null,
)
@@ -201,10 +201,10 @@ class ConfigLoader(
val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } }
if (strippedDocker.isEmpty()) result.remove("docker") else result["docker"] = strippedDocker
}
val bwrap = entry["bwrap"] as? Map<String, Any?>
if (bwrap != null) {
val strippedBwrap = bwrap.toMutableMap().apply { PINNED_BWRAP_KEYS.forEach { remove(it) } }
if (strippedBwrap.isEmpty()) result.remove("bwrap") else result["bwrap"] = strippedBwrap
val werkdock = entry["werkdock"] as? Map<String, Any?>
if (werkdock != null) {
val strippedWerkdock = werkdock.toMutableMap().apply { PINNED_WERKDOCK_KEYS.forEach { remove(it) } }
if (strippedWerkdock.isEmpty()) result.remove("werkdock") else result["werkdock"] = strippedWerkdock
}
return result
}
@@ -481,14 +481,62 @@ class ConfigLoader(
private fun loadFile(file: File): Map<String, Any?> {
if (!file.exists()) return emptyMap()
@Suppress("UNCHECKED_CAST")
return yaml.readValue(file, Map::class.java) as Map<String, Any?>
return renameLegacySandbox(yaml.readValue(file, Map::class.java) as Map<String, Any?>, file.toString())
}
/** Parses a `.werkator.yml` read from git (not from disk); blank or null yields no layer. */
private fun parseYaml(text: String?): Map<String, Any?> {
if (text.isNullOrBlank()) return emptyMap()
@Suppress("UNCHECKED_CAST")
return yaml.readValue(text, Map::class.java) as? Map<String, Any?> ?: emptyMap()
val raw = yaml.readValue(text, Map::class.java) as? Map<String, Any?> ?: emptyMap()
return renameLegacySandbox(raw, "the branch configuration")
}
/**
* Reads the pre-PR#19 `bwrap` section under its new name `werkdock`, including its
* `werkdock` key which is `binary` now. Done on the raw map of every layer, before
* merging, so nothing downstream merging, pinning, binding knows two names.
*
* Renaming rather than rejecting: the section is written in the machine configuration
* of every webspace instance, which no repository tracks. The warning is what makes
* the old name go away; the hard refusal belongs to the release that sets
* [ConfigVersions.FORMAT_BROKE_IN], where a file declaring no version can be caught
* by name at all.
*/
@Suppress("UNCHECKED_CAST")
private fun renameLegacySandbox(
raw: Map<String, Any?>,
source: String,
): Map<String, Any?> {
var renamed = false
val result =
raw.mapValues { (section, value) ->
if (section != "builds" && section != "branches") {
return@mapValues value
}
val entries = value as? Map<String, Any?> ?: return@mapValues value
entries.mapValues inner@{ (_, entry) ->
val settings = entry as? Map<String, Any?> ?: return@inner entry
val legacy = settings["bwrap"] as? Map<String, Any?> ?: return@inner entry
renamed = true
val moved =
legacy.mapKeys { (key, _) -> if (key == "werkdock") "binary" else key }
val existing = settings["werkdock"] as? Map<String, Any?> ?: emptyMap()
settings.toMutableMap().apply {
remove("bwrap")
// an explicit werkdock section wins: the new name is the one meant
put("werkdock", moved + existing)
}
}
}
if (renamed && warnedSections.add("bwrap-renamed:$source")) {
log.warn(
"reading the 'bwrap' section of {} as 'werkdock' (and 'bwrap.werkdock' as 'werkdock.binary'); " +
"rename it — the old name goes away with the next breaking configuration change",
source,
)
}
return result
}
@Suppress("UNCHECKED_CAST")
@@ -550,8 +598,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) and its executing binary. */
private val PINNED_BWRAP_KEYS = setOf("enabled", "rootfs", "werkdock")
/** `werkdock` keys a branch must never override: the sandbox policy (Step 17) and its executing binary. */
private val PINNED_WERKDOCK_KEYS = setOf("enabled", "rootfs", "binary")
/**
* The one key of a build definition that says *when* and *for which branches* it
@@ -38,9 +38,9 @@ data class WerkatorConfig(
): BranchConfig {
val branchConfig = branches[branch] ?: branches["default"] ?: BranchConfig()
val settings = effectiveBuildDefinitions()[build]?.applyTo(branchConfig) ?: branchConfig
if (settings.docker.enabled && settings.bwrap.enabled) {
if (settings.docker.enabled && settings.werkdock.enabled) {
throw IllegalArgumentException(
"builds.$build on '$branch' enables both docker and bwrap; a build runs in exactly one sandbox. " +
"builds.$build on '$branch' enables both docker and werkdock; a build runs in exactly one sandbox. " +
"Disable one of them.",
)
}
@@ -179,17 +179,22 @@ data class BranchConfig(
val statusContext: String = "",
val autoBuild: AutoBuildConfig = AutoBuildConfig(),
val docker: DockerConfig = DockerConfig(),
/** bubblewrap user-namespace sandbox; mutually exclusive with [docker]. */
val bwrap: BwrapConfig = BwrapConfig(),
/** werkdock sandbox (bubblewrap user namespace); mutually exclusive with [docker]. */
val werkdock: WerkdockConfig = WerkdockConfig(),
)
/**
* bubblewrap build sandbox (Step 17): runs the build in an unprivileged user namespace
* with a prepared Debian root filesystem. For hosts without root and without a Docker
* daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md`.
* The werkdock build sandbox (Step 17, executed by the werkdock CLI since step 21):
* runs the build in an unprivileged bubblewrap user namespace over a prepared Debian
* root filesystem. For hosts without root and without a Docker daemon (e.g. Hostsharing
* managed webspaces); see `docs/plan/17-bwrap-build-runtime.md`.
*
* The section was called `bwrap` until PR#19 and is still read under that name, with a
* warning: `bwrap` named the mechanism one layer below the tool that actually runs it,
* which made `bwrap.werkdock` the key naming its own executor.
*/
data class BwrapConfig(
/** Run the clean and build commands in a bwrap sandbox instead of natively. */
data class WerkdockConfig(
/** Run the clean and build commands in the sandbox instead of natively. */
val enabled: Boolean = false,
/**
* Path or URL of the prepared rootfs archive (e.g. `werkator-buildenv-trixie-java21.tar.zst`),
@@ -200,8 +205,9 @@ data class BwrapConfig(
/**
* 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.
* Was `bwrap.werkdock` until PR#19.
*/
val werkdock: String = "werkdock",
val binary: String = "werkdock",
/** Additional environment variables set inside the sandbox. */
val env: Map<String, String> = emptyMap(),
)
@@ -7,6 +7,16 @@
<div th:replace="~{fragments :: nav(${view})}"></div>
<div class="panel release-notes">
<h2>v1.2.0 <span class="muted">— 2026-09-03</span></h2>
<ul>
<li>The build sandbox for hosts without Docker is configured as <code>werkdock</code> now,
not <code>bwrap</code> (PR#19), and its <code>bwrap.werkdock</code> key — which named its
own executor — is <code>werkdock.binary</code>. The old section is still read, with a
warning naming the file, so no installation has to be changed before its next
configuration edit. <code>bwrap</code> named the mechanism one layer below the tool that
actually runs it: builds have been executed by the werkdock CLI since v1.0.0.</li>
</ul>
<h2>v1.1.2 <span class="muted">— 2026-09-03</span></h2>
<ul>
<li>On a Hostsharing Managed Webspace, an <code>instance-update</code> restart no longer looks
@@ -505,6 +505,8 @@ class BuildExecutorTest : FunSpec() {
}
test("with maxConcurrent 1 a second branch stays PENDING until the first finished") {
// branch-a blocks on a gate file the test creates, so the PENDING assertion
// below cannot race the first build finishing on a loaded machine
val h =
Harness(
"""
@@ -512,7 +514,7 @@ class BuildExecutorTest : FunSpec() {
maxConcurrent: 1
branches:
branch-a:
buildCommand: "sleep 1"
buildCommand: "until [ -f gate ]; do sleep 0.05; done"
cleanCommand: ""
branch-b:
buildCommand: "echo ok"
@@ -523,8 +525,13 @@ class BuildExecutorTest : FunSpec() {
h.executor.startBuild(h.repo, "branch-a", "sha-a")
h.executor.startBuild(h.repo, "branch-b", "sha-b")
eventually(30.seconds) {
h.repository.latestFor("branch-a")?.status shouldBe BuildStatus.RUNNING
}
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.PENDING
Files.createFile(h.workingDir.resolve("gate"))
awaitStatus(h, "branch-b", BuildStatus.SUCCESS)
awaitStatus(h, "branch-a", BuildStatus.SUCCESS)
val transitions = h.events.map { it.result.branch to it.result.status }
@@ -1,8 +1,8 @@
package de.hoennig.werkator.build
import de.hoennig.werkator.config.BranchConfig
import de.hoennig.werkator.config.BwrapConfig
import de.hoennig.werkator.config.DockerConfig
import de.hoennig.werkator.config.WerkdockConfig
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.Called
@@ -15,13 +15,13 @@ import java.nio.file.Paths
class DispatchingBuildRunnerTest : FunSpec() {
private val processBuildRunner = mockk<ProcessBuildRunner>()
private val dockerBuildRunner = mockk<DockerBuildRunner>()
private val bwrapBuildRunner = mockk<BwrapBuildRunner>()
private val dispatcher = DispatchingBuildRunner(processBuildRunner, dockerBuildRunner, bwrapBuildRunner)
private val werkdockBuildRunner = mockk<WerkdockBuildRunner>()
private val dispatcher = DispatchingBuildRunner(processBuildRunner, dockerBuildRunner, werkdockBuildRunner)
private val process = mockk<Process>()
private val dir = Paths.get(".")
init {
beforeEach { clearMocks(processBuildRunner, dockerBuildRunner, bwrapBuildRunner) }
beforeEach { clearMocks(processBuildRunner, dockerBuildRunner, werkdockBuildRunner) }
test("runs natively by default") {
val branchConfig = BranchConfig()
@@ -30,7 +30,7 @@ class DispatchingBuildRunnerTest : FunSpec() {
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
verify { dockerBuildRunner wasNot Called }
verify { bwrapBuildRunner wasNot Called }
verify { werkdockBuildRunner wasNot Called }
}
test("runs in Docker when the branch enables it") {
@@ -40,12 +40,12 @@ class DispatchingBuildRunnerTest : FunSpec() {
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
verify { processBuildRunner wasNot Called }
verify { bwrapBuildRunner wasNot Called }
verify { werkdockBuildRunner wasNot Called }
}
test("runs in bwrap when the branch enables it (and not Docker)") {
val branchConfig = BranchConfig(bwrap = BwrapConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst"))
every { bwrapBuildRunner.start("cmd", dir, emptyMap(), dir, branchConfig) } returns process
test("runs in the werkdock sandbox when the branch enables it (and not Docker)") {
val branchConfig = BranchConfig(werkdock = WerkdockConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst"))
every { werkdockBuildRunner.start("cmd", dir, emptyMap(), dir, branchConfig) } returns process
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
@@ -1,7 +1,8 @@
package de.hoennig.werkator.build
import de.hoennig.werkator.config.BranchConfig
import de.hoennig.werkator.config.BwrapConfig
import de.hoennig.werkator.config.WerkdockConfig
import de.hoennig.werkator.git.GitCommandException
import de.hoennig.werkator.git.GitCommandResult
import de.hoennig.werkator.git.GitCommandRunner
import io.kotest.assertions.throwables.shouldThrow
@@ -15,20 +16,20 @@ import io.mockk.verify
import java.nio.file.Files
import java.nio.file.Path
class BwrapBuildRunnerTest : FunSpec() {
class WerkdockBuildRunnerTest : FunSpec() {
private val commandRunner = mockk<GitCommandRunner>()
private lateinit var runner: BwrapBuildRunner
private lateinit var runner: WerkdockBuildRunner
private lateinit var repoDir: Path
private lateinit var workspace: Path
private val captured = mutableListOf<List<String>>()
private fun bwrapBranchConfig(
private fun werkdockBranchConfig(
rootfs: String = "/srv/buildenv.tar.zst",
env: Map<String, String> = emptyMap(),
): BranchConfig =
BranchConfig(
bwrap =
BwrapConfig(
werkdock =
WerkdockConfig(
enabled = true,
rootfs = rootfs,
env = env,
@@ -37,7 +38,11 @@ class BwrapBuildRunnerTest : FunSpec() {
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 importCommand(): List<String> = listOf("werkdock", "import", "/srv/buildenv.tar.zst", imageName())
private fun loadCommand(): List<String> = listOf("werkdock", "load", "-i", "/srv/buildenv.tar.zst", "--name", imageName())
/** The image is already there: `werkdock images` lists it, so no import 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", "")
@@ -52,9 +57,9 @@ class BwrapBuildRunnerTest : FunSpec() {
beforeEach {
clearMocks(commandRunner)
captured.clear()
repoDir = Files.createTempDirectory("werkator-bwrap-runner")
repoDir = Files.createTempDirectory("werkator-werkdock-runner")
workspace = repoDir.resolve("workspace")
runner = BwrapBuildRunner(commandRunner)
runner = WerkdockBuildRunner(commandRunner)
runner.processStarter = { command, _ ->
captured += command
ProcessBuilder("true").start()
@@ -64,7 +69,7 @@ class BwrapBuildRunnerTest : FunSpec() {
test("assembles the exact werkdock run command for a loaded image") {
givenImageLoaded()
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
captured.single() shouldBe
listOf(
@@ -88,34 +93,47 @@ class BwrapBuildRunnerTest : FunSpec() {
)
}
test("loads the image once when werkdock does not know it yet") {
test("imports the image once when werkdock does not know it yet") {
givenImageMissing()
every {
commandRunner.runOrThrow(
listOf("werkdock", "load", "-i", "/srv/buildenv.tar.zst", "--name", imageName()),
repoDir,
any(),
any(),
)
} returns GitCommandResult(0, "", "")
every { commandRunner.run(importCommand(), repoDir, any(), any()) } returns GitCommandResult(0, "", "")
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
verify {
commandRunner.runOrThrow(
listOf("werkdock", "load", "-i", "/srv/buildenv.tar.zst", "--name", imageName()),
repoDir,
any(),
any(),
)
}
verify { commandRunner.run(importCommand(), repoDir, any(), any()) }
verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) }
}
test("does not load an image werkdock already has") {
test("falls back to load -i --name on a werkdock without the import verb") {
givenImageMissing()
every { commandRunner.run(importCommand(), repoDir, any(), any()) } returns
GitCommandResult(125, "", "werkdock: unknown command \"import\"\n")
every { commandRunner.runOrThrow(loadCommand(), repoDir, any(), any()) } returns GitCommandResult(0, "", "")
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
verify { commandRunner.runOrThrow(loadCommand(), repoDir, any(), any()) }
}
test("propagates an import failure that is not a missing verb") {
givenImageMissing()
every { commandRunner.run(importCommand(), repoDir, any(), any()) } returns
GitCommandResult(125, "", "werkdock: unpacking /srv/buildenv.tar.zst failed\n")
val exception =
shouldThrow<GitCommandException> {
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
}
exception.message shouldContain "unpacking"
verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) }
}
test("does not import an image werkdock already has") {
givenImageLoaded()
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
verify(exactly = 0) { commandRunner.run(match { "import" in it }, any(), any(), any()) }
verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) }
}
@@ -124,7 +142,7 @@ class BwrapBuildRunnerTest : FunSpec() {
GitCommandResult(0, imageName() + "\n", "")
val branchConfig =
BranchConfig(
bwrap = BwrapConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst", werkdock = "/opt/bin/werkdock"),
werkdock = WerkdockConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst", binary = "/opt/bin/werkdock"),
)
runner.start("./gradlew test", workspace, emptyMap(), repoDir, branchConfig)
@@ -136,7 +154,7 @@ class BwrapBuildRunnerTest : FunSpec() {
givenImageLoaded()
val relativeWorkspace = repoDir.relativize(workspace)
runner.start("./gradlew test", relativeWorkspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
runner.start("./gradlew test", relativeWorkspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
val args = captured.single()
val absolute = workspace.toAbsolutePath().normalize().toString()
@@ -145,7 +163,7 @@ class BwrapBuildRunnerTest : FunSpec() {
args.count { it == "$absolute:$absolute" } shouldBe 1
}
test("adds bwrap env after the branch environment") {
test("adds the sandbox env after the branch environment") {
givenImageLoaded()
runner.start(
@@ -153,7 +171,7 @@ class BwrapBuildRunnerTest : FunSpec() {
workspace,
mapOf("branch" to "main"),
repoDir,
bwrapBranchConfig(env = mapOf("FOO" to "bar")),
werkdockBranchConfig(env = mapOf("FOO" to "bar")),
)
val args = captured.single()
@@ -171,7 +189,7 @@ class BwrapBuildRunnerTest : FunSpec() {
Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n")
givenImageLoaded()
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
val args = captured.single()
args[args.indexOf("$gitDir:$gitDir:ro") - 1] shouldBe "-v"
@@ -190,7 +208,7 @@ class BwrapBuildRunnerTest : FunSpec() {
givenImageLoaded()
Files.createDirectories(workspace)
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
val args = captured.single()
val gitDir = repoDir.resolve(".git")
@@ -199,37 +217,37 @@ class BwrapBuildRunnerTest : FunSpec() {
}
test("fails without a configured rootfs") {
val branchConfig = BranchConfig(bwrap = BwrapConfig(enabled = true))
val branchConfig = BranchConfig(werkdock = WerkdockConfig(enabled = true))
val exception =
shouldThrow<IllegalArgumentException> {
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, branchConfig)
}
exception.message shouldContain "bwrap.rootfs"
exception.message shouldContain "werkdock.rootfs"
}
test("downloads a URL rootfs once before loading it") {
test("downloads a URL rootfs once before importing it") {
val url = "https://example.test/buildenv.tar.zst"
val downloadTarget =
repoDir
.resolve(BwrapBuildRunner.BUILDENV_DIR)
.resolve(WerkdockBuildRunner.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 { "load" in it }, any(), any(), any()) } returns
every { commandRunner.run(match { "import" in it }, any(), any(), any()) } returns
GitCommandResult(0, "", "")
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig(rootfs = url))
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig(rootfs = url))
verify {
commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any())
}
verify {
commandRunner.runOrThrow(
listOf("werkdock", "load", "-i", downloadTarget.toString(), "--name", "werkator-buildenv-${url.sha12()}"),
commandRunner.run(
listOf("werkdock", "import", downloadTarget.toString(), "werkator-buildenv-${url.sha12()}"),
repoDir,
any(),
any(),
@@ -109,6 +109,21 @@ class InitCommandTest : FunSpec() {
projectContent shouldContain "repo: my-repo"
}
test("keeps dots in repository names") {
val tempDir = Files.createTempDirectory("werkator-init-test")
initCommand.workingDir = tempDir
every { gitService.getTopLevel(tempDir) } returns tempDir
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/mi/michael.hoennig.de.git"
initCommand.run()
val projectConfig = tempDir.resolve(".werkator.yml")
val projectContent = projectConfig.toFile().readText()
projectContent shouldContain "owner: mi"
projectContent shouldContain "repo: michael.hoennig.de"
}
test("does not overwrite existing files") {
val tempDir = Files.createTempDirectory("werkator-init-test")
initCommand.workingDir = tempDir
@@ -252,5 +267,50 @@ class InitCommandTest : FunSpec() {
// This should not throw IllegalArgumentException
initCommand.run()
}
test("--systemd reads the configuration from the repository root, not the current directory") {
val tempDir = Files.createTempDirectory("werkator-init-test")
// written before the run, so `init` keeps it instead of creating a template
tempDir.resolve(".werkator.yml").toFile().writeText(
"server:\n publicBaseUrl: \"https://werkator.example.org/\"\n port: 18099\n",
)
initCommand.workingDir = tempDir
initCommand.systemd = true
initCommand.jarPathResolver = { Paths.get("/home/ci/bin/werkator.jar") }
initCommand.javaExecutableResolver = { Paths.get("/usr/bin/java") }
every { gitService.getTopLevel(tempDir) } returns tempDir
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/my-org/my-repo.git"
initCommand.run()
// the host integration is generated only when the root's config has a public base URL
val htaccess = tempDir.resolve(".git/werkator/${SystemdServiceFiles.HTACCESS_NAME}")
htaccess.toFile().shouldExist()
htaccess.toFile().readText() shouldContain "18099"
tempDir.resolve(".git/werkator/${SystemdServiceFiles.MAINTENANCE_PAGE_NAME}").toFile().shouldExist()
}
test("--systemd warns once when the effective configuration cannot be loaded") {
val tempDir = Files.createTempDirectory("werkator-init-test")
val brokenLoader = mockk<de.hoennig.werkator.config.ConfigLoader>()
every { brokenLoader.load(any()) } throws IllegalStateException("gitea.owner is required")
val command = InitCommand(gitService, brokenLoader)
command.workingDir = tempDir
command.systemd = true
command.jarPathResolver = { Paths.get("/home/ci/bin/werkator.jar") }
command.javaExecutableResolver = { Paths.get("/usr/bin/java") }
every { gitService.getTopLevel(tempDir) } returns tempDir
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/my-org/my-repo.git"
val console = captureConsole { command.run() }
console.stdout shouldContain "gitea.owner is required"
// the three readers of the configuration must not repeat the warning
console.stdout.windowed("Warning:".length).count { it == "Warning:" } shouldBe 1
// the units are still written with the defaults
tempDir.resolve(".git/werkator/${SystemdServiceFiles.unitName(tempDir)}").toFile().shouldExist()
}
}
}
@@ -496,7 +496,7 @@ class ConfigLoaderTest : FunSpec() {
settings.docker.image shouldBe "attacker-image"
}
test("a branch cannot disable its bwrap sandbox or substitute a foreign rootfs through a build definition") {
test("the legacy bwrap section is read as werkdock, its werkdock key as binary") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText(
"""
@@ -505,6 +505,58 @@ class ConfigLoaderTest : FunSpec() {
bwrap:
enabled: true
rootfs: /host/rootfs.tar.zst
werkdock: /opt/bin/werkdock
env:
FOO: bar
""".trimIndent(),
)
val settings = loader.load(dir).buildSettings("any-branch", "default")
settings.werkdock.enabled shouldBe true
settings.werkdock.rootfs shouldBe "/host/rootfs.tar.zst"
settings.werkdock.binary shouldBe "/opt/bin/werkdock"
settings.werkdock.env shouldBe mapOf("FOO" to "bar")
}
test("a legacy bwrap section on a branch is pinned exactly like the new name") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText(
"""
builds:
default:
werkdock:
enabled: true
rootfs: /host/rootfs.tar.zst
""".trimIndent(),
)
val worktree = Files.createTempDirectory("werkator-test-worktree")
// the old name must not become a way around the pinning
worktree.resolve(".werkator.yml").toFile().writeText(
"""
builds:
default:
bwrap:
enabled: false
rootfs: /attacker/rootfs.tar.zst
""".trimIndent(),
)
val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default")
settings.werkdock.enabled shouldBe true
settings.werkdock.rootfs shouldBe "/host/rootfs.tar.zst"
}
test("a branch cannot disable its werkdock sandbox or substitute a foreign rootfs through a build definition") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText(
"""
builds:
default:
werkdock:
enabled: true
rootfs: /host/rootfs.tar.zst
""".trimIndent(),
)
val worktree = Files.createTempDirectory("werkator-test-worktree")
@@ -512,7 +564,7 @@ class ConfigLoaderTest : FunSpec() {
"""
builds:
default:
bwrap:
werkdock:
enabled: false
rootfs: /attacker/rootfs.tar.zst
env:
@@ -523,10 +575,10 @@ class ConfigLoaderTest : FunSpec() {
val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default")
// pinned: the sandbox can neither be switched off nor pointed at a foreign rootfs
settings.bwrap.enabled shouldBe true
settings.bwrap.rootfs shouldBe "/host/rootfs.tar.zst"
settings.werkdock.enabled shouldBe true
settings.werkdock.rootfs shouldBe "/host/rootfs.tar.zst"
// everything that describes the build itself stays the branch's own business
settings.bwrap.env shouldBe mapOf("FOO" to "from-branch")
settings.werkdock.env shouldBe mapOf("FOO" to "from-branch")
}
test("a build the branch invents inherits the host's sandbox policy") {
@@ -565,7 +617,7 @@ class ConfigLoaderTest : FunSpec() {
settings.requirePullRequest shouldBe true
}
test("enabling both docker and bwrap on a build is rejected, not picked silently") {
test("enabling both docker and werkdock on a build is rejected, not picked silently") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText(
"""
@@ -574,7 +626,7 @@ class ConfigLoaderTest : FunSpec() {
docker:
enabled: true
image: build-env
bwrap:
werkdock:
enabled: true
rootfs: /srv/rootfs.tar.zst
""".trimIndent(),
@@ -586,7 +638,7 @@ class ConfigLoaderTest : FunSpec() {
config.buildSettings("any-branch", "default")
}
exception.message shouldContain "both docker and bwrap"
exception.message shouldContain "both docker and werkdock"
exception.message shouldContain "builds.default"
}
+209 -66
View File
@@ -44,14 +44,24 @@
# Optional in the env file:
# 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)
# (default: https://git.javagil.de/mi/werkator.git)
# WERKATOR_REPO_DIR directory of the watched repository, absolute or relative to
# WERKATOR_PATH (default: werkator); it also names the systemd
# unit, exactly as `init --systemd` derives it
# WERKATOR_INSTALL_DIR directory holding the unpacked runtime bundle, absolute or
# relative to WERKATOR_PATH (default: .werkator)
# WERKATOR_SANDBOX build runtime of the host: werkdock (default, the bubblewrap
# sandbox; `bwrap` is accepted as its former name) or docker —
# a docker host needs neither the werkdock binary nor a rootfs
# WERKDOCK_REPO checkout of the werkdock repository, whose binary the
# instance runs (default: <repo>/../werkdock)
# WERKDOCK_BINARY the built werkdock binary (default: $WERKDOCK_REPO/dist/werkdock)
# WERKATOR_ROOTFS rootfs archive path for repo-init
# (default: <repo>/build/werkator-buildenv-trixie-java-go-node.tar.zst)
#
# Install layout on the host:
# Install layout on the host — the default, which the three keys above bend to an
# installation that predates this script (e.g. the docker host vm4006: the watched
# repository is ~/hs.hsadmin.ng, the runtime lives in ~/opt, there is no werkdock):
# $WERKATOR_PATH/werkator/ the watched repository (clone)
# $WERKATOR_PATH/.werkator/werkator/ the unpacked runtime bundle
# $WERKATOR_PATH/.werkator/bin/ the werkdock binary
@@ -112,10 +122,37 @@ require_env WERKATOR_REMOTE WERKATOR_PATH
HOST="$WERKATOR_REMOTE"
TARGET_DIR="$WERKATOR_PATH"
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"
REPO_URL="${WERKATOR_REPO_URL:-https://git.javagil.de/mi/werkator.git}"
# The host layout is three values, not one convention: an installation that grew
# before this script existed puts them elsewhere, and the defaults are exactly what
# `instance-install` creates, so an env file that names none of them behaves as before.
# Both directories may be absolute; a bare name is taken relative to WERKATOR_PATH.
resolve_dir() {
case "$1" in
/*) echo "$1" ;;
*) echo "$TARGET_DIR/$1" ;;
esac
}
REPO_DIR="$(resolve_dir "${WERKATOR_REPO_DIR:-werkator}")"
INSTALL_DIR="$(resolve_dir "${WERKATOR_INSTALL_DIR:-.werkator}")"
# where `repo-add` puts a further repository of the registry: beside the watched one
SIBLING_DIR="$(dirname "$REPO_DIR")"
SANDBOX="${WERKATOR_SANDBOX:-werkdock}"
# `bwrap` was the name of the config section until Werkator v1.2.0; accepted so an env
# file written for the older script keeps working, normalised so only one name is used.
[ "$SANDBOX" = "bwrap" ] && SANDBOX="werkdock"
case "$SANDBOX" in
werkdock|docker) ;;
*) die "WERKATOR_SANDBOX is 'werkdock' or 'docker', not '$SANDBOX'" ;;
esac
MACHINE_CONFIG="$REPO_DIR/.git/werkator/.werkator.yml"
WERKATOR_BIN="$INSTALL_DIR/werkator/bin/werkator"
# mirrors SystemdServiceFiles.unitName: the repository's directory name, every
# character outside [A-Za-z0-9_.-] replaced by a dash — the unit `init --systemd`
# writes, which is the one this script may stop and start.
UNIT="werkator-$(basename "$REPO_DIR" | sed 's/[^A-Za-z0-9_.-]/-/g').service"
ssh_present() {
ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" true 2>/dev/null
@@ -133,13 +170,21 @@ ensure_ssh() {
# Werkdock owns the host checks (`werkdock doctor` ports the old prerequisites
# script); the binary is uploaded first, so the check works pre-install.
# On a docker host there is no werkdock and no sandbox to check: the build runtime
# is the docker daemon, so the check is that the daemon answers this user.
check_prerequisites() {
if [ "$SANDBOX" = "docker" ]; then
echo "==> Checking the docker build runtime on $HOST (WERKATOR_SANDBOX=docker)"
ssh "$HOST" "docker info >/dev/null" || die "docker is not usable by this user on $HOST"
ssh "$HOST" "docker --version"
return 0
fi
ensure_werkdock_binary
echo "==> Uploading werkdock and running its doctor on $HOST (target dir: $TARGET_DIR)"
ssh "$HOST" "mkdir -p '$TARGET_DIR/.werkator/bin'"
scp -q "$WERKDOCK_BINARY" "$HOST:$TARGET_DIR/.werkator/bin/werkdock.new"
ssh "$HOST" "mv '$TARGET_DIR/.werkator/bin/werkdock.new' '$TARGET_DIR/.werkator/bin/werkdock' && chmod 755 '$TARGET_DIR/.werkator/bin/werkdock'"
if ! ssh "$HOST" "'$TARGET_DIR/.werkator/bin/werkdock' doctor '$TARGET_DIR'"; then
ssh "$HOST" "mkdir -p '$INSTALL_DIR/bin'"
scp -q "$WERKDOCK_BINARY" "$HOST:$INSTALL_DIR/bin/werkdock.new"
ssh "$HOST" "mv '$INSTALL_DIR/bin/werkdock.new' '$INSTALL_DIR/bin/werkdock' && chmod 755 '$INSTALL_DIR/bin/werkdock'"
if ! ssh "$HOST" "'$INSTALL_DIR/bin/werkdock' doctor '$TARGET_DIR'"; then
die "werkdock doctor failed on $HOST — install aborted"
fi
}
@@ -160,7 +205,7 @@ next to this repository, or point WERKDOCK_REPO/WERKDOCK_BINARY at your checkout
upload_fragment() {
[ -n "${WERKATOR_INIT_CONFIG:-}" ] || { echo ""; return 0; }
[ -f "$WERKATOR_INIT_CONFIG" ] || die "init fragment missing: $WERKATOR_INIT_CONFIG"
local remote="$TARGET_DIR/.werkator/$(basename "$WERKATOR_INIT_CONFIG")"
local remote="$INSTALL_DIR/$(basename "$WERKATOR_INIT_CONFIG")"
scp -q "$WERKATOR_INIT_CONFIG" "$HOST:$remote"
echo "$remote"
}
@@ -174,43 +219,85 @@ ensure_instance_artifacts() {
(cd "$REPO_ROOT" && ./gradlew runtimeBundle --console=plain -q)
fi
[ -f "$RUNTIME_BUNDLE" ] || die "runtime bundle missing: $RUNTIME_BUNDLE"
ensure_werkdock_binary
[ "$SANDBOX" = "docker" ] || ensure_werkdock_binary
}
# 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"
# Uploads one file and verifies it arrived whole: a transfer that dies mid-way
# (scp: Connection closed) otherwise leaves a truncated archive that unpacks into
# a broken runtime. Retries twice, because a dropped WAN connection is not a reason
# to abort a deployment.
upload_verified() {
local src="$1" dest="$2"
local local_sha remote_sha attempt
local_sha="$(sha256sum "$src" | cut -d' ' -f1)"
remote_sha="$(ssh "$HOST" "sha256sum '$dest' 2>/dev/null | cut -d' ' -f1" || true)"
if [ "$local_sha" = "$remote_sha" ]; then
echo " $(basename "$src"): already on the host, skipping"
return 0
fi
for attempt in 1 2 3; do
if scp -q "$src" "$HOST:$dest.part"; then
remote_sha="$(ssh "$HOST" "sha256sum '$dest.part' 2>/dev/null | cut -d' ' -f1" || true)"
if [ "$local_sha" = "$remote_sha" ]; then
ssh "$HOST" "mv '$dest.part' '$dest'"
return 0
fi
echo " checksum mismatch after transfer $attempt of $(basename "$src")" >&2
else
echo " transfer $attempt of $(basename "$src") failed" >&2
fi
done
ssh "$HOST" "rm -f '$dest.part'" || true
die "cannot upload $src to $HOST:$dest — three attempts failed"
}
# Uploads the instance artifacts, without touching the installed runtime: the
# service keeps running until swap_instance_runtime replaces it, so a failed
# transfer costs nothing but the transfer.
upload_instance_artifacts() {
if [ "$SANDBOX" = "docker" ]; then
echo "==> Uploading runtime bundle"
else
echo "==> Uploading runtime bundle and werkdock binary"
fi
ssh "$HOST" "mkdir -p '$INSTALL_DIR/bin'"
upload_verified "$RUNTIME_BUNDLE" "$INSTALL_DIR/$(basename "$RUNTIME_BUNDLE")"
if [ "$SANDBOX" != "docker" ]; then
upload_verified "$WERKDOCK_BINARY" "$INSTALL_DIR/bin/werkdock.new"
fi
}
# Swaps in the uploaded artifacts. The previous runtime stays as werkator.prev
# for one deployment as the rollback asset.
swap_instance_runtime() {
echo "==> Unpacking"
ssh "$HOST" "set -e
cd '$TARGET_DIR/.werkator'
mv bin/werkdock.new bin/werkdock && chmod 755 bin/werkdock
cd '$INSTALL_DIR'
[ ! -f bin/werkdock.new ] || { 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"
[ ! -x bin/werkdock ] || './bin/werkdock' version"
}
instance_install() {
ensure_ssh
check_prerequisites
ensure_instance_artifacts
deploy_instance
upload_instance_artifacts
swap_instance_runtime
echo
echo "==> Instance installed."
echo " Runtime: $WERKATOR_BIN"
echo " werkdock: $TARGET_DIR/.werkator/bin/werkdock"
[ "$SANDBOX" = "docker" ] || echo " werkdock: $INSTALL_DIR/bin/werkdock"
echo " Next: tools/remote werkator repo-init, then instance-start"
}
# Refuse to swap the runtime under a running build; FORCE=1 overrides.
require_idle() {
local port
port="$(ssh "$HOST" "cd '$TARGET_DIR/werkator' 2>/dev/null && '$WERKATOR_BIN' config:print 2>/dev/null" | awk '/^server:/{f=1;next} f && /^ port:/{print $2; exit}' | tr -d '"' || true)"
port="$(ssh "$HOST" "cd '$REPO_DIR' 2>/dev/null && '$WERKATOR_BIN' config:print 2>/dev/null" | awk '/^server:/{f=1;next} f && /^ port:/{print $2; exit}' | tr -d '"' || 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)"
@@ -224,6 +311,9 @@ instance_update() {
ensure_ssh
ensure_instance_artifacts
require_idle
# upload first, stop second: a transfer that fails must not leave the host
# without a running service (measured on vm4006, 2026-09-03)
upload_instance_artifacts
local was_active=0
if ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user is-active --quiet '$UNIT'"; then
was_active=1
@@ -232,7 +322,7 @@ instance_update() {
echo "==> Stopping $UNIT"
ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user stop '$UNIT'"
fi
deploy_instance
swap_instance_runtime
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'"
@@ -242,47 +332,104 @@ instance_update() {
echo "==> Instance updated."
}
# 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 with the instance fragment
# Clones one URL into one directory on the host.
# A private https origin authenticates with the shared `defaults.git.account` /
# `defaults.git.token` of `~/.werkator.yml` (ADR 0009). The whole authenticated
# clone runs in one remote python script: the token is read from the instance
# file on the host and passed to git via a one-shot GIT_ASKPASS script, so it
# appears in neither the local process list nor a repository config.
# Public origins (or an instance without shared credentials) clone anonymously.
clone_repo() {
local url="$1" dest="$2"
if ssh "$HOST" "test -d '$dest/.git'"; then
echo " (already cloned, skipping)"
return 0
fi
case "$url" in
https://*)
# The python helper travels base64-encoded: the clone command itself
# stays a plain `ssh` line, so no `$` inside the script is ever
# expanded by the local shell, and the token never leaves the host.
local helper_b64
helper_b64="$(python3 -c 'import base64,sys; print(base64.b64encode(sys.stdin.read().encode()).decode())' <<'PYEOF_CLONE'
import os, stat, subprocess, sys, tempfile, urllib.parse
url, dest = sys.argv[1], sys.argv[2]
try:
import yaml
cfg = yaml.safe_load(open(os.path.expanduser("~/.werkator.yml"))) or {}
except (FileNotFoundError, ImportError):
cfg = {}
d = (cfg.get("defaults") or {}).get("git") or {}
account, token = d.get("account"), d.get("token")
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
ask = None
if account and token:
parts = urllib.parse.urlsplit(url)
host = parts.netloc.rsplit("@", 1)[-1]
url = urllib.parse.urlunsplit(parts._replace(netloc=account + "@" + host))
ask = tempfile.NamedTemporaryFile(mode="w", prefix="werkator-clone-askpass-",
suffix=".sh", delete=False)
ask.write("#!/bin/sh\nexec echo \"$WERKATOR_CLONE_TOKEN\"\n")
ask.close()
os.chmod(ask.name, stat.S_IRWXU)
env.update(GIT_ASKPASS=ask.name, WERKATOR_CLONE_TOKEN=token)
try:
subprocess.run(["git", "clone", url, dest], env=env, check=True)
finally:
if ask is not None:
os.unlink(ask.name)
PYEOF_CLONE
)"
ssh "$HOST" "echo '$helper_b64' | base64 -d | python3 - '$url' '$dest'"
;;
*)
ssh "$HOST" "git clone '$url' '$dest'"
;;
esac
}
# Sets up the WATCHED repository: an https clone (a private origin
# authenticates with the shared `defaults.git.*` credentials of
# `~/.werkator.yml`; see `clone_repo`), the werkator init with the instance
# applied, and the rootfs archive for the sandbox builds. All configuration
# writing is init's — this script transports and invokes (step 23).
repo_init() {
ensure_ssh
[ -f "$ROOTFS" ] || die "rootfs archive missing: $ROOTFS — build it with tools/build-bwrap-rootfs.sh or set WERKATOR_ROOTFS"
[ "$SANDBOX" = "docker" ] || [ -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 watched repository"
if ssh "$HOST" "test -d '$TARGET_DIR/werkator/.git'"; then
echo " (already cloned, skipping)"
else
ssh "$HOST" "git clone '$REPO_URL' '$TARGET_DIR/werkator'"
fi
clone_repo "$REPO_URL" "$REPO_DIR"
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)"
if [ "$SANDBOX" = "docker" ]; then
echo "==> No rootfs needed (WERKATOR_SANDBOX=docker) — the build image is the repository's own Dockerfile"
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"
echo "==> Uploading the rootfs archive (skipped when unchanged)"
local rootfs_remote="$INSTALL_DIR/$(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
fi
echo "==> Running werkator init${WERKATOR_INIT_CONFIG:+ --apply $(basename "${WERKATOR_INIT_CONFIG}")}"
local fragment_remote
fragment_remote="$(upload_fragment)"
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' init ${fragment_remote:+--apply '$fragment_remote'}"
ssh "$HOST" "cd '$REPO_DIR' && '$WERKATOR_BIN' init ${fragment_remote:+--apply '$fragment_remote'}"
echo "==> Verifying the effective configuration"
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' config:print 2>/dev/null | grep -A4 'bwrap:' | head -5"
ssh "$HOST" "cd '$REPO_DIR' && '$WERKATOR_BIN' config:print 2>/dev/null | grep -A4 '$SANDBOX:' | head -5"
echo
echo "==> Repository ready."
echo " Repo: $TARGET_DIR/werkator"
echo " Repo: $REPO_DIR"
echo " Next: fill git.account/git.token in $MACHINE_CONFIG if the origin is private,"
echo " then tools/remote werkator instance-start"
}
@@ -306,11 +453,7 @@ repo_add() {
ssh "$HOST" "test -x '$WERKATOR_BIN'" || die "no instance on $HOST — run instance-install first"
echo "==> Cloning $url as '$name'"
if ssh "$HOST" "test -d '$TARGET_DIR/$name/.git'"; then
echo " (already cloned, skipping)"
else
ssh "$HOST" "git clone '$url' '$TARGET_DIR/$name'"
fi
clone_repo "$url" "$SIBLING_DIR/$name"
# The instance fragment carries the sandbox policy (bwrap rootfs and werkdock
# binary). Without it a watched repository builds on the bare host, where the
@@ -318,26 +461,26 @@ repo_add() {
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'}"
ssh "$HOST" "cd '$SIBLING_DIR/$name' && '$WERKATOR_BIN' init ${fragment_remote:+--apply '$fragment_remote'}"
echo "==> Checking the registry"
# Grepped locally: the entry may name the path absolute or as ~/<name>, and
# matching both is easier without a second layer of remote shell quoting.
if ssh "$HOST" "cat ~/.werkator.yml 2>/dev/null" |
grep -qE "path: *(~|$TARGET_DIR)/$name[[:space:]]*$"; then
grep -qE "path: *(~|$SIBLING_DIR)/$name[[:space:]]*$"; then
echo " (~/.werkator.yml already names this path)"
else
echo " not registered yet — add this entry to ~/.werkator.yml on $HOST:"
echo
echo " repositories:"
echo " - path: $TARGET_DIR/$name"
echo " - path: $SIBLING_DIR/$name"
echo " name: $name"
echo
fi
echo "==> Repository prepared."
echo " Repo: $TARGET_DIR/$name"
echo " Next: fill git.account/git.token in $TARGET_DIR/$name/.git/werkator/.werkator.yml if the origin is private"
echo " Repo: $SIBLING_DIR/$name"
echo " Next: fill git.account/git.token in $SIBLING_DIR/$name/.git/werkator/.werkator.yml if the origin is private"
echo " (or once for all repositories in the 'defaults' block of ~/.werkator.yml),"
echo " then restart the service — the registry is read at start."
}
@@ -353,11 +496,11 @@ instance_start() {
echo "==> Applying the instance fragment and generating the host integration (init --systemd)"
local fragment_remote
fragment_remote="$(upload_fragment)"
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' init ${fragment_remote:+--apply '$fragment_remote'} --systemd"
ssh "$HOST" "cd '$REPO_DIR' && '$WERKATOR_BIN' init ${fragment_remote:+--apply '$fragment_remote'} --systemd"
local htaccess_src="$TARGET_DIR/werkator/.git/werkator/werkator.htaccess"
local htaccess_src="$REPO_DIR/.git/werkator/werkator.htaccess"
local htaccess="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/.htaccess"
local maintenance_src="$TARGET_DIR/werkator/.git/werkator/werkator-maintenance.html"
local maintenance_src="$REPO_DIR/.git/werkator/werkator-maintenance.html"
local maintenance="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/werkator-maintenance.html"
if ssh "$HOST" "test -f '$htaccess_src'"; then
echo "==> Placing the generated Apache reverse proxy at $htaccess"
@@ -368,9 +511,9 @@ instance_start() {
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/werkator-docker-prune.service' ~/.config/systemd/user/ && \
ln -sf '$TARGET_DIR/werkator/.git/werkator/werkator-docker-prune.timer' ~/.config/systemd/user/ && \
ln -sf '$REPO_DIR/.git/werkator/$UNIT' ~/.config/systemd/user/ && \
ln -sf '$REPO_DIR/.git/werkator/werkator-docker-prune.service' ~/.config/systemd/user/ && \
ln -sf '$REPO_DIR/.git/werkator/werkator-docker-prune.timer' ~/.config/systemd/user/ && \
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"
@@ -388,7 +531,7 @@ port_forward() {
# the effective port, wherever it is configured (machine config or applied
# fragment) — config:print is the single answer, not this script's parser
local remote_port
remote_port="$(ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' config:print 2>/dev/null" | awk '/^server:/{f=1;next} f && /^ port:/{print $2; exit}' | tr -d '"')"
remote_port="$(ssh "$HOST" "cd '$REPO_DIR' && '$WERKATOR_BIN' config:print 2>/dev/null" | awk '/^server:/{f=1;next} f && /^ port:/{print $2; exit}' | tr -d '"')"
[ -n "$remote_port" ] || die "no server.port configured — run 'tools/remote werkator instance-start' first"
case "$COMMAND" in
@@ -430,7 +573,7 @@ port_forward() {
# CLI owns creation and format (step 23), this script only invokes it.
control_token() {
ensure_ssh
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' control-token"
ssh "$HOST" "cd '$REPO_DIR' && '$WERKATOR_BIN' control-token"
}
case "$REPO" in