Compare commits
6
Commits
main
...
6f67a3a5a3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f67a3a5a3 | ||
|
|
34c29ce30a | ||
|
|
2c64329d22 | ||
|
|
e1477eb8d0 | ||
|
|
389fae388e | ||
|
|
506e817c82 |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: architecture
|
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
|
# 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.
|
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.
|
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`).
|
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
|
## Watcher
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- 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`.
|
- 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.
|
- 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.
|
- 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.
|
- `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.
|
- 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.
|
- Git and Docker access shells out to the CLIs (`GitCommandRunner`, `docker`) — no JGit, no Docker SDK.
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ group = "de.hoennig"
|
|||||||
// so the UI footer (BuildProperties), --version and the release notes identify what is
|
// 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.
|
// 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.
|
// ReleaseVersionConsistencyTest fails the build if this and the top releases.html entry disagree.
|
||||||
version = "1.1.2"
|
version = "1.2.0"
|
||||||
|
|
||||||
java {
|
java {
|
||||||
toolchain {
|
toolchain {
|
||||||
|
|||||||
+11
-10
@@ -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
|
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
|
definitions and its overrides of the definitions from the project config, with
|
||||||
`buildCommand`, `cleanCommand`, `artifactDirs`, log file names, and
|
`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.
|
no other branch's builds are affected.
|
||||||
|
|
||||||
The branch layer is used in both places where it matters: the watcher reads the committed
|
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 repository-side settings: the whole `gitea`, `executor`, and `watcher` sections;
|
||||||
- the trust gate: `requirePullRequest`, and the Gitea status context: `statusContext`;
|
- the trust gate: `requirePullRequest`, and the Gitea status context: `statusContext`;
|
||||||
- the container sandbox policy: `docker.enabled`/`docker.network` and
|
- 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
|
long as only the host's configuration sets them, master-pinned once the committed
|
||||||
configuration does.
|
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.
|
`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.
|
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.
|
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.
|
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.
|
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.
|
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,23 @@ 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.
|
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.
|
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.
|
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).
|
`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/`.
|
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.
|
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 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.
|
||||||
Per-repo Gradle caches persist in `.git/werkator/buildenv/home`, bound as `/root`.
|
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.
|
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 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)
|
## `.git/werkator/.werkator.yml` (not committed)
|
||||||
|
|||||||
+7
-1
@@ -184,6 +184,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.
|
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.
|
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.
|
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.
|
Config file changes are not needed for an update; new keys take their defaults.
|
||||||
|
|
||||||
@@ -324,7 +329,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).
|
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.
|
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.
|
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
|
```bash
|
||||||
tools/remote --env-file .env.mih34 werkator check-prerequisites # uploads werkdock, runs its doctor
|
tools/remote --env-file .env.mih34 werkator check-prerequisites # uploads werkdock, runs its doctor
|
||||||
@@ -335,6 +340,7 @@ 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`.
|
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`.
|
||||||
|
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 loaded once per source into werkdock's image store (`~/.werkdock`), shared by every repository of the user.
|
The rootfs archive is loaded once per source into werkdock's image store (`~/.werkdock`), shared by every repository of the user.
|
||||||
Fill `git.account`/`git.token` in the machine config when the origin is private, and make the user's services survive logout with `loginctl enable-linger`.
|
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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -43,17 +43,17 @@ class ProcessBuildRunner : BuildRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects the runtime per branch: Docker when `branches.<name>.docker.enabled`,
|
* Selects the runtime per branch: Docker when `builds.<name>.docker.enabled`, the
|
||||||
* bubblewrap when `branches.<name>.bwrap.enabled`, native shell execution otherwise
|
* werkdock sandbox when `builds.<name>.werkdock.enabled`, native shell execution
|
||||||
* (the unchanged default). Docker and bwrap are mutually exclusive per branch and are
|
* otherwise (the unchanged default). Docker and werkdock are mutually exclusive per
|
||||||
* rejected together at config load, so the branch order here never has to "pick".
|
* branch and are rejected together at config load, so the order here never has to "pick".
|
||||||
*/
|
*/
|
||||||
@Primary
|
@Primary
|
||||||
@Component
|
@Component
|
||||||
class DispatchingBuildRunner(
|
class DispatchingBuildRunner(
|
||||||
private val processBuildRunner: ProcessBuildRunner,
|
private val processBuildRunner: ProcessBuildRunner,
|
||||||
private val dockerBuildRunner: DockerBuildRunner,
|
private val dockerBuildRunner: DockerBuildRunner,
|
||||||
private val bwrapBuildRunner: BwrapBuildRunner,
|
private val werkdockBuildRunner: WerkdockBuildRunner,
|
||||||
) : BuildRunner {
|
) : BuildRunner {
|
||||||
override fun start(
|
override fun start(
|
||||||
command: String,
|
command: String,
|
||||||
@@ -66,7 +66,7 @@ class DispatchingBuildRunner(
|
|||||||
val runner =
|
val runner =
|
||||||
when {
|
when {
|
||||||
branchConfig.docker.enabled -> dockerBuildRunner
|
branchConfig.docker.enabled -> dockerBuildRunner
|
||||||
branchConfig.bwrap.enabled -> bwrapBuildRunner
|
branchConfig.werkdock.enabled -> werkdockBuildRunner
|
||||||
else -> processBuildRunner
|
else -> processBuildRunner
|
||||||
}
|
}
|
||||||
return runner.start(command, workingDir, environment, repoDir, branchConfig, onAuxProcess)
|
return runner.start(command, workingDir, environment, repoDir, branchConfig, onAuxProcess)
|
||||||
|
|||||||
+17
-17
@@ -1,7 +1,7 @@
|
|||||||
package de.hoennig.werkator.build
|
package de.hoennig.werkator.build
|
||||||
|
|
||||||
import de.hoennig.werkator.config.BranchConfig
|
import de.hoennig.werkator.config.BranchConfig
|
||||||
import de.hoennig.werkator.config.BwrapConfig
|
import de.hoennig.werkator.config.WerkdockConfig
|
||||||
import de.hoennig.werkator.git.GitCommandRunner
|
import de.hoennig.werkator.git.GitCommandRunner
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
@@ -13,7 +13,7 @@ import java.security.MessageDigest
|
|||||||
* Runs build commands inside a bubblewrap user-namespace sandbox (Step 17 / ADR 0008),
|
* 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
|
* 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:
|
* 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.
|
* 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*, loaded once per source
|
||||||
@@ -36,10 +36,10 @@ import java.security.MessageDigest
|
|||||||
* native builds.
|
* native builds.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
class BwrapBuildRunner(
|
class WerkdockBuildRunner(
|
||||||
private val commandRunner: GitCommandRunner,
|
private val commandRunner: GitCommandRunner,
|
||||||
) : BuildRunner {
|
) : 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. */
|
/** Replaceable process launcher so unit tests can capture the assembled `werkdock` argv. */
|
||||||
internal var processStarter: (List<String>, Path) -> Process = { command, dir ->
|
internal var processStarter: (List<String>, Path) -> Process = { command, dir ->
|
||||||
@@ -54,14 +54,14 @@ class BwrapBuildRunner(
|
|||||||
branchConfig: BranchConfig,
|
branchConfig: BranchConfig,
|
||||||
onAuxProcess: (Process) -> Unit,
|
onAuxProcess: (Process) -> Unit,
|
||||||
): Process {
|
): Process {
|
||||||
val bwrap = branchConfig.bwrap
|
val sandbox = branchConfig.werkdock
|
||||||
require(bwrap.rootfs.isNotBlank()) { "branches.<name>.bwrap.rootfs must be set when bwrap.enabled is true" }
|
require(sandbox.rootfs.isNotBlank()) { "builds.<name>.werkdock.rootfs must be set when werkdock.enabled is true" }
|
||||||
val werkdock = bwrap.werkdock.ifBlank { "werkdock" }
|
val werkdock = sandbox.binary.ifBlank { "werkdock" }
|
||||||
val image = imageName(bwrap.rootfs)
|
val image = imageName(sandbox.rootfs)
|
||||||
ensureImage(werkdock, image, bwrap, repoDir, onAuxProcess)
|
ensureImage(werkdock, image, sandbox, repoDir, onAuxProcess)
|
||||||
val homeDir = repoDir.resolve(BUILDENV_DIR).resolve(HOME_DIR)
|
val homeDir = repoDir.resolve(BUILDENV_DIR).resolve(HOME_DIR)
|
||||||
Files.createDirectories(homeDir)
|
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)
|
return processStarter(args, repoDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ class BwrapBuildRunner(
|
|||||||
private fun ensureImage(
|
private fun ensureImage(
|
||||||
werkdock: String,
|
werkdock: String,
|
||||||
image: String,
|
image: String,
|
||||||
bwrap: BwrapConfig,
|
sandbox: WerkdockConfig,
|
||||||
repoDir: Path,
|
repoDir: Path,
|
||||||
onAuxProcess: (Process) -> Unit,
|
onAuxProcess: (Process) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -81,10 +81,10 @@ class BwrapBuildRunner(
|
|||||||
if (image in loaded) {
|
if (image in loaded) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val envDir = repoDir.resolve(BUILDENV_DIR).resolve(sourceKey(bwrap.rootfs))
|
val envDir = repoDir.resolve(BUILDENV_DIR).resolve(sourceKey(sandbox.rootfs))
|
||||||
Files.createDirectories(envDir)
|
Files.createDirectories(envDir)
|
||||||
val archive = localArchive(bwrap.rootfs, envDir, repoDir, onAuxProcess)
|
val archive = localArchive(sandbox.rootfs, envDir, repoDir, onAuxProcess)
|
||||||
log.info("loading build environment {} as werkdock image {}", bwrap.rootfs, image)
|
log.info("loading build environment {} as werkdock image {}", sandbox.rootfs, image)
|
||||||
commandRunner.runOrThrow(
|
commandRunner.runOrThrow(
|
||||||
listOf(werkdock, "load", "-i", archive, "--name", image),
|
listOf(werkdock, "load", "-i", archive, "--name", image),
|
||||||
repoDir,
|
repoDir,
|
||||||
@@ -93,7 +93,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.
|
* used as-is; an `http(s)` URL is downloaded once into the buildenv cache.
|
||||||
*/
|
*/
|
||||||
private fun localArchive(
|
private fun localArchive(
|
||||||
@@ -123,7 +123,7 @@ class BwrapBuildRunner(
|
|||||||
workspace: Path,
|
workspace: Path,
|
||||||
environment: Map<String, String>,
|
environment: Map<String, String>,
|
||||||
repoDir: Path,
|
repoDir: Path,
|
||||||
bwrap: BwrapConfig,
|
sandbox: WerkdockConfig,
|
||||||
werkdock: String,
|
werkdock: String,
|
||||||
image: String,
|
image: String,
|
||||||
homeDir: Path,
|
homeDir: Path,
|
||||||
@@ -148,7 +148,7 @@ class BwrapBuildRunner(
|
|||||||
for ((key, value) in environment) {
|
for ((key, value) in environment) {
|
||||||
args += listOf("-e", "$key=$value")
|
args += listOf("-e", "$key=$value")
|
||||||
}
|
}
|
||||||
for ((key, value) in bwrap.env) {
|
for ((key, value) in sandbox.env) {
|
||||||
args += listOf("-e", "$key=$value")
|
args += listOf("-e", "$key=$value")
|
||||||
}
|
}
|
||||||
args += listOf("-w", "$workspaceAbs")
|
args += listOf("-w", "$workspaceAbs")
|
||||||
@@ -242,12 +242,13 @@ class InitCommand(
|
|||||||
context: "." # Docker build context used with dockerfile
|
context: "." # Docker build context used with dockerfile
|
||||||
network: "" # Docker network mode for the build container; empty = Docker default (pinned)
|
network: "" # Docker network mode for the build container; empty = Docker default (pinned)
|
||||||
env: {} # additional environment variables set inside the build container
|
env: {} # additional environment variables set inside the build container
|
||||||
# bubblewrap user-namespace sandbox — for hosts without root and without a
|
# werkdock sandbox (bubblewrap user namespace) — for hosts without root and
|
||||||
# Docker daemon (e.g. Hostsharing managed webspaces). Mutually exclusive with docker.
|
# without a Docker daemon (e.g. Hostsharing managed webspaces). Mutually
|
||||||
bwrap:
|
# exclusive with docker. Called bwrap before v1.2.0, still read under that name.
|
||||||
enabled: false # run clean/build in a bwrap sandbox instead of natively (pinned)
|
werkdock:
|
||||||
|
enabled: false # run clean/build in the sandbox instead of natively (pinned)
|
||||||
rootfs: "" # prepared rootfs archive (path or URL); required when enabled (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
|
env: {} # additional environment variables set inside the sandbox
|
||||||
# Gitea check this build reports as; empty uses gitea.statusContext.
|
# Gitea check this build reports as; empty uses gitea.statusContext.
|
||||||
# Two builds of one commit under the same context overwrite each other.
|
# Two builds of one commit under the same context overwrite each other.
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ data class BuildDefinition(
|
|||||||
val statusContext: String? = null,
|
val statusContext: String? = null,
|
||||||
/** Overrides of the docker settings; null inherits them. */
|
/** Overrides of the docker settings; null inherits them. */
|
||||||
val docker: DockerOverrides? = null,
|
val docker: DockerOverrides? = null,
|
||||||
/** Overrides of the bwrap settings; null inherits them. */
|
/** Overrides of the werkdock settings; null inherits them. */
|
||||||
val bwrap: BwrapOverrides? = null,
|
val werkdock: WerkdockOverrides? = null,
|
||||||
) {
|
) {
|
||||||
/** The settings this build runs with: [branchConfig] with this definition applied; unset values fall through. */
|
/** The settings this build runs with: [branchConfig] with this definition applied; unset values fall through. */
|
||||||
fun applyTo(branchConfig: BranchConfig): BranchConfig =
|
fun applyTo(branchConfig: BranchConfig): BranchConfig =
|
||||||
@@ -64,12 +64,12 @@ data class BuildDefinition(
|
|||||||
network = docker?.network ?: branchConfig.docker.network,
|
network = docker?.network ?: branchConfig.docker.network,
|
||||||
env = docker?.env ?: branchConfig.docker.env,
|
env = docker?.env ?: branchConfig.docker.env,
|
||||||
),
|
),
|
||||||
bwrap =
|
werkdock =
|
||||||
branchConfig.bwrap.copy(
|
branchConfig.werkdock.copy(
|
||||||
enabled = bwrap?.enabled ?: branchConfig.bwrap.enabled,
|
enabled = werkdock?.enabled ?: branchConfig.werkdock.enabled,
|
||||||
rootfs = bwrap?.rootfs ?: branchConfig.bwrap.rootfs,
|
rootfs = werkdock?.rootfs ?: branchConfig.werkdock.rootfs,
|
||||||
werkdock = bwrap?.werkdock ?: branchConfig.bwrap.werkdock,
|
binary = werkdock?.binary ?: branchConfig.werkdock.binary,
|
||||||
env = bwrap?.env ?: branchConfig.bwrap.env,
|
env = werkdock?.env ?: branchConfig.werkdock.env,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -169,13 +169,13 @@ data class DockerOverrides(
|
|||||||
val env: Map<String, String>? = null,
|
val env: Map<String, String>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Nullable bubblewrap overrides of a [BuildDefinition]; null values inherit the branch's setting. */
|
/** Nullable werkdock overrides of a [BuildDefinition]; null values inherit the branch's setting. */
|
||||||
data class BwrapOverrides(
|
data class WerkdockOverrides(
|
||||||
/** Run the build in the bwrap sandbox instead of natively. Pinned — a branch must not escape its sandbox. */
|
/** Run the build in the sandbox instead of natively. Pinned — a branch must not escape its sandbox. */
|
||||||
val enabled: Boolean? = null,
|
val enabled: Boolean? = null,
|
||||||
/** Rootfs archive source. Pinned — a branch must not substitute a foreign rootfs. */
|
/** Rootfs archive source. Pinned — a branch must not substitute a foreign rootfs. */
|
||||||
val rootfs: String? = null,
|
val rootfs: String? = null,
|
||||||
/** The werkdock CLI executing the sandbox. Pinned — a branch must not substitute the executing binary. */
|
/** 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,
|
val env: Map<String, String>? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -201,10 +201,10 @@ class ConfigLoader(
|
|||||||
val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } }
|
val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } }
|
||||||
if (strippedDocker.isEmpty()) result.remove("docker") else result["docker"] = strippedDocker
|
if (strippedDocker.isEmpty()) result.remove("docker") else result["docker"] = strippedDocker
|
||||||
}
|
}
|
||||||
val bwrap = entry["bwrap"] as? Map<String, Any?>
|
val werkdock = entry["werkdock"] as? Map<String, Any?>
|
||||||
if (bwrap != null) {
|
if (werkdock != null) {
|
||||||
val strippedBwrap = bwrap.toMutableMap().apply { PINNED_BWRAP_KEYS.forEach { remove(it) } }
|
val strippedWerkdock = werkdock.toMutableMap().apply { PINNED_WERKDOCK_KEYS.forEach { remove(it) } }
|
||||||
if (strippedBwrap.isEmpty()) result.remove("bwrap") else result["bwrap"] = strippedBwrap
|
if (strippedWerkdock.isEmpty()) result.remove("werkdock") else result["werkdock"] = strippedWerkdock
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -481,14 +481,62 @@ class ConfigLoader(
|
|||||||
private fun loadFile(file: File): Map<String, Any?> {
|
private fun loadFile(file: File): Map<String, Any?> {
|
||||||
if (!file.exists()) return emptyMap()
|
if (!file.exists()) return emptyMap()
|
||||||
@Suppress("UNCHECKED_CAST")
|
@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. */
|
/** Parses a `.werkator.yml` read from git (not from disk); blank or null yields no layer. */
|
||||||
private fun parseYaml(text: String?): Map<String, Any?> {
|
private fun parseYaml(text: String?): Map<String, Any?> {
|
||||||
if (text.isNullOrBlank()) return emptyMap()
|
if (text.isNullOrBlank()) return emptyMap()
|
||||||
@Suppress("UNCHECKED_CAST")
|
@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")
|
@Suppress("UNCHECKED_CAST")
|
||||||
@@ -550,8 +598,8 @@ class ConfigLoader(
|
|||||||
/** `docker` keys a branch must never override: the sandbox policy. */
|
/** `docker` keys a branch must never override: the sandbox policy. */
|
||||||
private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
|
private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
|
||||||
|
|
||||||
/** `bwrap` keys a branch must never override: the sandbox policy (Step 17) and its executing binary. */
|
/** `werkdock` keys a branch must never override: the sandbox policy (Step 17) and its executing binary. */
|
||||||
private val PINNED_BWRAP_KEYS = setOf("enabled", "rootfs", "werkdock")
|
private val PINNED_WERKDOCK_KEYS = setOf("enabled", "rootfs", "binary")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one key of a build definition that says *when* and *for which branches* it
|
* The one key of a build definition that says *when* and *for which branches* it
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ data class WerkatorConfig(
|
|||||||
): BranchConfig {
|
): BranchConfig {
|
||||||
val branchConfig = branches[branch] ?: branches["default"] ?: BranchConfig()
|
val branchConfig = branches[branch] ?: branches["default"] ?: BranchConfig()
|
||||||
val settings = effectiveBuildDefinitions()[build]?.applyTo(branchConfig) ?: branchConfig
|
val settings = effectiveBuildDefinitions()[build]?.applyTo(branchConfig) ?: branchConfig
|
||||||
if (settings.docker.enabled && settings.bwrap.enabled) {
|
if (settings.docker.enabled && settings.werkdock.enabled) {
|
||||||
throw IllegalArgumentException(
|
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.",
|
"Disable one of them.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -179,17 +179,22 @@ data class BranchConfig(
|
|||||||
val statusContext: String = "",
|
val statusContext: String = "",
|
||||||
val autoBuild: AutoBuildConfig = AutoBuildConfig(),
|
val autoBuild: AutoBuildConfig = AutoBuildConfig(),
|
||||||
val docker: DockerConfig = DockerConfig(),
|
val docker: DockerConfig = DockerConfig(),
|
||||||
/** bubblewrap user-namespace sandbox; mutually exclusive with [docker]. */
|
/** werkdock sandbox (bubblewrap user namespace); mutually exclusive with [docker]. */
|
||||||
val bwrap: BwrapConfig = BwrapConfig(),
|
val werkdock: WerkdockConfig = WerkdockConfig(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* bubblewrap build sandbox (Step 17): runs the build in an unprivileged user namespace
|
* The werkdock build sandbox (Step 17, executed by the werkdock CLI since step 21):
|
||||||
* with a prepared Debian root filesystem. For hosts without root and without a Docker
|
* runs the build in an unprivileged bubblewrap user namespace over a prepared Debian
|
||||||
* daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md`.
|
* 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(
|
data class WerkdockConfig(
|
||||||
/** Run the clean and build commands in a bwrap sandbox instead of natively. */
|
/** Run the clean and build commands in the sandbox instead of natively. */
|
||||||
val enabled: Boolean = false,
|
val enabled: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* Path or URL of the prepared rootfs archive (e.g. `werkator-buildenv-trixie-java21.tar.zst`),
|
* 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
|
* 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.
|
* 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. */
|
/** Additional environment variables set inside the sandbox. */
|
||||||
val env: Map<String, String> = emptyMap(),
|
val env: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,16 @@
|
|||||||
<div th:replace="~{fragments :: nav(${view})}"></div>
|
<div th:replace="~{fragments :: nav(${view})}"></div>
|
||||||
<div class="panel release-notes">
|
<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>
|
<h2>v1.1.2 <span class="muted">— 2026-09-03</span></h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>On a Hostsharing Managed Webspace, an <code>instance-update</code> restart no longer looks
|
<li>On a Hostsharing Managed Webspace, an <code>instance-update</code> restart no longer looks
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package de.hoennig.werkator.build
|
package de.hoennig.werkator.build
|
||||||
|
|
||||||
import de.hoennig.werkator.config.BranchConfig
|
import de.hoennig.werkator.config.BranchConfig
|
||||||
import de.hoennig.werkator.config.BwrapConfig
|
|
||||||
import de.hoennig.werkator.config.DockerConfig
|
import de.hoennig.werkator.config.DockerConfig
|
||||||
|
import de.hoennig.werkator.config.WerkdockConfig
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.mockk.Called
|
import io.mockk.Called
|
||||||
@@ -15,13 +15,13 @@ import java.nio.file.Paths
|
|||||||
class DispatchingBuildRunnerTest : FunSpec() {
|
class DispatchingBuildRunnerTest : FunSpec() {
|
||||||
private val processBuildRunner = mockk<ProcessBuildRunner>()
|
private val processBuildRunner = mockk<ProcessBuildRunner>()
|
||||||
private val dockerBuildRunner = mockk<DockerBuildRunner>()
|
private val dockerBuildRunner = mockk<DockerBuildRunner>()
|
||||||
private val bwrapBuildRunner = mockk<BwrapBuildRunner>()
|
private val werkdockBuildRunner = mockk<WerkdockBuildRunner>()
|
||||||
private val dispatcher = DispatchingBuildRunner(processBuildRunner, dockerBuildRunner, bwrapBuildRunner)
|
private val dispatcher = DispatchingBuildRunner(processBuildRunner, dockerBuildRunner, werkdockBuildRunner)
|
||||||
private val process = mockk<Process>()
|
private val process = mockk<Process>()
|
||||||
private val dir = Paths.get(".")
|
private val dir = Paths.get(".")
|
||||||
|
|
||||||
init {
|
init {
|
||||||
beforeEach { clearMocks(processBuildRunner, dockerBuildRunner, bwrapBuildRunner) }
|
beforeEach { clearMocks(processBuildRunner, dockerBuildRunner, werkdockBuildRunner) }
|
||||||
|
|
||||||
test("runs natively by default") {
|
test("runs natively by default") {
|
||||||
val branchConfig = BranchConfig()
|
val branchConfig = BranchConfig()
|
||||||
@@ -30,7 +30,7 @@ class DispatchingBuildRunnerTest : FunSpec() {
|
|||||||
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
|
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
|
||||||
|
|
||||||
verify { dockerBuildRunner wasNot Called }
|
verify { dockerBuildRunner wasNot Called }
|
||||||
verify { bwrapBuildRunner wasNot Called }
|
verify { werkdockBuildRunner wasNot Called }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("runs in Docker when the branch enables it") {
|
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
|
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
|
||||||
|
|
||||||
verify { processBuildRunner wasNot Called }
|
verify { processBuildRunner wasNot Called }
|
||||||
verify { bwrapBuildRunner wasNot Called }
|
verify { werkdockBuildRunner wasNot Called }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("runs in bwrap when the branch enables it (and not Docker)") {
|
test("runs in the werkdock sandbox when the branch enables it (and not Docker)") {
|
||||||
val branchConfig = BranchConfig(bwrap = BwrapConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst"))
|
val branchConfig = BranchConfig(werkdock = WerkdockConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst"))
|
||||||
every { bwrapBuildRunner.start("cmd", dir, emptyMap(), dir, branchConfig) } returns process
|
every { werkdockBuildRunner.start("cmd", dir, emptyMap(), dir, branchConfig) } returns process
|
||||||
|
|
||||||
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
|
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
|
||||||
|
|
||||||
|
|||||||
+21
-21
@@ -1,7 +1,7 @@
|
|||||||
package de.hoennig.werkator.build
|
package de.hoennig.werkator.build
|
||||||
|
|
||||||
import de.hoennig.werkator.config.BranchConfig
|
import de.hoennig.werkator.config.BranchConfig
|
||||||
import de.hoennig.werkator.config.BwrapConfig
|
import de.hoennig.werkator.config.WerkdockConfig
|
||||||
import de.hoennig.werkator.git.GitCommandResult
|
import de.hoennig.werkator.git.GitCommandResult
|
||||||
import de.hoennig.werkator.git.GitCommandRunner
|
import de.hoennig.werkator.git.GitCommandRunner
|
||||||
import io.kotest.assertions.throwables.shouldThrow
|
import io.kotest.assertions.throwables.shouldThrow
|
||||||
@@ -15,20 +15,20 @@ import io.mockk.verify
|
|||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
class BwrapBuildRunnerTest : FunSpec() {
|
class WerkdockBuildRunnerTest : FunSpec() {
|
||||||
private val commandRunner = mockk<GitCommandRunner>()
|
private val commandRunner = mockk<GitCommandRunner>()
|
||||||
private lateinit var runner: BwrapBuildRunner
|
private lateinit var runner: WerkdockBuildRunner
|
||||||
private lateinit var repoDir: Path
|
private lateinit var repoDir: Path
|
||||||
private lateinit var workspace: Path
|
private lateinit var workspace: Path
|
||||||
private val captured = mutableListOf<List<String>>()
|
private val captured = mutableListOf<List<String>>()
|
||||||
|
|
||||||
private fun bwrapBranchConfig(
|
private fun werkdockBranchConfig(
|
||||||
rootfs: String = "/srv/buildenv.tar.zst",
|
rootfs: String = "/srv/buildenv.tar.zst",
|
||||||
env: Map<String, String> = emptyMap(),
|
env: Map<String, String> = emptyMap(),
|
||||||
): BranchConfig =
|
): BranchConfig =
|
||||||
BranchConfig(
|
BranchConfig(
|
||||||
bwrap =
|
werkdock =
|
||||||
BwrapConfig(
|
WerkdockConfig(
|
||||||
enabled = true,
|
enabled = true,
|
||||||
rootfs = rootfs,
|
rootfs = rootfs,
|
||||||
env = env,
|
env = env,
|
||||||
@@ -52,9 +52,9 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
beforeEach {
|
beforeEach {
|
||||||
clearMocks(commandRunner)
|
clearMocks(commandRunner)
|
||||||
captured.clear()
|
captured.clear()
|
||||||
repoDir = Files.createTempDirectory("werkator-bwrap-runner")
|
repoDir = Files.createTempDirectory("werkator-werkdock-runner")
|
||||||
workspace = repoDir.resolve("workspace")
|
workspace = repoDir.resolve("workspace")
|
||||||
runner = BwrapBuildRunner(commandRunner)
|
runner = WerkdockBuildRunner(commandRunner)
|
||||||
runner.processStarter = { command, _ ->
|
runner.processStarter = { command, _ ->
|
||||||
captured += command
|
captured += command
|
||||||
ProcessBuilder("true").start()
|
ProcessBuilder("true").start()
|
||||||
@@ -64,7 +64,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
test("assembles the exact werkdock run command for a loaded image") {
|
test("assembles the exact werkdock run command for a loaded image") {
|
||||||
givenImageLoaded()
|
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
|
captured.single() shouldBe
|
||||||
listOf(
|
listOf(
|
||||||
@@ -99,7 +99,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
)
|
)
|
||||||
} returns GitCommandResult(0, "", "")
|
} 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 {
|
verify {
|
||||||
commandRunner.runOrThrow(
|
commandRunner.runOrThrow(
|
||||||
@@ -114,7 +114,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
test("does not load an image werkdock already has") {
|
test("does not load an image werkdock already has") {
|
||||||
givenImageLoaded()
|
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.runOrThrow(match { "load" in it }, any(), any(), any()) }
|
verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) }
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
GitCommandResult(0, imageName() + "\n", "")
|
GitCommandResult(0, imageName() + "\n", "")
|
||||||
val branchConfig =
|
val branchConfig =
|
||||||
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)
|
runner.start("./gradlew test", workspace, emptyMap(), repoDir, branchConfig)
|
||||||
@@ -136,7 +136,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
givenImageLoaded()
|
givenImageLoaded()
|
||||||
val relativeWorkspace = repoDir.relativize(workspace)
|
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 args = captured.single()
|
||||||
val absolute = workspace.toAbsolutePath().normalize().toString()
|
val absolute = workspace.toAbsolutePath().normalize().toString()
|
||||||
@@ -145,7 +145,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
args.count { it == "$absolute:$absolute" } shouldBe 1
|
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()
|
givenImageLoaded()
|
||||||
|
|
||||||
runner.start(
|
runner.start(
|
||||||
@@ -153,7 +153,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
workspace,
|
workspace,
|
||||||
mapOf("branch" to "main"),
|
mapOf("branch" to "main"),
|
||||||
repoDir,
|
repoDir,
|
||||||
bwrapBranchConfig(env = mapOf("FOO" to "bar")),
|
werkdockBranchConfig(env = mapOf("FOO" to "bar")),
|
||||||
)
|
)
|
||||||
|
|
||||||
val args = captured.single()
|
val args = captured.single()
|
||||||
@@ -171,7 +171,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n")
|
Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n")
|
||||||
givenImageLoaded()
|
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()
|
val args = captured.single()
|
||||||
args[args.indexOf("$gitDir:$gitDir:ro") - 1] shouldBe "-v"
|
args[args.indexOf("$gitDir:$gitDir:ro") - 1] shouldBe "-v"
|
||||||
@@ -190,7 +190,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
givenImageLoaded()
|
givenImageLoaded()
|
||||||
Files.createDirectories(workspace)
|
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 args = captured.single()
|
||||||
val gitDir = repoDir.resolve(".git")
|
val gitDir = repoDir.resolve(".git")
|
||||||
@@ -199,21 +199,21 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("fails without a configured rootfs") {
|
test("fails without a configured rootfs") {
|
||||||
val branchConfig = BranchConfig(bwrap = BwrapConfig(enabled = true))
|
val branchConfig = BranchConfig(werkdock = WerkdockConfig(enabled = true))
|
||||||
|
|
||||||
val exception =
|
val exception =
|
||||||
shouldThrow<IllegalArgumentException> {
|
shouldThrow<IllegalArgumentException> {
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, branchConfig)
|
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 loading it") {
|
||||||
val url = "https://example.test/buildenv.tar.zst"
|
val url = "https://example.test/buildenv.tar.zst"
|
||||||
val downloadTarget =
|
val downloadTarget =
|
||||||
repoDir
|
repoDir
|
||||||
.resolve(BwrapBuildRunner.BUILDENV_DIR)
|
.resolve(WerkdockBuildRunner.BUILDENV_DIR)
|
||||||
.resolve(url.sha12())
|
.resolve(url.sha12())
|
||||||
.resolve("buildenv.tar.zst")
|
.resolve("buildenv.tar.zst")
|
||||||
givenImageMissing()
|
givenImageMissing()
|
||||||
@@ -222,7 +222,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
every { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) } returns
|
every { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) } returns
|
||||||
GitCommandResult(0, "", "")
|
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 {
|
verify {
|
||||||
commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any())
|
commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any())
|
||||||
@@ -496,7 +496,7 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
settings.docker.image shouldBe "attacker-image"
|
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")
|
val dir = Files.createTempDirectory("werkator-test")
|
||||||
dir.resolve(".werkator.yml").toFile().writeText(
|
dir.resolve(".werkator.yml").toFile().writeText(
|
||||||
"""
|
"""
|
||||||
@@ -505,6 +505,58 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
bwrap:
|
bwrap:
|
||||||
enabled: true
|
enabled: true
|
||||||
rootfs: /host/rootfs.tar.zst
|
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(),
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
val worktree = Files.createTempDirectory("werkator-test-worktree")
|
val worktree = Files.createTempDirectory("werkator-test-worktree")
|
||||||
@@ -512,7 +564,7 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
"""
|
"""
|
||||||
builds:
|
builds:
|
||||||
default:
|
default:
|
||||||
bwrap:
|
werkdock:
|
||||||
enabled: false
|
enabled: false
|
||||||
rootfs: /attacker/rootfs.tar.zst
|
rootfs: /attacker/rootfs.tar.zst
|
||||||
env:
|
env:
|
||||||
@@ -523,10 +575,10 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default")
|
val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default")
|
||||||
|
|
||||||
// pinned: the sandbox can neither be switched off nor pointed at a foreign rootfs
|
// pinned: the sandbox can neither be switched off nor pointed at a foreign rootfs
|
||||||
settings.bwrap.enabled shouldBe true
|
settings.werkdock.enabled shouldBe true
|
||||||
settings.bwrap.rootfs shouldBe "/host/rootfs.tar.zst"
|
settings.werkdock.rootfs shouldBe "/host/rootfs.tar.zst"
|
||||||
// everything that describes the build itself stays the branch's own business
|
// 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") {
|
test("a build the branch invents inherits the host's sandbox policy") {
|
||||||
@@ -565,7 +617,7 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
settings.requirePullRequest shouldBe true
|
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")
|
val dir = Files.createTempDirectory("werkator-test")
|
||||||
dir.resolve(".werkator.yml").toFile().writeText(
|
dir.resolve(".werkator.yml").toFile().writeText(
|
||||||
"""
|
"""
|
||||||
@@ -574,7 +626,7 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
docker:
|
docker:
|
||||||
enabled: true
|
enabled: true
|
||||||
image: build-env
|
image: build-env
|
||||||
bwrap:
|
werkdock:
|
||||||
enabled: true
|
enabled: true
|
||||||
rootfs: /srv/rootfs.tar.zst
|
rootfs: /srv/rootfs.tar.zst
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
@@ -586,7 +638,7 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
config.buildSettings("any-branch", "default")
|
config.buildSettings("any-branch", "default")
|
||||||
}
|
}
|
||||||
|
|
||||||
exception.message shouldContain "both docker and bwrap"
|
exception.message shouldContain "both docker and werkdock"
|
||||||
exception.message shouldContain "builds.default"
|
exception.message shouldContain "builds.default"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+140
-45
@@ -45,13 +45,23 @@
|
|||||||
# WERKATOR_INIT_CONFIG the init fragment to apply (repo-init, instance-start)
|
# WERKATOR_INIT_CONFIG the init fragment to apply (repo-init, instance-start)
|
||||||
# WERKATOR_REPO_URL https clone URL of the watched repository
|
# WERKATOR_REPO_URL https clone URL of the watched repository
|
||||||
# (default: https://github.com/mhoennig/werkator.git)
|
# (default: https://github.com/mhoennig/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
|
# WERKDOCK_REPO checkout of the werkdock repository, whose binary the
|
||||||
# instance runs (default: <repo>/../werkdock)
|
# instance runs (default: <repo>/../werkdock)
|
||||||
# WERKDOCK_BINARY the built werkdock binary (default: $WERKDOCK_REPO/dist/werkdock)
|
# WERKDOCK_BINARY the built werkdock binary (default: $WERKDOCK_REPO/dist/werkdock)
|
||||||
# WERKATOR_ROOTFS rootfs archive path for repo-init
|
# WERKATOR_ROOTFS rootfs archive path for repo-init
|
||||||
# (default: <repo>/build/werkator-buildenv-trixie-java-go-node.tar.zst)
|
# (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/ the watched repository (clone)
|
||||||
# $WERKATOR_PATH/.werkator/werkator/ the unpacked runtime bundle
|
# $WERKATOR_PATH/.werkator/werkator/ the unpacked runtime bundle
|
||||||
# $WERKATOR_PATH/.werkator/bin/ the werkdock binary
|
# $WERKATOR_PATH/.werkator/bin/ the werkdock binary
|
||||||
@@ -113,9 +123,36 @@ HOST="$WERKATOR_REMOTE"
|
|||||||
TARGET_DIR="$WERKATOR_PATH"
|
TARGET_DIR="$WERKATOR_PATH"
|
||||||
ROOTFS="${WERKATOR_ROOTFS:-$REPO_ROOT/build/werkator-buildenv-trixie-java-go-node.tar.zst}"
|
ROOTFS="${WERKATOR_ROOTFS:-$REPO_ROOT/build/werkator-buildenv-trixie-java-go-node.tar.zst}"
|
||||||
REPO_URL="${WERKATOR_REPO_URL:-https://github.com/mhoennig/werkator.git}"
|
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"
|
# The host layout is three values, not one convention: an installation that grew
|
||||||
UNIT="werkator-werkator.service"
|
# 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_present() {
|
||||||
ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" true 2>/dev/null
|
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
|
# Werkdock owns the host checks (`werkdock doctor` ports the old prerequisites
|
||||||
# script); the binary is uploaded first, so the check works pre-install.
|
# 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() {
|
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
|
ensure_werkdock_binary
|
||||||
echo "==> Uploading werkdock and running its doctor on $HOST (target dir: $TARGET_DIR)"
|
echo "==> Uploading werkdock and running its doctor on $HOST (target dir: $TARGET_DIR)"
|
||||||
ssh "$HOST" "mkdir -p '$TARGET_DIR/.werkator/bin'"
|
ssh "$HOST" "mkdir -p '$INSTALL_DIR/bin'"
|
||||||
scp -q "$WERKDOCK_BINARY" "$HOST:$TARGET_DIR/.werkator/bin/werkdock.new"
|
scp -q "$WERKDOCK_BINARY" "$HOST:$INSTALL_DIR/bin/werkdock.new"
|
||||||
ssh "$HOST" "mv '$TARGET_DIR/.werkator/bin/werkdock.new' '$TARGET_DIR/.werkator/bin/werkdock' && chmod 755 '$TARGET_DIR/.werkator/bin/werkdock'"
|
ssh "$HOST" "mv '$INSTALL_DIR/bin/werkdock.new' '$INSTALL_DIR/bin/werkdock' && chmod 755 '$INSTALL_DIR/bin/werkdock'"
|
||||||
if ! ssh "$HOST" "'$TARGET_DIR/.werkator/bin/werkdock' doctor '$TARGET_DIR'"; then
|
if ! ssh "$HOST" "'$INSTALL_DIR/bin/werkdock' doctor '$TARGET_DIR'"; then
|
||||||
die "werkdock doctor failed on $HOST — install aborted"
|
die "werkdock doctor failed on $HOST — install aborted"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -160,7 +205,7 @@ next to this repository, or point WERKDOCK_REPO/WERKDOCK_BINARY at your checkout
|
|||||||
upload_fragment() {
|
upload_fragment() {
|
||||||
[ -n "${WERKATOR_INIT_CONFIG:-}" ] || { echo ""; return 0; }
|
[ -n "${WERKATOR_INIT_CONFIG:-}" ] || { echo ""; return 0; }
|
||||||
[ -f "$WERKATOR_INIT_CONFIG" ] || die "init fragment missing: $WERKATOR_INIT_CONFIG"
|
[ -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"
|
scp -q "$WERKATOR_INIT_CONFIG" "$HOST:$remote"
|
||||||
echo "$remote"
|
echo "$remote"
|
||||||
}
|
}
|
||||||
@@ -174,43 +219,85 @@ ensure_instance_artifacts() {
|
|||||||
(cd "$REPO_ROOT" && ./gradlew runtimeBundle --console=plain -q)
|
(cd "$REPO_ROOT" && ./gradlew runtimeBundle --console=plain -q)
|
||||||
fi
|
fi
|
||||||
[ -f "$RUNTIME_BUNDLE" ] || die "runtime bundle missing: $RUNTIME_BUNDLE"
|
[ -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
|
# Uploads one file and verifies it arrived whole: a transfer that dies mid-way
|
||||||
# werkator.prev for one deployment as the rollback asset.
|
# (scp: Connection closed) otherwise leaves a truncated archive that unpacks into
|
||||||
deploy_instance() {
|
# 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"
|
echo "==> Uploading runtime bundle and werkdock binary"
|
||||||
ssh "$HOST" "mkdir -p '$TARGET_DIR/.werkator/bin'"
|
fi
|
||||||
scp -q "$RUNTIME_BUNDLE" "$HOST:$TARGET_DIR/.werkator/"
|
ssh "$HOST" "mkdir -p '$INSTALL_DIR/bin'"
|
||||||
scp -q "$WERKDOCK_BINARY" "$HOST:$TARGET_DIR/.werkator/bin/werkdock.new"
|
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"
|
echo "==> Unpacking"
|
||||||
ssh "$HOST" "set -e
|
ssh "$HOST" "set -e
|
||||||
cd '$TARGET_DIR/.werkator'
|
cd '$INSTALL_DIR'
|
||||||
mv bin/werkdock.new bin/werkdock && chmod 755 bin/werkdock
|
[ ! -f bin/werkdock.new ] || { mv bin/werkdock.new bin/werkdock && chmod 755 bin/werkdock; }
|
||||||
rm -rf werkator.prev
|
rm -rf werkator.prev
|
||||||
[ ! -d werkator ] || mv werkator werkator.prev
|
[ ! -d werkator ] || mv werkator werkator.prev
|
||||||
tar xzf '$(basename "$RUNTIME_BUNDLE")'
|
tar xzf '$(basename "$RUNTIME_BUNDLE")'
|
||||||
'./werkator/bin/werkator' --version
|
'./werkator/bin/werkator' --version
|
||||||
'./bin/werkdock' version"
|
[ ! -x bin/werkdock ] || './bin/werkdock' version"
|
||||||
}
|
}
|
||||||
|
|
||||||
instance_install() {
|
instance_install() {
|
||||||
ensure_ssh
|
ensure_ssh
|
||||||
check_prerequisites
|
check_prerequisites
|
||||||
ensure_instance_artifacts
|
ensure_instance_artifacts
|
||||||
deploy_instance
|
upload_instance_artifacts
|
||||||
|
swap_instance_runtime
|
||||||
echo
|
echo
|
||||||
echo "==> Instance installed."
|
echo "==> Instance installed."
|
||||||
echo " Runtime: $WERKATOR_BIN"
|
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"
|
echo " Next: tools/remote werkator repo-init, then instance-start"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Refuse to swap the runtime under a running build; FORCE=1 overrides.
|
# Refuse to swap the runtime under a running build; FORCE=1 overrides.
|
||||||
require_idle() {
|
require_idle() {
|
||||||
local port
|
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
|
[ -n "$port" ] || return 0
|
||||||
local current
|
local current
|
||||||
current="$(ssh "$HOST" "curl -s --max-time 5 http://127.0.0.1:$port/api/builds/current" || true)"
|
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_ssh
|
||||||
ensure_instance_artifacts
|
ensure_instance_artifacts
|
||||||
require_idle
|
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
|
local was_active=0
|
||||||
if ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user is-active --quiet '$UNIT'"; then
|
if ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user is-active --quiet '$UNIT'"; then
|
||||||
was_active=1
|
was_active=1
|
||||||
@@ -232,7 +322,7 @@ instance_update() {
|
|||||||
echo "==> Stopping $UNIT"
|
echo "==> Stopping $UNIT"
|
||||||
ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user stop '$UNIT'"
|
ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user stop '$UNIT'"
|
||||||
fi
|
fi
|
||||||
deploy_instance
|
swap_instance_runtime
|
||||||
if [ "$was_active" = "1" ]; then
|
if [ "$was_active" = "1" ]; then
|
||||||
echo "==> Starting $UNIT"
|
echo "==> Starting $UNIT"
|
||||||
ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user start '$UNIT' && sleep 3 && systemctl --user is-active '$UNIT'"
|
ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user start '$UNIT' && sleep 3 && systemctl --user is-active '$UNIT'"
|
||||||
@@ -249,18 +339,22 @@ instance_update() {
|
|||||||
# writing is init's — this script transports and invokes (step 23).
|
# writing is init's — this script transports and invokes (step 23).
|
||||||
repo_init() {
|
repo_init() {
|
||||||
ensure_ssh
|
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"
|
ssh "$HOST" "test -x '$WERKATOR_BIN'" || die "no instance on $HOST — run instance-install first"
|
||||||
|
|
||||||
echo "==> Cloning the watched repository"
|
echo "==> Cloning the watched repository"
|
||||||
if ssh "$HOST" "test -d '$TARGET_DIR/werkator/.git'"; then
|
if ssh "$HOST" "test -d '$REPO_DIR/.git'"; then
|
||||||
echo " (already cloned, skipping)"
|
echo " (already cloned, skipping)"
|
||||||
else
|
else
|
||||||
ssh "$HOST" "git clone '$REPO_URL' '$TARGET_DIR/werkator'"
|
ssh "$HOST" "git clone '$REPO_URL' '$REPO_DIR'"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$SANDBOX" = "docker" ]; then
|
||||||
|
echo "==> No rootfs needed (WERKATOR_SANDBOX=docker) — the build image is the repository's own Dockerfile"
|
||||||
|
else
|
||||||
echo "==> Uploading the rootfs archive (skipped when unchanged)"
|
echo "==> Uploading the rootfs archive (skipped when unchanged)"
|
||||||
local rootfs_remote="$TARGET_DIR/.werkator/$(basename "$ROOTFS")"
|
local rootfs_remote="$INSTALL_DIR/$(basename "$ROOTFS")"
|
||||||
local local_sha remote_sha
|
local local_sha remote_sha
|
||||||
local_sha="$(sha256sum "$ROOTFS" | cut -d' ' -f1)"
|
local_sha="$(sha256sum "$ROOTFS" | cut -d' ' -f1)"
|
||||||
remote_sha="$(ssh "$HOST" "sha256sum '$rootfs_remote' 2>/dev/null | cut -d' ' -f1" || true)"
|
remote_sha="$(ssh "$HOST" "sha256sum '$rootfs_remote' 2>/dev/null | cut -d' ' -f1" || true)"
|
||||||
@@ -271,18 +365,19 @@ repo_init() {
|
|||||||
remote_sha="$(ssh "$HOST" "sha256sum '$rootfs_remote' | cut -d' ' -f1")"
|
remote_sha="$(ssh "$HOST" "sha256sum '$rootfs_remote' | cut -d' ' -f1")"
|
||||||
[ "$local_sha" = "$remote_sha" ] || die "rootfs upload checksum mismatch"
|
[ "$local_sha" = "$remote_sha" ] || die "rootfs upload checksum mismatch"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Running werkator init${WERKATOR_INIT_CONFIG:+ --apply $(basename "${WERKATOR_INIT_CONFIG}")}"
|
echo "==> Running werkator init${WERKATOR_INIT_CONFIG:+ --apply $(basename "${WERKATOR_INIT_CONFIG}")}"
|
||||||
local fragment_remote
|
local fragment_remote
|
||||||
fragment_remote="$(upload_fragment)"
|
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"
|
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
|
||||||
echo "==> Repository ready."
|
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 " Next: fill git.account/git.token in $MACHINE_CONFIG if the origin is private,"
|
||||||
echo " then tools/remote werkator instance-start"
|
echo " then tools/remote werkator instance-start"
|
||||||
}
|
}
|
||||||
@@ -306,10 +401,10 @@ repo_add() {
|
|||||||
ssh "$HOST" "test -x '$WERKATOR_BIN'" || die "no instance on $HOST — run instance-install first"
|
ssh "$HOST" "test -x '$WERKATOR_BIN'" || die "no instance on $HOST — run instance-install first"
|
||||||
|
|
||||||
echo "==> Cloning $url as '$name'"
|
echo "==> Cloning $url as '$name'"
|
||||||
if ssh "$HOST" "test -d '$TARGET_DIR/$name/.git'"; then
|
if ssh "$HOST" "test -d '$SIBLING_DIR/$name/.git'"; then
|
||||||
echo " (already cloned, skipping)"
|
echo " (already cloned, skipping)"
|
||||||
else
|
else
|
||||||
ssh "$HOST" "git clone '$url' '$TARGET_DIR/$name'"
|
ssh "$HOST" "git clone '$url' '$SIBLING_DIR/$name'"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The instance fragment carries the sandbox policy (bwrap rootfs and werkdock
|
# The instance fragment carries the sandbox policy (bwrap rootfs and werkdock
|
||||||
@@ -318,26 +413,26 @@ repo_add() {
|
|||||||
echo "==> Running werkator init in $name${WERKATOR_INIT_CONFIG:+ --apply $(basename "${WERKATOR_INIT_CONFIG}")}"
|
echo "==> Running werkator init in $name${WERKATOR_INIT_CONFIG:+ --apply $(basename "${WERKATOR_INIT_CONFIG}")}"
|
||||||
local fragment_remote
|
local fragment_remote
|
||||||
fragment_remote="$(upload_fragment)"
|
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"
|
echo "==> Checking the registry"
|
||||||
# Grepped locally: the entry may name the path absolute or as ~/<name>, and
|
# 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.
|
# matching both is easier without a second layer of remote shell quoting.
|
||||||
if ssh "$HOST" "cat ~/.werkator.yml 2>/dev/null" |
|
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)"
|
echo " (~/.werkator.yml already names this path)"
|
||||||
else
|
else
|
||||||
echo " not registered yet — add this entry to ~/.werkator.yml on $HOST:"
|
echo " not registered yet — add this entry to ~/.werkator.yml on $HOST:"
|
||||||
echo
|
echo
|
||||||
echo " repositories:"
|
echo " repositories:"
|
||||||
echo " - path: $TARGET_DIR/$name"
|
echo " - path: $SIBLING_DIR/$name"
|
||||||
echo " name: $name"
|
echo " name: $name"
|
||||||
echo
|
echo
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Repository prepared."
|
echo "==> Repository prepared."
|
||||||
echo " Repo: $TARGET_DIR/$name"
|
echo " Repo: $SIBLING_DIR/$name"
|
||||||
echo " Next: fill git.account/git.token in $TARGET_DIR/$name/.git/werkator/.werkator.yml if the origin is private"
|
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 " (or once for all repositories in the 'defaults' block of ~/.werkator.yml),"
|
||||||
echo " then restart the service — the registry is read at start."
|
echo " then restart the service — the registry is read at start."
|
||||||
}
|
}
|
||||||
@@ -353,11 +448,11 @@ instance_start() {
|
|||||||
echo "==> Applying the instance fragment and generating the host integration (init --systemd)"
|
echo "==> Applying the instance fragment and generating the host integration (init --systemd)"
|
||||||
local fragment_remote
|
local fragment_remote
|
||||||
fragment_remote="$(upload_fragment)"
|
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 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"
|
local maintenance="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/werkator-maintenance.html"
|
||||||
if ssh "$HOST" "test -f '$htaccess_src'"; then
|
if ssh "$HOST" "test -f '$htaccess_src'"; then
|
||||||
echo "==> Placing the generated Apache reverse proxy at $htaccess"
|
echo "==> Placing the generated Apache reverse proxy at $htaccess"
|
||||||
@@ -368,9 +463,9 @@ instance_start() {
|
|||||||
|
|
||||||
echo "==> Linking the units into ~/.config/systemd/user and enabling the service"
|
echo "==> Linking the units into ~/.config/systemd/user and enabling the service"
|
||||||
ssh "$HOST" "mkdir -p ~/.config/systemd/user && \
|
ssh "$HOST" "mkdir -p ~/.config/systemd/user && \
|
||||||
ln -sf '$TARGET_DIR/werkator/.git/werkator/$UNIT' ~/.config/systemd/user/ && \
|
ln -sf '$REPO_DIR/.git/werkator/$UNIT' ~/.config/systemd/user/ && \
|
||||||
ln -sf '$TARGET_DIR/werkator/.git/werkator/werkator-docker-prune.service' ~/.config/systemd/user/ && \
|
ln -sf '$REPO_DIR/.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/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 daemon-reload && \
|
||||||
XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user restart '$UNIT' && \
|
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"
|
XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user status '$UNIT' --no-pager -l | head -12"
|
||||||
@@ -388,7 +483,7 @@ port_forward() {
|
|||||||
# the effective port, wherever it is configured (machine config or applied
|
# the effective port, wherever it is configured (machine config or applied
|
||||||
# fragment) — config:print is the single answer, not this script's parser
|
# fragment) — config:print is the single answer, not this script's parser
|
||||||
local remote_port
|
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"
|
[ -n "$remote_port" ] || die "no server.port configured — run 'tools/remote werkator instance-start' first"
|
||||||
|
|
||||||
case "$COMMAND" in
|
case "$COMMAND" in
|
||||||
@@ -430,7 +525,7 @@ port_forward() {
|
|||||||
# CLI owns creation and format (step 23), this script only invokes it.
|
# CLI owns creation and format (step 23), this script only invokes it.
|
||||||
control_token() {
|
control_token() {
|
||||||
ensure_ssh
|
ensure_ssh
|
||||||
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' control-token"
|
ssh "$HOST" "cd '$REPO_DIR' && '$WERKATOR_BIN' control-token"
|
||||||
}
|
}
|
||||||
|
|
||||||
case "$REPO" in
|
case "$REPO" in
|
||||||
|
|||||||
Reference in New Issue
Block a user