Merge remote-tracking branch 'origin/main' into 20-build-duration-tracking
This commit is contained in:
@@ -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 and Docker), 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 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.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Werkator Architecture
|
# Werkator Architecture
|
||||||
@@ -19,12 +19,13 @@ WerkatorApplication ← @SpringBootApplication
|
|||||||
CliRunner ← CommandLineRunner + ExitCodeGenerator
|
CliRunner ← CommandLineRunner + ExitCodeGenerator
|
||||||
WerkatorCommand ← root @Command, delegates to subcommands
|
WerkatorCommand ← root @Command, delegates to subcommands
|
||||||
commands/
|
commands/
|
||||||
InitCommand ← "init [--systemd]"
|
InitCommand ← "init [--systemd] [--apply FILE]"
|
||||||
ServerCommand ← "server"
|
ServerCommand ← "server"
|
||||||
StatusCommand ← "status [--history]"
|
StatusCommand ← "status [--history]"
|
||||||
BuildCommand ← "build [<branch>]"
|
BuildCommand ← "build [<branch>]"
|
||||||
RetryCommand ← "retry"
|
RetryCommand ← "retry"
|
||||||
ConfigPrintCommand ← "config:print [--full]"
|
ConfigPrintCommand ← "config:print [--full]"
|
||||||
|
ControlTokenCommand ← "control-token"
|
||||||
```
|
```
|
||||||
|
|
||||||
`status`, `build`, and `retry` implement `Callable<Int>` for their exit codes (0 success, 1 build failure, 2 usage/config errors).
|
`status`, `build`, and `retry` implement `Callable<Int>` for their exit codes (0 success, 1 build failure, 2 usage/config errors).
|
||||||
@@ -40,14 +41,15 @@ Two independent staleness signals, never merged: the `live-indicator` badge says
|
|||||||
|
|
||||||
## Configuration System
|
## Configuration System
|
||||||
|
|
||||||
Werkator is configured by two YAML files, deep-merged by `ConfigLoader` (later wins):
|
Werkator is configured by three YAML files, deep-merged by `ConfigLoader` (later wins):
|
||||||
|
|
||||||
1. `.werkator.yml` at the repo root — committed, shared team settings.
|
1. `.werkator.yml` at the repo root — committed, shared team settings.
|
||||||
2. `.git/werkator/.werkator.yml` — not committed; machine-specific overrides and secrets (`git.account`, `git.token`).
|
2. `.git/werkator/.werkator.applied.yml` — not committed; the instance fragment installed verbatim by `init --apply` (step 23), validated strictly (unknown keys refused) and replaced wholesale on re-apply.
|
||||||
|
3. `.git/werkator/.werkator.yml` — not committed; machine-specific overrides and secrets (`git.account`, `git.token`); hand-edited, always wins.
|
||||||
|
|
||||||
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`, and `docker.enabled`/`docker.network`.
|
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`.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -59,17 +61,23 @@ Three places must stay in sync when config keys change: the `WerkatorConfig` dat
|
|||||||
|
|
||||||
`GitService` shells out to the `git` CLI via `GitCommandRunner` (a thin `ProcessBuilder` wrapper; no JGit). Commands that need repo information take it as a constructor dependency so tests can mock it. HTTPS fetches authenticate via a temporary, secret-free `GIT_ASKPASS` script (`GitAskPass`) with credentials from config passed through environment variables.
|
`GitService` shells out to the `git` CLI via `GitCommandRunner` (a thin `ProcessBuilder` wrapper; no JGit). Commands that need repo information take it as a constructor dependency so tests can mock it. HTTPS fetches authenticate via a temporary, secret-free `GIT_ASKPASS` script (`GitAskPass`) with credentials from config passed through environment variables.
|
||||||
|
|
||||||
|
## Repository Context
|
||||||
|
|
||||||
|
Everything repository-scoped goes through a `RepoContext` (`repo` package, ADR 0009): the primary checkout (`workingDir`), the repository's `BuildResultRepository` (`.git/werkator/build-results.json`), its `ArtifactStore` (keyed by the repository path), and a short `name` defaulting to the directory basename — the future route segment. `RepoContexts.open(dir)` builds one; `RepoConfiguration` provides the single current-directory context as a bean, and the `BuildResultRepository`/`ArtifactStore` beans are that context's, so code that still injects them sees the same objects. Git access and config loading stay path-based services taking `repo.workingDir`. The context object is the identity (executor pools, watcher memory are keyed by it), so exactly one is opened per repository; the registry of step 22 session C opens one per entry. Not yet repository-scoped and left for that session: `StateDirMigration` (once per process on the cwd), the metrics collector's repository size, `ServerCommand`'s config, and `RunningBuild`, which carries no repository yet.
|
||||||
|
|
||||||
## Build Execution
|
## Build Execution
|
||||||
|
|
||||||
`BuildExecutor` runs builds asynchronously: up to `executor.maxConcurrent` branches concurrently (default 1), but never more than one build per branch at a time. Each branch builds in its own reusable git worktree at `.git/werkator/worktrees/<branchKey>` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted via `BuildResultRepository` (JSON file under `.git/werkator/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. Every run belongs to a named build definition (job, ADR 0007): the YAML `builds` section defines triggers (`onPush`, `atTimes`), branch selectors (`branches` globs, `activeWithin`), and build-setting overrides applied last over the merged branch config; the implicit `default` build (`onPush`, all branches) preserves the job-less behavior. Definitions are part of the branch layer — a branch may add and override its own, and they apply to that branch alone (its selectors are evaluated for it only) — while `executor.maxConcurrent` stays pinned. `BuildResult.build` records the job; restart, retry, and startup recovery re-run by that name, resolving settings from the *current* config. `BuildResult.name` — the pool, `<branch>@<build>` for non-default builds — keys everything display- and retention-side (repository grouping via `latestPerName`, retention pools, branches-view rows, permanent latest-green links), while `BuildResult.branch` keys everything git-side: origin lookups, gone-from-origin pruning, worktrees (every build runs in its branch's worktree, serialized per branch), and Gitea links/statuses. `branches.*.autoBuild` survives as a deprecated alias for a scheduled default-pool rebuild. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build.
|
`BuildExecutor` runs builds asynchronously: `startBuild(repo, branch, commit, build)` takes the `RepoContext` first; up to `executor.maxConcurrent` builds run concurrently across all repositories (default 1, sized once from the first build's config — an instance-level setting), but never more than one build per (repository, branch) at a time. Each branch builds in its own reusable git worktree at `.git/werkator/worktrees/<branchKey>` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted in the build's own `RepoContext.results` (JSON file under that repository's `.git/werkator/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. Every run belongs to a named build definition (job, ADR 0007): the YAML `builds` section defines triggers (`onPush`, `atTimes`), branch selectors (`branches` globs, `activeWithin`), and build-setting overrides applied last over the merged branch config; the implicit `default` build (`onPush`, all branches) preserves the job-less behavior. Definitions are part of the branch layer — a branch may add and override its own, and they apply to that branch alone (its selectors are evaluated for it only) — while `executor.maxConcurrent` stays pinned. `BuildResult.build` records the job; restart, retry, and startup recovery re-run by that name, resolving settings from the *current* config. `BuildResult.name` — the pool, `<branch>@<build>` for non-default builds — keys everything display- and retention-side (repository grouping via `latestPerName`, retention pools, branches-view rows, permanent latest-green links), while `BuildResult.branch` keys everything git-side: origin lookups, gone-from-origin pruning, worktrees (every build runs in its branch's worktree, serialized per branch), and Gitea links/statuses. `branches.*.autoBuild` survives as a deprecated alias for a scheduled default-pool rebuild. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build.
|
||||||
|
|
||||||
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 branch behind the `BuildRunner` interface: `DispatchingBuildRunner` (`@Primary`) routes to native `ProcessBuildRunner` (the default) or to `DockerBuildRunner` when `branches.<name>.docker.enabled`. 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 `BwrapBuildRunner` when `bwrap.enabled` — docker and bwrap are mutually exclusive per build and rejected in `buildSettings`, never picked silently. The Docker runner shells out to the `docker` CLI (no SDK): it (re)builds the configured image when the Dockerfile inputs changed (tracked via the `org.werkator.build-inputs-sha256` image label), maintains a per-repo Gradle cache volume, mounts the worktree and the Docker socket into a labelled (`org.hoennig.werkator`) `--rm --init` container, and repairs workspace ownership in-container after each command (under a rootless daemon the container runs as root, which is the host user, and the repair degenerates to `0:0`). Git works inside the container: the primary `.git` is mounted read-only with `.git/werkator/` masked by an empty tmpfs (credential isolation) and the worktree's admin dir mounted read-write (`gitMetadataMounts`). The returned `Process` is the attached `docker run` client, so log streaming and termination work exactly like native builds.
|
||||||
|
|
||||||
|
`BwrapBuildRunner` (ADR 0008) is the third runtime, for hosts without root and without Docker — Hostsharing Managed Webspaces. It shells out to the `bwrap` CLI (no library): a prepared rootfs archive (`bwrap.rootfs`, built by `tools/build-bwrap-rootfs.sh`) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`, with uid 0 inside mapped to the calling user; isolation is filesystem-only — network, uid, `/proc`, `/dev` are the host's by contract. It reuses the Docker runner's `gitMetadataMounts`; mount order matters (repo dir read-write before the metadata mounts and the workspace), and bind mountpoints missing from the rootfs are pre-created there, since the rootfs is a plain host directory while bwrap cannot mkdir against the read-only sandbox root. `bwrap.enabled`/`bwrap.rootfs` are pinned like the docker sandbox policy. The returned `Process` is the attached `bwrap` process, so streaming and cancellation are unchanged. Plan step 21 will extract the generic sandbox machinery into the standalone tool Werkdock (grown in `werkdock/`); the runner then delegates to the `werkdock` CLI.
|
||||||
|
|
||||||
## Watcher
|
## Watcher
|
||||||
|
|
||||||
`Watcher` replaces the legacy blocking main loop with a non-blocking fixed-delay poll cycle: fetch origin, enqueue due branches (changed local, recent new origin, due auto-build slots) via `BuildExecutor`, then prune results, artifacts, and stale worktrees. Branches whose build has `requirePullRequest` are enqueued only while their head commit matches a pull-request head, detected without an API token by listing `refs/pull/*/head` via `git ls-remote` (lazily, at most once per poll cycle); manual `build` commands bypass this gate, and `watcher.pullRequestGate: false` disables it globally for plain-git origins without pull-request refs. Which builds are due is decided per branch from that branch's own definitions (`definitionsFor`): the primary config with the branch's committed `.werkator.yml` merged on top, cached per branch by its head commit *and* the primary config it was merged with, so the `git show` runs only when the branch moved while an edited machine or project config still takes effect on the next poll, and falling back to the primary definitions when that config is unreadable. Nothing is scheduled until `Watcher.start()` is called explicitly (server/watch mode) — CLI commands and tests never start the loop. "Already built" is tracked via the result repository, not by moving local branch refs.
|
`Watcher` replaces the legacy blocking main loop with a non-blocking fixed-delay poll cycle over a `RepoContext` (`start(repo)`, `poll(repo)`, `recoverOnStartup(repo)`; what it remembers per repository — the logged fetch error, the deprecation warning, the cached branch definitions — lives in a `RepoWatch` keyed by context, while `WatcherState` is still one per instance): fetch origin, enqueue due branches (changed local, recent new origin, due auto-build slots) via `BuildExecutor`, then prune results, artifacts, and stale worktrees. Branches whose build has `requirePullRequest` are enqueued only while their head commit matches a pull-request head, detected without an API token by listing `refs/pull/*/head` via `git ls-remote` (lazily, at most once per poll cycle); manual `build` commands bypass this gate, and `watcher.pullRequestGate: false` disables it globally for plain-git origins without pull-request refs. Which builds are due is decided per branch from that branch's own definitions (`definitionsFor`): the primary config with the branch's committed `.werkator.yml` merged on top, cached per branch by its head commit *and* the primary config it was merged with, so the `git show` runs only when the branch moved while an edited machine or project config still takes effect on the next poll, and falling back to the primary definitions when that config is unreadable. Nothing is scheduled until `Watcher.start()` is called explicitly (server/watch mode) — CLI commands and tests never start the loop. "Already built" is tracked via the result repository, not by moving local branch refs.
|
||||||
After the enqueue decision — and only after it, because a local ref lagging behind origin *is* the change signal — the cycle fast-forwards the primary checkout's local branch refs to their origin counterparts (`watcher.fastForwardLocalRefs`, `GitService.fastForwardLocalBranches`), so build tools reading the shared `.git` from a worktree see the refs they expect; diverged or ahead branches are never touched. Auto-build slot state lives in `.git/werkator/auto-builds.json`; watcher health is exposed via `Watcher.state()`.
|
After the enqueue decision — and only after it, because a local ref lagging behind origin *is* the change signal — the cycle fast-forwards the primary checkout's local branch refs to their origin counterparts (`watcher.fastForwardLocalRefs`, `GitService.fastForwardLocalBranches`), so build tools reading the shared `.git` from a worktree see the refs they expect; diverged or ahead branches are never touched. Auto-build slot state lives in `.git/werkator/auto-builds.json`; watcher health is exposed via `Watcher.state()`.
|
||||||
|
|
||||||
## System Metrics
|
## System Metrics
|
||||||
|
|||||||
@@ -35,4 +35,5 @@ replay_pid*
|
|||||||
# Other
|
# Other
|
||||||
/.local/
|
/.local/
|
||||||
/.env
|
/.env
|
||||||
|
/.env.*
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,14 @@ builds:
|
|||||||
- build/reports
|
- build/reports
|
||||||
stdoutLog: build.stdout.log # filename for captured stdout
|
stdoutLog: build.stdout.log # filename for captured stdout
|
||||||
stderrLog: build.stderr.log # filename for captured stderr
|
stderrLog: build.stderr.log # filename for captured stderr
|
||||||
|
|
||||||
|
# Werkdock builds itself: the Go module in werkdock/ (plan step 21).
|
||||||
|
# The first gofmt call prints any unformatted files, the second fails
|
||||||
|
# the build on them. Needs the go toolchain in the build environment.
|
||||||
|
werkdock:
|
||||||
|
trigger:
|
||||||
|
onPush: true
|
||||||
|
cleanCommand: rm -rf werkdock/dist
|
||||||
|
buildCommand: cd werkdock && gofmt -l . && test -z "$(gofmt -l .)" && go vet ./... && go test ./... && CGO_ENABLED=0 go build -o dist/werkdock .
|
||||||
|
artifactDirs:
|
||||||
|
- werkdock/dist
|
||||||
|
|||||||
@@ -30,18 +30,19 @@ IMPORTANT: Before designing or modifying code in any production package, load th
|
|||||||
|
|
||||||
### Package Structure
|
### Package Structure
|
||||||
|
|
||||||
All production code lives under `de.hoennig.werkator`, with sub-packages `commands` (picocli subcommands), `config` (YAML config loading and schema), `git` (git CLI access), `gitea` (Gitea commit-status API client), `build` (build execution, results, workspaces), `artifacts` (filesystem artifact store), `watcher` (branch polling, auto-builds, startup recovery), `metrics` (system resource sampling and aggregation), and `server` (JSON API controllers, Thymeleaf UI, artifact serving, control token, watcher and metrics lifecycles). Tests mirror this structure under `src/test/kotlin`.
|
All production code lives under `de.hoennig.werkator`, with sub-packages `commands` (picocli subcommands), `config` (YAML config loading and schema), `git` (git CLI access), `gitea` (Gitea commit-status API client), `build` (build execution, results, workspaces), `artifacts` (filesystem artifact store), `repo` (the `RepoContext` a repository is worked on through: checkout, results, artifact store, name), `watcher` (branch polling, auto-builds, startup recovery), `metrics` (system resource sampling and aggregation), and `server` (JSON API controllers, Thymeleaf UI, artifact serving, control token, watcher and metrics lifecycles). Tests mirror this structure under `src/test/kotlin`.
|
||||||
|
|
||||||
### Hard Invariants
|
### Hard Invariants
|
||||||
|
|
||||||
- `exitProcess` is called only from `main()` — never inside `CliRunner.run()`; this keeps the Spring context alive during tests.
|
- `exitProcess` is called only from `main()` — never inside `CliRunner.run()`; this keeps the Spring context alive during tests.
|
||||||
- Nothing is scheduled during CLI runs or tests: the watcher poll loop and metrics sampling start only via an explicit `start()` in the `server` profile.
|
- Nothing is scheduled during CLI runs or tests: the watcher poll loop and metrics sampling start only via an explicit `start()` in the `server` profile.
|
||||||
- Builds run detached in worktrees under `.git/werkator/worktrees/<branchKey>`; the primary checkout is never used for builds; never assume a single running build.
|
- Builds run detached in worktrees under `.git/werkator/worktrees/<branchKey>`; the primary checkout is never used for builds; never assume a single running build.
|
||||||
|
- 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 a context. Today exactly one context exists, the current working directory; the registry (step 22 session C) opens one per entry.
|
||||||
- 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`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone.
|
- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and bubblewrap (`bwrap.enabled`, `bwrap.rootfs`, `bwrap.werkdock`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone.
|
||||||
- A build definition carries the complete description of its build, split in two: the `trigger` block (`onPush`, `atTimes`, `branches`, `activeWithin`) says when and for which branches it runs, everything else what it does. `builds.default` is the base every other definition inherits its settings — never its `trigger` — from. The split is structural so that a selector added to `TriggerConfig` later is non-inheritable by construction; writing a trigger key flat is refused, never ignored, because ignoring it leaves a build that silently stops running. A `!` prefix in `trigger.branches` excludes and always wins.
|
- A build definition carries the complete description of its build, split in two: the `trigger` block (`onPush`, `atTimes`, `branches`, `activeWithin`) says when and for which branches it runs, everything else what it does. `builds.default` is the base every other definition inherits its settings — never its `trigger` — from. The split is structural so that a selector added to `TriggerConfig` later is non-inheritable by construction; writing a trigger key flat is refused, never ignored, because ignoring it leaves a build that silently stops running. A `!` prefix in `trigger.branches` excludes and always wins.
|
||||||
- The inheritance is applied after all layers are merged: that order is what makes a build a branch invents inherit the host's sandbox policy instead of the data-class default, so the pinning also holds for a build the host has never heard of. Pinned are `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, and `bwrap.rootfs`. Docker and bwrap are mutually exclusive per branch — enabling both is rejected at start.
|
- The inheritance is applied after all layers are merged: that order is what makes a build a branch invents inherit the host's sandbox policy instead of the data-class default, so the pinning also holds for a build the host has never heard of. Pinned are `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, `bwrap.rootfs`, and `bwrap.werkdock`. Docker and bwrap are mutually exclusive per branch — enabling both is rejected at start.
|
||||||
- `builds` or the legacy `branches`, never both: `branches` is read only while the merged config defines no build at all (`builds.maxConcurrent` is not one), and ignored with a warning as soon as one exists. The section is deprecated and goes away once the repositories have migrated; then `ConfigVersions.FORMAT_BROKE_IN` gets set and a leftover `branches:` key must be rejected by name — the version check alone cannot catch a file that declares no version.
|
- `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.
|
||||||
@@ -79,6 +80,9 @@ All major decisions are in `docs/adrs/`. Run `adr-status` (after `source .envrc`
|
|||||||
- **Rewrite architecture**: JSON-file persistence behind a repository interface, server-rendered UI with JSON polling, no managed nginx — systemd unit behind the host's reverse proxy (ADR 0004)
|
- **Rewrite architecture**: JSON-file persistence behind a repository interface, server-rendered UI with JSON polling, no managed nginx — systemd unit behind the host's reverse proxy (ADR 0004)
|
||||||
- **Managed nginx/TLS**: revises ADR 0004 — an opt-in nginx+certbot container for hosts without a reverse proxy (e.g. Hostsharing), planned as `docs/plan/13-nginx-tls.md` (ADR 0005)
|
- **Managed nginx/TLS**: revises ADR 0004 — an opt-in nginx+certbot container for hosts without a reverse proxy (e.g. Hostsharing), planned as `docs/plan/13-nginx-tls.md` (ADR 0005)
|
||||||
- **Runtime bundle distribution**: `./gradlew runtimeBundle` builds a jlink-trimmed JRE + jar tarball for hosts without a Java runtime; GraalVM native image and a containerized runtime were rejected (ADR 0006)
|
- **Runtime bundle distribution**: `./gradlew runtimeBundle` builds a jlink-trimmed JRE + jar tarball for hosts without a Java runtime; GraalVM native image and a containerized runtime were rejected (ADR 0006)
|
||||||
|
- **Build definitions**: a top-level `builds` section of named builds with `trigger` blocks replaces the branch-owned `autoBuild` schedules (ADR 0007)
|
||||||
|
- **bwrap build runtime**: on hosts without root and without Docker (Hostsharing Managed Webspaces), builds run in a `bwrap` user-namespace sandbox over a prepared rootfs — filesystem isolation only, network and uid shared with the host; proot/fakechroot and unisolated native builds were rejected (ADR 0008)
|
||||||
|
- **Multi-repo instance**: one instance serves a registry of self-contained repositories (instance config in `~/.werkator.yml`, repo config in each repo); revises the one-instance-per-repository tenet, implementation planned as `docs/plan/22-multi-repo.md` (ADR 0009)
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Bubblewrap User-Namespace Sandbox as the Third Build Runtime
|
||||||
|
|
||||||
|
**Status:**
|
||||||
|
- proposed: 2026-08-10
|
||||||
|
- accepted: 2026-09-01
|
||||||
|
- rejected: -
|
||||||
|
- superseded: -
|
||||||
|
|
||||||
|
**Decision [accepted]:** On hosts without root and without a Docker daemon — Hostsharing Managed Webspaces — builds run inside a `bwrap` (bubblewrap) user-namespace sandbox over a prepared rootfs archive, implemented by `BwrapBuildRunner` as the third runtime behind the `BuildRunner` interface.
|
||||||
|
Filesystem isolation only: network, uid mapping, `/proc`, `/dev`, and `/tmp` are shared with the host by contract.
|
||||||
|
|
||||||
|
Note: plan step 17 announced this decision as "ADR 0007", but 0007 was taken by the build-definitions decision on 2026-08-28; it is recorded here as 0008.
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
Werkator's build sandbox was Docker (ADR 0004ff., plan step 11) or nothing (`native`).
|
||||||
|
Managed Webspaces provide neither root nor a Docker daemon, so a Werkator instance there could only build with the host's own toolchains — no isolation, and no way to install the toolchain versions a project needs.
|
||||||
|
The platform does provide unprivileged user namespaces and ships `bubblewrap 0.8.0` (verified on h68, kernel 6.1), which allows mounting a self-prepared root filesystem without any privilege.
|
||||||
|
|
||||||
|
### Technical Background
|
||||||
|
|
||||||
|
`BwrapBuildRunner` shells out to the `bwrap` CLI — same pattern as git and docker, no library.
|
||||||
|
The rootfs comes from a project-built archive (`tools/build-bwrap-rootfs.sh`), unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`; uid 0 inside maps to the calling user.
|
||||||
|
The git metadata mounts of step 16 are reused unchanged: read-only `.git`, tmpfs mask over `.git/werkator/`, read-write worktree admin directory — so secrets stay outside the sandbox exactly as in Docker builds.
|
||||||
|
`bwrap` creates bind mountpoints against the sandbox view, so every mountpoint must exist in (or be pre-created in) the rootfs; the runner handles that.
|
||||||
|
Config: `bwrap.enabled`/`bwrap.rootfs` are pinned like the docker sandbox policy — a branch can never turn its sandbox off or swap the rootfs; docker and bwrap are mutually exclusive per build and rejected loudly, never picked silently.
|
||||||
|
Floor: bubblewrap 0.8.0 has no `--overlay` (added in 0.9.0), so throwaway writable layers are built from tmpfs/bind mounts, not overlays.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
* bwrap user-namespace sandbox (prepared rootfs, filesystem isolation only)
|
||||||
|
* proot / fakechroot (syscall- or libc-level path rewriting)
|
||||||
|
* plain native with hand-installed toolchains (no isolation)
|
||||||
|
|
||||||
|
### bwrap User-Namespace Sandbox
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
- Real kernel-level mount isolation without root; works with what the platform already ships.
|
||||||
|
- The prepared-rootfs model gives every project its own toolchain versions, like a Docker image does.
|
||||||
|
- Shelling out to a CLI matches the existing git/docker access pattern; the attached process supports log streaming and cancellation unchanged.
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
- The rootfs archive is project infrastructure that must be built and uploaded (~1.5 GB unpacked, disk/quota checked by `werkdock doctor`, which ported the original prerequisites script).
|
||||||
|
- Filesystem-only isolation: network and process view are the host's — acceptable here, and pinned so a branch cannot widen it, but weaker than Docker.
|
||||||
|
- Mountpoint pre-creation and mount ordering are subtle (hardened on the real webspace; see the fix messages preserved in commit `71f1fc6`).
|
||||||
|
|
||||||
|
### proot / fakechroot
|
||||||
|
|
||||||
|
Rejected: syscall tracing (proot) is an order of magnitude slower and historically fragile with modern toolchains; fakechroot's `LD_PRELOAD` path rewriting breaks on statically linked tools and does not isolate anything the kernel enforces.
|
||||||
|
|
||||||
|
### Plain Native with Hand-Installed Toolchains
|
||||||
|
|
||||||
|
Rejected: no isolation, host pollution, and toolchain versions become webspace-global instead of per project — exactly the situation the sandbox exists to end.
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
bwrap, as implemented in PR #4.
|
||||||
|
The generic sandbox machinery (rootfs build, prerequisites check, invocation logic) is planned to be extracted into the standalone tool **Werkdock** (plan step 21); `BwrapBuildRunner` will then delegate to the `werkdock` CLI.
|
||||||
|
That extraction changes the executor behind the config keys, not this decision.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# One Werkator Instance Serves a Set of Repositories
|
||||||
|
|
||||||
|
**Status:**
|
||||||
|
- proposed: 2026-09-01
|
||||||
|
- accepted: 2026-09-01
|
||||||
|
- rejected: -
|
||||||
|
- superseded: -
|
||||||
|
|
||||||
|
**Decision [accepted]:** The founding tenet "one instance per repository" is revised to "one instance per repository *set*": a Werkator instance aggregates self-contained repositories listed in an instance registry, sharing one service, one port, one UI, one watcher schedule, and one global executor cap.
|
||||||
|
Implementation is planned as `docs/plan/22-multi-repo.md`; this ADR records the decision and its shape, not the code.
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
"One instance per repository" (docs/Werkator-Konzept.md) does not scale even to two repositories on a Hostsharing Managed Webspace: building Werkbaum next to Werkator on mih34 would need a second service, a second assigned port, a second tunnel or domain, a second UI and metrics page.
|
||||||
|
Every repository multiplies operations while the instance-level resources could be shared.
|
||||||
|
|
||||||
|
### Technical Background
|
||||||
|
|
||||||
|
Everything repository-specific already lives inside the repository or is keyed by it: machine config with secrets, build results, auto-build slots, worktrees, and buildenvs under `.git/werkator/`; the artifact store under a per-repo key.
|
||||||
|
An instance can therefore aggregate repositories without absorbing their state — adding or removing a repository is a registry entry, never a data migration, and single-repo mode stays the degenerate case (a registry of one, implicitly the current working directory).
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
* One instance per repository set (registry + aggregation) — chosen
|
||||||
|
* A federation dashboard proxying several single-repo instances
|
||||||
|
* Status quo: one instance, one repository
|
||||||
|
|
||||||
|
### One Instance per Repository Set
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
- One service, port, UI, tunnel/domain, metrics page for any number of repositories.
|
||||||
|
- Repositories stay self-contained; per-repo secrets, history, and pinning semantics are untouched.
|
||||||
|
- Global concurrency control across all repositories.
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
- The repo dimension must be threaded through executor pools, watcher cycle, routes, and UI — the largest refactor since the rewrite (mitigated by a behavior-preserving `RepoContext` session first).
|
||||||
|
- Instance-level and repo-level configuration must be split cleanly (see below).
|
||||||
|
|
||||||
|
### Federation Dashboard over Single-Repo Instances
|
||||||
|
|
||||||
|
Rejected: less invasive, but it keeps N services, N ports, and N tunnels — it solves only the UI aggregation, not the operations burden that motivated the change.
|
||||||
|
|
||||||
|
### Status Quo
|
||||||
|
|
||||||
|
Rejected: on a webspace, ports and domains are the scarce, manually assigned resource; per-repository services do not scale there.
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
One instance per repository set, with the configuration split decided 2026-09-01:
|
||||||
|
|
||||||
|
- **Instance config** lives in `~/.werkator.yml` in the home directory of the user running the instance — one instance per OS user, matching the platform model. It carries `server.*` (port, domain/public base URL, nginx), the repository registry, the control token, the global `executor.maxConcurrent`, and the watcher schedule. The file name stays `.werkator.yml` in all three locations; the location carries the meaning.
|
||||||
|
- **Repo defaults** may live in the home file, but only in an explicit `defaults:` block, merged *below* every repository's own layers (home defaults → committed project config → repo machine config → branch layer); pinning semantics are unchanged. Accepted cost: secrets may then live in two places.
|
||||||
|
- **Repo config** stays in each repository: the committed `.werkator.yml` and the machine config in its `.git/werkator/`.
|
||||||
|
- Instance keys found in a repo's machine config are ignored with a warning naming both files once a home config exists — never merged silently.
|
||||||
|
- When a home config with a registry exists, `werkator server` serves the registry regardless of the current directory; without one it serves the current directory exactly as before.
|
||||||
|
- Repository names (routes, UI) default to the directory basename, are overridable per registry entry, and duplicates abort the start loudly.
|
||||||
|
|
||||||
|
Consequences: `docs/Werkator-Konzept.md` and AGENTS.md change their wording when the implementation lands (plan step 22 sessions B–D); until then this ADR documents the target and the existing behavior remains accurate.
|
||||||
@@ -86,6 +86,12 @@ gitea:
|
|||||||
|
|
||||||
Then, you have to configure *Werkator* by amending this config file according to [configuration.md](configuration.md).
|
Then, you have to configure *Werkator* by amending this config file according to [configuration.md](configuration.md).
|
||||||
|
|
||||||
|
### 5. Optionally Install an Instance Fragment (`--apply`)
|
||||||
|
|
||||||
|
`init --apply FILE` installs a YAML fragment in the configuration schema as the applied instance layer — see [configuration.md](configuration.md#the-applied-instance-fragment-init---apply).
|
||||||
|
Deployment tooling hands its parameters over this way instead of patching config files; the fragment is validated strictly and replaced wholesale on re-apply.
|
||||||
|
It runs before `--systemd`, so an applied `server.port` reaches the generated unit and the Apache `.htaccess` (written beside the units when a `publicBaseUrl` is configured).
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
`init` prints one line per action taken:
|
`init` prints one line per action taken:
|
||||||
|
|||||||
+22
-12
@@ -7,10 +7,17 @@ Werkator is configured via YAML files. Settings are merged from several sources
|
|||||||
| Layer | Path | Committed to Git | Purpose |
|
| Layer | Path | Committed to Git | Purpose |
|
||||||
|--------------------------|----------------------------|------------------|----------------------------------------------|
|
|--------------------------|----------------------------|------------------|----------------------------------------------|
|
||||||
| Project config | `.werkator.yml` | Yes | Shared team settings |
|
| Project config | `.werkator.yml` | Yes | Shared team settings |
|
||||||
|
| Applied instance fragment | `.git/werkator/.werkator.applied.yml` | No | Instance parameters installed by `init --apply` |
|
||||||
| Repo installation config | `.git/werkator/.werkator.yml` | No | Machine- or user-specific overrides, secrets |
|
| Repo installation config | `.git/werkator/.werkator.yml` | No | Machine- or user-specific overrides, secrets |
|
||||||
| Branch config | `.werkator.yml` committed on a branch | Yes | That branch's build settings and build definitions |
|
| Branch config | `.werkator.yml` committed on a branch | Yes | That branch's build settings and build definitions |
|
||||||
|
|
||||||
The repo install config (`.git/werkator/.werkator.yml`) wins on any key present in both files. Typically used to set `git.token` and `git.account` without committing them.
|
The repo install config (`.git/werkator/.werkator.yml`) wins on any key present in several files; the applied fragment wins over the project config. Typically the repo install config sets `git.token` and `git.account` without committing them.
|
||||||
|
|
||||||
|
### The applied instance fragment (`init --apply`)
|
||||||
|
|
||||||
|
`werkator init --apply FILE` installs a YAML fragment in this very schema as its own layer (step 23) — the file a deployment wrapper hands over instead of patching configs.
|
||||||
|
The fragment is validated strictly before installing: an unknown key is refused loudly, never ignored, because a typo would otherwise install a value that silently does nothing.
|
||||||
|
It is then copied verbatim (comments included) to `.git/werkator/.werkator.applied.yml`; re-applying replaces the file, so nothing accumulates or duplicates, and the hand-edited repo install config — which always wins — is never rewritten.
|
||||||
|
|
||||||
### Which Werkator a file is written for
|
### Which Werkator a file is written for
|
||||||
|
|
||||||
@@ -91,7 +98,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` — host-pinned as
|
`bwrap.enabled`/`bwrap.rootfs`/`bwrap.werkdock` — host-pinned as
|
||||||
long as only the host's configuration sets them, master-pinned once the committed
|
long as only the host's configuration sets them, master-pinned once the committed
|
||||||
configuration does.
|
configuration does.
|
||||||
|
|
||||||
@@ -201,7 +208,9 @@ builds:
|
|||||||
cleanCommand: rm -rf build
|
cleanCommand: rm -rf build
|
||||||
# shell command for each build
|
# shell command for each build
|
||||||
buildCommand: ./gradlew --console=plain --no-daemon test
|
buildCommand: ./gradlew --console=plain --no-daemon test
|
||||||
# directories copied as build artifacts
|
# directories copied as build artifacts; each is archived at its own
|
||||||
|
# workspace-relative path, except build/reports, which archives as reports/
|
||||||
|
# and is browsed by the artifact page's report index
|
||||||
artifactDirs:
|
artifactDirs:
|
||||||
- build/reports
|
- build/reports
|
||||||
- build/doc
|
- build/doc
|
||||||
@@ -351,7 +360,7 @@ 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 `bwrap` with all their keys.
|
||||||
A definition carries the complete description of its build; unset keys fall back to `builds.default` and then to Werkator's own defaults.
|
A definition carries the complete description of its build; unset keys fall back to `builds.default` and then to Werkator's own defaults.
|
||||||
`requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, and `bwrap.rootfs` are pinned (master-pinned, see [the branch layer](#the-branch-layer-a-branch-describes-its-own-ci)): they are read from the repo install/project config even when a branch sets them in its own committed config.
|
`requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, `bwrap.rootfs`, and `bwrap.werkdock` are pinned (master-pinned, see [the branch layer](#the-branch-layer-a-branch-describes-its-own-ci)): they are read from the repo install/project config even when a branch sets them in its own committed config.
|
||||||
Inheritance from `builds.default` covers the settings only — the `trigger` block says when and where *this* build runs and is never inherited.
|
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.
|
||||||
@@ -409,16 +418,17 @@ All Werkator containers carry `org.hoennig.werkator` labels; stale build contain
|
|||||||
|
|
||||||
### Notes on `builds.<name>.bwrap`
|
### Notes on `builds.<name>.bwrap`
|
||||||
|
|
||||||
With `bwrap.enabled`, Werkator shells out to the `bwrap` CLI (bubblewrap) instead of native execution.
|
With `bwrap.enabled`, Werkator runs the build in a bubblewrap sandbox instead of native execution.
|
||||||
This is the third runtime, for hosts without root and without a Docker daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md` and ADR 0007.
|
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.
|
||||||
`bwrap` must be on the `PATH`.
|
Since step 21 session C the sandbox is executed by the `werkdock` CLI (`bwrap.werkdock`, default: resolved via `PATH`) — Werkator no longer invokes `bwrap` itself; `bwrap` must be installed for werkdock.
|
||||||
|
`werkdock doctor` checks the host's capability (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.
|
`bwrap.rootfs` names the prepared root filesystem archive — a Debian-base rootfs with the build tools (JDK, git, locales, project-specific tooling) built elsewhere, since `debootstrap` is unavailable on the target.
|
||||||
It is a local path or an `http(s)` URL; a URL is downloaded once.
|
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; verify the host's user-namespace capability first with `tools/werkator-build-prerequisites.sh`.
|
Build the archive with `tools/build-bwrap-rootfs.sh` on any machine with Docker.
|
||||||
The archive is unpacked on demand (`tar --no-same-owner`) into `.git/werkator/buildenv/<envKey>/rootfs`, shared across all branch worktrees like the Docker Gradle cache volume; `<envKey>` derives from a hash of the source, so a changed `rootfs` unpacks a fresh environment and stale ones can be pruned.
|
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-branch 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.
|
`bwrap.env` adds environment variables inside the sandbox; the environment is otherwise cleared (docker semantics) — the server's environment does not leak in.
|
||||||
Files created inside the sandbox are owned by the host user, because uid 0 maps back to the unprivileged webspace user.
|
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 `bwrap` are mutually exclusive per branch: enabling both is rejected at start, not silently picked.
|
||||||
|
|||||||
@@ -261,3 +261,33 @@ All nginx and certificate failures are non-fatal warnings — the plain HTTP ser
|
|||||||
The nginx container cannot reach `localhost` of the host, so the proxy upstream defaults to `serverName`; set `server.nginx.upstreamHost` if the host is reachable under a different name from inside containers.
|
The nginx container cannot reach `localhost` of the host, so the proxy upstream defaults to `serverName`; set `server.nginx.upstreamHost` if the host is reachable under a different name from inside containers.
|
||||||
With the managed nginx, set `server.bindAddress: 0.0.0.0` explicitly (or an address reachable from the Docker network) — the default `127.0.0.1` makes Werkator unreachable for the proxy container.
|
With the managed nginx, set `server.bindAddress: 0.0.0.0` explicitly (or an address reachable from the Docker network) — the default `127.0.0.1` makes Werkator unreachable for the proxy container.
|
||||||
See [configuration.md](configuration.md) for all `server.nginx.*` keys.
|
See [configuration.md](configuration.md) for all `server.nginx.*` keys.
|
||||||
|
|
||||||
|
## Hostsharing Managed Webspace
|
||||||
|
|
||||||
|
The third deployment variant (plan step 21, verified live on a real webspace): no root, no Docker daemon, no own reverse proxy.
|
||||||
|
Werkator runs as a systemd *user* service on the assigned localhost port ("eigener Serverdienst"), the platform's managed Apache terminates TLS and proxies via `.htaccess`, and builds run in the bubblewrap sandbox executed by the [werkdock](../werkdock/README.md) CLI (ADR 0008, step 21 session C).
|
||||||
|
|
||||||
|
Werkator is never built on the webspace: the runtime bundle and the werkdock binary are built locally and uploaded (ADR 0006).
|
||||||
|
All steps are driven by `tools/remote`; commands name their role — `instance-*` manages the installed Werkator, `repo-*` the repository it watches.
|
||||||
|
Each instance is a pair of files (step 23): a transport env file selected with `--env-file` (default `.env`), and a YAML fragment in the configuration schema, named by its `WERKATOR_INIT_CONFIG` key and installed remotely via `werkator init --apply` — e.g. `.env.mih34` + `.env.mih34.yml`, both gitignored.
|
||||||
|
The fragment carries the Werkator configuration (`server.port`, `publicBaseUrl`, systemd limits, `builds.default.bwrap.*`); the env file only says where and how to reach the host.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/remote --env-file .env.mih34 werkator check-prerequisites # uploads werkdock, runs its doctor
|
||||||
|
tools/remote --env-file .env.mih34 werkator instance-install # upload + unpack the runtime bundle and werkdock
|
||||||
|
tools/remote --env-file .env.mih34 werkator repo-init # clone the watched repo, rootfs archive, init --apply
|
||||||
|
tools/remote --env-file .env.mih34 werkator instance-start # init --apply --systemd, place .htaccess, enable the unit
|
||||||
|
tools/remote --env-file .env.mih34 port-forward start # browser tunnel while no public domain is set up
|
||||||
|
```
|
||||||
|
|
||||||
|
Layout on the host: the watched repository at `$WERKATOR_PATH/werkator/`, the unpacked runtime at `$WERKATOR_PATH/.werkator/werkator/`, the werkdock binary at `$WERKATOR_PATH/.werkator/bin/werkdock`.
|
||||||
|
The rootfs archive is loaded once per source into werkdock's image store (`~/.werkdock`), shared by every repository of the user.
|
||||||
|
Fill `git.account`/`git.token` in the machine config when the origin is private, and make the user's services survive logout with `loginctl enable-linger`.
|
||||||
|
|
||||||
|
Updates are one command, refused while a build runs (`FORCE=1` overrides):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/remote --env-file .env.mih34 werkator instance-update
|
||||||
|
```
|
||||||
|
|
||||||
|
The previous runtime stays as `.werkator/werkator.prev` for one deployment as the rollback asset.
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ Two claims could **not** be verified from a Hostsharing primary source; check th
|
|||||||
|
|
||||||
## ADR
|
## ADR
|
||||||
|
|
||||||
Write ADR 0007: bubblewrap user-namespace sandbox as the third build runtime (options considered: bwrap (chosen), proot/fakechroot (slow, fragile), plain native with hand-installed toolchains (no isolation, host pollution)).
|
Write ADR 0008 (step text originally said 0007, but 0007 was taken by build definitions): bubblewrap user-namespace sandbox as the third build runtime (options considered: bwrap (chosen), proot/fakechroot (slow, fragile), plain native with hand-installed toolchains (no isolation, host pollution)).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -182,4 +182,4 @@ Write ADR 0007: bubblewrap user-namespace sandbox as the third build runtime (op
|
|||||||
- `./gradlew ktlintFormat` then `./gradlew build` is green — also on a machine without Docker (Testcontainers smoke test skipped, not failed).
|
- `./gradlew ktlintFormat` then `./gradlew build` is green — also on a machine without Docker (Testcontainers smoke test skipped, not failed).
|
||||||
- On a Managed Webspace: Werkator (from the runtime bundle) builds a real branch of a repo inside the bwrap sandbox; git commands work in the worktree; `.git/werkator/` is not readable from the build; a write to `/usr` fails.
|
- On a Managed Webspace: Werkator (from the runtime bundle) builds a real branch of a repo inside the bwrap sandbox; git commands work in the worktree; `.git/werkator/` is not readable from the build; a write to `/usr` fails.
|
||||||
- On the same webspace: the UI answers over HTTPS under the domain through the Apache `.htaccess` proxy, the service survives a logout and a reboot (systemd lingering), and Gitea statuses carry `publicBaseUrl` links that resolve.
|
- On the same webspace: the UI answers over HTTPS under the domain through the Apache `.htaccess` proxy, the service survives a logout and a reboot (systemd lingering), and Gitea statuses carry `publicBaseUrl` links that resolve.
|
||||||
- Docs updated: `docs/configuration.md` (bwrap section), architecture skill (third runtime), ADR 0007, and `docs/deployment.md` gains "Hostsharing Managed Webspace" as a third deployment variant — written only once the setup above is verified on a real webspace, not from this plan.
|
- Docs updated: `docs/configuration.md` (bwrap section), architecture skill (third runtime), ADR 0008, and `docs/deployment.md` gains "Hostsharing Managed Webspace" as a third deployment variant — written only once the setup above is verified on a real webspace, not from this plan.
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Step 21: Werkdock Extraction and the Managed-Webspace Install Path
|
||||||
|
|
||||||
|
Prerequisites: step 17 (merged to `main` as PR #4, commit `71f1fc6`).
|
||||||
|
Read `README.md` first.
|
||||||
|
This step is a roadmap: it records where the bwrap work drifted from the original intent, and breaks the correction into sessions (A–D below).
|
||||||
|
Each session is sized like a normal step.
|
||||||
|
Werkdock is developed in the `werkdock/` subdirectory of this repository first and moves to its own repository later; the whole effort runs on the branch `werkdock-extraction`.
|
||||||
|
|
||||||
|
## What Was Planned, and What the Branch Built Instead
|
||||||
|
|
||||||
|
Two intents from the existing documentation ended up competing in the bwrap work merged as PR #4:
|
||||||
|
|
||||||
|
1. **ADR 0006 / step 15**: Werkator is *built locally* and distributed as a self-contained runtime bundle; the target host only unpacks and runs it.
|
||||||
|
That install path exists and is documented — but only for Hostsharing **container servers** (`docs/deployment.md`, "Hosts Without a Java Runtime", verified on vm4006).
|
||||||
|
2. **Step 17**: bubblewrap as the third build runtime, with the stated target use case "Werkator builds Werkator itself on a Managed Webspace".
|
||||||
|
|
||||||
|
Step 17 itself proved the self-build unnecessary for deployment: the precondition section records that the runtime bundle runs on the webspace unchanged (glibc floor `GLIBC_2.15`, checked on h68), "so no container build and no second build machine are needed for this platform".
|
||||||
|
The merged code nevertheless implements the self-build end to end — `tools/remote install` clones the repository onto the webspace and `tools/remote build` builds Werkator there inside the bwrap sandbox.
|
||||||
|
That is a working prototype and a good proof of the sandbox, but as a *deployment* path it inverts intent 1: the webspace should receive a locally built bundle, exactly like vm4006 does.
|
||||||
|
|
||||||
|
Independently, the bwrap machinery itself (rootfs archive build, prerequisites check, the mount/uid-mapping invocation in `BwrapBuildRunner`) is generic filesystem isolation, not Werkator-specific.
|
||||||
|
The plan is to extract it as a small docker-like tool: filesystem isolation only, everything else (network, uid, `/proc`, `/dev`) shared with the host — usable on Managed Webspaces to install one's own program versions, with Werkator as its first consumer.
|
||||||
|
It grows in the `werkdock/` subdirectory of this repository and moves to its own repository once it stands on its own.
|
||||||
|
|
||||||
|
## Naming the Extracted Tool
|
||||||
|
|
||||||
|
**Werkdock** — decided 2026-09-01.
|
||||||
|
A dock is the enclosed basin in which ships are built, so the name carries both halves of the tool at once: the closed-off area (filesystem isolation) and the docker-light ambition.
|
||||||
|
The metaphor extends to the contract: the dock gate controls what passes, while the water outside is shared with the whole harbor — network, uid, `/proc`, `/dev` from the host.
|
||||||
|
The audible nearness to Docker is read as an honest genre label, not as an accident.
|
||||||
|
"Dock" is the same word in German and English — the only candidate that needed no translation in either direction.
|
||||||
|
As of 2026-09-01 there is no GitHub repository, product, or company of that name.
|
||||||
|
|
||||||
|
Considered and dropped, over three naming rounds:
|
||||||
|
|
||||||
|
- *Werkwrap* (the working title): names the mechanism — a wrapper over `bwrap` — rather than the result; one abandoned zero-star GitHub repo of that name also exists.
|
||||||
|
- *Werkroot*: technically the most precise (the isolated artifact is a root filesystem; lineage chroot → fakeroot), completely free — the runner-up.
|
||||||
|
- *Werkgrund*: "own ground to build on", free; but "Grund" also reads as "reason" and signals neither isolation nor containers.
|
||||||
|
- German root-words: *Wurzelwerk* (the finest word, but a well-known German gardening brand, and it inverts the Werk-family order), *Werkwurzel* (family-true but botanical), *Stammwerk*, *Wurzelraum*.
|
||||||
|
- Enclosed-area words: *Werkkammer* (sober engineering chamber), *Werkinsel* (isolation literally from *insula*), *Werkgehege* (best tagline — „damit sich Programmversionen nicht ins Gehege kommen" — but zoo overtones), *Werkklause*, *Werkzone*, *Werkhof* (the Swiss municipal works yard), *Werkgarten* (walled-garden connotation).
|
||||||
|
- *Werkbank* (taken on GitHub at least twice, and a common German word), *Werkbox* (crowded `*box` sandbox namespace), *Kapsel* (SAP's Kapsel framework).
|
||||||
|
|
||||||
|
## The Sessions
|
||||||
|
|
||||||
|
### A — Close step 17's open ends (this repo)
|
||||||
|
|
||||||
|
The `BuildRunner` half is a keeper regardless of the extraction; it is merged (PR #4), but its paperwork is not finished.
|
||||||
|
|
||||||
|
- Rename `docs/prs/2026-08-31-PR#000-bwrap-build-runtime.md` and its scenario IDs to the real number, #4.
|
||||||
|
- Mark `tools/remote install`/`build` in the script header as a prototype of the self-build workflow, superseded by session D.
|
||||||
|
- Write the bwrap-runtime ADR — step 17 says "ADR 0007", but 0007 is taken by build definitions since 2026-08-28; the ADR becomes **0008**.
|
||||||
|
- Update the architecture skill: it does not mention the third runtime yet.
|
||||||
|
|
||||||
|
### B — Bootstrap Werkdock (subdirectory `werkdock/`, later its own repo)
|
||||||
|
|
||||||
|
A docker-like CLI over `bwrap`, filesystem isolation only.
|
||||||
|
|
||||||
|
- Semantics: an *image* is a rootfs archive; an *instance* is an unpacked, writable directory tree and corresponds to a docker container; `werkdock run [flags] IMAGE [CMD...]` executes in the sandbox with uid 0 mapped to the calling user.
|
||||||
|
- The surface is docker-compatible as far as the filesystem-only contract allows — verbs, flags, and (deferred) a Docker-Engine-API daemon for Testcontainers; levels and limits in Werkdock RFC 0002.
|
||||||
|
- Decided 2026-09-01: RFC 0002 levels 2 and 3 are deferred indefinitely; the immediate goal of this session is the minimal build-capable CLI — `doctor`, `load`, `run` — sufficient for the sandbox builds of Werkator, Werkbaum (Kotlin/Gradle backend plus Node frontend), and Werkdock itself (Go); while Werkdock lives in this repository, its own CI is just a build definition in this repository's `.werkator.yml`.
|
||||||
|
- Host-shared by design, not by omission: network, uid mapping, `/proc`, `/dev`, `/tmp` come from the host; document this as the contract, since it is what makes the tool work without root on a Managed Webspace.
|
||||||
|
- Moves in from Werkator: `tools/build-bwrap-rootfs.sh` (becomes the image build), the generic half of `tools/werkator-build-prerequisites.sh` (becomes `werkdock doctor`: userns capability, quota headroom), and the invocation logic of `BwrapBuildRunner` (mount ordering, mountpoint pre-creation, uid mapping — the parts hardened on the real webspace; the squash commit `71f1fc6` preserves the individual fix messages).
|
||||||
|
- Known floor: bubblewrap 0.8.0 on the webspaces has no `--overlay`; writable spots are tmpfs/bind mounts until the platform reaches 0.9.
|
||||||
|
- Own docs, plan, and ADRs under `werkdock/` from the start, so the later repository split is a directory move; the Werkator side only keeps what is Werkator-specific (the git-metadata mounts of step 16 and the config pinning).
|
||||||
|
- Keep `werkdock/` self-contained: no imports from Werkator code, no Gradle coupling to the Werkator build — it must build and test on its own.
|
||||||
|
|
||||||
|
### C — Werkator consumes Werkdock (this repo, after B; implemented 2026-09-01 on branch `werkator-consumes-werkdock`)
|
||||||
|
|
||||||
|
- `BwrapBuildRunner` shells out to `werkdock run` instead of assembling the raw `bwrap` argv — same pattern as git and docker: CLI, no library.
|
||||||
|
- Config keys (`bwrap.enabled`, `bwrap.rootfs`) and their pinning stay as they are; only the executor behind them changes.
|
||||||
|
One key was added: `bwrap.werkdock` (the executing binary, default via PATH) — pinned like the rest of the sandbox policy, since a branch must not substitute the executing binary.
|
||||||
|
- Decided: the git-metadata mounts stay Werkator-side, passed as `-v …:ro` / `--tmpfs` / `-v` options whose flag order werkdock preserves (it grew `--tmpfs` and an ordered mount list for exactly this); the secrets-masking of `.git/werkator/` holds unchanged.
|
||||||
|
- Decided: the rootfs archive becomes a werkdock *image* (`werkator-buildenv-<source-hash>`, checked via `werkdock images`, loaded via `werkdock load`) in werkdock's own store — shared across every repository of the OS user, which resolves step 22's buildenv-sharing question; only the URL download cache and the persistent `/root` toolchain home stay under `.git/werkator/buildenv/`.
|
||||||
|
- Consequence of werkdock's `--clearenv`: the server environment no longer leaks into builds, and the runner's TMPDIR workaround is gone.
|
||||||
|
|
||||||
|
### D — The Managed-Webspace install path (this repo, independent of B/C; implemented 2026-09-01 on branch `werkator-consumes-werkdock`)
|
||||||
|
|
||||||
|
Bring intent 1 to the webspace: build locally, install the bundle — Werkator never builds itself on the target.
|
||||||
|
|
||||||
|
- `tools/remote install` loses the repository clone and the GitHub-key step; it uploads the locally built runtime bundle (built on demand, as today) and runs `init`.
|
||||||
|
- The rootfs upload stays, but for its real purpose: the sandbox for the repositories this instance *watches*, not for building Werkator.
|
||||||
|
- `tools/remote build` (the self-build) is retired with session A's prototype marker.
|
||||||
|
- Untangle the two roles `tools/remote` mixes (noted 2026-09-01): some of its commands manage the *builder* (the installed Werkator instance: install, start, control-token, the runtime bundle, the rootfs it builds others in), others act on the *built* (the watched repository: build, branch selection) — and Werkator itself overlaps with both (the instance IS a Werkator, and status/build/retry exist as `werkator` CLI commands too).
|
||||||
|
On the self-building instance both roles coincide in one product, which misleads: "updating werkator" can mean swapping the builder's bundle or building the repo's head, and they are different operations with different risks.
|
||||||
|
Session D's replacement must name the role in every command and in the script's vocabulary (e.g. `instance install`/`instance update` vs `repo build`), and prefer delegating built-side operations to the `werkator` CLI instead of reimplementing them.
|
||||||
|
- `docs/deployment.md` gains "Hostsharing Managed Webspace" as the third deployment variant — step 17 required this to be written from a verified setup, and the branch's live run provides exactly that.
|
||||||
|
|
||||||
|
## Session Notes
|
||||||
|
|
||||||
|
- 2026-09-01: The fat build image exists and is live on mih34: `tools/build-bwrap-rootfs.sh` gained `--pkgs-extra`, the archive `werkator-buildenv-trixie-java-go-node.tar.zst` (515 MB, JDK 21 + Go + Node/npm) was built locally, uploaded checksum-verified, and the machine config switched to it (deduplicating nine identical bwrap blocks the install prototype had appended).
|
||||||
|
The old archive and its unpacked environment stay as rollback until the `werkdock` build pool is green.
|
||||||
|
- 2026-09-01, later: sessions A and B are done and live-verified on mih34 — the skeleton (`doctor`, `load`, `run` over the bwrap engine) builds itself there as pool `<branch>@werkdock`, and the CI-built static binary runs.
|
||||||
|
Three defects found and fixed on the way: unanchored tar excludes dropped the Go stdlib's `sys` directory from the archive, pam_tmpdir's `TMPDIR` leaked into the sandbox (Werkator-side fix; Werkdock is immune via `--clearenv`), and non-report artifacts were stored below `reports/` and invisible in the UI.
|
||||||
|
The image was then trimmed (headless JDK, en/de locales only, no man/doc/apt-lists): 351 MB compressed — smaller than the original JDK-only archive despite carrying Go and Node.
|
||||||
|
All rollback assets on mih34 are removed; the PR for this branch is prepared (PR-doc with `PR#000` placeholder) and will be opened later.
|
||||||
|
- 2026-09-01, session C deployed to mih34: the werkdock binary sits at `.werkator/bin/werkdock`, the machine config names it in `bwrap.werkdock`, the runtime bundle carries the delegating runner, and the TMPDIR workaround left the machine config (obsolete under werkdock's clearenv).
|
||||||
|
- 2026-09-01, session D done and live-verified on mih34: `tools/remote` reworked to role-named commands (`instance-install`/`instance-update`/`instance-start` for the builder, `repo-init` for the built; the retired `install`/`build`/`start` fail loudly naming their successors); the self-build, the repo clone for it, and the GitHub-key step are gone — the instance installs from locally built artifacts (bundle + werkdock), the watched repo clones anonymously via https.
|
||||||
|
`instance-update` refuses to swap under a running build, `repo-init` is idempotent (checksum-skipped rootfs upload; the machine-config guard whose indentation mismatch once appended nine duplicate bwrap blocks is fixed); `docs/deployment.md` gained the Managed Webspace as the third variant, written from the verified setup.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Session A: PR-doc renamed to #4, ADR 0008 written, architecture skill mentions the third runtime, `tools/remote` header carries the prototype note.
|
||||||
|
- Session B: the `werkdock/` subdirectory holds a self-contained tool in which `werkdock doctor`, an image build, and `werkdock run` work on a Managed Webspace without any Werkator involvement.
|
||||||
|
- Session C: `./gradlew build` green with `BwrapBuildRunner` delegating to `werkdock`; the pinned-key tests and the metadata-masking tests unchanged and green.
|
||||||
|
- Session D: a fresh Managed Webspace reaches a running, HTTPS-reachable Werkator via `tools/remote werkator install` + `start` without ever compiling on the target; `docs/deployment.md` documents it.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Step 22: One Werkator Instance, Many Repositories
|
||||||
|
|
||||||
|
Prerequisites: none in code; step 21's Werkdock work is independent.
|
||||||
|
Read `README.md` first.
|
||||||
|
This step is a roadmap in sessions (A–E), like step 21; each session is sized for one focused Claude Code session.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
"One instance per repository" is a founding tenet (`docs/Werkator-Konzept.md`, AGENTS.md) — and on a Managed Webspace it does not scale even to two repositories.
|
||||||
|
Building Werkbaum next to Werkator on mih34 today means: a second pac user or a second service, a second assigned port, a second tunnel or domain, a second UI, a second metrics page.
|
||||||
|
Every repository added multiplies operations, while the instance-level resources (port, UI, watcher schedule, executor slots, metrics) could be shared.
|
||||||
|
The goal: one Werkator instance serves a *set* of repositories — one service, one port, one UI — while each repository keeps its own configuration, secrets, history, and artifacts.
|
||||||
|
|
||||||
|
## The Guiding Idea: the Repository Stays Self-Contained
|
||||||
|
|
||||||
|
Everything repository-specific already lives *inside* the repository: the machine config with secrets in `.git/werkator/`, build results, auto-build slots, worktrees, buildenvs — and the artifact store is already keyed per repo path.
|
||||||
|
The multi-repo instance therefore does not absorb repository state; it becomes an *aggregator* over self-contained repositories.
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- Adding or removing a repository is editing a registry entry, never a data migration.
|
||||||
|
- A repository can move between instances (or back to its own) without losing anything.
|
||||||
|
- Single-repo mode stays the degenerate case: a registry of one, implicitly the current working directory — existing installations keep working without any config change.
|
||||||
|
|
||||||
|
## Key Ownership Splits
|
||||||
|
|
||||||
|
Today all config comes from the repo's own layers; multi-repo splits ownership:
|
||||||
|
|
||||||
|
- **Instance-level** (decided 2026-09-01: a `.werkator.yml` in the *home directory* of the user running the instance — the name stays `.werkator.yml` in all three locations, the location carries the meaning): `server.*` (port, bind address, public base URL / domain, nginx), the repository registry, the control token (one UI, one token), `executor.maxConcurrent` as the *global* cap, watcher interval, metrics.
|
||||||
|
- **Repo defaults** (decided 2026-09-01): the home file MAY carry defaults for repo-level keys (e.g. one `git.account`/`git.token` for all repos of the same forge), in an explicit `defaults:` block so instance keys and repo defaults never blur syntactically.
|
||||||
|
The block merges BELOW every repo's own layers: home `defaults` → committed project config → repo machine config → branch layer (pinning semantics unchanged — home and repo machine config are both host-side layers, the branch layer still cannot reach pinned keys).
|
||||||
|
Accepted cost: secrets may then live in two places; a repo without its own secrets is no longer self-contained on its own.
|
||||||
|
- **Repo-level** (unchanged, from the repo's own layers — machine config in its `.git/werkator/`, committed project config, branch layer): `gitea.*` (each repo has its own owner/repo/token/statusContext), `git.*` credentials, `builds`, retention, per-repo watcher options (e.g. `pullRequestGate`), sandbox policy and its pinning.
|
||||||
|
- **Both**: a per-repo concurrency cap below the global one may come later; not in the first cut.
|
||||||
|
|
||||||
|
The pinning model is untouched: pinned keys still come from each repo's machine config, and the branch layer still cannot reach them.
|
||||||
|
|
||||||
|
## The Sessions
|
||||||
|
|
||||||
|
### A — Decision and schema (ADR 0009)
|
||||||
|
|
||||||
|
- ~~Write ADR 0009~~ — done 2026-09-01: `docs/adrs/0009-2026-09-01.multi-repo-instance.md` revises the one-instance-per-repository tenet to one-instance-per-*set* and records the aggregator idea, the key ownership split, the four 2026-09-01 decisions, and the rejected federation-dashboard alternative.
|
||||||
|
- Define the instance config: `~/.werkator.yml` (decided 2026-09-01) — the repository registry plus the instance-level keys above; one instance per OS user, which matches the platform model (pac users on a webspace, service users elsewhere).
|
||||||
|
`werkator server` without a home config serves the current directory exactly as today.
|
||||||
|
- Decide the transition for instance keys that today sit in a repo's machine config (mih34's carries `server.*`): once a home config exists, repo-level instance keys are ignored with a warning naming both files — never merged silently.
|
||||||
|
- Repo identity for display and routes (decided 2026-09-01): a short unique name per registry entry, defaulting to the repository's directory basename, overridable in the entry; duplicate resulting names abort the start loudly. Used as the route segment (`/repos/<name>/…`) and UI grouping key.
|
||||||
|
- Precedence (decided 2026-09-01): when a home config with a registry exists, `werkator server` serves the registry regardless of the current directory — one user, one instance, deterministic; without a home config it serves the current directory exactly as today.
|
||||||
|
- Update `docs/Werkator-Konzept.md` and the AGENTS.md architecture wording ("one instance per repository set") when the implementation lands (sessions B–D) — until then the existing behavior description remains accurate; the AGENTS.md decision list carries ADR 0009 already.
|
||||||
|
|
||||||
|
### B — RepoContext refactor, behavior unchanged
|
||||||
|
|
||||||
|
- ~~Introduce a `RepoContext` (working dir, config loading, git access, result repository, artifact store key, watcher state) and thread it through executor, watcher, and server code paths that today implicitly use the single `workingDir`.~~ — done 2026-09-02 (PR #11): `RepoContext` (`repo` package) carries `name`, `workingDir`, `results`, `artifactStore`; git access and config loading stay path-based services taking `repo.workingDir` (the home `defaults:` layer of session C is the moment config loading needs the context). The watcher's per-repo memory lives in a `RepoWatch` keyed by context; `WatcherState` stays one per instance until session C.
|
||||||
|
- ~~The executor becomes instance-global with repo-scoped pools: serialization per (repo, branch), the global `maxConcurrent` across repos; `BuildResult` needs no schema change — results stay in each repo's own JSON file, the repo dimension exists only in memory and in routes.~~ — done 2026-09-02: `startBuild(repo, branch, commit, build)`, pools keyed by (context, branch), one semaphore.
|
||||||
|
- ~~Single-repo behavior, routes, and UI stay byte-identical; the full test suite is the acceptance gate.~~ — done: no route, template, or config change; the current-directory context is a bean and the result/artifact-store beans are its members.
|
||||||
|
- Carried over to session C (found while threading): `StateDirMigration` runs once per process on the cwd and must run per registered repo; `SystemMetricsCollector` measures the cwd's repository size; `ServerCommand` reads `server.*` from the cwd; `RunningBuild` carries no repository, so `BuildExecutor.currentBuilds()` and the watcher's worktree pruning cannot tell repos apart yet (harmless today: at worst a worktree of another repo's branch name is kept one cycle longer).
|
||||||
|
|
||||||
|
### C — The registry and N repositories
|
||||||
|
|
||||||
|
- Load the registry, build one `RepoContext` per entry; fail the start loudly on duplicate names or unreadable repos (config-version violations abort only that repo's registration, like branch-config violations fail only that branch).
|
||||||
|
- Watcher multiplexing: one poll cycle iterates the contexts (fetch, enqueue, prune per repo) with per-repo error isolation — one unreachable origin must not starve the others; `WatcherState` gains the repo dimension for the health banner.
|
||||||
|
- Startup recovery per repo; auto-build slots stay in each repo's `.git/werkator/`.
|
||||||
|
- CLI commands gain an optional repo selector and default to the current working directory, so `werkator status` inside a repo behaves as today.
|
||||||
|
|
||||||
|
### D — Server, API, and UI scoping
|
||||||
|
|
||||||
|
- Routes gain the repo segment (`/api/repos/<name>/builds/…`, `/repos/<name>/builds/<key>`); with exactly one registered repo the today-routes keep working (redirect or alias) so bookmarks and posted Gitea links survive.
|
||||||
|
- Latest/branches/history views group by repo or gain a repo column; one instance-wide metrics page; one control token.
|
||||||
|
- Gitea status links use the repo-scoped URLs.
|
||||||
|
|
||||||
|
### E — Rollout on mih34: Werkbaum joins
|
||||||
|
|
||||||
|
- Registry with the Werkator and Werkbaum repositories under the existing user, one service, one port, the existing tunnel.
|
||||||
|
- Write Werkbaum's `.werkator.yml`: Gradle backend build and npm frontend build in the shared trimmed image (Node is already in it).
|
||||||
|
- Record the deployment; retire the second-instance/second-user idea from the notes.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Fairness across repos when the global concurrency cap is contended (round-robin per repo vs. FIFO) — decide in session C with the real queue behavior at hand.
|
||||||
|
- Whether buildenv rootfs trees should be shared across repos (today each repo unpacks its own under `.git/werkator/buildenv/`) — the natural answer is Werkdock's image store (step 21 session C), not instance-level state; until then duplicate unpacked rootfs trees are the accepted cost.
|
||||||
|
- ~~Whether `artifactKey` needs a repo prefix or stays globally unique by construction (random suffix) — decide in session B when the routes are designed.~~ — decided 2026-09-02: no prefix. The key is derived from pool name and start time, and both the results file and the artifact store are per repository, so it only ever has to be unique within one; the repo dimension enters through the route segment in session D, never through the key. A prefix would also change every existing artifact directory name.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Session A: ADR 0009 written (done 2026-09-01); the registry and key ownership land in `docs/configuration.md` together with the implementing sessions, since that reference describes implemented configuration only.
|
||||||
|
- ~~Session B: full suite green with `RepoContext` threaded through; no route or behavior change observable.~~ — done 2026-09-02.
|
||||||
|
- Session C: an instance with two registered repos builds pushes in both, with per-repo error isolation proven by a test (one broken origin, the other keeps building).
|
||||||
|
- Session D: both repos browsable in one UI; single-repo installations keep their existing URLs.
|
||||||
|
- Session E: mih34 builds Werkator and Werkbaum from one service; `docs/deployment.md` describes the registry setup.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Step 23: Init Owns the Files, `tools/remote` Wraps
|
||||||
|
|
||||||
|
Prerequisites: step 21 session D (the role-named `tools/remote`).
|
||||||
|
Read `README.md` first.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
`werkator init` and `tools/remote` overlap: both write the machine config — init as a commented template, the script by appending heredoc blocks (`bwrap`, `server`) and patching values with `sed`.
|
||||||
|
The script re-implements configuration knowledge Werkator owns (YAML shape, indentation, key names), outside the three-places sync invariant — the indentation-mismatch of one append guard produced nine duplicate `bwrap` blocks on mih34 before it was found.
|
||||||
|
Smaller duplications of the same kind: the script re-implements control-token generation in bash (`ControlTokenService` owns it), and `check-prerequisites` still pipes the bash script whose generic half exists as `werkdock doctor`.
|
||||||
|
|
||||||
|
## The Decision (2026-09-01)
|
||||||
|
|
||||||
|
Werkator becomes the executing app wherever possible; `tools/remote` shrinks to a wrapper: build artifacts locally, transport them, execute Werkator/werkdock remotely, switch services.
|
||||||
|
|
||||||
|
Parameters travel as **files**, not as many CLI options — and each side gets the format that is native to it (refined 2026-09-01):
|
||||||
|
|
||||||
|
- The **wrapper** keeps a small, bash-sourceable env file with the transport values only: `tools/remote --env-file .env.mih34 werkator repo-init` selects the target (default: `.env`; named `--env-file` like docker's flag for the same thing, since `--env` means a single variable there), so several instances (`.env.mih34`, `.env.vm4006`, later a Werkbaum instance) are files, not edits.
|
||||||
|
- **Werkator** takes a **YAML fragment in its own config schema**: `werkator init --apply mih34.yml` deep-merges the fragment into the machine config, idempotently — creating sections that are missing, updating the given values, never duplicating.
|
||||||
|
No mapping table exists: the fragment says `server: {port: …}` and `builds: {default: {bwrap: …}}` directly, is validated by the existing schema binding, and is documented by the existing `docs/configuration.md`.
|
||||||
|
- The wrapper uploads the fragment alongside the artifacts and calls `werkator init --apply …` remotely — the heredocs and `sed` calls in `tools/remote` disappear.
|
||||||
|
- The env file names the fragment (`WERKATOR_INIT_CONFIG=mih34.yml`), keeping one entry point per instance.
|
||||||
|
|
||||||
|
The removed legacy env-to-YAML conversion stays removed — there is no conversion at all anymore: the fragment already *is* configuration in the one schema, applied once at setup time; the server reads nothing but its YAML at runtime.
|
||||||
|
|
||||||
|
## The Files per Instance
|
||||||
|
|
||||||
|
- `.env.mih34` (wrapper): `WERKATOR_REMOTE`, `WERKATOR_PATH`, `WERKATOR_LOCAL_PORT`, `WERKATOR_REPO_URL`, `WERKATOR_ROOTFS` (the *local* archive to upload), `WERKATOR_INIT_CONFIG`.
|
||||||
|
- `mih34.yml` (init fragment): `server.*` (port, publicBaseUrl, systemd limits) and `builds.default.bwrap.*` (enabled, the *remote* rootfs path, werkdock path) — exactly the blocks the script used to append.
|
||||||
|
- Secrets (`git.token`, `gitea` keys) stay out of both files on purpose — they are entered in the machine config on the host, as today.
|
||||||
|
|
||||||
|
## The Sessions
|
||||||
|
|
||||||
|
### A — Werkator side (implemented 2026-09-01 on branch `init-apply-config`)
|
||||||
|
|
||||||
|
- `init --apply FILE`: deep-merge the YAML fragment into the machine config — reusing the loader's merge, creating missing sections, updating given values, never duplicating (the duplication class dies here); a fragment that fails the schema binding or carries unknown keys is refused loudly.
|
||||||
|
- `init --systemd` keeps generating the units; decide in the session whether the Apache `.htaccess` becomes part of the host-integration output when the applied config carries `server.port` and a public domain (proposal: yes, under `init --systemd`, since it is generated host integration exactly like the units).
|
||||||
|
- New subcommand `werkator control-token`: print the token, creating it exactly like `ControlTokenService` does — the bash duplication in the wrapper dies.
|
||||||
|
- Tests per the writing-tests conventions; `docs/bootstrapping.md` documents `--apply` (the fragment keys need no new reference — they are ordinary `docs/configuration.md` keys).
|
||||||
|
|
||||||
|
### B — Wrapper side (implemented 2026-09-01 on branch `init-apply-config`)
|
||||||
|
|
||||||
|
- `tools/remote --env-file FILE` (default `.env`); the init fragment named by `WERKATOR_INIT_CONFIG` is uploaded, and the remote init runs with `--apply`.
|
||||||
|
- `repo-init` and `instance-start` lose their heredoc/`sed` config writing; `control-token` delegates to the new subcommand.
|
||||||
|
- `check-prerequisites` uploads the werkdock binary first and runs `werkdock doctor`; `tools/werkator-build-prerequisites.sh` retires (its werkdock port is the survivor).
|
||||||
|
|
||||||
|
### C — Live verification and docs (done 2026-09-01)
|
||||||
|
|
||||||
|
- Run the full wrapper flow against mih34 (`instance-update`, `repo-init`, `instance-start` as no-op re-runs); `docs/deployment.md`'s webspace section switches to the `--env` invocations.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Session A: done 2026-09-01 — `werkator init --apply …` installs and replaces a fragment idempotently; `werkator control-token` exists; full suite green.
|
||||||
|
Deviation from the sketch above: the fragment is NOT merged into the machine config — it is installed verbatim as its own layer (`.git/werkator/.werkator.applied.yml`, above project, below machine config), because an in-place merge would re-serialize the machine config, destroying its comments and rewriting the file that holds the secrets; a verbatim copy also makes re-apply a plain file replacement.
|
||||||
|
The `.htaccess` decision fell as proposed: generated beside the units by `init --systemd` whenever a `publicBaseUrl` is configured; the wrapper copies it into the domain docroot.
|
||||||
|
- Session B: done 2026-09-01 — `tools/remote` contains no YAML heredocs and no `sed` into the machine config (the port lookups for idle check and port-forward read the *effective* config via `config:print`, so a port living in the applied fragment is found too); the prerequisites bash script is gone.
|
||||||
|
- Session C: done 2026-09-01 — verified live on mih34 with the `.env.mih34` + `.env.mih34.yml` pair: instance-update, doctor-based check-prerequisites (PASS 6/6), repo-init applying the fragment, instance-start placing the generated `.htaccess` and restarting the unit, control-token via the CLI; `docs/deployment.md` shows only `--env-file`-style calls.
|
||||||
|
Known niggle: validating a fragment that carries a `builds.default` without triggers logs the loader's "no build defines onPush" warning, although a fragment is judged out of context — cosmetic, fix when it annoys.
|
||||||
+11
-1
@@ -91,6 +91,14 @@ Added for running Werkator on Hostsharing Managed Webspaces (2026-08-10):
|
|||||||
|
|
||||||
- [ ] `17-bwrap-build-runtime.md` — Werkator on a Managed Webspace: bubblewrap user-namespace build sandbox with a prepared rootfs (precondition check first — see the step file), plus web access under a domain via the platform's Apache proxy and Let's Encrypt
|
- [ ] `17-bwrap-build-runtime.md` — Werkator on a Managed Webspace: bubblewrap user-namespace build sandbox with a prepared rootfs (precondition check first — see the step file), plus web access under a domain via the platform's Apache proxy and Let's Encrypt
|
||||||
|
|
||||||
|
Added to correct the bwrap prototype's drift toward self-building on the webspace (2026-09-01):
|
||||||
|
|
||||||
|
- [ ] `21-werkdock-extraction-and-webspace-install.md` — roadmap in four sessions: close step 17's open ends, grow the sandbox tooling into **Werkdock** (a docker-like filesystem-only sandbox CLI, developed in the `werkdock/` subdirectory, later its own repository), let Werkator consume it, and replace the webspace self-build with the local-build-plus-install path of ADR 0006
|
||||||
|
|
||||||
|
Added after step 21 session D exposed that `tools/remote` re-implements configuration Werkator owns (2026-09-01):
|
||||||
|
|
||||||
|
- [ ] `23-init-owns-the-files.md` — Werkator becomes the executing app, `tools/remote` a thin wrapper: the wrapper takes a transport env file (`remote --env .env.mih34 werkator …`), init takes a YAML fragment in the real config schema (`werkator init --apply mih34.yml`, deep-merged idempotently — no mapping table, no heredocs), `werkator control-token` and `werkdock doctor` replace the bash duplications
|
||||||
|
|
||||||
Added for surfacing build time as a trend (2026-08-31):
|
Added for surfacing build time as a trend (2026-08-31):
|
||||||
|
|
||||||
- [ ] `20-build-duration-tracking.md` — a per-name duration trend over the existing history, derived on read in the History view: series, window average/min/max, and a visible marker when the latest build is slower than its window average (grouped by the history's own `name`, so branch builds and named jobs stay separate — complements Step 14, which owns phase timing)
|
- [ ] `20-build-duration-tracking.md` — a per-name duration trend over the existing history, derived on read in the History view: series, window average/min/max, and a visible marker when the latest build is slower than its window average (grouped by the history's own `name`, so branch builds and named jobs stay separate — complements Step 14, which owns phase timing)
|
||||||
@@ -101,7 +109,9 @@ Steps 07–09 depend on 04–06.
|
|||||||
Steps 11 and 12 are optional/deferrable; 10 only needs 04–06.
|
Steps 11 and 12 are optional/deferrable; 10 only needs 04–06.
|
||||||
Step 13 depends on 07, 11, and 12.
|
Step 13 depends on 07, 11, and 12.
|
||||||
Step 15 depends on 12 and 13 and revises the containerized-runtime sketch in `docs/bootstrapping.md` (ADR 0006 is written as part of the step; GraalVM native image was evaluated and rejected there).
|
Step 15 depends on 12 and 13 and revises the containerized-runtime sketch in `docs/bootstrapping.md` (ADR 0006 is written as part of the step; GraalVM native image was evaluated and rejected there).
|
||||||
Step 17 depends on 11, 15, and 16, and starts with a hard precondition check on the target webspace (ADR 0007 is written as part of the step).
|
Step 17 depends on 11, 15, and 16, and starts with a hard precondition check on the target webspace (ADR 0008 is written as part of the step; the number 0007 announced in the step file was already taken).
|
||||||
Step 18 depends on nothing in code but on the watched repository having migrated — its precondition check is a hard gate, not a formality.
|
Step 18 depends on nothing in code but on the watched repository having migrated — its precondition check is a hard gate, not a formality.
|
||||||
Step 19 depends on nothing; `WatcherState` and `/api/watcher` already carry everything it needs to render.
|
Step 19 depends on nothing; `WatcherState` and `/api/watcher` already carry everything it needs to render.
|
||||||
Step 20 depends on nothing; the duration is already recorded, and the trend is derived read-only from `repository.history()`.
|
Step 20 depends on nothing; the duration is already recorded, and the trend is derived read-only from `repository.history()`.
|
||||||
|
Step 21 depends on 17; its sessions B and C grow Werkdock in the `werkdock/` subdirectory (later its own repository), and session D supersedes the self-build prototype in `tools/remote`.
|
||||||
|
Step 23 depends on 21 session D; its per-instance file convention (transport env + init fragment) also feeds step 22's instance setup and should land before Werkbaum rolls out.
|
||||||
|
|||||||
+4
-4
@@ -31,7 +31,7 @@ Observed in production on 2026-08-31: a restart of master rebuilt a commit from
|
|||||||
- A row on `/` (Latest) and `/history` stands for a recorded build.
|
- A row on `/` (Latest) and `/history` stands for a recorded build.
|
||||||
- A build *name* is the pool: the branch itself for the default build, `<branch>@<build>` for a named one.
|
- A build *name* is the pool: the branch itself for the default build, `<branch>@<build>` for a named one.
|
||||||
|
|
||||||
#### Scenario#000.01: The Branches view builds the branch's current origin head
|
#### Scenario#3.01: The Branches view builds the branch's current origin head
|
||||||
|
|
||||||
So that a restart answers "build this branch as it is", which is what a branch row means.
|
So that a restart answers "build this branch as it is", which is what a branch row means.
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ So that a restart answers "build this branch as it is", which is what a branch r
|
|||||||
- [BuildsApiControllerTest: "restart with atOriginHead builds the branch as it is now, not the recorded commit"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
- [BuildsApiControllerTest: "restart with atOriginHead builds the branch as it is now, not the recorded commit"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||||
- [UiControllerTest: "the branches view restarts at the branch's origin head, the latest view repeats the run"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
- [UiControllerTest: "the branches view restarts at the branch's origin head, the latest view repeats the run"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||||||
|
|
||||||
#### Scenario#000.02: The row keeps its build definition and its real branch
|
#### Scenario#3.02: The row keeps its build definition and its real branch
|
||||||
|
|
||||||
So that restarting a named build does not silently turn it into a different build.
|
So that restarting a named build does not silently turn it into a different build.
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ So that restarting a named build does not silently turn it into a different buil
|
|||||||
|
|
||||||
- [BuildsApiControllerTest: "restart with atOriginHead keeps the recorded build definition and its real branch"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
- [BuildsApiControllerTest: "restart with atOriginHead keeps the recorded build definition and its real branch"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||||
|
|
||||||
#### Scenario#000.03: A branch that is gone from origin is refused by name
|
#### Scenario#3.03: A branch that is gone from origin is refused by name
|
||||||
|
|
||||||
So that a restart cannot quietly fall back to a commit the user did not ask for.
|
So that a restart cannot quietly fall back to a commit the user did not ask for.
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ So that a restart cannot quietly fall back to a commit the user did not ask for.
|
|||||||
|
|
||||||
- [BuildsApiControllerTest: "restart with atOriginHead of a branch gone from origin is refused by name"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
- [BuildsApiControllerTest: "restart with atOriginHead of a branch gone from origin is refused by name"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||||
|
|
||||||
#### Scenario#000.04: Latest and History still repeat the recorded run
|
#### Scenario#3.04: Latest and History still repeat the recorded run
|
||||||
|
|
||||||
So that the one view whose rows are runs keeps the behavior that fits them.
|
So that the one view whose rows are runs keeps the behavior that fits them.
|
||||||
|
|
||||||
+5
-5
@@ -28,7 +28,7 @@ Step 17 (docs/plan/17-bwrap-build-runtime.md) defines a third build runtime behi
|
|||||||
- `bwrap.enabled` and `bwrap.rootfs` are pinned (host-set), so a branch's committed config cannot switch its own sandbox off or substitute a foreign rootfs.
|
- `bwrap.enabled` and `bwrap.rootfs` are pinned (host-set), so a branch's committed config cannot switch its own sandbox off or substitute a foreign rootfs.
|
||||||
- `docker.enabled` and `bwrap.enabled` are mutually exclusive per branch; enabling both is rejected, not silently picked.
|
- `docker.enabled` and `bwrap.enabled` are mutually exclusive per branch; enabling both is rejected, not silently picked.
|
||||||
|
|
||||||
#### Scenario#000.01: A bwrap build runs the command inside the sandbox as root
|
#### Scenario#4.01: A bwrap build runs the command inside the sandbox as root
|
||||||
|
|
||||||
So that the build is isolated from the host exactly as the native and Docker runtimes intend.
|
So that the build is isolated from the host exactly as the native and Docker runtimes intend.
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ So that the build is isolated from the host exactly as the native and Docker run
|
|||||||
|
|
||||||
- [BwrapBuildRunnerTest](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
- [BwrapBuildRunnerTest](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
|
||||||
#### Scenario#000.02: Git metadata mounts keep secrets out of the sandbox
|
#### Scenario#4.02: Git metadata mounts keep secrets out of the sandbox
|
||||||
|
|
||||||
So that builds can run read-only git commands but never reach the machine config or the control token.
|
So that builds can run read-only git commands but never reach the machine config or the control token.
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ So that builds can run read-only git commands but never reach the machine config
|
|||||||
|
|
||||||
- [BwrapBuildRunnerTest](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
- [BwrapBuildRunnerTest](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
|
||||||
#### Scenario#000.03: A branch cannot turn its sandbox off or swap its rootfs
|
#### Scenario#4.03: A branch cannot turn its sandbox off or swap its rootfs
|
||||||
|
|
||||||
So that the pinned sandbox policy holds for builds a branch invents as well as for ones the host already knows.
|
So that the pinned sandbox policy holds for builds a branch invents as well as for ones the host already knows.
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ So that the pinned sandbox policy holds for builds a branch invents as well as f
|
|||||||
|
|
||||||
- [ConfigLoaderTest](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
|
- [ConfigLoaderTest](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
|
||||||
|
|
||||||
#### Scenario#000.04: The dispatcher routes builds to the bwrap runtime
|
#### Scenario#4.04: The dispatcher routes builds to the bwrap runtime
|
||||||
|
|
||||||
So that a bwrap-explicit branch builds inside the sandbox rather than natively.
|
So that a bwrap-explicit branch builds inside the sandbox rather than natively.
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ So that a bwrap-explicit branch builds inside the sandbox rather than natively.
|
|||||||
- Werkator's own build runs the full test suite, which includes `TestcontainersSmokeTest`.
|
- Werkator's own build runs the full test suite, which includes `TestcontainersSmokeTest`.
|
||||||
- On a Docker-less host (the webspace, and the bwrap sandbox that builds there), that test must not fail the self-build.
|
- On a Docker-less host (the webspace, and the bwrap sandbox that builds there), that test must not fail the self-build.
|
||||||
|
|
||||||
#### Scenario#000.05: The Testcontainers smoke test is skipped, not failed, without Docker
|
#### Scenario#4.05: The Testcontainers smoke test is skipped, not failed, without Docker
|
||||||
|
|
||||||
So that a Docker-less build of Werkator itself stays green.
|
So that a Docker-less build of Werkator itself stays green.
|
||||||
|
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
> **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 bwrap work merged as PR #4 drifted from the original intent (plan step 21): it built Werkator *on* the webspace instead of extracting the generic sandbox machinery into a reusable tool.
|
||||||
|
This PR starts that extraction: **Werkdock**, a docker-like sandbox CLI over `bwrap` — filesystem isolation only — grown in the `werkdock/` subdirectory until it stands on its own.
|
||||||
|
The concrete goal of the session (decided 2026-09-01): the sandbox builds of Werkator, Werkbaum, and Werkdock itself must work on a Managed Webspace.
|
||||||
|
Getting there surfaced and fixed three real defects: the rootfs archive silently lost every directory named `sys`/`proc`/`dev` (tar exclude patterns are unanchored by default), the sandbox inherited the host's pam_tmpdir `TMPDIR` and broke every tool honoring it, and non-report build artifacts were stored mislabeled under `reports/` and never shown in the UI.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- OCI image pull, the Docker Engine API daemon, and Testcontainers support (RFC 0002 levels 2 and 3, deferred indefinitely).
|
||||||
|
- Persistent Werkdock instances (`run` currently requires `--rm`) and the verbs beyond `doctor`/`load`/`run`.
|
||||||
|
- Session C (Werkator's `BwrapBuildRunner` delegating to the `werkdock` CLI) and session D (the webspace install path replacing the self-build prototype).
|
||||||
|
- Composable toolchain mounts (RFC 0003 stays a candidate).
|
||||||
|
- Multi-repository support for one Werkator instance.
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: Werkdock, a docker-shaped sandbox CLI
|
||||||
|
|
||||||
|
#### Background
|
||||||
|
|
||||||
|
- An *image* is a rootfs archive, unpacked into the store at `$WERKDOCK_HOME` (default `~/.werkdock`); an *instance* corresponds to a docker container.
|
||||||
|
- The contract is filesystem isolation only: network, uid mapping target, `/proc`, `/dev`, `/tmp` come from the host.
|
||||||
|
- RFC 0001 decided Go (stdlib-only, one static binary); RFC 0002 decided the docker-compatible surface.
|
||||||
|
|
||||||
|
#### Scenario#6.01: A command runs inside the sandbox as root with a clean environment
|
||||||
|
|
||||||
|
So that builds are reproducible and docker knowledge transfers.
|
||||||
|
|
||||||
|
- **Given** a loaded image and the bwrap CLI on the host
|
||||||
|
- **When** `werkdock run --rm -v /repo:/repo -e CI=true -w /repo IMAGE sh -c '...'` is invoked
|
||||||
|
- **Then** the command runs with uid 0 mapped to the calling user, the rootfs read-only at `/`, tmpfs on `/tmp` and `/root`
|
||||||
|
- **and** the environment is cleared (`--clearenv`) with `HOME`/`PATH` set explicitly and the `-e` variables applied
|
||||||
|
- **and** the command's exit code is passed through (werkdock's own errors exit 125, like docker).
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [TestArgvAssemblesTheHardenedInvocation](../../werkdock/internal/engine/bwrap_test.go)
|
||||||
|
- [TestRunInsideRealSandbox](../../werkdock/internal/engine/bwrap_test.go) (gated: skips without bwrap/userns)
|
||||||
|
- [TestRunPassesTheExitCodeThrough](../../werkdock/internal/engine/bwrap_test.go)
|
||||||
|
|
||||||
|
#### Scenario#6.02: Docker flags whose promise cannot be kept are refused loudly
|
||||||
|
|
||||||
|
So that a docker user is never silently under-isolated.
|
||||||
|
|
||||||
|
- **Given** the docker-shaped `run` flag surface
|
||||||
|
- **When** `-p`, `--network`, `--memory`, `--cpus`, `--user`, or `-d` is passed
|
||||||
|
- **Then** the invocation fails with the reason, never a silent no-op
|
||||||
|
- **and** `run` without `--rm` fails with "persistent instances are not implemented yet".
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [TestParseRunRefusesDockerFlagsLoudly](../../werkdock/internal/cli/run_test.go)
|
||||||
|
- [TestParseRunRequiresRmForNow](../../werkdock/internal/cli/run_test.go)
|
||||||
|
|
||||||
|
#### Scenario#6.03: Images load atomically and mountpoints are pre-created
|
||||||
|
|
||||||
|
So that a failed load leaves no half image and bind targets missing from a read-only rootfs cannot fail the run.
|
||||||
|
|
||||||
|
- **Given** a rootfs archive
|
||||||
|
- **When** `werkdock load -i ARCHIVE` imports it and a later `run` binds paths the rootfs does not ship
|
||||||
|
- **Then** the image is unpacked to a temp directory and renamed into place (a broken archive leaves nothing behind)
|
||||||
|
- **and** missing bind mountpoints are pre-created inside the rootfs — directories for directory sources, files for file sources
|
||||||
|
- **and** a bind destination escaping the rootfs is refused.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [TestLoadUnpacksArchiveIntoTheStore, TestLoadLeavesNoHalfImageOnFailure](../../werkdock/internal/store/store_test.go)
|
||||||
|
- [TestEnsureMountpointsCreatesMissingAndSkipsExisting, TestEnsureMountpointsRefusesEscapingDestinations](../../werkdock/internal/engine/bwrap_test.go)
|
||||||
|
|
||||||
|
#### Scenario#6.04: `werkdock doctor` decides whether a host can run sandboxes
|
||||||
|
|
||||||
|
So that a broken host fails loudly before the first build, in the PASS/FAIL format of the prerequisites script it ports.
|
||||||
|
|
||||||
|
- **Given** a target host
|
||||||
|
- **When** `werkdock doctor [TARGET_DIR]` runs
|
||||||
|
- **Then** it checks the userns probe (uid 0 inside, uid_map back to the caller, read-only root enforced), tar/zstd, free space, and group-quota headroom
|
||||||
|
- **and** exits non-zero when any check fails.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [doctor_test.go](../../werkdock/internal/doctor/doctor_test.go) (probe evaluation, df/quota parsers incl. wrapped quota lines)
|
||||||
|
|
||||||
|
### Feature: Werkdock builds itself on the webspace
|
||||||
|
|
||||||
|
#### Scenario#6.05: The `werkdock` build definition compiles the Go module in the sandbox
|
||||||
|
|
||||||
|
So that "Werkdock builds itself" is CI reality while it lives in this repository.
|
||||||
|
|
||||||
|
- **Given** the `builds.werkdock` definition in `.werkator.yml` and a build image containing the go toolchain
|
||||||
|
- **When** a commit is pushed
|
||||||
|
- **Then** the pool `<branch>@werkdock` runs gofmt gate, `go vet`, `go test`, and a static `go build`
|
||||||
|
- **and** the binary is stored as a build artifact (verified live on mih34: the CI-built binary downloads and runs).
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- live run on mih34 (config change; the definition mechanics are covered by the existing build-definition tests)
|
||||||
|
|
||||||
|
#### Scenario#6.06: The rootfs archive contains everything its packages installed
|
||||||
|
|
||||||
|
So that a directory of the Go stdlib named `sys` is never again silently missing (seen live: "package internal/runtime/sys is not in std").
|
||||||
|
|
||||||
|
- **Given** `tools/build-bwrap-rootfs.sh`
|
||||||
|
- **When** the archive is packed
|
||||||
|
- **Then** only the top-level `/proc`, `/sys`, `/dev` mountpoints are excluded (anchored patterns), not every path component of that name.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- live rebuild + archive listing (script change; asserted by the green go build in Scenario#6.05)
|
||||||
|
|
||||||
|
#### Scenario#6.07: The sandbox resets TMPDIR to its own /tmp
|
||||||
|
|
||||||
|
So that hosts with pam_tmpdir (`TMPDIR=/tmp/user/<uid>`) cannot break tools honoring TMPDIR inside the sandbox.
|
||||||
|
|
||||||
|
- **Given** a server environment carrying `TMPDIR`/`TMP`
|
||||||
|
- **When** `BwrapBuildRunner` assembles the invocation
|
||||||
|
- **Then** both are set back to `/tmp` before the configured environment, which can still override them.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [BwrapBuildRunnerTest](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
|
||||||
|
### Feature: honest artifact paths
|
||||||
|
|
||||||
|
#### Scenario#6.08: Non-report artifact directories keep their own paths and appear on the artifact page
|
||||||
|
|
||||||
|
So that a built binary is neither mislabeled below `reports/` nor invisible.
|
||||||
|
|
||||||
|
- **Given** a build with `artifactDirs` beyond `build/reports`
|
||||||
|
- **When** its artifacts are persisted and the artifact page is rendered
|
||||||
|
- **Then** `build/reports` still archives as `reports/` (the browsable report anchor and every existing link)
|
||||||
|
- **and** every other directory archives at its workspace-relative path
|
||||||
|
- **and** the page lists those files (capped at 200) with download links, logs staying in their own section.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [FileArtifactStoreTest](../../src/test/kotlin/de/hoennig/werkator/artifacts/FileArtifactStoreTest.kt)
|
||||||
|
- [UiControllerTest."artifact index lists plain files outside reports/…"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
Werkdock is its own Go module in `werkdock/` — stdlib-only, no Gradle coupling, `CGO_ENABLED=0 go build` yields one ~3.5 MB static binary.
|
||||||
|
The layering anticipates RFC 0002 level 3: CLI verbs are thin frontends over `internal/engine` (a `RunSpec` behind an `Engine` interface, bwrap first, native namespaces possible later per RFC 0001), `internal/store` (images on disk), and `internal/doctor`.
|
||||||
|
The bwrap invocation is the port of the runner hardened live in PR #4 — mount ordering, mountpoint pre-creation, uid mapping — plus `--clearenv` (which makes Werkdock immune to the TMPDIR class of bugs by construction).
|
||||||
|
The decisions are recorded as RFCs in `werkdock/docs/rfcs/`: 0001 language (Go, over Rust/Python/Kotlin-Native/bash, ten-criteria scoring), 0002 docker-compatible surface (level 1 now, OCI and daemon deferred), 0003 composable toolchain mounts (candidate).
|
||||||
|
The build image grew into one fat trixie rootfs (JDK 21 headless + Go + Node/npm) and then shrank below the original JDK-only archive: 351 MB vs 375 MB, after trimming X11 (headless JDK), non-en/de locales, man/doc, and apt lists.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- The version werkdock reports (`0.1.0-dev`) has no release process yet; it gets one when the repository split nears.
|
||||||
|
|
||||||
|
## Additional Changes
|
||||||
|
|
||||||
|
- Step 21 plan: session notes, the deferral decisions, and the builder-vs-built role tangle in `tools/remote` noted for session D.
|
||||||
|
- ADR 0008 (bwrap runtime) written; step 17 and the plan README now point at 0008 (0007 was already taken).
|
||||||
|
- PR-docs of PR #3 and PR #4 renamed from their `PR#000` placeholders.
|
||||||
|
- Architecture skill: the third runtime documented; AGENTS.md decision list caught up with ADR 0007/0008.
|
||||||
|
- `tools/build-bwrap-rootfs.sh` gained `--pkgs-extra`.
|
||||||
|
- `docs/configuration.md`: artifactDirs archiving described.
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- PR #4 (bwrap build runtime) — Werkdock ports its hardened invocation.
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- PR #7: session C (`BwrapBuildRunner` delegates to the `werkdock` CLI) and session D (the webspace install path replaces the self-build prototype in `tools/remote`).
|
||||||
|
- PR #8/#9: `tools/remote` and `werkator init` stop duplicating each other's configuration writing.
|
||||||
|
- PR #10: multi-repository support for one Werkator instance (step 22).
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
> **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
|
||||||
|
|
||||||
|
"One instance per repository" is a founding tenet (`docs/Werkator-Konzept.md`, AGENTS.md), and it does not scale even to two repositories on a Hostsharing Managed Webspace: building Werkbaum next to Werkator on mih34 would need a second service, a second assigned port, a second tunnel or domain, a second UI, a second metrics page.
|
||||||
|
Every repository added multiplies operations, while the instance-level resources (port, UI, watcher schedule, executor slots, metrics) could be shared.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Implementing the refactor — this PR is the decision and the roadmap only; the sessions it lays out (B–E: `RepoContext` refactor, registry/watcher multiplexing, routes/UI scoping, mih34 rollout with Werkbaum) are future PRs.
|
||||||
|
- Changing `docs/Werkator-Konzept.md`'s or AGENTS.md's architecture wording — that happens when the implementation lands, not with the decision.
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
ADR 0009 revises the tenet to "one instance per repository *set*": a Werkator instance aggregates self-contained repositories listed in an instance registry, sharing one service, one port, one UI, one watcher schedule, and one global executor cap, while each repository keeps its own configuration, secrets, history, and artifacts — nothing repository-specific moves out of the repository, so adding or removing one is a registry entry, never a data migration.
|
||||||
|
Config ownership splits three ways: **instance-level** config (`server.*`, the registry, the control token, the global `executor.maxConcurrent`, watcher schedule) lives in `~/.werkator.yml` in the home directory of the user running the instance — one instance per OS user, matching the platform's pac-user model; the file name stays `.werkator.yml` everywhere, only the location carries the meaning.
|
||||||
|
**Repo defaults** may live in the same home file, but only in an explicit `defaults:` block merged *below* every repository's own layers (home defaults → committed project config → repo machine config → branch layer) — pinning semantics are unchanged, at the accepted cost that secrets may then live in two places.
|
||||||
|
**Repo-level** config (`gitea.*`, `git.*` credentials, `builds`, retention, sandbox policy) stays exactly where it is today, in each repository's own layers.
|
||||||
|
Four follow-on decisions were folded in during review: repo-level instance keys found once a home config exists are ignored with a warning naming both files, never merged silently; when a home config with a registry exists it is served regardless of the current directory (registry wins over cwd); repository names for routes/UI default to the directory basename, are overridable per entry, and duplicates abort the start loudly; the `.werkator.yml` name is kept in all three locations rather than inventing a separate instance-config filename.
|
||||||
|
A federation dashboard proxying several single-repo instances was considered and rejected: it solves only UI aggregation, not the per-repository operations burden (ports, tunnels, services) that motivates the change.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Fairness across repos when the global concurrency cap is contended (round-robin vs. FIFO) — deferred to session C, decided with the real queue behavior at hand.
|
||||||
|
- Whether buildenv rootfs trees should be shared across repos, or whether that is better solved by Werkdock's image store — deferred; duplicate unpacked rootfs trees are the accepted interim cost.
|
||||||
|
- Whether `artifactKey` needs a repo prefix or stays globally unique by construction — deferred to session B, when routes are designed.
|
||||||
|
|
||||||
|
## Additional Changes
|
||||||
|
|
||||||
|
- `docs/plan/22-multi-repo.md`: the full five-session roadmap (A–E).
|
||||||
|
- AGENTS.md: decision list gained ADR 0009.
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- None in code; branches from `werkdock-extraction` (PR #6) but is otherwise independent of the Werkdock/webspace work in PR #7/#8/#9.
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- Session B: `RepoContext` refactor, behavior-preserving.
|
||||||
|
- Session C: the registry and N repositories, watcher multiplexing.
|
||||||
|
- Session D: server/API/UI repo scoping.
|
||||||
|
- Session E: rollout on mih34 with Werkbaum joining the instance.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
> **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
|
||||||
|
|
||||||
|
ADR 0009 (PR #10) decided that one Werkator instance serves a set of repositories, but the code assumes a single one everywhere: the executor, the watcher, the commands, and the controllers resolve results, artifacts, worktrees, and git access through an implicit working directory, and the result repository and artifact store are context-wide beans.
|
||||||
|
A registry of repositories cannot be threaded through that — every code path would have to learn a `workingDir` parameter it does not have and a results file it cannot pick.
|
||||||
|
Step 22 session B is the behavior-preserving refactor that gives those paths one explicit handle to a repository, so that sessions C and D only have to open more of them and put a name on the routes.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- The registry, the home `~/.werkator.yml`, and N repositories (session C).
|
||||||
|
- Repository-scoped routes, API paths, or UI grouping (session D) — every route, template, and JSON shape is unchanged.
|
||||||
|
- Any configuration change; `docs/configuration.md` is untouched.
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: one explicit handle per repository
|
||||||
|
|
||||||
|
#### Background
|
||||||
|
|
||||||
|
- A `RepoContext` bundles what is repository-scoped: the primary checkout, the repository's results file, its artifact store, and a short name (the directory basename by default) meant for display and, later, routes.
|
||||||
|
- The context object is the identity: executor pools and the watcher's memory are keyed by it, so exactly one is opened per repository.
|
||||||
|
|
||||||
|
#### Scenario#11.01: Builds are serialized per repository and branch under one global cap
|
||||||
|
|
||||||
|
So that two repositories in one instance never build the same branch name in each other's worktree, while the instance-level `executor.maxConcurrent` stays the only concurrency limit.
|
||||||
|
|
||||||
|
- **Given** the executor and a `RepoContext`
|
||||||
|
- **When** `startBuild(repo, branch, commit, build)` is called
|
||||||
|
- **Then** the PENDING result is written to that context's results and the artifacts persist to that context's store
|
||||||
|
- **and** a second build of the same branch in the same context waits for the first, while other branches run concurrently up to the global cap
|
||||||
|
- **and** a duplicate is only detected within the same context.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [BuildExecutorTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt) (the existing serialization, concurrency, and duplicate tests, now over a context)
|
||||||
|
- [BuildExecutorArtifactIntegrationTest](../../src/test/kotlin/de/hoennig/werkator/artifacts/BuildExecutorArtifactIntegrationTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#11.02: The watcher polls a repository context and keeps its memory per repository
|
||||||
|
|
||||||
|
So that the next session can iterate contexts in one cycle without one repository's fetch outage silencing another's log or cache.
|
||||||
|
|
||||||
|
- **Given** the watcher and a `RepoContext`
|
||||||
|
- **When** `start(repo)`, `poll(repo)`, or `recoverOnStartup(repo)` runs
|
||||||
|
- **Then** results, artifacts, auto-build slots, and worktrees are those of the context
|
||||||
|
- **and** the logged fetch error, the `autoBuild` deprecation warning, and the cached branch definitions are remembered per context.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [WatcherTest](../../src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt) (every existing poll, recovery, and prune test, now over a context)
|
||||||
|
- [ServerModeApplicationTest](../../src/test/kotlin/de/hoennig/werkator/ServerModeApplicationTest.kt) (the server profile starts the watcher over the served repository)
|
||||||
|
|
||||||
|
#### Scenario#11.03: A single-repository installation behaves exactly as before
|
||||||
|
|
||||||
|
So that no route, file location, or display changes for existing installations.
|
||||||
|
|
||||||
|
- **Given** no registry (there is none yet)
|
||||||
|
- **When** the CLI or the server starts in a repository
|
||||||
|
- **Then** the current working directory is the one context, named after its directory
|
||||||
|
- **and** the result and artifact-store beans are that context's members, so `status`, the JSON API, and the UI read the same files as before.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [RepoContextsTest](../../src/test/kotlin/de/hoennig/werkator/repo/RepoContextsTest.kt)
|
||||||
|
- the unchanged controller, command, and integration tests of the full suite
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
`RepoContext` (`repo` package) is a plain class with `name`, `workingDir`, `results`, and `artifactStore`; `RepoContexts.open(dir)` builds one over `.git/werkator/build-results.json` and a `FileArtifactStore` keyed by the path, and `RepoConfiguration` provides the current directory as the single bean.
|
||||||
|
`BuildExecutor.startBuild` takes the context first, keeps its per-branch serial workers in a map keyed by `(context, branch)`, and writes results and artifacts through the build's own context; the semaphore stays one per executor, since the cap is instance-level per ADR 0009.
|
||||||
|
`Watcher.start/poll/recoverOnStartup` take the context, and the three mutable per-repository fields moved into a `RepoWatch` keyed by context; the observable `WatcherState` is untouched.
|
||||||
|
`ConsoleBuildRunner`, `BuildCommand`, `RetryCommand`, `BuildsApiController`, `UiController`, and `BranchListing` lost their settable `workingDir` in favor of the injected context.
|
||||||
|
Git access and config loading stay path-based services taking `repo.workingDir`: the home `defaults:` layer of session C is the point where config loading needs the context, and it was not built ahead of that need.
|
||||||
|
The open `artifactKey` question is decided against a repository prefix: the results file and the artifact store are per repository, so the key only has to be unique within one, and the repo dimension will enter through the route segment.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- `RunningBuild` carries no repository, so `currentBuilds()` and the watcher's worktree pruning cannot tell repositories apart yet — harmless with one context, listed for session C in the plan.
|
||||||
|
- `StateDirMigration`, the metrics collector's repository size, and `ServerCommand`'s config still read the current directory — instance-level or per-registry-entry concerns, deferred to session C.
|
||||||
|
|
||||||
|
## Additional Changes
|
||||||
|
|
||||||
|
- Architecture skill: new "Repository Context" section; the executor and watcher paragraphs describe the context-based signatures.
|
||||||
|
- AGENTS.md: `repo` in the package list and a hard invariant that repository-scoped state goes through a `RepoContext`.
|
||||||
|
- `docs/plan/22-multi-repo.md`: session B ticked with the carry-overs to session C, the `artifactKey` question decided.
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- PR #10 (ADR 0009 and the step 22 roadmap).
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- Session C: the registry and N repositories, watcher multiplexing.
|
||||||
|
- Session D: server/API/UI repo scoping.
|
||||||
|
- Session E: rollout on mih34 with Werkbaum.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
> **WARNING:** This document describes only the change applied in this PR.
|
||||||
|
> It may already be outdated once the next PR is merged.
|
||||||
|
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
PR #6 grew Werkdock as a standalone sandbox CLI, but `BwrapBuildRunner` still assembled its own raw `bwrap` invocation — the extraction was only half done, and the two implementations could drift.
|
||||||
|
Separately, the webspace deployment path in `tools/remote` still followed the original self-build prototype: clone Werkator's own repository onto the target and build it there, which is exactly the pattern ADR 0006 rejected ("build locally, install the bundle") and step 21 set out to correct.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- `tools/remote`'s configuration-writing duplication (heredocs/`sed` into the machine config) — that is step 23, PR #8/#9.
|
||||||
|
- Multi-repository support for one Werkator instance (step 22, PR #10).
|
||||||
|
- RFC 0002 levels 2/3 and RFC 0003 (composable toolchain mounts) stay deferred/candidate.
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: `BwrapBuildRunner` delegates to Werkdock
|
||||||
|
|
||||||
|
#### Scenario#7.01: A build runs through `werkdock run` instead of a raw `bwrap` invocation
|
||||||
|
|
||||||
|
So that Werkator and Werkdock never carry two implementations of the same sandbox invocation.
|
||||||
|
|
||||||
|
- **Given** `bwrap.enabled` and a configured rootfs archive
|
||||||
|
- **When** a build needs the sandbox
|
||||||
|
- **Then** `BwrapBuildRunner` loads the image via `werkdock images`/`werkdock load` (once, keyed by `imageName(rootfs)` = `werkator-buildenv-<hash-of-source>`) and runs the build via `werkdock run --rm`
|
||||||
|
- **and** the configured `bwrap.werkdock` binary (default: `werkdock` via `PATH`) is what gets invoked.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [assembles the exact werkdock run command for a loaded image](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
- [loads the image once when werkdock does not know it yet](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
- [does not load an image werkdock already has](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
- [uses the configured werkdock binary path](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#7.02: The git-metadata mask survives the move to ordered `-v`/`--tmpfs` flags
|
||||||
|
|
||||||
|
So that secrets stay outside the sandbox exactly as before, now expressed as flag order instead of an internal mount list.
|
||||||
|
|
||||||
|
- **Given** a worktree build
|
||||||
|
- **When** the invocation is assembled
|
||||||
|
- **Then** `.git` is bound read-only, then `.git/werkator/` is masked with `--tmpfs`, then the worktree's admin dir is bound read-write, in that exact order
|
||||||
|
- **and** Werkdock's `Mount` list (replacing the earlier unordered `Bind` list) preserves the order flags were given in.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [exposes git metadata read-only with the werkator dir masked, in mount order](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
- [mounts no git metadata when the workspace is not a worktree](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
- [TestParseRunKeepsMountFlagOrderAcrossVolumeAndTmpfs](../../werkdock/internal/cli/run_test.go)
|
||||||
|
|
||||||
|
#### Scenario#7.03: Werkdock gained what the delegation needed
|
||||||
|
|
||||||
|
So that `images`/`:rw` were built because Werkator's runner needed them, not speculatively.
|
||||||
|
|
||||||
|
- **Given** the new `werkdock images` verb and `:rw` volume option
|
||||||
|
- **When** the runner checks whether an image is already loaded, or mounts the admin dir read-write
|
||||||
|
- **Then** `images` lists loaded image names (one per line, `docker images --format` shaped) and `-v src:dst:rw` is accepted alongside the existing `:ro`.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [TestListNamesLoadedImagesAndIgnoresTmpLeftovers](../../werkdock/internal/store/store_test.go)
|
||||||
|
- [TestParseRunAcceptsTheExplicitRwVolumeOption](../../werkdock/internal/cli/run_test.go)
|
||||||
|
- [TestImageNameFromArchive](../../werkdock/internal/store/store_test.go)
|
||||||
|
|
||||||
|
#### Scenario#7.04: The TMPDIR workaround is gone because it is now structurally impossible
|
||||||
|
|
||||||
|
So that the fix and its own workaround do not both linger in the codebase.
|
||||||
|
|
||||||
|
- **Given** Werkdock's `--clearenv`
|
||||||
|
- **When** a build runs in the sandbox
|
||||||
|
- **Then** no host `TMPDIR`/`TMP` reaches the sandboxed process at all, so `BwrapBuildRunner`'s earlier explicit `--setenv TMPDIR /tmp` workaround (PR #4) is removed as dead code, not merely redundant.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [adds bwrap env after the branch environment](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
- [TestArgvAssemblesTheHardenedInvocation](../../werkdock/internal/engine/bwrap_test.go)
|
||||||
|
|
||||||
|
### Feature: the webspace install path replaces the self-build prototype
|
||||||
|
|
||||||
|
#### Scenario#7.05: `tools/remote` separates the builder role from the built (watched) repository role
|
||||||
|
|
||||||
|
So that "build Werkator on the webspace" and "Werkator watches a repository on the webspace" are never conflated again.
|
||||||
|
|
||||||
|
- **Given** a Managed Webspace target
|
||||||
|
- **When** the wrapper manages the Werkator runtime versus a repository Werkator watches
|
||||||
|
- **Then** `instance-install`/`instance-update`/`instance-start` install and run the Werkator **builder** binary+bundle
|
||||||
|
- **and** `repo-init` prepares a repository to be **built by** that instance
|
||||||
|
- **and** the retired `install`/`build`/`start` commands fail loudly, naming their successors, instead of silently doing the old thing.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- manual invocation of the retired commands on mih34 (shell script; no automated test harness for `tools/remote`)
|
||||||
|
|
||||||
|
#### Scenario#7.06: The self-build prototype is gone
|
||||||
|
|
||||||
|
So that Werkator is never again built by checking out its own source onto the target and compiling there.
|
||||||
|
|
||||||
|
- **Given** the old prototype cloned werkator's own repository onto the webspace and built it in place
|
||||||
|
- **When** an instance is installed or updated now
|
||||||
|
- **Then** the runtime bundle is built locally and transported (`instance-install`/`instance-update`), never cloned-and-built on the target.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- live run on mih34: `instance-update` against a runtime bundle built locally
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
`BwrapBuildRunner.invocation()` no longer builds a `bwrap` argv; it shells out to the `werkdock` binary named by `bwrap.werkdock` (a new pinned config key, alongside `bwrap.enabled`/`bwrap.rootfs`) for `images`, `load`, and `run --rm`.
|
||||||
|
`werkdock/internal/engine/engine.go` was rewritten from an unordered `Bind` list to an ordered `Mount` list (`MountBind`/`MountRoBind`/`MountTmpfs`) specifically so the CLI's `-v`/`--tmpfs` flag order — which the git-metadata mask depends on — survives into the sandbox invocation unchanged.
|
||||||
|
`werkdock/internal/cli/images.go` is new; `run.go`'s volume parsing gained the `:rw` option.
|
||||||
|
`tools/remote` was reorganized around two roles instead of one flat command list: builder lifecycle (`instance-install`/`instance-update`/`instance-start`) versus watched-repository lifecycle (`repo-init`); the old `install`/`build`/`start` now `die` with the successor's name.
|
||||||
|
`require_idle()`/`FORCE=1` guards a runtime swap against a build in progress.
|
||||||
|
|
||||||
|
## Additional Changes
|
||||||
|
|
||||||
|
- `docs/deployment.md`: the webspace section now describes the role-separated commands.
|
||||||
|
- `docs/configuration.md` and `AGENTS.md`: `bwrap.werkdock` documented as a fourth pinned bwrap key.
|
||||||
|
- Step 21 plan: sessions C and D marked done with live-verification notes.
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- PR #6 (Werkdock bootstrap) — this PR is the consumer of the CLI it built.
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- PR #8/#9: `tools/remote`'s remaining configuration-writing duplication with `werkator init` is resolved next (step 23).
|
||||||
|
- PR #10: multi-repository support for one Werkator instance (step 22).
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
> **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
|
||||||
|
|
||||||
|
`werkator init` and `tools/remote` overlap: both write the machine config — init as a commented template, the script by appending heredoc blocks and patching values with `sed`.
|
||||||
|
The script re-implements configuration knowledge Werkator already owns (YAML shape, indentation, key names) outside the three-places sync invariant; an indentation mismatch in one append guard produced nine duplicate `bwrap` blocks on mih34 (step 21 session D) before it was found.
|
||||||
|
Smaller duplications of the same kind: the script re-implements control-token generation in bash, and `check-prerequisites` still pipes a bash script whose generic half now exists as `werkdock doctor`.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Implementing the change — this PR is the plan only; PR #9 implements it.
|
||||||
|
- Multi-repository support for one Werkator instance (step 22, PR #10).
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
`docs/plan/23-init-owns-the-files.md` records the decision: Werkator becomes the executing app wherever possible, `tools/remote` shrinks to a wrapper.
|
||||||
|
Parameters travel as files, each side getting the format native to it: the wrapper keeps a small env file with transport-only values (`--env-file FILE`, mirroring Docker's flag naming since `--env` there means a single variable); Werkator takes a YAML fragment in its own config schema, applied via a new `init --apply FILE`, validated by the existing schema binding and needing no separate mapping table.
|
||||||
|
The plan was refined once during review: the first sketch proposed an env-file-only transport; the fragment being a first-class YAML file in Werkator's own schema replaced that, so there is no env-key-to-config-key conversion table to maintain at all.
|
||||||
|
Three sessions are laid out: A (Werkator side: `init --apply`, `control-token` subcommand), B (wrapper side: `tools/remote` loses its heredocs), C (live verification on mih34 and doc updates).
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- PR #7 (webspace install path) — this plan corrects the remaining duplication that PR left in place.
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- PR #9: implements sessions A, B, and C of this plan.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
> **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` wrote the machine config by appending heredoc blocks and patching values with `sed`, duplicating configuration knowledge (YAML shape, indentation, key names) that `WerkatorConfig` already owns — an indentation mismatch in one append guard produced nine duplicate `bwrap` blocks on mih34 before it was found (step 21 session D).
|
||||||
|
The script also re-implemented control-token generation in bash and still piped a prerequisites bash script whose generic half now exists as `werkdock doctor`.
|
||||||
|
PR #8 laid out the fix; this PR implements it.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Multi-repository support for one Werkator instance (step 22, PR #10).
|
||||||
|
- A mapping table between env keys and config keys — deliberately absent, see The Solution.
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: `werkator init --apply` installs an instance config fragment
|
||||||
|
|
||||||
|
#### Background
|
||||||
|
|
||||||
|
- The applied fragment is a fourth config layer: project `.werkator.yml` → applied fragment → repo-install machine config (secrets, always wins) → branch layer.
|
||||||
|
- It is strictly validated (unknown keys rejected) and installed verbatim, never merged in place — an in-place merge would re-serialize and destroy the machine config's comments and secrets.
|
||||||
|
|
||||||
|
#### Scenario#9.01: A valid fragment becomes its own layer, above the project config and below the machine config
|
||||||
|
|
||||||
|
So that an instance-specific setting (e.g. `server.port`) takes effect without touching the committed project config or the machine config's secrets.
|
||||||
|
|
||||||
|
- **Given** a project `.werkator.yml` and a machine config with a secret
|
||||||
|
- **When** `init --apply FILE` installs a fragment that also sets a key the machine config sets
|
||||||
|
- **Then** the effective config shows the fragment's value where the machine config is silent, and the machine config's value where both set the same key.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [the applied instance fragment layers above the project config and below the machine config](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
|
||||||
|
- [--apply installs the fragment as the applied layer and the effective config sees it](../../src/test/kotlin/de/hoennig/werkator/commands/InitCommandTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#9.02: Re-applying a fragment replaces it, never duplicates it
|
||||||
|
|
||||||
|
So that repeated `instance-update` runs stay idempotent — the duplication class PR #8 was written against dies here structurally.
|
||||||
|
|
||||||
|
- **Given** a fragment already installed as the applied layer
|
||||||
|
- **When** `init --apply` runs again with a changed fragment
|
||||||
|
- **Then** the applied layer file is atomically replaced, not appended to.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [applyInstanceFragment installs a valid fragment verbatim, and re-applying replaces it](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#9.03: An invalid fragment is refused loudly and installs nothing
|
||||||
|
|
||||||
|
So that a typo in a fragment never becomes a silent no-op or a half-applied layer.
|
||||||
|
|
||||||
|
- **Given** a fragment with an unknown key, or a missing/empty file
|
||||||
|
- **When** `init --apply FILE` runs
|
||||||
|
- **Then** it fails loudly and the applied layer is left exactly as it was before the attempt.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [applyInstanceFragment refuses an unknown key loudly instead of installing a silent no-op](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
|
||||||
|
- [applyInstanceFragment refuses a missing or empty fragment](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
|
||||||
|
- [--apply with an invalid fragment installs nothing](../../src/test/kotlin/de/hoennig/werkator/commands/InitCommandTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#9.04: `init --systemd` generates the Apache reverse-proxy file alongside the units
|
||||||
|
|
||||||
|
So that host integration for a public domain is generated exactly like the systemd units, not hand-written.
|
||||||
|
|
||||||
|
- **Given** an effective config with `server.publicBaseUrl` set
|
||||||
|
- **When** `init --systemd` runs
|
||||||
|
- **Then** `werkator.htaccess` is generated proxying to the configured localhost port, for the wrapper to place in the domain docroot.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [the htaccess proxies everything to the configured localhost port](../../src/test/kotlin/de/hoennig/werkator/commands/SystemdServiceFilesTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#9.05: `werkator control-token` prints the same token the server would create
|
||||||
|
|
||||||
|
So that the wrapper's bash re-implementation of token generation is no longer needed.
|
||||||
|
|
||||||
|
- **Given** a repository with or without an existing control token
|
||||||
|
- **When** `werkator control-token` runs
|
||||||
|
- **Then** it creates the token exactly like `ControlTokenService` would and prints the same value on a re-run
|
||||||
|
- **and** it fails with exit code 2 outside a repository.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [creates the token like the server would and prints the same one on a re-run](../../src/test/kotlin/de/hoennig/werkator/commands/ControlTokenCommandTest.kt)
|
||||||
|
- [fails with exit code 2 outside a repository](../../src/test/kotlin/de/hoennig/werkator/commands/ControlTokenCommandTest.kt)
|
||||||
|
|
||||||
|
### Feature: `tools/remote` becomes a thin wrapper
|
||||||
|
|
||||||
|
#### Scenario#9.06: The wrapper selects its instance by file, not by many options
|
||||||
|
|
||||||
|
So that several instances (mih34, vm4006, a future Werkbaum instance) are files, not edits to one script.
|
||||||
|
|
||||||
|
- **Given** a transport env file (default `.env`) naming a config fragment via `WERKATOR_INIT_CONFIG`
|
||||||
|
- **When** `tools/remote --env-file .env.mih34 werkator repo-init` runs
|
||||||
|
- **Then** the fragment is uploaded and `werkator init --apply` is invoked remotely with it — no heredoc or `sed` writes the machine config.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- live run on mih34 with `.env.mih34` + `.env.mih34.yml`: `instance-update`, `repo-init`, `instance-start` as idempotent re-runs (shell script; no automated test harness for `tools/remote`)
|
||||||
|
|
||||||
|
#### Scenario#9.07: `check-prerequisites` delegates to `werkdock doctor`
|
||||||
|
|
||||||
|
So that the generic host-readiness checks are not duplicated between a bash script and Werkdock's own porting of it.
|
||||||
|
|
||||||
|
- **Given** a target host
|
||||||
|
- **When** `tools/remote werkator check-prerequisites` runs
|
||||||
|
- **Then** it uploads the `werkdock` binary and runs `werkdock doctor`, and `tools/werkator-build-prerequisites.sh` is deleted.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- live run on mih34: doctor-based check-prerequisites, PASS 6/6
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
Session A (Werkator): `ConfigLoader` reads a new `ConfigFiles.APPLIED` path (`.git/werkator/.werkator.applied.yml`) as a layer between project and repo-install config; `applyInstanceFragment(workingDir, fragment)` validates the fragment against a strict Jackson mapper (`FAIL_ON_UNKNOWN_PROPERTIES=true`, fragment validation only — the regular mapper stays lenient for forward-compatibility) and then copies it verbatim via an atomic move, never merging in place.
|
||||||
|
`InitCommand` gained `--apply FILE`, applied before `--systemd` handling so generated units/htaccess see the fragment; `SystemdServiceFiles.htaccessContent(port)` is the new generated file, written when `server.publicBaseUrl` is non-blank.
|
||||||
|
`ControlTokenCommand` is a new subcommand delegating to the existing `ControlTokenService`.
|
||||||
|
Session B (wrapper): `tools/remote --env-file FILE` (default `.env`) replaced positional/flag-heavy invocation; `repo-init`/`instance-start` lost their heredoc/`sed` config writing in favor of uploading the named fragment and calling `werkator init --apply`; port lookups (`require_idle`, `port_forward`) now parse `werkator config:print` output instead of grepping the machine config file directly, so a port living in the applied fragment is found too; `tools/werkator-build-prerequisites.sh` is deleted.
|
||||||
|
Session C: verified live end-to-end on mih34 with a `.env.mih34` + `.env.mih34.yml` pair.
|
||||||
|
|
||||||
|
Deviation from the PR #8 plan: the fragment is not deep-merged into the machine config as first sketched — it is installed as its own verbatim layer, because an in-place merge would re-serialize the machine config and destroy its comments and secrets; a verbatim copy also makes re-apply a plain file replacement instead of a merge algorithm.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Applying a fragment that carries `builds.default` without triggers logs the loader's "no build defines onPush" warning, even though a fragment is judged out of context — cosmetic, fix when it annoys.
|
||||||
|
|
||||||
|
## Additional Changes
|
||||||
|
|
||||||
|
- `docs/configuration.md`: the layer table now shows four layers; a new subsection documents the applied instance fragment.
|
||||||
|
- `docs/bootstrapping.md`: a new section documents `--apply`.
|
||||||
|
- `docs/deployment.md`: the webspace section shows only `--env-file`-style invocations.
|
||||||
|
- `.gitignore`: `/.env.*` added for per-instance env files.
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- PR #7 (webspace install path) — the role-separated `tools/remote` this PR simplifies.
|
||||||
|
- PR #8 (step 23 plan) — the decision this PR implements.
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- PR #10: multi-repository support for one Werkator instance (step 22).
|
||||||
@@ -2,6 +2,7 @@ package de.hoennig.werkator
|
|||||||
|
|
||||||
import de.hoennig.werkator.commands.BuildCommand
|
import de.hoennig.werkator.commands.BuildCommand
|
||||||
import de.hoennig.werkator.commands.ConfigPrintCommand
|
import de.hoennig.werkator.commands.ConfigPrintCommand
|
||||||
|
import de.hoennig.werkator.commands.ControlTokenCommand
|
||||||
import de.hoennig.werkator.commands.InitCommand
|
import de.hoennig.werkator.commands.InitCommand
|
||||||
import de.hoennig.werkator.commands.RetryCommand
|
import de.hoennig.werkator.commands.RetryCommand
|
||||||
import de.hoennig.werkator.commands.ServerCommand
|
import de.hoennig.werkator.commands.ServerCommand
|
||||||
@@ -22,6 +23,7 @@ import picocli.CommandLine.Command
|
|||||||
BuildCommand::class,
|
BuildCommand::class,
|
||||||
RetryCommand::class,
|
RetryCommand::class,
|
||||||
ConfigPrintCommand::class,
|
ConfigPrintCommand::class,
|
||||||
|
ControlTokenCommand::class,
|
||||||
],
|
],
|
||||||
mixinStandardHelpOptions = true,
|
mixinStandardHelpOptions = true,
|
||||||
versionProvider = BuildPropertiesVersionProvider::class,
|
versionProvider = BuildPropertiesVersionProvider::class,
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
package de.hoennig.werkator.artifacts
|
package de.hoennig.werkator.artifacts
|
||||||
|
|
||||||
import de.hoennig.werkator.build.ArtifactStore
|
import de.hoennig.werkator.build.ArtifactStore
|
||||||
import de.hoennig.werkator.config.ConfigLoader
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
class ArtifactsConfiguration {
|
class ArtifactsConfiguration {
|
||||||
/**
|
/** The current repository's artifact store, for the code paths that still take the store bean. */
|
||||||
* Store relative to the working directory, matching how `ConfigLoader` and the
|
|
||||||
* `BuildResultRepository` bean resolve their files. Nothing is touched until the
|
|
||||||
* first build persists, so the bean is safe outside a git repository.
|
|
||||||
*/
|
|
||||||
@Bean
|
@Bean
|
||||||
fun artifactStore(configLoader: ConfigLoader): ArtifactStore = FileArtifactStore(configLoader)
|
fun artifactStore(repo: RepoContext): ArtifactStore = repo.artifactStore
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,12 +151,18 @@ class FileArtifactStore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy `archived_artefact_dir_path`: `build/reports` archives as `reports/`, everything else below `reports/<dir>`. */
|
/**
|
||||||
|
* `build/reports` archives as `reports/` — the browsable-reports anchor of the
|
||||||
|
* artifact page and the legacy `archived_artefact_dir_path` layout. Every other
|
||||||
|
* directory archives at its own workspace-relative path: it is not a report,
|
||||||
|
* and hiding e.g. a built binary below `reports/` made it both mislabeled and
|
||||||
|
* invisible (the report index only scans for HTML pages).
|
||||||
|
*/
|
||||||
private fun archivedPath(artifactDir: String): String =
|
private fun archivedPath(artifactDir: String): String =
|
||||||
if (artifactDir == "build/reports") {
|
if (artifactDir == "build/reports") {
|
||||||
"reports"
|
"reports"
|
||||||
} else {
|
} else {
|
||||||
"reports/$artifactDir"
|
artifactDir
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
package de.hoennig.werkator.build
|
package de.hoennig.werkator.build
|
||||||
|
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import java.nio.file.Paths
|
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
class BuildConfiguration {
|
class BuildConfiguration {
|
||||||
/**
|
/** The current repository's results, for the code paths that still take the repository bean. */
|
||||||
* Results file relative to the working directory, matching how `ConfigLoader`
|
|
||||||
* resolves the `.git/werkator/` override file. Nothing is touched until the
|
|
||||||
* first build runs, so the bean is safe outside a git repository.
|
|
||||||
*/
|
|
||||||
@Bean
|
@Bean
|
||||||
fun buildResultRepository(): BuildResultRepository = FileBuildResultRepository(Paths.get(".git/werkator/build-results.json"))
|
fun buildResultRepository(repo: RepoContext): BuildResultRepository = repo.results
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import de.hoennig.werkator.config.BranchConfig
|
|||||||
import de.hoennig.werkator.config.BuildDefinition
|
import de.hoennig.werkator.config.BuildDefinition
|
||||||
import de.hoennig.werkator.config.ConfigLoader
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
import de.hoennig.werkator.gitea.GiteaClient
|
import de.hoennig.werkator.gitea.GiteaClient
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.context.event.ContextClosedEvent
|
import org.springframework.context.event.ContextClosedEvent
|
||||||
@@ -14,7 +15,6 @@ import java.io.InputStream
|
|||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
|
||||||
import java.nio.file.StandardOpenOption
|
import java.nio.file.StandardOpenOption
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -26,31 +26,30 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs builds asynchronously: up to `executor.maxConcurrent` branches at the same time
|
* Runs builds asynchronously: up to `executor.maxConcurrent` builds at the same time
|
||||||
* (default 1), but never more than one build per branch. Each branch builds in its
|
* across all repositories (default 1), but never more than one build per branch of
|
||||||
* own git worktree via [BranchWorkspaces], never in the primary checkout.
|
* a repository. Each branch builds in its own git worktree via [BranchWorkspaces],
|
||||||
* Every status transition is persisted via the [BuildResultRepository], published
|
* never in the primary checkout. Every status transition is persisted in the
|
||||||
* to Gitea (non-fatal), and emitted as a [BuildStatusChangedEvent].
|
* build's [RepoContext.results], published to Gitea (non-fatal), and emitted as a
|
||||||
|
* [BuildStatusChangedEvent].
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class BuildExecutor(
|
class BuildExecutor(
|
||||||
private val repository: BuildResultRepository,
|
|
||||||
private val configLoader: ConfigLoader,
|
private val configLoader: ConfigLoader,
|
||||||
private val giteaClient: GiteaClient,
|
private val giteaClient: GiteaClient,
|
||||||
private val buildRunner: BuildRunner,
|
private val buildRunner: BuildRunner,
|
||||||
private val workspaces: BranchWorkspaces,
|
private val workspaces: BranchWorkspaces,
|
||||||
private val artifactStore: ArtifactStore,
|
|
||||||
private val eventPublisher: ApplicationEventPublisher,
|
private val eventPublisher: ApplicationEventPublisher,
|
||||||
) {
|
) {
|
||||||
private val log = LoggerFactory.getLogger(BuildExecutor::class.java)
|
private val log = LoggerFactory.getLogger(BuildExecutor::class.java)
|
||||||
|
|
||||||
/** One serial worker per branch enforces at most one build per branch. */
|
/** One serial worker per (repository, branch) enforces at most one build per branch of a repository. */
|
||||||
private val branchWorkers = ConcurrentHashMap<String, ExecutorService>()
|
private val branchWorkers = ConcurrentHashMap<Pair<RepoContext, String>, ExecutorService>()
|
||||||
|
|
||||||
/** All accepted, not yet finished builds by artifact key — queued and running. */
|
/** All accepted, not yet finished builds by artifact key — queued and running. */
|
||||||
private val builds = ConcurrentHashMap<String, ActiveBuild>()
|
private val builds = ConcurrentHashMap<String, ActiveBuild>()
|
||||||
|
|
||||||
/** Global concurrency limit; sized from `executor.maxConcurrent` on first use. */
|
/** Global concurrency limit across all repositories; sized from `executor.maxConcurrent` on first use. */
|
||||||
@Volatile
|
@Volatile
|
||||||
private var slots: Semaphore? = null
|
private var slots: Semaphore? = null
|
||||||
|
|
||||||
@@ -61,9 +60,10 @@ class BuildExecutor(
|
|||||||
fun currentBuilds(): List<RunningBuild> = builds.values.filter { it.running }.map { it.runningBuild }
|
fun currentBuilds(): List<RunningBuild> = builds.values.filter { it.running }.map { it.runningBuild }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists a PENDING result and queues the build; returns immediately.
|
* Persists a PENDING result in [repo] and queues the build; returns immediately.
|
||||||
* A build of the same branch waits until the branch's previous build finished;
|
* A build of the same branch waits until the branch's previous build finished;
|
||||||
* builds of other branches run concurrently while slots are free.
|
* builds of other branches — of this or any other repository — run concurrently
|
||||||
|
* while slots are free.
|
||||||
* While a build of the same branch and commit is already queued or executing (and
|
* While a build of the same branch and commit is already queued or executing (and
|
||||||
* not cancel-requested), that build is returned instead of stacking a duplicate —
|
* not cancel-requested), that build is returned instead of stacking a duplicate —
|
||||||
* a double-triggered UI restart must not queue the same commit twice. Re-running
|
* a double-triggered UI restart must not queue the same commit twice. Re-running
|
||||||
@@ -76,15 +76,16 @@ class BuildExecutor(
|
|||||||
* worktree, serialized with the branch's other builds.
|
* worktree, serialized with the branch's other builds.
|
||||||
*/
|
*/
|
||||||
fun startBuild(
|
fun startBuild(
|
||||||
|
repo: RepoContext,
|
||||||
branch: String,
|
branch: String,
|
||||||
commit: String,
|
commit: String,
|
||||||
workingDir: Path = Paths.get("."),
|
|
||||||
build: String = BuildDefinition.DEFAULT,
|
build: String = BuildDefinition.DEFAULT,
|
||||||
): RunningBuild {
|
): RunningBuild {
|
||||||
val name = BuildDefinition.poolName(branch, build)
|
val name = BuildDefinition.poolName(branch, build)
|
||||||
val duplicate =
|
val duplicate =
|
||||||
builds.values.firstOrNull {
|
builds.values.firstOrNull {
|
||||||
!it.cancelled.get() &&
|
!it.cancelled.get() &&
|
||||||
|
it.repo === repo &&
|
||||||
it.runningBuild.name == name &&
|
it.runningBuild.name == name &&
|
||||||
it.runningBuild.commit == commit
|
it.runningBuild.commit == commit
|
||||||
}
|
}
|
||||||
@@ -114,13 +115,13 @@ class BuildExecutor(
|
|||||||
duration = null,
|
duration = null,
|
||||||
artifactKey = runningBuild.artifactKey,
|
artifactKey = runningBuild.artifactKey,
|
||||||
)
|
)
|
||||||
repository.append(pending)
|
repo.results.append(pending)
|
||||||
eventPublisher.publishEvent(BuildStatusChangedEvent(pending))
|
eventPublisher.publishEvent(BuildStatusChangedEvent(pending))
|
||||||
val activeBuild = ActiveBuild(runningBuild, workingDir)
|
val activeBuild = ActiveBuild(runningBuild, repo)
|
||||||
builds[runningBuild.artifactKey] = activeBuild
|
builds[runningBuild.artifactKey] = activeBuild
|
||||||
publishGiteaStatus(activeBuild, BuildStatus.PENDING, duration = null)
|
publishGiteaStatus(activeBuild, BuildStatus.PENDING, duration = null)
|
||||||
branchWorkers
|
branchWorkers
|
||||||
.computeIfAbsent(branch) { serialWorker(it) }
|
.computeIfAbsent(repo to branch) { serialWorker(branch) }
|
||||||
.submit { execute(activeBuild) }
|
.submit { execute(activeBuild) }
|
||||||
return runningBuild
|
return runningBuild
|
||||||
}
|
}
|
||||||
@@ -171,7 +172,7 @@ class BuildExecutor(
|
|||||||
var finalStatus: BuildStatus? = BuildStatus.FAILED
|
var finalStatus: BuildStatus? = BuildStatus.FAILED
|
||||||
var workspace: Path? = null
|
var workspace: Path? = null
|
||||||
try {
|
try {
|
||||||
slot = slotsFor(build.workingDir)
|
slot = slotsFor(build.repo.workingDir)
|
||||||
slot.acquire()
|
slot.acquire()
|
||||||
if (build.cancelled.get()) {
|
if (build.cancelled.get()) {
|
||||||
finalStatus = BuildStatus.CANCELLED
|
finalStatus = BuildStatus.CANCELLED
|
||||||
@@ -188,7 +189,7 @@ class BuildExecutor(
|
|||||||
workspaces.prepare(
|
workspaces.prepare(
|
||||||
branch = build.runningBuild.branch,
|
branch = build.runningBuild.branch,
|
||||||
commit = build.runningBuild.commit,
|
commit = build.runningBuild.commit,
|
||||||
repoDir = build.workingDir,
|
repoDir = build.repo.workingDir,
|
||||||
)
|
)
|
||||||
workspace = preparedWorkspace
|
workspace = preparedWorkspace
|
||||||
val exitCode = runBuildCommands(build, preparedWorkspace)
|
val exitCode = runBuildCommands(build, preparedWorkspace)
|
||||||
@@ -220,7 +221,7 @@ class BuildExecutor(
|
|||||||
val duration = build.runningBuild.runningSince?.let { Duration.between(it, Instant.now()) }
|
val duration = build.runningBuild.runningSince?.let { Duration.between(it, Instant.now()) }
|
||||||
val result = transition(build, finalStatus, duration)
|
val result = transition(build, finalStatus, duration)
|
||||||
try {
|
try {
|
||||||
artifactStore.persist(result, build.runningBuild.stagingDir, workspace)
|
build.repo.artifactStore.persist(result, build.runningBuild.stagingDir, workspace)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.warn("could not persist artifacts of {}: {}", result.artifactKey, e.message)
|
log.warn("could not persist artifacts of {}: {}", result.artifactKey, e.message)
|
||||||
}
|
}
|
||||||
@@ -231,8 +232,9 @@ class BuildExecutor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The semaphore is sized once from the first build's config;
|
* The semaphore is sized once from the first build's config — the global cap is an
|
||||||
* changing `executor.maxConcurrent` requires a restart.
|
* instance-level setting (ADR 0009) and does not vary by repository; changing
|
||||||
|
* `executor.maxConcurrent` requires a restart.
|
||||||
*/
|
*/
|
||||||
private fun slotsFor(workingDir: Path): Semaphore {
|
private fun slotsFor(workingDir: Path): Semaphore {
|
||||||
slots?.let { return it }
|
slots?.let { return it }
|
||||||
@@ -256,7 +258,7 @@ class BuildExecutor(
|
|||||||
build: ActiveBuild,
|
build: ActiveBuild,
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
): Int {
|
): Int {
|
||||||
val branchConfig = buildConfig(build.runningBuild, build.workingDir, workspace)
|
val branchConfig = buildConfig(build.runningBuild, build.repo.workingDir, workspace)
|
||||||
val buildCommand = branchConfig.buildCommand
|
val buildCommand = branchConfig.buildCommand
|
||||||
val stagingDir = build.runningBuild.stagingDir
|
val stagingDir = build.runningBuild.stagingDir
|
||||||
Files.newOutputStream(stagingDir.resolve(branchConfig.stdoutLog)).use { stdoutLog ->
|
Files.newOutputStream(stagingDir.resolve(branchConfig.stdoutLog)).use { stdoutLog ->
|
||||||
@@ -293,7 +295,7 @@ class BuildExecutor(
|
|||||||
command = command,
|
command = command,
|
||||||
workingDir = workspace,
|
workingDir = workspace,
|
||||||
environment = mapOf("branch" to build.runningBuild.branch),
|
environment = mapOf("branch" to build.runningBuild.branch),
|
||||||
repoDir = build.workingDir,
|
repoDir = build.repo.workingDir,
|
||||||
branchConfig = branchConfig,
|
branchConfig = branchConfig,
|
||||||
onAuxProcess = { aux ->
|
onAuxProcess = { aux ->
|
||||||
// preparation phases (e.g. a Docker image build) must die on cancellation
|
// preparation phases (e.g. a Docker image build) must die on cancellation
|
||||||
@@ -363,7 +365,7 @@ class BuildExecutor(
|
|||||||
): BuildResult {
|
): BuildResult {
|
||||||
val runningBuild = build.runningBuild
|
val runningBuild = build.runningBuild
|
||||||
val updated =
|
val updated =
|
||||||
repository.updateByArtifactKey(runningBuild.artifactKey) {
|
build.repo.results.updateByArtifactKey(runningBuild.artifactKey) {
|
||||||
it.copy(
|
it.copy(
|
||||||
status = status,
|
status = status,
|
||||||
runningSince = runningBuild.runningSince ?: it.runningSince,
|
runningSince = runningBuild.runningSince ?: it.runningSince,
|
||||||
@@ -378,7 +380,7 @@ class BuildExecutor(
|
|||||||
runningSince = runningBuild.runningSince,
|
runningSince = runningBuild.runningSince,
|
||||||
duration = duration,
|
duration = duration,
|
||||||
artifactKey = runningBuild.artifactKey,
|
artifactKey = runningBuild.artifactKey,
|
||||||
).also { repository.append(it) }
|
).also { build.repo.results.append(it) }
|
||||||
eventPublisher.publishEvent(BuildStatusChangedEvent(updated))
|
eventPublisher.publishEvent(BuildStatusChangedEvent(updated))
|
||||||
publishGiteaStatus(build, status, duration)
|
publishGiteaStatus(build, status, duration)
|
||||||
return updated
|
return updated
|
||||||
@@ -395,7 +397,7 @@ class BuildExecutor(
|
|||||||
status = status,
|
status = status,
|
||||||
description = description(status, duration),
|
description = description(status, duration),
|
||||||
targetUrl = null,
|
targetUrl = null,
|
||||||
workingDir = build.workingDir,
|
workingDir = build.repo.workingDir,
|
||||||
// from the primary config, not the worktree: statusContext is pinned, so a
|
// from the primary config, not the worktree: statusContext is pinned, so a
|
||||||
// branch cannot report under a check name it was not given
|
// branch cannot report under a check name it was not given
|
||||||
context = statusContextOf(build),
|
context = statusContextOf(build),
|
||||||
@@ -409,7 +411,7 @@ class BuildExecutor(
|
|||||||
private fun statusContextOf(build: ActiveBuild): String =
|
private fun statusContextOf(build: ActiveBuild): String =
|
||||||
try {
|
try {
|
||||||
configLoader
|
configLoader
|
||||||
.load(build.workingDir)
|
.load(build.repo.workingDir)
|
||||||
.buildSettings(build.runningBuild.branch, build.runningBuild.build)
|
.buildSettings(build.runningBuild.branch, build.runningBuild.build)
|
||||||
.statusContext
|
.statusContext
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -496,7 +498,7 @@ class BuildExecutor(
|
|||||||
|
|
||||||
private class ActiveBuild(
|
private class ActiveBuild(
|
||||||
val runningBuild: RunningBuild,
|
val runningBuild: RunningBuild,
|
||||||
val workingDir: Path,
|
val repo: RepoContext,
|
||||||
) {
|
) {
|
||||||
val cancelled = AtomicBoolean(false)
|
val cancelled = AtomicBoolean(false)
|
||||||
|
|
||||||
|
|||||||
@@ -10,20 +10,30 @@ import java.nio.file.Path
|
|||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs build commands inside a bubblewrap user-namespace sandbox (Step 17 / ADR 0007),
|
* Runs build commands inside a bubblewrap user-namespace sandbox (Step 17 / ADR 0008),
|
||||||
* for hosts without root and without a Docker daemon (e.g. Hostsharing managed
|
* for hosts without root and without a Docker daemon (e.g. Hostsharing managed
|
||||||
* webspaces). Shells out to the `bwrap` CLI via the generic [GitCommandRunner] process
|
* webspaces). Since step 21 session C it no longer assembles the raw `bwrap` argv:
|
||||||
* wrapper — no library, consistent with git and docker.
|
* it shells out to the `werkdock` CLI (`bwrap.werkdock`, default via PATH) — the same
|
||||||
|
* pattern as git and docker, CLI, no library.
|
||||||
*
|
*
|
||||||
* The prepared rootfs (a Debian-base archive built elsewhere, since `debootstrap` is not
|
* The rootfs archive becomes a werkdock *image*, loaded once per source
|
||||||
* available on the target) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs`,
|
* (`werkator-buildenv-<hash>`, the hash over the source string, so a changed source
|
||||||
* shared across all branch worktrees like the Docker gradle cache volume; `<envKey>` derives
|
* loads a fresh image) into werkdock's own store (`$WERKDOCK_HOME`, default
|
||||||
* from a hash of the archive source, so a changed source unpacks a fresh rootfs and stale
|
* `~/.werkdock`) — shared by every repository of this OS user, unlike the old
|
||||||
* ones can be pruned. The returned [Process] is the attached `bwrap` process, so log
|
* per-repo unpack. Only the download cache for URL sources and the persistent
|
||||||
* streaming and cancellation work exactly like native builds (`--die-with-parent` plus
|
* toolchain home (bound to `/root` for Gradle/Go caches) stay under
|
||||||
* `--unshare-pid` tear down the whole tree on cancel). Git works inside the sandbox with
|
* `.git/werkator/buildenv/`.
|
||||||
* the same layered mounts as the Docker runner: the primary `.git` read-only with
|
*
|
||||||
* `.git/werkator/` masked, see [gitMetadataMounts].
|
* Werkdock clears the environment inside the sandbox (docker semantics), so the
|
||||||
|
* server's environment no longer leaks in — only the explicit `-e` variables below
|
||||||
|
* plus werkdock's own `HOME`/`PATH` exist inside; the pam_tmpdir TMPDIR class of
|
||||||
|
* bugs is gone by construction. Git works inside the sandbox with the same layered
|
||||||
|
* mounts as the Docker runner, expressed as werkdock flags whose order is
|
||||||
|
* significant and preserved: read-only `.git`, tmpfs mask over `.git/werkator`,
|
||||||
|
* read-write worktree admin dir — see [gitMetadataMounts]. The returned [Process]
|
||||||
|
* is the attached `werkdock run`, whose `bwrap` child dies with it
|
||||||
|
* (`--die-with-parent`), so log streaming and cancellation work exactly like
|
||||||
|
* native builds.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
class BwrapBuildRunner(
|
class BwrapBuildRunner(
|
||||||
@@ -31,7 +41,7 @@ class BwrapBuildRunner(
|
|||||||
) : BuildRunner {
|
) : BuildRunner {
|
||||||
private val log = LoggerFactory.getLogger(BwrapBuildRunner::class.java)
|
private val log = LoggerFactory.getLogger(BwrapBuildRunner::class.java)
|
||||||
|
|
||||||
/** Replaceable process launcher so unit tests can capture the assembled `bwrap` argv. */
|
/** Replaceable process launcher so unit tests can capture the assembled `werkdock` argv. */
|
||||||
internal var processStarter: (List<String>, Path) -> Process = { command, dir ->
|
internal var processStarter: (List<String>, Path) -> Process = { command, dir ->
|
||||||
ProcessBuilder(command).directory(dir.toFile()).start()
|
ProcessBuilder(command).directory(dir.toFile()).start()
|
||||||
}
|
}
|
||||||
@@ -46,76 +56,37 @@ class BwrapBuildRunner(
|
|||||||
): Process {
|
): Process {
|
||||||
val bwrap = branchConfig.bwrap
|
val bwrap = branchConfig.bwrap
|
||||||
require(bwrap.rootfs.isNotBlank()) { "branches.<name>.bwrap.rootfs must be set when bwrap.enabled is true" }
|
require(bwrap.rootfs.isNotBlank()) { "branches.<name>.bwrap.rootfs must be set when bwrap.enabled is true" }
|
||||||
val buildEnvRoot = buildEnvRoot(repoDir)
|
val werkdock = bwrap.werkdock.ifBlank { "werkdock" }
|
||||||
val envKey = envKey(bwrap.rootfs)
|
val image = imageName(bwrap.rootfs)
|
||||||
val rootfsDir = buildEnvRoot.resolve(envKey).resolve(ROOTFS_DIR)
|
ensureImage(werkdock, image, bwrap, repoDir, onAuxProcess)
|
||||||
ensureRootfs(bwrap, rootfsDir, repoDir, onAuxProcess)
|
val homeDir = repoDir.resolve(BUILDENV_DIR).resolve(HOME_DIR)
|
||||||
val homeDir = buildEnvRoot.resolve(HOME_DIR)
|
|
||||||
Files.createDirectories(homeDir)
|
Files.createDirectories(homeDir)
|
||||||
val args =
|
val args = invocation(command, workingDir, environment, repoDir, bwrap, werkdock, image, homeDir)
|
||||||
invocation(command, workingDir, environment, repoDir, bwrap, rootfsDir, homeDir)
|
|
||||||
ensureMountpoints(rootfsDir, args)
|
|
||||||
return processStarter(args, repoDir)
|
return processStarter(args, repoDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* bwrap creates mountpoint directories for bind destinations inside the sandbox —
|
* Loads the rootfs archive into the werkdock image store once per source.
|
||||||
* against the read-only rootfs bind that fails with "Can't mkdir parents ...
|
* `werkdock images` answers existence through the CLI, like `docker image
|
||||||
* Read-only file system" for every destination that does not exist in the rootfs
|
* inspect` does for the Docker runner.
|
||||||
* (the workspace under the repo, for example). The rootfs directory itself is a
|
|
||||||
* plain host directory, so we pre-create the mountpoints there; bwrap then finds
|
|
||||||
* them and has nothing left to mkdir.
|
|
||||||
*/
|
*/
|
||||||
private fun ensureMountpoints(
|
private fun ensureImage(
|
||||||
rootfsDir: Path,
|
werkdock: String,
|
||||||
args: List<String>,
|
image: String,
|
||||||
) {
|
|
||||||
var i = 0
|
|
||||||
while (i < args.size) {
|
|
||||||
val arg = args[i]
|
|
||||||
if (arg == "--bind" || arg == "--ro-bind") {
|
|
||||||
val dest = args[i + 2]
|
|
||||||
val mountpoint = rootfsDir.resolve(dest.substring(1))
|
|
||||||
// Skip anything that already exists in the rootfs (e.g. /etc/resolv.conf
|
|
||||||
// is a file the rootfs ships); only missing dirs are created.
|
|
||||||
if (dest.startsWith("/") && !Files.exists(mountpoint)) {
|
|
||||||
Files.createDirectories(mountpoint)
|
|
||||||
}
|
|
||||||
i += 3
|
|
||||||
} else if (arg == "--proc" || arg == "--dev" || arg == "--tmpfs") {
|
|
||||||
// The rootfs archive ships no /proc, /dev (excluded when packed), so
|
|
||||||
// these mountpoints must exist too.
|
|
||||||
val dest = args[i + 1]
|
|
||||||
if (dest.startsWith("/") && !Files.exists(rootfsDir.resolve(dest.substring(1)))) {
|
|
||||||
Files.createDirectories(rootfsDir.resolve(dest.substring(1)))
|
|
||||||
}
|
|
||||||
i += 2
|
|
||||||
} else {
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unpacks the configured archive into [rootfsDir] once per environment version
|
|
||||||
* (identified by [envKey]). Missing means "not yet unpacked"; the environment is a
|
|
||||||
* cache like the Docker image and the Gradle volume, and stale ones are pruned with
|
|
||||||
* the rest of `.git/werkator`.
|
|
||||||
*/
|
|
||||||
private fun ensureRootfs(
|
|
||||||
bwrap: BwrapConfig,
|
bwrap: BwrapConfig,
|
||||||
rootfsDir: Path,
|
|
||||||
repoDir: Path,
|
repoDir: Path,
|
||||||
onAuxProcess: (Process) -> Unit,
|
onAuxProcess: (Process) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (Files.isDirectory(rootfsDir)) {
|
val loaded = commandRunner.runOrThrow(listOf(werkdock, "images"), repoDir, onProcess = onAuxProcess).lines()
|
||||||
|
if (image in loaded) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Files.createDirectories(rootfsDir)
|
val envDir = repoDir.resolve(BUILDENV_DIR).resolve(sourceKey(bwrap.rootfs))
|
||||||
val archive = localArchive(bwrap.rootfs, rootfsDir.parent, repoDir, onAuxProcess)
|
Files.createDirectories(envDir)
|
||||||
log.info("unpacking build environment {} into {}", bwrap.rootfs, rootfsDir)
|
val archive = localArchive(bwrap.rootfs, envDir, repoDir, onAuxProcess)
|
||||||
|
log.info("loading build environment {} as werkdock image {}", bwrap.rootfs, image)
|
||||||
commandRunner.runOrThrow(
|
commandRunner.runOrThrow(
|
||||||
listOf("tar", "--no-same-owner", "-xf", archive, "-C", rootfsDir.toString()),
|
listOf(werkdock, "load", "-i", archive, "--name", image),
|
||||||
repoDir,
|
repoDir,
|
||||||
onProcess = onAuxProcess,
|
onProcess = onAuxProcess,
|
||||||
)
|
)
|
||||||
@@ -123,9 +94,7 @@ class BwrapBuildRunner(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves [BwrapConfig.rootfs] to a local archive path: a bare or `file:` path is
|
* Resolves [BwrapConfig.rootfs] to a local archive path: a bare or `file:` path is
|
||||||
* used as-is; an `http(s)` URL is downloaded once into the buildenv root. GNU tar
|
* used as-is; an `http(s)` URL is downloaded once into the buildenv cache.
|
||||||
* auto-detects the compression from the archive magic, so a `.tar.gz` or `.tar.zst`
|
|
||||||
* needs no extra flag.
|
|
||||||
*/
|
*/
|
||||||
private fun localArchive(
|
private fun localArchive(
|
||||||
rootfs: String,
|
rootfs: String,
|
||||||
@@ -155,69 +124,48 @@ class BwrapBuildRunner(
|
|||||||
environment: Map<String, String>,
|
environment: Map<String, String>,
|
||||||
repoDir: Path,
|
repoDir: Path,
|
||||||
bwrap: BwrapConfig,
|
bwrap: BwrapConfig,
|
||||||
rootfsDir: Path,
|
werkdock: String,
|
||||||
|
image: String,
|
||||||
homeDir: Path,
|
homeDir: Path,
|
||||||
): List<String> {
|
): List<String> {
|
||||||
// bwrap creates mountpoints for bind destinations inside the sandbox; a
|
// Mounts at absolute host paths — same contract as the Docker runner.
|
||||||
// relative workspace path would resolve there into the read-only rootfs
|
// Relative paths come from the CLI relative to the repo, so resolve them
|
||||||
// ("Can't mkdir parents ...: Read-only file system"). Bind at absolute
|
// against repoDir, not against the process working directory.
|
||||||
// host paths instead — same contract as the Docker runner. Relative
|
|
||||||
// paths come from the CLI relative to the repo, so resolve them against
|
|
||||||
// repoDir, not against the process working directory.
|
|
||||||
val repoDirAbs = repoDir.toAbsolutePath().normalize()
|
val repoDirAbs = repoDir.toAbsolutePath().normalize()
|
||||||
val workspaceAbs =
|
val workspaceAbs =
|
||||||
if (workspace.isAbsolute) workspace.normalize() else repoDirAbs.resolve(workspace).normalize()
|
if (workspace.isAbsolute) workspace.normalize() else repoDirAbs.resolve(workspace).normalize()
|
||||||
val homeDirAbs =
|
val homeDirAbs =
|
||||||
if (homeDir.isAbsolute) homeDir.normalize() else repoDirAbs.resolve(homeDir).normalize()
|
if (homeDir.isAbsolute) homeDir.normalize() else repoDirAbs.resolve(homeDir).normalize()
|
||||||
val args =
|
val args = mutableListOf(werkdock, "run", "--rm")
|
||||||
mutableListOf(
|
// The repo read-write FIRST, as the base the later mountpoints (workspace,
|
||||||
"bwrap",
|
// worktree admin dir) are created in; the git metadata mounts then layer
|
||||||
"--unshare-user",
|
// the isolation on top, and the workspace bind last shadows the tmpfs mask
|
||||||
"--unshare-pid",
|
// at exactly its own path (it lives under .git/werkator/worktrees).
|
||||||
"--die-with-parent",
|
args += listOf("-v", "$repoDirAbs:$repoDirAbs")
|
||||||
"--uid",
|
|
||||||
"0",
|
|
||||||
"--gid",
|
|
||||||
"0",
|
|
||||||
"--ro-bind",
|
|
||||||
rootfsDir.toString(),
|
|
||||||
"/",
|
|
||||||
)
|
|
||||||
// Bind the repo read-write FIRST so bwrap can create the mountpoints of
|
|
||||||
// the later binds (workspace, worktree admin dir) inside it — creating
|
|
||||||
// them against the read-only rootfs fails with "Can't mkdir parents ...
|
|
||||||
// Read-only file system". The git metadata mounts below then layer the
|
|
||||||
// usual isolation on top: read-only .git, tmpfs mask over .git/werkator,
|
|
||||||
// read-write worktree admin dir.
|
|
||||||
args += listOf("--bind", "$repoDirAbs", "$repoDirAbs")
|
|
||||||
// Git metadata mounts BEFORE the workspace bind: the tmpfs mask over
|
|
||||||
// .git/werkator must not shadow the workspace, which lives under
|
|
||||||
// .git/werkator/worktrees — the later workspace bind shadows the mask
|
|
||||||
// at exactly its own path and nothing else.
|
|
||||||
args += gitMetadataMounts(workspaceAbs, repoDir)
|
args += gitMetadataMounts(workspaceAbs, repoDir)
|
||||||
args += listOf("--bind", "$workspaceAbs", "$workspaceAbs")
|
args += listOf("-v", "$workspaceAbs:$workspaceAbs")
|
||||||
args += listOf("--bind", "$homeDirAbs", "/root")
|
args += listOf("-v", "$homeDirAbs:/root")
|
||||||
args += listOf("--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf")
|
|
||||||
args += listOf("--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp")
|
|
||||||
args += listOf("--setenv", "HOME", "/root")
|
|
||||||
for ((key, value) in environment) {
|
for ((key, value) in environment) {
|
||||||
args += listOf("--setenv", key, value)
|
args += listOf("-e", "$key=$value")
|
||||||
}
|
}
|
||||||
for ((key, value) in bwrap.env) {
|
for ((key, value) in bwrap.env) {
|
||||||
args += listOf("--setenv", key, value)
|
args += listOf("-e", "$key=$value")
|
||||||
}
|
}
|
||||||
args += listOf("--chdir", "$workspaceAbs", "/bin/sh", "-c", command)
|
args += listOf("-w", "$workspaceAbs")
|
||||||
|
args += image
|
||||||
|
args += listOf("/bin/sh", "-c", command)
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes git work inside the sandbox without exposing Werkator's secrets — the same
|
* Makes git work inside the sandbox without exposing Werkator's secrets — the same
|
||||||
* three layered mounts as the Docker runner, expressed in `bwrap` flags (bwrap nests
|
* three layered mounts as the Docker runner, expressed as werkdock flags (werkdock
|
||||||
* mounts by target path like Docker): the primary `.git` read-only, an empty tmpfs
|
* preserves the -v/--tmpfs flag order, and bwrap nests mounts by target path): the
|
||||||
* masking `.git/werkator/` (machine config with `git.token`, control token, build
|
* primary `.git` read-only, an empty tmpfs masking `.git/werkator/` (machine config
|
||||||
* state), and this worktree's admin directory read-write so index-refreshing commands
|
* with `git.token`, control token, build state), and this worktree's admin directory
|
||||||
* keep working. Object and ref writes stay blocked by the read-only `.git` mount.
|
* read-write so index-refreshing commands keep working. Object and ref writes stay
|
||||||
* No mounts are added when the workspace is not a worktree of [repoDir].
|
* blocked by the read-only `.git` mount. No mounts are added when the workspace is
|
||||||
|
* not a worktree of [repoDir].
|
||||||
*/
|
*/
|
||||||
private fun gitMetadataMounts(
|
private fun gitMetadataMounts(
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
@@ -239,28 +187,27 @@ class BwrapBuildRunner(
|
|||||||
if (!adminDir.startsWith(gitDir) || !Files.isDirectory(adminDir)) {
|
if (!adminDir.startsWith(gitDir) || !Files.isDirectory(adminDir)) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
val args = mutableListOf("--ro-bind", "$gitDir", "$gitDir")
|
val args = mutableListOf("-v", "$gitDir:$gitDir:ro")
|
||||||
val werkatorDir = gitDir.resolve("werkator")
|
val werkatorDir = gitDir.resolve("werkator")
|
||||||
if (Files.isDirectory(werkatorDir)) {
|
if (Files.isDirectory(werkatorDir)) {
|
||||||
args += listOf("--tmpfs", "$werkatorDir")
|
args += listOf("--tmpfs", "$werkatorDir")
|
||||||
}
|
}
|
||||||
args += listOf("--bind", "$adminDir", "$adminDir")
|
args += listOf("-v", "$adminDir:$adminDir")
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildEnvRoot(repoDir: Path): Path = repoDir.resolve(BUILDENV_DIR)
|
/** A short hash of the archive source, so a changed source loads a fresh image. */
|
||||||
|
private fun sourceKey(rootfs: String): String =
|
||||||
/** A short hash of the archive source, so a changed source unpacks a fresh rootfs. */
|
|
||||||
private fun envKey(rootfs: String): String =
|
|
||||||
MessageDigest
|
MessageDigest
|
||||||
.getInstance("SHA-256")
|
.getInstance("SHA-256")
|
||||||
.digest(rootfs.toByteArray())
|
.digest(rootfs.toByteArray())
|
||||||
.joinToString("") { "%02x".format(it) }
|
.joinToString("") { "%02x".format(it) }
|
||||||
.take(12)
|
.take(12)
|
||||||
|
|
||||||
|
private fun imageName(rootfs: String): String = "werkator-buildenv-${sourceKey(rootfs)}"
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val BUILDENV_DIR = ".git/werkator/buildenv"
|
const val BUILDENV_DIR = ".git/werkator/buildenv"
|
||||||
const val ROOTFS_DIR = "rootfs"
|
|
||||||
const val HOME_DIR = "home"
|
const val HOME_DIR = "home"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package de.hoennig.werkator.commands
|
|||||||
|
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import picocli.CommandLine.Command
|
import picocli.CommandLine.Command
|
||||||
import picocli.CommandLine.ExitCode
|
import picocli.CommandLine.ExitCode
|
||||||
import picocli.CommandLine.Parameters
|
import picocli.CommandLine.Parameters
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
|
||||||
import java.util.concurrent.Callable
|
import java.util.concurrent.Callable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +24,8 @@ import java.util.concurrent.Callable
|
|||||||
class BuildCommand(
|
class BuildCommand(
|
||||||
private val gitService: GitService,
|
private val gitService: GitService,
|
||||||
private val consoleBuildRunner: ConsoleBuildRunner,
|
private val consoleBuildRunner: ConsoleBuildRunner,
|
||||||
|
/** The repository to build: the current working directory (a repo selector comes with the registry). */
|
||||||
|
var repo: RepoContext,
|
||||||
) : Callable<Int> {
|
) : Callable<Int> {
|
||||||
@Parameters(
|
@Parameters(
|
||||||
index = "0",
|
index = "0",
|
||||||
@@ -33,7 +35,8 @@ class BuildCommand(
|
|||||||
)
|
)
|
||||||
var branchFragment: String? = null
|
var branchFragment: String? = null
|
||||||
|
|
||||||
var workingDir: Path = Paths.get(".")
|
private val workingDir: Path
|
||||||
|
get() = repo.workingDir
|
||||||
|
|
||||||
override fun call(): Int {
|
override fun call(): Int {
|
||||||
val branch: String
|
val branch: String
|
||||||
@@ -47,7 +50,7 @@ class BuildCommand(
|
|||||||
return ExitCode.USAGE
|
return ExitCode.USAGE
|
||||||
}
|
}
|
||||||
println("building branch $branch at commit ${commit.take(12)}")
|
println("building branch $branch at commit ${commit.take(12)}")
|
||||||
val status = consoleBuildRunner.buildAndStream(branch, commit, workingDir)
|
val status = consoleBuildRunner.buildAndStream(repo, branch, commit)
|
||||||
return if (status == BuildStatus.SUCCESS) ExitCode.OK else ExitCode.SOFTWARE
|
return if (status == BuildStatus.SUCCESS) ExitCode.OK else ExitCode.SOFTWARE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
package de.hoennig.werkator.commands
|
package de.hoennig.werkator.commands
|
||||||
|
|
||||||
import de.hoennig.werkator.build.ArtifactStore
|
|
||||||
import de.hoennig.werkator.build.BuildExecutor
|
import de.hoennig.werkator.build.BuildExecutor
|
||||||
import de.hoennig.werkator.build.BuildResult
|
import de.hoennig.werkator.build.BuildResult
|
||||||
import de.hoennig.werkator.build.BuildResultRepository
|
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
import de.hoennig.werkator.build.RunningBuild
|
import de.hoennig.werkator.build.RunningBuild
|
||||||
import de.hoennig.werkator.config.BuildDefinition
|
import de.hoennig.werkator.config.BuildDefinition
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import de.hoennig.werkator.server.UiFormats
|
import de.hoennig.werkator.server.UiFormats
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -14,7 +13,6 @@ import java.nio.channels.Channels
|
|||||||
import java.nio.channels.FileChannel
|
import java.nio.channels.FileChannel
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
|
||||||
import java.nio.file.StandardOpenOption
|
import java.nio.file.StandardOpenOption
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
|
||||||
@@ -26,31 +24,29 @@ import java.time.Duration
|
|||||||
@Component
|
@Component
|
||||||
class ConsoleBuildRunner(
|
class ConsoleBuildRunner(
|
||||||
private val buildExecutor: BuildExecutor,
|
private val buildExecutor: BuildExecutor,
|
||||||
private val repository: BuildResultRepository,
|
|
||||||
private val artifactStore: ArtifactStore,
|
|
||||||
) {
|
) {
|
||||||
var pollIntervalMillis = 200L
|
var pollIntervalMillis = 200L
|
||||||
|
|
||||||
var persistTimeoutMillis = 30_000L
|
var persistTimeoutMillis = 30_000L
|
||||||
|
|
||||||
/** Builds [branch] at [commit], blocking until the build finished; returns the final status. */
|
/** Builds [branch] of [repo] at [commit], blocking until the build finished; returns the final status. */
|
||||||
fun buildAndStream(
|
fun buildAndStream(
|
||||||
|
repo: RepoContext,
|
||||||
branch: String,
|
branch: String,
|
||||||
commit: String,
|
commit: String,
|
||||||
workingDir: Path = Paths.get("."),
|
|
||||||
buildDefinition: String = BuildDefinition.DEFAULT,
|
buildDefinition: String = BuildDefinition.DEFAULT,
|
||||||
): BuildStatus {
|
): BuildStatus {
|
||||||
val build = buildExecutor.startBuild(branch, commit, workingDir, buildDefinition)
|
val build = buildExecutor.startBuild(repo, branch, commit, buildDefinition)
|
||||||
var printed = 0L
|
var printed = 0L
|
||||||
var result: BuildResult? = null
|
var result: BuildResult? = null
|
||||||
while (result?.status?.isTerminal != true) {
|
while (result?.status?.isTerminal != true) {
|
||||||
printed += printNewLogBytes(build.liveLogFile, printed)
|
printed += printNewLogBytes(build.liveLogFile, printed)
|
||||||
result = repository.history().firstOrNull { it.artifactKey == build.artifactKey }
|
result = repo.results.history().firstOrNull { it.artifactKey == build.artifactKey }
|
||||||
if (result?.status?.isTerminal != true) {
|
if (result?.status?.isTerminal != true) {
|
||||||
Thread.sleep(pollIntervalMillis)
|
Thread.sleep(pollIntervalMillis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drainAfterBuild(build, printed)
|
drainAfterBuild(repo, build, printed)
|
||||||
val after = result.duration?.let { " after ${UiFormats.duration(it)}" } ?: ""
|
val after = result.duration?.let { " after ${UiFormats.duration(it)}" } ?: ""
|
||||||
println("build of branch $branch: ${result.status.name.lowercase()}$after")
|
println("build of branch $branch: ${result.status.name.lowercase()}$after")
|
||||||
return result.status
|
return result.status
|
||||||
@@ -64,6 +60,7 @@ class ConsoleBuildRunner(
|
|||||||
* stored copy (which is byte-identical, so the offset carries over).
|
* stored copy (which is byte-identical, so the offset carries over).
|
||||||
*/
|
*/
|
||||||
private fun drainAfterBuild(
|
private fun drainAfterBuild(
|
||||||
|
repo: RepoContext,
|
||||||
build: RunningBuild,
|
build: RunningBuild,
|
||||||
alreadyPrinted: Long,
|
alreadyPrinted: Long,
|
||||||
) {
|
) {
|
||||||
@@ -79,7 +76,7 @@ class ConsoleBuildRunner(
|
|||||||
}
|
}
|
||||||
Thread.sleep(pollIntervalMillis)
|
Thread.sleep(pollIntervalMillis)
|
||||||
}
|
}
|
||||||
artifactStore.artifactDir(build.artifactKey)?.let { artifactDir ->
|
repo.artifactStore.artifactDir(build.artifactKey)?.let { artifactDir ->
|
||||||
printNewLogBytes(artifactDir.resolve(BuildExecutor.LIVE_LOG_FILE), printed)
|
printNewLogBytes(artifactDir.resolve(BuildExecutor.LIVE_LOG_FILE), printed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package de.hoennig.werkator.commands
|
||||||
|
|
||||||
|
import de.hoennig.werkator.git.GitService
|
||||||
|
import de.hoennig.werkator.server.ControlTokenService
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import picocli.CommandLine.Command
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import java.util.concurrent.Callable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the control token guarding the mutating build endpoints, creating it
|
||||||
|
* exactly like the server does ([ControlTokenService] owns the format) — so no
|
||||||
|
* wrapper script ever needs its own token generator (step 23).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Command(
|
||||||
|
name = "control-token",
|
||||||
|
description = ["Print the control token for the mutating build endpoints, creating it if missing"],
|
||||||
|
mixinStandardHelpOptions = true,
|
||||||
|
)
|
||||||
|
class ControlTokenCommand(
|
||||||
|
private val gitService: GitService,
|
||||||
|
) : Callable<Int> {
|
||||||
|
var workingDir: Path = Paths.get(".")
|
||||||
|
|
||||||
|
override fun call(): Int {
|
||||||
|
val root =
|
||||||
|
try {
|
||||||
|
gitService.getTopLevel(workingDir.toAbsolutePath().normalize())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Error: ${e.message}")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
println(ControlTokenService(root.resolve(".git/werkator/control-token")).token())
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,16 @@ class InitCommand(
|
|||||||
)
|
)
|
||||||
var systemd: Boolean = false
|
var systemd: Boolean = false
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
names = ["--apply"],
|
||||||
|
description = [
|
||||||
|
"install a config-schema YAML fragment as the applied instance layer " +
|
||||||
|
"(validated strictly; re-applying replaces the previous fragment)",
|
||||||
|
],
|
||||||
|
paramLabel = "FILE",
|
||||||
|
)
|
||||||
|
var apply: Path? = null
|
||||||
|
|
||||||
/** Replaceable for tests: the jar this JVM was started from, or null when not run via `java -jar`. */
|
/** Replaceable for tests: the jar this JVM was started from, or null when not run via `java -jar`. */
|
||||||
internal var jarPathResolver: () -> Path? = { runningJarPath() }
|
internal var jarPathResolver: () -> Path? = { runningJarPath() }
|
||||||
|
|
||||||
@@ -52,6 +62,17 @@ class InitCommand(
|
|||||||
|
|
||||||
createRepoInstallConfig(root, detected, normalizedWorkingDir)
|
createRepoInstallConfig(root, detected, normalizedWorkingDir)
|
||||||
createProjectConfig(root, detected, normalizedWorkingDir)
|
createProjectConfig(root, detected, normalizedWorkingDir)
|
||||||
|
// before the systemd files, which read the effective configuration —
|
||||||
|
// an applied fragment's port and limits must reach the generated unit
|
||||||
|
apply?.let { fragment ->
|
||||||
|
try {
|
||||||
|
val target = configLoader.applyInstanceFragment(root, fragment)
|
||||||
|
println("applied $fragment as ${target.toFile().relativeTo(normalizedWorkingDir.toFile())}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Error: ${e.message}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if (systemd) {
|
if (systemd) {
|
||||||
createSystemdFiles(root, normalizedWorkingDir)
|
createSystemdFiles(root, normalizedWorkingDir)
|
||||||
}
|
}
|
||||||
@@ -226,6 +247,7 @@ class InitCommand(
|
|||||||
bwrap:
|
bwrap:
|
||||||
enabled: false # run clean/build in a bwrap sandbox instead of natively (pinned)
|
enabled: false # run clean/build in a bwrap 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)
|
||||||
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.
|
||||||
@@ -273,12 +295,14 @@ class InitCommand(
|
|||||||
* already loadable (re-running `init --systemd` on an installed instance); during
|
* already loadable (re-running `init --systemd` on an installed instance); during
|
||||||
* the very first bootstrap they stay unset and the defaults (no directives) apply.
|
* the very first bootstrap they stay unset and the defaults (no directives) apply.
|
||||||
*/
|
*/
|
||||||
private fun loadedSystemdConfig(): de.hoennig.werkator.config.SystemdConfig =
|
private fun loadedSystemdConfig(): de.hoennig.werkator.config.SystemdConfig = loadedServerConfig().systemd
|
||||||
|
|
||||||
|
private fun loadedServerConfig(): de.hoennig.werkator.config.ServerConfig =
|
||||||
try {
|
try {
|
||||||
configLoader.load(Paths.get(".")).server.systemd
|
configLoader.load(Paths.get(".")).server
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
de.hoennig.werkator.config
|
de.hoennig.werkator.config
|
||||||
.SystemdConfig()
|
.ServerConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createSystemdFiles(
|
private fun createSystemdFiles(
|
||||||
@@ -324,6 +348,19 @@ class InitCommand(
|
|||||||
pruneTimerFile.toFile().writeText(SystemdServiceFiles.pruneTimerContent())
|
pruneTimerFile.toFile().writeText(SystemdServiceFiles.pruneTimerContent())
|
||||||
println("created ${pruneTimerFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
|
println("created ${pruneTimerFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
|
||||||
|
|
||||||
|
// generated host integration like the units: only meaningful behind a web
|
||||||
|
// frontend, so it needs a public base URL; unused elsewhere and harmless
|
||||||
|
val server = loadedServerConfig()
|
||||||
|
if (server.publicBaseUrl.isNotBlank()) {
|
||||||
|
val htaccessFile = werkatorDir.resolve(SystemdServiceFiles.HTACCESS_NAME)
|
||||||
|
htaccessFile.toFile().writeText(SystemdServiceFiles.htaccessContent(server.port))
|
||||||
|
println(
|
||||||
|
"created ${htaccessFile.toFile().relativeTo(
|
||||||
|
normalizedWorkingDir.toFile(),
|
||||||
|
)} (Apache reverse proxy; copy it into the domain docroot on a managed webspace)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
println("install and start the service and the nightly Docker cleanup with:")
|
println("install and start the service and the nightly Docker cleanup with:")
|
||||||
println(" ln -sf $unitFile ~/.config/systemd/user/$unitName")
|
println(" ln -sf $unitFile ~/.config/systemd/user/$unitName")
|
||||||
println(" ln -sf $pruneServiceFile ~/.config/systemd/user/${SystemdServiceFiles.PRUNE_SERVICE_NAME}")
|
println(" ln -sf $pruneServiceFile ~/.config/systemd/user/${SystemdServiceFiles.PRUNE_SERVICE_NAME}")
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
package de.hoennig.werkator.commands
|
package de.hoennig.werkator.commands
|
||||||
|
|
||||||
import de.hoennig.werkator.build.BuildResult
|
import de.hoennig.werkator.build.BuildResult
|
||||||
import de.hoennig.werkator.build.BuildResultRepository
|
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import picocli.CommandLine.Command
|
import picocli.CommandLine.Command
|
||||||
import picocli.CommandLine.ExitCode
|
import picocli.CommandLine.ExitCode
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
|
||||||
import java.util.concurrent.Callable
|
import java.util.concurrent.Callable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,16 +24,18 @@ import java.util.concurrent.Callable
|
|||||||
)
|
)
|
||||||
class RetryCommand(
|
class RetryCommand(
|
||||||
private val gitService: GitService,
|
private val gitService: GitService,
|
||||||
private val repository: BuildResultRepository,
|
|
||||||
private val consoleBuildRunner: ConsoleBuildRunner,
|
private val consoleBuildRunner: ConsoleBuildRunner,
|
||||||
|
/** The repository to retry in: the current working directory (a repo selector comes with the registry). */
|
||||||
|
var repo: RepoContext,
|
||||||
) : Callable<Int> {
|
) : Callable<Int> {
|
||||||
var workingDir: Path = Paths.get(".")
|
private val workingDir: Path
|
||||||
|
get() = repo.workingDir
|
||||||
|
|
||||||
override fun call(): Int {
|
override fun call(): Int {
|
||||||
val failed: List<BuildResult>
|
val failed: List<BuildResult>
|
||||||
try {
|
try {
|
||||||
fetchBestEffort()
|
fetchBestEffort()
|
||||||
failed = repository.latestPerName().filter { it.status == BuildStatus.FAILED }
|
failed = repo.results.latestPerName().filter { it.status == BuildStatus.FAILED }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
System.err.println("error: ${e.message}")
|
System.err.println("error: ${e.message}")
|
||||||
return ExitCode.USAGE
|
return ExitCode.USAGE
|
||||||
@@ -52,7 +53,7 @@ class RetryCommand(
|
|||||||
}
|
}
|
||||||
println("retrying build ${result.name} at commit ${commit.take(12)}")
|
println("retrying build ${result.name} at commit ${commit.take(12)}")
|
||||||
// a failed build retries its recorded build definition (settings from the current config)
|
// a failed build retries its recorded build definition (settings from the current config)
|
||||||
val status = consoleBuildRunner.buildAndStream(result.branch, commit, workingDir, result.build)
|
val status = consoleBuildRunner.buildAndStream(repo, result.branch, commit, result.build)
|
||||||
if (status != BuildStatus.SUCCESS) {
|
if (status != BuildStatus.SUCCESS) {
|
||||||
anyFailed = true
|
anyFailed = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ object SystemdServiceFiles {
|
|||||||
const val ENV_FILE_NAME = "werkator.env"
|
const val ENV_FILE_NAME = "werkator.env"
|
||||||
|
|
||||||
/** Host-global unit names of the nightly Docker cleanup — shared by all Werkator repositories on the host. */
|
/** Host-global unit names of the nightly Docker cleanup — shared by all Werkator repositories on the host. */
|
||||||
|
const val HTACCESS_NAME = "werkator.htaccess"
|
||||||
|
|
||||||
const val PRUNE_SERVICE_NAME = "werkator-docker-prune.service"
|
const val PRUNE_SERVICE_NAME = "werkator-docker-prune.service"
|
||||||
const val PRUNE_TIMER_NAME = "werkator-docker-prune.timer"
|
const val PRUNE_TIMER_NAME = "werkator-docker-prune.timer"
|
||||||
|
|
||||||
@@ -89,6 +91,20 @@ object SystemdServiceFiles {
|
|||||||
#JAVA_OPTS=-Xmx256m
|
#JAVA_OPTS=-Xmx256m
|
||||||
""".trimIndent() + "\n"
|
""".trimIndent() + "\n"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apache reverse proxy for a Hostsharing managed webspace: the platform's
|
||||||
|
* Apache terminates TLS for the domain and forwards everything to the
|
||||||
|
* localhost port of the "eigener Serverdienst". Generated host integration
|
||||||
|
* like the units — the wrapper copies it into the domain's docroot.
|
||||||
|
*/
|
||||||
|
fun htaccessContent(port: Int): String =
|
||||||
|
"""
|
||||||
|
DirectoryIndex disabled
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /
|
||||||
|
RewriteRule .* http://127.0.0.1:$port%{REQUEST_URI} [proxy]
|
||||||
|
""".trimIndent() + "\n"
|
||||||
|
|
||||||
private fun sanitize(name: String): String = name.replace(Regex("[^A-Za-z0-9_.-]"), "-")
|
private fun sanitize(name: String): String = name.replace(Regex("[^A-Za-z0-9_.-]"), "-")
|
||||||
|
|
||||||
/** Escape `%` specifiers in systemd unit values (legacy `systemd_path`). */
|
/** Escape `%` specifiers in systemd unit values (legacy `systemd_path`). */
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ data class BuildDefinition(
|
|||||||
branchConfig.bwrap.copy(
|
branchConfig.bwrap.copy(
|
||||||
enabled = bwrap?.enabled ?: branchConfig.bwrap.enabled,
|
enabled = bwrap?.enabled ?: branchConfig.bwrap.enabled,
|
||||||
rootfs = bwrap?.rootfs ?: branchConfig.bwrap.rootfs,
|
rootfs = bwrap?.rootfs ?: branchConfig.bwrap.rootfs,
|
||||||
|
werkdock = bwrap?.werkdock ?: branchConfig.bwrap.werkdock,
|
||||||
env = bwrap?.env ?: branchConfig.bwrap.env,
|
env = bwrap?.env ?: branchConfig.bwrap.env,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -174,5 +175,7 @@ data class BwrapOverrides(
|
|||||||
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. */
|
||||||
|
val werkdock: String? = null,
|
||||||
val env: Map<String, String>? = null,
|
val env: Map<String, String>? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ object ConfigFiles {
|
|||||||
/** The machine-specific configuration inside `.git`; secrets live here. */
|
/** The machine-specific configuration inside `.git`; secrets live here. */
|
||||||
const val REPO_INSTALL = ".git/werkator/$COMMITTED"
|
const val REPO_INSTALL = ".git/werkator/$COMMITTED"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The applied instance fragment (`init --apply`, step 23): a config-schema YAML
|
||||||
|
* fragment installed verbatim as its own layer — above the committed project
|
||||||
|
* config, below the hand-edited machine config. Kept separate so applying never
|
||||||
|
* rewrites the machine config (its comments and secrets stay untouched) and
|
||||||
|
* re-applying is a plain file replacement, never a merge that can duplicate.
|
||||||
|
*/
|
||||||
|
const val APPLIED = ".git/werkator/.werkator.applied.yml"
|
||||||
|
|
||||||
/** The name the committed configuration had before the rename. */
|
/** The name the committed configuration had before the rename. */
|
||||||
const val LEGACY_COMMITTED = ".gittally.yml"
|
const val LEGACY_COMMITTED = ".gittally.yml"
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import org.springframework.beans.factory.ObjectProvider
|
|||||||
import org.springframework.boot.info.BuildProperties
|
import org.springframework.boot.info.BuildProperties
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -28,6 +30,17 @@ class ConfigLoader(
|
|||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||||
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
|
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For validating instance fragments ([applyInstanceFragment]) only: unknown keys
|
||||||
|
* fail there instead of being ignored — the regular layers stay lenient so an old
|
||||||
|
* Werkator can read a newer file.
|
||||||
|
*/
|
||||||
|
private val strictYaml =
|
||||||
|
ObjectMapper(YAMLFactory().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER))
|
||||||
|
.registerKotlinModule()
|
||||||
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
|
||||||
|
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
|
||||||
|
|
||||||
/** Keys already reported by [dropNonDefinitionBuilds]; the config is loaded on every poll cycle. */
|
/** Keys already reported by [dropNonDefinitionBuilds]; the config is loaded on every poll cycle. */
|
||||||
private val warnedBuildKeys = ConcurrentHashMap.newKeySet<String>()
|
private val warnedBuildKeys = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
@@ -285,13 +298,50 @@ class ConfigLoader(
|
|||||||
val repoInstallName = ConfigFiles.firstExisting(workingDir, ConfigFiles.repoInstall)
|
val repoInstallName = ConfigFiles.firstExisting(workingDir, ConfigFiles.repoInstall)
|
||||||
val projectName = ConfigFiles.firstExisting(workingDir)
|
val projectName = ConfigFiles.firstExisting(workingDir)
|
||||||
val repoInstall = loadFile(workingDir.resolve(repoInstallName).toFile())
|
val repoInstall = loadFile(workingDir.resolve(repoInstallName).toFile())
|
||||||
|
val applied = loadFile(workingDir.resolve(ConfigFiles.APPLIED).toFile())
|
||||||
val project = loadFile(workingDir.resolve(projectName).toFile())
|
val project = loadFile(workingDir.resolve(projectName).toFile())
|
||||||
// per file, so the message names the file to fix — the merged map has no provenance
|
// per file, so the message names the file to fix — the merged map has no provenance
|
||||||
checkVersion(project, projectName, ROLLBACK_HINT)
|
checkVersion(project, projectName, ROLLBACK_HINT)
|
||||||
|
checkVersion(applied, ConfigFiles.APPLIED, ROLLBACK_HINT)
|
||||||
checkVersion(repoInstall, repoInstallName, ROLLBACK_HINT)
|
checkVersion(repoInstall, repoInstallName, ROLLBACK_HINT)
|
||||||
checkTriggerBlocks(project, projectName, ROLLBACK_HINT)
|
checkTriggerBlocks(project, projectName, ROLLBACK_HINT)
|
||||||
|
checkTriggerBlocks(applied, ConfigFiles.APPLIED, ROLLBACK_HINT)
|
||||||
checkTriggerBlocks(repoInstall, repoInstallName, ROLLBACK_HINT)
|
checkTriggerBlocks(repoInstall, repoInstallName, ROLLBACK_HINT)
|
||||||
return deepMerge(project, repoInstall)
|
// the applied instance fragment sits above the committed project config and
|
||||||
|
// below the hand-edited machine config, which always has the last word
|
||||||
|
return deepMerge(deepMerge(project, applied), repoInstall)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and installs an instance fragment (`init --apply`, step 23): the file
|
||||||
|
* must be non-empty, pass the version and trigger checks, and bind *strictly*
|
||||||
|
* against the schema — an unknown key is refused loudly, never ignored, because a
|
||||||
|
* typo in a fragment would otherwise install a value that silently does nothing.
|
||||||
|
* The fragment is then copied verbatim (comments included) to [ConfigFiles.APPLIED];
|
||||||
|
* re-applying replaces the file, so nothing can accumulate or duplicate.
|
||||||
|
*/
|
||||||
|
fun applyInstanceFragment(
|
||||||
|
workingDir: Path,
|
||||||
|
fragment: Path,
|
||||||
|
): Path {
|
||||||
|
val raw = loadFile(fragment.toFile())
|
||||||
|
require(raw.isNotEmpty()) { "instance fragment $fragment is missing, empty, or not a YAML mapping" }
|
||||||
|
checkVersion(raw, fragment.toString(), ROLLBACK_HINT)
|
||||||
|
checkTriggerBlocks(raw, fragment.toString(), ROLLBACK_HINT)
|
||||||
|
try {
|
||||||
|
strictYaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw)), WerkatorConfig::class.java)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"instance fragment $fragment does not match the configuration schema: ${e.message}",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val target = workingDir.resolve(ConfigFiles.APPLIED)
|
||||||
|
Files.createDirectories(target.parent)
|
||||||
|
val temp = Files.createTempFile(target.parent, ".werkator.applied", ".tmp")
|
||||||
|
Files.copy(fragment, temp, StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
|
||||||
|
return target
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -403,8 +453,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). */
|
/** `bwrap` keys a branch must never override: the sandbox policy (Step 17) and its executing binary. */
|
||||||
private val PINNED_BWRAP_KEYS = setOf("enabled", "rootfs")
|
private val PINNED_BWRAP_KEYS = setOf("enabled", "rootfs", "werkdock")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
|||||||
@@ -197,6 +197,11 @@ data class BwrapConfig(
|
|||||||
* Pinned — a branch must not substitute a foreign rootfs via its committed config.
|
* Pinned — a branch must not substitute a foreign rootfs via its committed config.
|
||||||
*/
|
*/
|
||||||
val rootfs: String = "",
|
val rootfs: String = "",
|
||||||
|
/**
|
||||||
|
* The werkdock CLI executing the sandbox (step 21 session C); empty or the default
|
||||||
|
* resolves via PATH. Pinned — a branch must not substitute the executing binary.
|
||||||
|
*/
|
||||||
|
val werkdock: String = "werkdock",
|
||||||
/** Additional environment variables set inside the sandbox. */
|
/** Additional environment variables set inside the sandbox. */
|
||||||
val env: Map<String, String> = emptyMap(),
|
val env: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.hoennig.werkator.repo
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
class RepoConfiguration {
|
||||||
|
/**
|
||||||
|
* The single-repository case: the current working directory, which is how every
|
||||||
|
* CLI command and the server resolve their files. Only paths are computed here, so
|
||||||
|
* the bean is safe outside a git repository.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
fun currentRepo(repoContexts: RepoContexts): RepoContext = repoContexts.open(Paths.get("."))
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package de.hoennig.werkator.repo
|
||||||
|
|
||||||
|
import de.hoennig.werkator.build.ArtifactStore
|
||||||
|
import de.hoennig.werkator.build.BuildResultRepository
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything Werkator needs to work on one repository (ADR 0009): its primary
|
||||||
|
* checkout, and the state that already lives inside or is keyed by it — build
|
||||||
|
* results in `.git/werkator/`, the artifact store keyed by the repository path.
|
||||||
|
* Git access and config loading stay path-based services and take [workingDir].
|
||||||
|
*
|
||||||
|
* One instance exists per registered repository, and the instance itself is the
|
||||||
|
* identity: the executor serializes builds per (context, branch), so two contexts
|
||||||
|
* for the same directory would build it concurrently. Today there is exactly one,
|
||||||
|
* the current working directory ([RepoConfiguration]); the registry of the next
|
||||||
|
* session creates one per entry.
|
||||||
|
*/
|
||||||
|
class RepoContext(
|
||||||
|
/** Short unique name for display and, once routes carry it, the route segment; defaults to the directory basename. */
|
||||||
|
val name: String,
|
||||||
|
/** The primary checkout; never built in, its `.git/werkator/` holds the repository's state. */
|
||||||
|
val workingDir: Path,
|
||||||
|
val results: BuildResultRepository,
|
||||||
|
val artifactStore: ArtifactStore,
|
||||||
|
) {
|
||||||
|
override fun toString(): String = "RepoContext($name at $workingDir)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package de.hoennig.werkator.repo
|
||||||
|
|
||||||
|
import de.hoennig.werkator.artifacts.FileArtifactStore
|
||||||
|
import de.hoennig.werkator.build.FileBuildResultRepository
|
||||||
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
/** Opens a [RepoContext] over a repository directory; nothing is touched until the first build. */
|
||||||
|
@Component
|
||||||
|
class RepoContexts(
|
||||||
|
private val configLoader: ConfigLoader,
|
||||||
|
) {
|
||||||
|
fun open(
|
||||||
|
workingDir: Path,
|
||||||
|
name: String = defaultName(workingDir),
|
||||||
|
): RepoContext =
|
||||||
|
RepoContext(
|
||||||
|
name = name,
|
||||||
|
workingDir = workingDir,
|
||||||
|
results = FileBuildResultRepository(workingDir.resolve(RESULTS_FILE)),
|
||||||
|
artifactStore = FileArtifactStore(configLoader, workingDir),
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Results file relative to the repository, next to the machine config in `.git/werkator/`. */
|
||||||
|
const val RESULTS_FILE = ".git/werkator/build-results.json"
|
||||||
|
|
||||||
|
/** The directory basename (ADR 0009); a filesystem root has none and falls back to a constant. */
|
||||||
|
fun defaultName(workingDir: Path): String =
|
||||||
|
workingDir
|
||||||
|
.toAbsolutePath()
|
||||||
|
.normalize()
|
||||||
|
.fileName
|
||||||
|
?.toString()
|
||||||
|
?: "repository"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
package de.hoennig.werkator.server
|
package de.hoennig.werkator.server
|
||||||
|
|
||||||
import de.hoennig.werkator.build.BuildResultRepository
|
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import java.nio.file.Path
|
|
||||||
import java.nio.file.Paths
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The branches-view data, shared by the JSON API and the server-rendered page:
|
* The branches-view data, shared by the JSON API and the server-rendered page:
|
||||||
@@ -18,10 +16,10 @@ import java.nio.file.Paths
|
|||||||
@Component
|
@Component
|
||||||
class BranchListing(
|
class BranchListing(
|
||||||
private val gitService: GitService,
|
private val gitService: GitService,
|
||||||
private val repository: BuildResultRepository,
|
|
||||||
) {
|
) {
|
||||||
fun branches(workingDir: Path = Paths.get(".")): List<BranchDto> {
|
fun branches(repo: RepoContext): List<BranchDto> {
|
||||||
val heads = gitService.originBranchHeads(workingDir)
|
val repository = repo.results
|
||||||
|
val heads = gitService.originBranchHeads(repo.workingDir)
|
||||||
val namedResults = repository.latestPerName().filter { it.name != it.branch && it.branch in heads }
|
val namedResults = repository.latestPerName().filter { it.name != it.branch && it.branch in heads }
|
||||||
val branchesWithNamedPool = namedResults.map { it.branch }.toSet()
|
val branchesWithNamedPool = namedResults.map { it.branch }.toSet()
|
||||||
val branchRows =
|
val branchRows =
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import de.hoennig.werkator.build.BuildResultRepository
|
|||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
import de.hoennig.werkator.config.BuildDefinition
|
import de.hoennig.werkator.config.BuildDefinition
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
@@ -20,7 +21,6 @@ import java.nio.ByteBuffer
|
|||||||
import java.nio.channels.FileChannel
|
import java.nio.channels.FileChannel
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
|
||||||
import java.nio.file.StandardOpenOption
|
import java.nio.file.StandardOpenOption
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,15 +38,17 @@ class BuildsApiController(
|
|||||||
private val controlTokens: ControlTokenService,
|
private val controlTokens: ControlTokenService,
|
||||||
private val gitService: GitService,
|
private val gitService: GitService,
|
||||||
private val branchListing: BranchListing,
|
private val branchListing: BranchListing,
|
||||||
|
private val repo: RepoContext,
|
||||||
) {
|
) {
|
||||||
var workingDir: Path = Paths.get(".")
|
private val workingDir: Path
|
||||||
|
get() = repo.workingDir
|
||||||
|
|
||||||
@GetMapping("/api/builds/latest")
|
@GetMapping("/api/builds/latest")
|
||||||
fun latest(): List<BuildResultDto> = repository.latestPerName().map { BuildResultDto.from(it, it.isLatestGreen()) }
|
fun latest(): List<BuildResultDto> = repository.latestPerName().map { BuildResultDto.from(it, it.isLatestGreen()) }
|
||||||
|
|
||||||
/** The legacy branches view: every origin branch with its latest build or `unknown`. */
|
/** The legacy branches view: every origin branch with its latest build or `unknown`. */
|
||||||
@GetMapping("/api/branches")
|
@GetMapping("/api/branches")
|
||||||
fun branches(): List<BranchDto> = branchListing.branches(workingDir)
|
fun branches(): List<BranchDto> = branchListing.branches(repo)
|
||||||
|
|
||||||
@GetMapping("/api/builds/history")
|
@GetMapping("/api/builds/history")
|
||||||
fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it, it.isLatestGreen()) }
|
fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it, it.isLatestGreen()) }
|
||||||
@@ -122,6 +124,7 @@ class BuildsApiController(
|
|||||||
// a restarted build re-runs its recorded build definition (settings from the current config)
|
// a restarted build re-runs its recorded build definition (settings from the current config)
|
||||||
val running =
|
val running =
|
||||||
buildExecutor.startBuild(
|
buildExecutor.startBuild(
|
||||||
|
repo = repo,
|
||||||
branch = branchName,
|
branch = branchName,
|
||||||
commit = commit,
|
commit = commit,
|
||||||
build = latest?.build ?: BuildDefinition.DEFAULT,
|
build = latest?.build ?: BuildDefinition.DEFAULT,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.hoennig.werkator.server
|
package de.hoennig.werkator.server
|
||||||
|
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import de.hoennig.werkator.watcher.Watcher
|
import de.hoennig.werkator.watcher.Watcher
|
||||||
import jakarta.annotation.PreDestroy
|
import jakarta.annotation.PreDestroy
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||||
@@ -8,18 +9,19 @@ import org.springframework.context.event.EventListener
|
|||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the watcher poll loop once the server context is ready and stops it on
|
* Starts the watcher poll loop over the served repository once the server context
|
||||||
* shutdown. Only in the `server` profile — CLI commands and tests never start
|
* is ready and stops it on shutdown. Only in the `server` profile — CLI commands
|
||||||
* the loop (see [Watcher]).
|
* and tests never start the loop (see [Watcher]).
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@Profile("server")
|
@Profile("server")
|
||||||
class ServerWatcherLifecycle(
|
class ServerWatcherLifecycle(
|
||||||
private val watcher: Watcher,
|
private val watcher: Watcher,
|
||||||
|
private val repo: RepoContext,
|
||||||
) {
|
) {
|
||||||
@EventListener(ApplicationReadyEvent::class)
|
@EventListener(ApplicationReadyEvent::class)
|
||||||
fun onApplicationReady() {
|
fun onApplicationReady() {
|
||||||
watcher.start()
|
watcher.start(repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import de.hoennig.werkator.config.ConfigFiles
|
|||||||
import de.hoennig.werkator.config.ConfigLoader
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import org.springframework.beans.factory.ObjectProvider
|
import org.springframework.beans.factory.ObjectProvider
|
||||||
import org.springframework.boot.info.BuildProperties
|
import org.springframework.boot.info.BuildProperties
|
||||||
@@ -22,7 +23,6 @@ import org.springframework.web.servlet.view.RedirectView
|
|||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
|
||||||
import kotlin.io.path.name
|
import kotlin.io.path.name
|
||||||
import kotlin.streams.asSequence
|
import kotlin.streams.asSequence
|
||||||
|
|
||||||
@@ -43,8 +43,10 @@ class UiController(
|
|||||||
private val branchListing: BranchListing,
|
private val branchListing: BranchListing,
|
||||||
private val branchPermalinks: BranchPermalinks,
|
private val branchPermalinks: BranchPermalinks,
|
||||||
private val buildProperties: ObjectProvider<BuildProperties>,
|
private val buildProperties: ObjectProvider<BuildProperties>,
|
||||||
|
private val repo: RepoContext,
|
||||||
) {
|
) {
|
||||||
var workingDir: Path = Paths.get(".")
|
private val workingDir: Path
|
||||||
|
get() = repo.workingDir
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permanent redirects for the legacy script's static page names, so bookmarks
|
* Permanent redirects for the legacy script's static page names, so bookmarks
|
||||||
@@ -71,7 +73,7 @@ class UiController(
|
|||||||
@GetMapping("/branches")
|
@GetMapping("/branches")
|
||||||
fun branches(model: Model): String {
|
fun branches(model: Model): String {
|
||||||
val links = baseModel(model, view = "branches", pageTitle = "Branches")
|
val links = baseModel(model, view = "branches", pageTitle = "Branches")
|
||||||
model.addAttribute("rows", branchListing.branches(workingDir).map { BuildRowView.from(it, links) })
|
model.addAttribute("rows", branchListing.branches(repo).map { BuildRowView.from(it, links) })
|
||||||
model.addAttribute("apiPath", "/api/branches")
|
model.addAttribute("apiPath", "/api/branches")
|
||||||
model.addAttribute("allowRestart", true)
|
model.addAttribute("allowRestart", true)
|
||||||
// a row here stands for a branch, not for a past run
|
// a row here stands for a branch, not for a past run
|
||||||
@@ -203,9 +205,27 @@ class UiController(
|
|||||||
?: emptyList<LogFileView>(),
|
?: emptyList<LogFileView>(),
|
||||||
)
|
)
|
||||||
model.addAttribute("reportIndexes", artifactDir?.let { reportIndexes(it) } ?: emptyList<String>())
|
model.addAttribute("reportIndexes", artifactDir?.let { reportIndexes(it) } ?: emptyList<String>())
|
||||||
|
model.addAttribute("fileArtifacts", artifactDir?.let { fileArtifacts(it) } ?: emptyList<String>())
|
||||||
return "artifact"
|
return "artifact"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain artifact files outside `reports/` — build outputs like binaries or
|
||||||
|
* jars, archived at their workspace-relative paths. The top-level log files
|
||||||
|
* have their own section. Capped so a huge output tree cannot flood the page.
|
||||||
|
*/
|
||||||
|
private fun fileArtifacts(artifactDir: Path): List<String> =
|
||||||
|
Files.walk(artifactDir).use { paths ->
|
||||||
|
paths
|
||||||
|
.asSequence()
|
||||||
|
.filter { Files.isRegularFile(it) }
|
||||||
|
.map { artifactDir.relativize(it).toString() }
|
||||||
|
.filterNot { it.startsWith("reports/") || (!it.contains('/') && it.endsWith(".log")) }
|
||||||
|
.sorted()
|
||||||
|
.take(MAX_FILE_ARTIFACTS)
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
/** Adds the attributes every page needs and returns the Gitea link helper for row building. */
|
/** Adds the attributes every page needs and returns the Gitea link helper for row building. */
|
||||||
private fun baseModel(
|
private fun baseModel(
|
||||||
model: Model,
|
model: Model,
|
||||||
@@ -366,6 +386,8 @@ class UiController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val MAX_FILE_ARTIFACTS = 200
|
||||||
|
|
||||||
private val FAILURES_COUNTER = Regex("""id="failures">\s*<div class="counter">(\d+)""")
|
private val FAILURES_COUNTER = Regex("""id="failures">\s*<div class="counter">(\d+)""")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package de.hoennig.werkator.watcher
|
package de.hoennig.werkator.watcher
|
||||||
|
|
||||||
import de.hoennig.werkator.build.ArtifactKeys
|
import de.hoennig.werkator.build.ArtifactKeys
|
||||||
import de.hoennig.werkator.build.ArtifactStore
|
|
||||||
import de.hoennig.werkator.build.BuildExecutor
|
import de.hoennig.werkator.build.BuildExecutor
|
||||||
import de.hoennig.werkator.build.BuildResultRepository
|
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
import de.hoennig.werkator.build.GitWorktreeWorkspaces
|
import de.hoennig.werkator.build.GitWorktreeWorkspaces
|
||||||
import de.hoennig.werkator.config.BuildDefinition
|
import de.hoennig.werkator.config.BuildDefinition
|
||||||
@@ -12,11 +10,11 @@ import de.hoennig.werkator.config.ConfigLoader
|
|||||||
import de.hoennig.werkator.config.DurationParser
|
import de.hoennig.werkator.config.DurationParser
|
||||||
import de.hoennig.werkator.config.WerkatorConfig
|
import de.hoennig.werkator.config.WerkatorConfig
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
@@ -39,8 +37,6 @@ import java.util.concurrent.TimeUnit
|
|||||||
class Watcher(
|
class Watcher(
|
||||||
private val gitService: GitService,
|
private val gitService: GitService,
|
||||||
private val buildExecutor: BuildExecutor,
|
private val buildExecutor: BuildExecutor,
|
||||||
private val repository: BuildResultRepository,
|
|
||||||
private val artifactStore: ArtifactStore,
|
|
||||||
private val configLoader: ConfigLoader,
|
private val configLoader: ConfigLoader,
|
||||||
private val clock: Clock,
|
private val clock: Clock,
|
||||||
) {
|
) {
|
||||||
@@ -51,39 +47,51 @@ class Watcher(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var state = WatcherState()
|
private var state = WatcherState()
|
||||||
|
|
||||||
/** The branches.*.autoBuild deprecation is logged once per watcher instance, not once per poll. */
|
/** What the watcher remembers about a repository between polls, keyed by the context (identity). */
|
||||||
@Volatile
|
private val watched = ConcurrentHashMap<RepoContext, RepoWatch>()
|
||||||
private var warnedDeprecatedAutoBuild = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The fetch failure last written to the log, so a lasting outage does not repeat the
|
|
||||||
* same warning on every poll — one wrong token produced 297 identical lines before
|
|
||||||
* this. Null while the last fetch succeeded, which is also what makes the recovery
|
|
||||||
* loggable.
|
|
||||||
*/
|
|
||||||
@Volatile
|
|
||||||
private var loggedFetchError: String? = null
|
|
||||||
|
|
||||||
/** Build definitions per branch, cached by the branch's head commit — see [definitionsFor]. */
|
|
||||||
private val branchDefinitions = ConcurrentHashMap<String, CachedDefinitions>()
|
|
||||||
|
|
||||||
fun state(): WatcherState = state
|
fun state(): WatcherState = state
|
||||||
|
|
||||||
|
private fun watchOf(repo: RepoContext): RepoWatch = watched.computeIfAbsent(repo) { RepoWatch() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-repository poll memory: what was logged already, and the cached branch
|
||||||
|
* definitions. Kept apart from the shared [WatcherState] so that the next session
|
||||||
|
* can iterate contexts without one repository's outage silencing another's.
|
||||||
|
*/
|
||||||
|
private class RepoWatch {
|
||||||
|
/** The branches.*.autoBuild deprecation is logged once per repository, not once per poll. */
|
||||||
|
@Volatile
|
||||||
|
var warnedDeprecatedAutoBuild = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fetch failure last written to the log, so a lasting outage does not repeat the
|
||||||
|
* same warning on every poll — one wrong token produced 297 identical lines before
|
||||||
|
* this. Null while the last fetch succeeded, which is also what makes the recovery
|
||||||
|
* loggable.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var loggedFetchError: String? = null
|
||||||
|
|
||||||
|
/** Build definitions per branch, cached by the branch's head commit — see [definitionsFor]. */
|
||||||
|
val branchDefinitions = ConcurrentHashMap<String, CachedDefinitions>()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the startup recovery and schedules the poll loop with the fixed delay
|
* Runs the startup recovery and schedules the poll loop with the fixed delay
|
||||||
* `watcher.pollInterval`; the first poll runs immediately.
|
* `watcher.pollInterval`; the first poll runs immediately.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun start(workingDir: Path = Paths.get(".")) {
|
fun start(repo: RepoContext) {
|
||||||
check(scheduler == null) { "watcher is already running" }
|
check(scheduler == null) { "watcher is already running" }
|
||||||
recoverOnStartup(workingDir)
|
recoverOnStartup(repo)
|
||||||
val interval = DurationParser.parse(configLoader.load(workingDir).watcher.pollInterval)
|
val interval = DurationParser.parse(configLoader.load(repo.workingDir).watcher.pollInterval)
|
||||||
scheduler =
|
scheduler =
|
||||||
Executors
|
Executors
|
||||||
.newSingleThreadScheduledExecutor { runnable ->
|
.newSingleThreadScheduledExecutor { runnable ->
|
||||||
Thread(runnable, "werkator-watcher").apply { isDaemon = true }
|
Thread(runnable, "werkator-watcher").apply { isDaemon = true }
|
||||||
}.also {
|
}.also {
|
||||||
it.scheduleWithFixedDelay({ pollSafely(workingDir) }, 0, interval.toMillis(), TimeUnit.MILLISECONDS)
|
it.scheduleWithFixedDelay({ pollSafely(repo) }, 0, interval.toMillis(), TimeUnit.MILLISECONDS)
|
||||||
}
|
}
|
||||||
state = state.copy(running = true)
|
state = state.copy(running = true)
|
||||||
}
|
}
|
||||||
@@ -100,7 +108,9 @@ class Watcher(
|
|||||||
* superseded PENDING builds as INTERRUPTED, then re-enqueue every branch whose
|
* superseded PENDING builds as INTERRUPTED, then re-enqueue every branch whose
|
||||||
* latest build never finished and which still exists on origin.
|
* latest build never finished and which still exists on origin.
|
||||||
*/
|
*/
|
||||||
fun recoverOnStartup(workingDir: Path = Paths.get(".")) {
|
fun recoverOnStartup(repo: RepoContext) {
|
||||||
|
val workingDir = repo.workingDir
|
||||||
|
val repository = repo.results
|
||||||
try {
|
try {
|
||||||
gitService.fetchOrigin(workingDir)
|
gitService.fetchOrigin(workingDir)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -130,7 +140,7 @@ class Watcher(
|
|||||||
}
|
}
|
||||||
log.info("restarting unfinished build {} of branch {}", result.build, result.branch)
|
log.info("restarting unfinished build {} of branch {}", result.build, result.branch)
|
||||||
// the re-run resolves its settings from the current config by the recorded build name
|
// the re-run resolves its settings from the current config by the recorded build name
|
||||||
buildExecutor.startBuild(result.branch, commit, workingDir, result.build)
|
buildExecutor.startBuild(repo, result.branch, commit, result.build)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,46 +151,48 @@ class Watcher(
|
|||||||
* fast-forward the local branch refs, and finally prune results, artifacts, and
|
* fast-forward the local branch refs, and finally prune results, artifacts, and
|
||||||
* worktrees of branches gone from origin.
|
* worktrees of branches gone from origin.
|
||||||
*/
|
*/
|
||||||
fun poll(workingDir: Path = Paths.get(".")) {
|
fun poll(repo: RepoContext) {
|
||||||
val startedAt = clock.instant()
|
val startedAt = clock.instant()
|
||||||
|
val workingDir = repo.workingDir
|
||||||
|
val watch = watchOf(repo)
|
||||||
try {
|
try {
|
||||||
gitService.fetchOrigin(workingDir)
|
gitService.fetchOrigin(workingDir)
|
||||||
if (loggedFetchError != null) {
|
if (watch.loggedFetchError != null) {
|
||||||
log.info("fetching origin succeeded again")
|
log.info("fetching origin succeeded again")
|
||||||
loggedFetchError = null
|
watch.loggedFetchError = null
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val failure = e.message ?: e.javaClass.simpleName
|
val failure = e.message ?: e.javaClass.simpleName
|
||||||
if (loggedFetchError != failure) {
|
if (watch.loggedFetchError != failure) {
|
||||||
log.warn("fetching origin failed; retrying every cycle until it succeeds: {}", failure)
|
log.warn("fetching origin failed; retrying every cycle until it succeeds: {}", failure)
|
||||||
loggedFetchError = failure
|
watch.loggedFetchError = failure
|
||||||
}
|
}
|
||||||
state = state.copy(lastPollAt = startedAt, lastFetchError = failure)
|
state = state.copy(lastPollAt = startedAt, lastFetchError = failure)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val config = configLoader.load(workingDir)
|
val config = configLoader.load(workingDir)
|
||||||
val originBranches = gitService.originBranches(workingDir)
|
val originBranches = gitService.originBranches(workingDir)
|
||||||
enqueueDueBranches(config, originBranches.toSet(), workingDir)
|
enqueueDueBranches(repo, config, originBranches.toSet())
|
||||||
if (config.watcher.fastForwardLocalRefs) {
|
if (config.watcher.fastForwardLocalRefs) {
|
||||||
fastForwardLocalRefs(workingDir)
|
fastForwardLocalRefs(workingDir)
|
||||||
}
|
}
|
||||||
prune(config, originBranches, workingDir)
|
prune(repo, config, originBranches)
|
||||||
state =
|
state =
|
||||||
state.copy(
|
state.copy(
|
||||||
lastPollAt = startedAt,
|
lastPollAt = startedAt,
|
||||||
lastFetchError = null,
|
lastFetchError = null,
|
||||||
lastPollError = null,
|
lastPollError = null,
|
||||||
queuedBranches =
|
queuedBranches =
|
||||||
repository
|
repo.results
|
||||||
.latestPerName()
|
.latestPerName()
|
||||||
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
||||||
.map { it.name },
|
.map { it.name },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pollSafely(workingDir: Path) {
|
private fun pollSafely(repo: RepoContext) {
|
||||||
try {
|
try {
|
||||||
poll(workingDir)
|
poll(repo)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.error("poll cycle failed", e)
|
log.error("poll cycle failed", e)
|
||||||
state = state.copy(lastPollAt = clock.instant(), lastPollError = e.message ?: e.javaClass.simpleName)
|
state = state.copy(lastPollAt = clock.instant(), lastPollError = e.message ?: e.javaClass.simpleName)
|
||||||
@@ -208,16 +220,17 @@ class Watcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun enqueueDueBranches(
|
private fun enqueueDueBranches(
|
||||||
|
repo: RepoContext,
|
||||||
config: WerkatorConfig,
|
config: WerkatorConfig,
|
||||||
originBranches: Set<String>,
|
originBranches: Set<String>,
|
||||||
workingDir: Path,
|
|
||||||
) {
|
) {
|
||||||
|
val workingDir = repo.workingDir
|
||||||
// one ls-remote per poll cycle at most, and only when a due branch requires a pull request
|
// one ls-remote per poll cycle at most, and only when a due branch requires a pull request
|
||||||
val pullRequestHeads = lazy { gitService.pullRequestHeads(workingDir) }
|
val pullRequestHeads = lazy { gitService.pullRequestHeads(workingDir) }
|
||||||
// one for-each-ref per cycle at most, and only when a definition filters by activeWithin
|
// one for-each-ref per cycle at most, and only when a definition filters by activeWithin
|
||||||
val headCommitTimes = lazy { gitService.originBranchCommitTimes(workingDir) }
|
val headCommitTimes = lazy { gitService.originBranchCommitTimes(workingDir) }
|
||||||
val heads = gitService.originBranchHeads(workingDir)
|
val heads = gitService.originBranchHeads(workingDir)
|
||||||
branchDefinitions.keys.retainAll(originBranches)
|
watchOf(repo).branchDefinitions.keys.retainAll(originBranches)
|
||||||
val changedLocal =
|
val changedLocal =
|
||||||
gitService
|
gitService
|
||||||
.localBranches(workingDir)
|
.localBranches(workingDir)
|
||||||
@@ -226,15 +239,15 @@ class Watcher(
|
|||||||
gitService.newOriginBranches(DurationParser.parse(config.watcher.newBranchMaxAge), workingDir)
|
gitService.newOriginBranches(DurationParser.parse(config.watcher.newBranchMaxAge), workingDir)
|
||||||
val changed = (changedLocal + newOrigin).distinct()
|
val changed = (changedLocal + newOrigin).distinct()
|
||||||
for (branch in changed) {
|
for (branch in changed) {
|
||||||
val onPush = definitionsFor(branch, heads[branch], workingDir, config).filterValues { it.trigger.onPush }
|
val onPush = definitionsFor(repo, branch, heads[branch], config).filterValues { it.trigger.onPush }
|
||||||
for ((buildName, definition) in onPush) {
|
for ((buildName, definition) in onPush) {
|
||||||
if (selects(definition, branch, headCommitTimes)) {
|
if (selects(definition, branch, headCommitTimes)) {
|
||||||
startBuildIfDue(branch, allowSameCommit = false, config, pullRequestHeads, workingDir, buildName)
|
startBuildIfDue(repo, branch, allowSameCommit = false, config, pullRequestHeads, buildName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
enqueueScheduledBuilds(config, originBranches, heads, pullRequestHeads, headCommitTimes, workingDir)
|
enqueueScheduledBuilds(repo, config, originBranches, heads, pullRequestHeads, headCommitTimes)
|
||||||
enqueueDeprecatedAutoBuilds(config, originBranches, pullRequestHeads, workingDir)
|
enqueueDeprecatedAutoBuilds(repo, config, originBranches, pullRequestHeads)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -252,11 +265,13 @@ class Watcher(
|
|||||||
* instead of failing the poll cycle.
|
* instead of failing the poll cycle.
|
||||||
*/
|
*/
|
||||||
private fun definitionsFor(
|
private fun definitionsFor(
|
||||||
|
repo: RepoContext,
|
||||||
branch: String,
|
branch: String,
|
||||||
headCommit: String?,
|
headCommit: String?,
|
||||||
workingDir: Path,
|
|
||||||
primary: WerkatorConfig,
|
primary: WerkatorConfig,
|
||||||
): Map<String, BuildDefinition> {
|
): Map<String, BuildDefinition> {
|
||||||
|
val workingDir = repo.workingDir
|
||||||
|
val branchDefinitions = watchOf(repo).branchDefinitions
|
||||||
val commit = headCommit ?: return primary.effectiveBuildDefinitions()
|
val commit = headCommit ?: return primary.effectiveBuildDefinitions()
|
||||||
branchDefinitions[branch]?.takeIf { it.commit == commit && it.primary == primary }?.let { return it.definitions }
|
branchDefinitions[branch]?.takeIf { it.commit == commit && it.primary == primary }?.let { return it.definitions }
|
||||||
val definitions =
|
val definitions =
|
||||||
@@ -305,14 +320,15 @@ class Watcher(
|
|||||||
* without pull-request refs.
|
* without pull-request refs.
|
||||||
*/
|
*/
|
||||||
private fun startBuildIfDue(
|
private fun startBuildIfDue(
|
||||||
|
repo: RepoContext,
|
||||||
branch: String,
|
branch: String,
|
||||||
allowSameCommit: Boolean,
|
allowSameCommit: Boolean,
|
||||||
config: WerkatorConfig,
|
config: WerkatorConfig,
|
||||||
pullRequestHeads: Lazy<Set<String>>,
|
pullRequestHeads: Lazy<Set<String>>,
|
||||||
workingDir: Path,
|
|
||||||
build: String = BuildDefinition.DEFAULT,
|
build: String = BuildDefinition.DEFAULT,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val latest = repository.latestFor(BuildDefinition.poolName(branch, build))
|
val workingDir = repo.workingDir
|
||||||
|
val latest = repo.results.latestFor(BuildDefinition.poolName(branch, build))
|
||||||
if (latest?.status == BuildStatus.PENDING || latest?.status == BuildStatus.RUNNING) {
|
if (latest?.status == BuildStatus.PENDING || latest?.status == BuildStatus.RUNNING) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -328,7 +344,7 @@ class Watcher(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
log.info("enqueueing build {} of branch {} at commit {}", build, branch, commit)
|
log.info("enqueueing build {} of branch {} at commit {}", build, branch, commit)
|
||||||
buildExecutor.startBuild(branch, commit, workingDir, build)
|
buildExecutor.startBuild(repo, branch, commit, build)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,20 +354,20 @@ class Watcher(
|
|||||||
* the point of a scheduled build.
|
* the point of a scheduled build.
|
||||||
*/
|
*/
|
||||||
private fun enqueueScheduledBuilds(
|
private fun enqueueScheduledBuilds(
|
||||||
|
repo: RepoContext,
|
||||||
config: WerkatorConfig,
|
config: WerkatorConfig,
|
||||||
originBranches: Set<String>,
|
originBranches: Set<String>,
|
||||||
heads: Map<String, String>,
|
heads: Map<String, String>,
|
||||||
pullRequestHeads: Lazy<Set<String>>,
|
pullRequestHeads: Lazy<Set<String>>,
|
||||||
headCommitTimes: Lazy<Map<String, Instant>>,
|
headCommitTimes: Lazy<Map<String, Instant>>,
|
||||||
workingDir: Path,
|
|
||||||
) {
|
) {
|
||||||
val autoBuildState = lazy { FileAutoBuildState(workingDir.resolve(AUTO_BUILDS_FILE)) }
|
val autoBuildState = lazy { FileAutoBuildState(repo.workingDir.resolve(AUTO_BUILDS_FILE)) }
|
||||||
val now = clock.instant()
|
val now = clock.instant()
|
||||||
val today = LocalDate.ofInstant(now, ZoneOffset.UTC)
|
val today = LocalDate.ofInstant(now, ZoneOffset.UTC)
|
||||||
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
|
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
|
||||||
for (branch in originBranches) {
|
for (branch in originBranches) {
|
||||||
val scheduled =
|
val scheduled =
|
||||||
definitionsFor(branch, heads[branch], workingDir, config).filterValues {
|
definitionsFor(repo, branch, heads[branch], config).filterValues {
|
||||||
it.trigger.atTimes.isNotEmpty()
|
it.trigger.atTimes.isNotEmpty()
|
||||||
}
|
}
|
||||||
for ((buildName, definition) in scheduled) {
|
for ((buildName, definition) in scheduled) {
|
||||||
@@ -363,7 +379,7 @@ class Watcher(
|
|||||||
if (autoBuildState.value.isTriggered(pool, today, slot)) {
|
if (autoBuildState.value.isTriggered(pool, today, slot)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (startBuildIfDue(branch, allowSameCommit = true, config, pullRequestHeads, workingDir, buildName)) {
|
if (startBuildIfDue(repo, branch, allowSameCommit = true, config, pullRequestHeads, buildName)) {
|
||||||
autoBuildState.value.markTriggered(pool, today, slot)
|
autoBuildState.value.markTriggered(pool, today, slot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,10 +392,10 @@ class Watcher(
|
|||||||
* `builds` entry with `atTimes` and a single-branch selector would do.
|
* `builds` entry with `atTimes` and a single-branch selector would do.
|
||||||
*/
|
*/
|
||||||
private fun enqueueDeprecatedAutoBuilds(
|
private fun enqueueDeprecatedAutoBuilds(
|
||||||
|
repo: RepoContext,
|
||||||
config: WerkatorConfig,
|
config: WerkatorConfig,
|
||||||
originBranches: Set<String>,
|
originBranches: Set<String>,
|
||||||
pullRequestHeads: Lazy<Set<String>>,
|
pullRequestHeads: Lazy<Set<String>>,
|
||||||
workingDir: Path,
|
|
||||||
) {
|
) {
|
||||||
val autoBuildBranches =
|
val autoBuildBranches =
|
||||||
config.branches.filter { (branch, branchConfig) ->
|
config.branches.filter { (branch, branchConfig) ->
|
||||||
@@ -388,14 +404,15 @@ class Watcher(
|
|||||||
if (autoBuildBranches.isEmpty()) {
|
if (autoBuildBranches.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!warnedDeprecatedAutoBuild) {
|
val watch = watchOf(repo)
|
||||||
warnedDeprecatedAutoBuild = true
|
if (!watch.warnedDeprecatedAutoBuild) {
|
||||||
|
watch.warnedDeprecatedAutoBuild = true
|
||||||
log.warn(
|
log.warn(
|
||||||
"branches.*.autoBuild is deprecated; define a build with atTimes in the builds section instead (branches: {})",
|
"branches.*.autoBuild is deprecated; define a build with atTimes in the builds section instead (branches: {})",
|
||||||
autoBuildBranches.keys.joinToString(", "),
|
autoBuildBranches.keys.joinToString(", "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val autoBuildState = FileAutoBuildState(workingDir.resolve(AUTO_BUILDS_FILE))
|
val autoBuildState = FileAutoBuildState(repo.workingDir.resolve(AUTO_BUILDS_FILE))
|
||||||
val now = clock.instant()
|
val now = clock.instant()
|
||||||
val today = LocalDate.ofInstant(now, ZoneOffset.UTC)
|
val today = LocalDate.ofInstant(now, ZoneOffset.UTC)
|
||||||
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
|
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
|
||||||
@@ -408,7 +425,7 @@ class Watcher(
|
|||||||
log.warn("skipping auto build of branch {}: branch is not on origin", branch)
|
log.warn("skipping auto build of branch {}: branch is not on origin", branch)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (startBuildIfDue(branch, allowSameCommit = true, config, pullRequestHeads, workingDir)) {
|
if (startBuildIfDue(repo, branch, allowSameCommit = true, config, pullRequestHeads)) {
|
||||||
autoBuildState.markTriggered(branch, today, slot)
|
autoBuildState.markTriggered(branch, today, slot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -416,28 +433,29 @@ class Watcher(
|
|||||||
|
|
||||||
/** Results first, then artifacts of dropped results, then worktrees of branches gone from origin. */
|
/** Results first, then artifacts of dropped results, then worktrees of branches gone from origin. */
|
||||||
private fun prune(
|
private fun prune(
|
||||||
|
repo: RepoContext,
|
||||||
config: WerkatorConfig,
|
config: WerkatorConfig,
|
||||||
originBranches: List<String>,
|
originBranches: List<String>,
|
||||||
workingDir: Path,
|
|
||||||
) {
|
) {
|
||||||
val retentionCutoff =
|
val retentionCutoff =
|
||||||
config.artifacts.retentionMaxAge
|
config.artifacts.retentionMaxAge
|
||||||
.takeIf { it.isNotBlank() }
|
.takeIf { it.isNotBlank() }
|
||||||
?.let { clock.instant().minus(DurationParser.parse(it)) }
|
?.let { clock.instant().minus(DurationParser.parse(it)) }
|
||||||
repository.prune(
|
repo.results.prune(
|
||||||
originBranches,
|
originBranches,
|
||||||
config.artifacts.retentionPerBranch,
|
config.artifacts.retentionPerBranch,
|
||||||
config.artifacts.keepLatestGreen,
|
config.artifacts.keepLatestGreen,
|
||||||
retentionCutoff,
|
retentionCutoff,
|
||||||
)
|
)
|
||||||
artifactStore.prune(repository.history())
|
repo.artifactStore.prune(repo.results.history())
|
||||||
pruneWorktrees(originBranches, workingDir)
|
pruneWorktrees(repo, originBranches)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pruneWorktrees(
|
private fun pruneWorktrees(
|
||||||
|
repo: RepoContext,
|
||||||
originBranches: List<String>,
|
originBranches: List<String>,
|
||||||
workingDir: Path,
|
|
||||||
) {
|
) {
|
||||||
|
val workingDir = repo.workingDir
|
||||||
val worktreesDir = workingDir.resolve(GitWorktreeWorkspaces.WORKTREES_DIR)
|
val worktreesDir = workingDir.resolve(GitWorktreeWorkspaces.WORKTREES_DIR)
|
||||||
if (!Files.isDirectory(worktreesDir)) {
|
if (!Files.isDirectory(worktreesDir)) {
|
||||||
return
|
return
|
||||||
@@ -445,7 +463,7 @@ class Watcher(
|
|||||||
val keep = originBranches.map { ArtifactKeys.branchKey(it) }.toMutableSet()
|
val keep = originBranches.map { ArtifactKeys.branchKey(it) }.toMutableSet()
|
||||||
// never delete under a build that is still queued or executing
|
// never delete under a build that is still queued or executing
|
||||||
buildExecutor.currentBuilds().forEach { keep += ArtifactKeys.branchKey(it.branch) }
|
buildExecutor.currentBuilds().forEach { keep += ArtifactKeys.branchKey(it.branch) }
|
||||||
repository
|
repo.results
|
||||||
.latestPerName()
|
.latestPerName()
|
||||||
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
||||||
.forEach { keep += ArtifactKeys.branchKey(it.branch) }
|
.forEach { keep += ArtifactKeys.branchKey(it.branch) }
|
||||||
|
|||||||
@@ -70,7 +70,13 @@
|
|||||||
th:text="${report.failures} + ' failed'">2 failed</span>
|
th:text="${report.failures} + ' failed'">2 failed</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p th:if="${#lists.isEmpty(reportIndexes)}" class="muted">
|
<ul th:if="${!#lists.isEmpty(fileArtifacts)}">
|
||||||
|
<li th:each="file : ${fileArtifacts}">
|
||||||
|
<a th:href="${filesBase} + '/' + ${file}" target="_blank"
|
||||||
|
rel="noopener noreferrer" th:text="${file}">werkdock/dist/werkdock</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p th:if="${#lists.isEmpty(reportIndexes) and #lists.isEmpty(fileArtifacts)}" class="muted">
|
||||||
No artifact directories were produced by this build.
|
No artifact directories were produced by this build.
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
+3
-3
@@ -6,6 +6,7 @@ import de.hoennig.werkator.build.FileBuildResultRepository
|
|||||||
import de.hoennig.werkator.build.ProcessBuildRunner
|
import de.hoennig.werkator.build.ProcessBuildRunner
|
||||||
import de.hoennig.werkator.config.ConfigLoader
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
import de.hoennig.werkator.gitea.GiteaClient
|
import de.hoennig.werkator.gitea.GiteaClient
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import io.kotest.assertions.nondeterministic.eventually
|
import io.kotest.assertions.nondeterministic.eventually
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
@@ -37,18 +38,17 @@ class BuildExecutorArtifactIntegrationTest : FunSpec() {
|
|||||||
)
|
)
|
||||||
val workspace = Files.createDirectories(workingDir.resolve("workspace"))
|
val workspace = Files.createDirectories(workingDir.resolve("workspace"))
|
||||||
val store = FileArtifactStore(ConfigLoader(), workingDir)
|
val store = FileArtifactStore(ConfigLoader(), workingDir)
|
||||||
|
val repo = RepoContext("test", workingDir, FileBuildResultRepository(workingDir.resolve("build-results.json")), store)
|
||||||
val executor =
|
val executor =
|
||||||
BuildExecutor(
|
BuildExecutor(
|
||||||
repository = FileBuildResultRepository(workingDir.resolve("build-results.json")),
|
|
||||||
configLoader = ConfigLoader(),
|
configLoader = ConfigLoader(),
|
||||||
giteaClient = mockk<GiteaClient>(relaxed = true),
|
giteaClient = mockk<GiteaClient>(relaxed = true),
|
||||||
buildRunner = ProcessBuildRunner(),
|
buildRunner = ProcessBuildRunner(),
|
||||||
workspaces = BranchWorkspaces { _, _, _ -> workspace },
|
workspaces = BranchWorkspaces { _, _, _ -> workspace },
|
||||||
artifactStore = store,
|
|
||||||
eventPublisher = ApplicationEventPublisher { },
|
eventPublisher = ApplicationEventPublisher { },
|
||||||
)
|
)
|
||||||
|
|
||||||
val build = executor.startBuild("main", "abc123", workingDir)
|
val build = executor.startBuild(repo, "main", "abc123")
|
||||||
|
|
||||||
lateinit var artifactDir: java.nio.file.Path
|
lateinit var artifactDir: java.nio.file.Path
|
||||||
eventually(30.seconds) {
|
eventually(30.seconds) {
|
||||||
|
|||||||
@@ -90,9 +90,11 @@ class FileArtifactStoreTest : FunSpec() {
|
|||||||
Files.readString(artifactDir.resolve("build.stdout.log")) shouldBe "out"
|
Files.readString(artifactDir.resolve("build.stdout.log")) shouldBe "out"
|
||||||
Files.readString(artifactDir.resolve("build.stderr.log")) shouldBe "err"
|
Files.readString(artifactDir.resolve("build.stderr.log")) shouldBe "err"
|
||||||
Files.readString(artifactDir.resolve("build.log")) shouldBe "live"
|
Files.readString(artifactDir.resolve("build.log")) shouldBe "live"
|
||||||
// legacy layout: build/reports archives as reports/, other dirs below reports/<dir>
|
// build/reports archives as reports/ (the artifact page's browsable
|
||||||
|
// anchor), every other dir at its own workspace-relative path
|
||||||
Files.exists(artifactDir.resolve("reports/tests/index.html")) shouldBe true
|
Files.exists(artifactDir.resolve("reports/tests/index.html")) shouldBe true
|
||||||
Files.exists(artifactDir.resolve("reports/build/doc/readme.txt")) shouldBe true
|
Files.exists(artifactDir.resolve("build/doc/readme.txt")) shouldBe true
|
||||||
|
Files.exists(artifactDir.resolve("reports/build")) shouldBe false
|
||||||
Files.exists(staging) shouldBe false
|
Files.exists(staging) shouldBe false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,8 +106,8 @@ class FileArtifactStoreTest : FunSpec() {
|
|||||||
h.store.persist(build, stagingDir(), workspace)
|
h.store.persist(build, stagingDir(), workspace)
|
||||||
|
|
||||||
val artifactDir = h.branchesDir().resolve(build.artifactKey)
|
val artifactDir = h.branchesDir().resolve(build.artifactKey)
|
||||||
Files.exists(artifactDir.resolve("reports/build/doc/readme.txt")) shouldBe true
|
Files.exists(artifactDir.resolve("build/doc/readme.txt")) shouldBe true
|
||||||
Files.exists(artifactDir.resolve("reports/tests")) shouldBe false
|
Files.exists(artifactDir.resolve("reports")) shouldBe false
|
||||||
}
|
}
|
||||||
|
|
||||||
test("persist without a workspace stores only the logs") {
|
test("persist without a workspace stores only the logs") {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.hoennig.werkator.build
|
|||||||
|
|
||||||
import de.hoennig.werkator.config.ConfigLoader
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
import de.hoennig.werkator.gitea.GiteaClient
|
import de.hoennig.werkator.gitea.GiteaClient
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import io.kotest.assertions.nondeterministic.eventually
|
import io.kotest.assertions.nondeterministic.eventually
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.booleans.shouldBeFalse
|
import io.kotest.matchers.booleans.shouldBeFalse
|
||||||
@@ -48,14 +49,13 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
Files.createDirectories(workingDir.resolve(workspaceSubdir))
|
Files.createDirectories(workingDir.resolve(workspaceSubdir))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val repo = RepoContext("test", workingDir, repository, artifactStore)
|
||||||
val executor =
|
val executor =
|
||||||
BuildExecutor(
|
BuildExecutor(
|
||||||
repository = repository,
|
|
||||||
configLoader = ConfigLoader(),
|
configLoader = ConfigLoader(),
|
||||||
giteaClient = giteaClient,
|
giteaClient = giteaClient,
|
||||||
buildRunner = buildRunner,
|
buildRunner = buildRunner,
|
||||||
workspaces = workspaces,
|
workspaces = workspaces,
|
||||||
artifactStore = artifactStore,
|
|
||||||
eventPublisher =
|
eventPublisher =
|
||||||
ApplicationEventPublisher { event ->
|
ApplicationEventPublisher { event ->
|
||||||
if (event is BuildStatusChangedEvent) {
|
if (event is BuildStatusChangedEvent) {
|
||||||
@@ -112,7 +112,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
cleanCommand = "echo clean-\$branch",
|
cleanCommand = "echo clean-\$branch",
|
||||||
)
|
)
|
||||||
|
|
||||||
val build = h.executor.startBuild("main", "abc123", h.workingDir)
|
val build = h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
|
|
||||||
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
||||||
awaitIdle(h)
|
awaitIdle(h)
|
||||||
@@ -141,7 +141,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("build commands run in the workspace prepared for the branch") {
|
test("build commands run in the workspace prepared for the branch") {
|
||||||
val h = harness(buildCommand = "pwd", workspaceSubdir = "branch-workspace")
|
val h = harness(buildCommand = "pwd", workspaceSubdir = "branch-workspace")
|
||||||
|
|
||||||
val build = h.executor.startBuild("main", "abc123", h.workingDir)
|
val build = h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
|
|
||||||
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
||||||
awaitIdle(h)
|
awaitIdle(h)
|
||||||
@@ -151,7 +151,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("the repository reports RUNNING while the build sleeps") {
|
test("the repository reports RUNNING while the build sleeps") {
|
||||||
val h = harness("sleep 10")
|
val h = harness("sleep 10")
|
||||||
|
|
||||||
val build = h.executor.startBuild("main", "abc123", h.workingDir)
|
val build = h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
|
|
||||||
eventually(10.seconds) {
|
eventually(10.seconds) {
|
||||||
h.repository.latestFor("main")?.status shouldBe BuildStatus.RUNNING
|
h.repository.latestFor("main")?.status shouldBe BuildStatus.RUNNING
|
||||||
@@ -166,8 +166,8 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
// the first build sleeps, the second (queued behind it) finishes instantly
|
// the first build sleeps, the second (queued behind it) finishes instantly
|
||||||
val h = harness("test -f slow-done || { touch slow-done; sleep 2; }")
|
val h = harness("test -f slow-done || { touch slow-done; sleep 2; }")
|
||||||
|
|
||||||
h.executor.startBuild("main", "abc123", h.workingDir)
|
h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
val second = h.executor.startBuild("main", "abc124", h.workingDir)
|
val second = h.executor.startBuild(h.repo, "main", "abc124")
|
||||||
|
|
||||||
eventually(30.seconds) {
|
eventually(30.seconds) {
|
||||||
h.repository
|
h.repository
|
||||||
@@ -207,7 +207,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
val h = harness("unused", buildRunner = auxRunner)
|
val h = harness("unused", buildRunner = auxRunner)
|
||||||
|
|
||||||
val build = h.executor.startBuild("main", "abc123", h.workingDir)
|
val build = h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
eventually(10.seconds) {
|
eventually(10.seconds) {
|
||||||
h.repository.latestFor("main")?.status shouldBe BuildStatus.RUNNING
|
h.repository.latestFor("main")?.status shouldBe BuildStatus.RUNNING
|
||||||
}
|
}
|
||||||
@@ -232,7 +232,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
|
|
||||||
val nightly = h.executor.startBuild("main", "sha-1", h.workingDir, "pitest")
|
val nightly = h.executor.startBuild(h.repo, "main", "sha-1", "pitest")
|
||||||
awaitStatus(h, "main@pitest", BuildStatus.SUCCESS)
|
awaitStatus(h, "main@pitest", BuildStatus.SUCCESS)
|
||||||
awaitIdle(h)
|
awaitIdle(h)
|
||||||
|
|
||||||
@@ -250,7 +250,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
h.repository.latestFor("main") shouldBe null
|
h.repository.latestFor("main") shouldBe null
|
||||||
|
|
||||||
// the same branch under the default build runs the regular command
|
// the same branch under the default build runs the regular command
|
||||||
val regular = h.executor.startBuild("main", "sha-2", h.workingDir)
|
val regular = h.executor.startBuild(h.repo, "main", "sha-2")
|
||||||
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
||||||
awaitIdle(h)
|
awaitIdle(h)
|
||||||
Files.readString(regular.stagingDir.resolve("build.stdout.log")) shouldContain "regular-main"
|
Files.readString(regular.stagingDir.resolve("build.stdout.log")) shouldContain "regular-main"
|
||||||
@@ -263,7 +263,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("a build whose definition was removed from the config falls back to the branch's settings") {
|
test("a build whose definition was removed from the config falls back to the branch's settings") {
|
||||||
val h = harness(buildCommand = "echo regular-\$branch")
|
val h = harness(buildCommand = "echo regular-\$branch")
|
||||||
|
|
||||||
val build = h.executor.startBuild("main", "sha-1", h.workingDir, "gone-build")
|
val build = h.executor.startBuild(h.repo, "main", "sha-1", "gone-build")
|
||||||
awaitStatus(h, "main@gone-build", BuildStatus.SUCCESS)
|
awaitStatus(h, "main@gone-build", BuildStatus.SUCCESS)
|
||||||
awaitIdle(h)
|
awaitIdle(h)
|
||||||
|
|
||||||
@@ -273,23 +273,23 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("startBuild returns the active build of the same branch and commit instead of stacking a duplicate") {
|
test("startBuild returns the active build of the same branch and commit instead of stacking a duplicate") {
|
||||||
val h = harness("sleep 30")
|
val h = harness("sleep 30")
|
||||||
|
|
||||||
val first = h.executor.startBuild("main", "abc123", h.workingDir)
|
val first = h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
// a double-triggered UI restart: same branch, same commit, while queued or running
|
// a double-triggered UI restart: same branch, same commit, while queued or running
|
||||||
val duplicate = h.executor.startBuild("main", "abc123", h.workingDir)
|
val duplicate = h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
duplicate.artifactKey shouldBe first.artifactKey
|
duplicate.artifactKey shouldBe first.artifactKey
|
||||||
h.repository.history().map { it.artifactKey } shouldContainExactly listOf(first.artifactKey)
|
h.repository.history().map { it.artifactKey } shouldContainExactly listOf(first.artifactKey)
|
||||||
|
|
||||||
// another build definition of the same commit is its own pool — not a duplicate
|
// another build definition of the same commit is its own pool — not a duplicate
|
||||||
val nightly = h.executor.startBuild("main", "abc123", h.workingDir, "pitest")
|
val nightly = h.executor.startBuild(h.repo, "main", "abc123", "pitest")
|
||||||
nightly.artifactKey shouldNotBe first.artifactKey
|
nightly.artifactKey shouldNotBe first.artifactKey
|
||||||
|
|
||||||
// another commit of the branch is a distinct build, queued behind the first
|
// another commit of the branch is a distinct build, queued behind the first
|
||||||
val newerCommit = h.executor.startBuild("main", "abc124", h.workingDir)
|
val newerCommit = h.executor.startBuild(h.repo, "main", "abc124")
|
||||||
newerCommit.artifactKey shouldNotBe first.artifactKey
|
newerCommit.artifactKey shouldNotBe first.artifactKey
|
||||||
|
|
||||||
// a cancel-requested build no longer blocks re-queueing its commit
|
// a cancel-requested build no longer blocks re-queueing its commit
|
||||||
h.executor.cancel(first.artifactKey).shouldBeTrue()
|
h.executor.cancel(first.artifactKey).shouldBeTrue()
|
||||||
val again = h.executor.startBuild("main", "abc123", h.workingDir)
|
val again = h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
again.artifactKey shouldNotBe first.artifactKey
|
again.artifactKey shouldNotBe first.artifactKey
|
||||||
|
|
||||||
h.executor.cancel(nightly.artifactKey).shouldBeTrue()
|
h.executor.cancel(nightly.artifactKey).shouldBeTrue()
|
||||||
@@ -303,8 +303,8 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("a build cancelled while still queued records neither runningSince nor a duration") {
|
test("a build cancelled while still queued records neither runningSince nor a duration") {
|
||||||
val h = harness("sleep 30")
|
val h = harness("sleep 30")
|
||||||
|
|
||||||
val first = h.executor.startBuild("main", "abc123", h.workingDir)
|
val first = h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
val second = h.executor.startBuild("main", "abc124", h.workingDir)
|
val second = h.executor.startBuild(h.repo, "main", "abc124")
|
||||||
eventually(30.seconds) {
|
eventually(30.seconds) {
|
||||||
h.executor.currentBuilds().map { it.artifactKey } shouldContain first.artifactKey
|
h.executor.currentBuilds().map { it.artifactKey } shouldContain first.artifactKey
|
||||||
}
|
}
|
||||||
@@ -325,7 +325,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("a failing build command records FAILED with a duration") {
|
test("a failing build command records FAILED with a duration") {
|
||||||
val h = harness("exit 3")
|
val h = harness("exit 3")
|
||||||
|
|
||||||
h.executor.startBuild("main", "abc123", h.workingDir)
|
h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
|
|
||||||
awaitStatus(h, "main", BuildStatus.FAILED)
|
awaitStatus(h, "main", BuildStatus.FAILED)
|
||||||
awaitIdle(h)
|
awaitIdle(h)
|
||||||
@@ -337,7 +337,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("a failing clean command fails the build without running the build command") {
|
test("a failing clean command fails the build without running the build command") {
|
||||||
val h = harness(buildCommand = "echo forbidden-\$branch", cleanCommand = "exit 1")
|
val h = harness(buildCommand = "echo forbidden-\$branch", cleanCommand = "exit 1")
|
||||||
|
|
||||||
val build = h.executor.startBuild("main", "abc123", h.workingDir)
|
val build = h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
|
|
||||||
awaitStatus(h, "main", BuildStatus.FAILED)
|
awaitStatus(h, "main", BuildStatus.FAILED)
|
||||||
awaitIdle(h)
|
awaitIdle(h)
|
||||||
@@ -348,7 +348,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("cancel kills a sleeping process tree and records CANCELLED") {
|
test("cancel kills a sleeping process tree and records CANCELLED") {
|
||||||
val h = harness("echo \$\$ > pid-file; sleep 30 & sleep 30 & wait")
|
val h = harness("echo \$\$ > pid-file; sleep 30 & sleep 30 & wait")
|
||||||
|
|
||||||
val build = h.executor.startBuild("main", "abc123", h.workingDir)
|
val build = h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
|
|
||||||
lateinit var root: ProcessHandle
|
lateinit var root: ProcessHandle
|
||||||
var children = emptyList<ProcessHandle>()
|
var children = emptyList<ProcessHandle>()
|
||||||
@@ -376,7 +376,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("shutdown kills an executing build and records INTERRUPTED, not FAILED") {
|
test("shutdown kills an executing build and records INTERRUPTED, not FAILED") {
|
||||||
val h = harness("echo \$\$ > pid-file; sleep 30")
|
val h = harness("echo \$\$ > pid-file; sleep 30")
|
||||||
|
|
||||||
val build = h.executor.startBuild("main", "abc123", h.workingDir)
|
val build = h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
eventually(10.seconds) {
|
eventually(10.seconds) {
|
||||||
Files.exists(h.workingDir.resolve("pid-file")).shouldBeTrue()
|
Files.exists(h.workingDir.resolve("pid-file")).shouldBeTrue()
|
||||||
}
|
}
|
||||||
@@ -402,8 +402,8 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("a build still queued at shutdown stays PENDING for the startup recovery") {
|
test("a build still queued at shutdown stays PENDING for the startup recovery") {
|
||||||
val h = harness("sleep 30")
|
val h = harness("sleep 30")
|
||||||
|
|
||||||
val first = h.executor.startBuild("main", "sha-1", h.workingDir)
|
val first = h.executor.startBuild(h.repo, "main", "sha-1")
|
||||||
val second = h.executor.startBuild("main", "sha-2", h.workingDir)
|
val second = h.executor.startBuild(h.repo, "main", "sha-2")
|
||||||
eventually(10.seconds) {
|
eventually(10.seconds) {
|
||||||
h.repository
|
h.repository
|
||||||
.history()
|
.history()
|
||||||
@@ -432,7 +432,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("shutdown without any build in flight is a no-op") {
|
test("shutdown without any build in flight is a no-op") {
|
||||||
val h = harness("echo ok")
|
val h = harness("echo ok")
|
||||||
|
|
||||||
h.executor.startBuild("main", "abc123", h.workingDir)
|
h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
||||||
awaitIdle(h)
|
awaitIdle(h)
|
||||||
|
|
||||||
@@ -450,7 +450,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("the live log grows while the build is still running") {
|
test("the live log grows while the build is still running") {
|
||||||
val h = harness("echo one-\$branch; sleep 3; echo two-\$branch")
|
val h = harness("echo one-\$branch; sleep 3; echo two-\$branch")
|
||||||
|
|
||||||
val build = h.executor.startBuild("main", "abc123", h.workingDir)
|
val build = h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
|
|
||||||
eventually(10.seconds) {
|
eventually(10.seconds) {
|
||||||
Files.readString(build.liveLogFile) shouldContain "one-main"
|
Files.readString(build.liveLogFile) shouldContain "one-main"
|
||||||
@@ -467,7 +467,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
h.giteaClient.publishStatus(any(), any(), any(), any(), any(), any())
|
h.giteaClient.publishStatus(any(), any(), any(), any(), any(), any())
|
||||||
} throws RuntimeException("gitea down")
|
} throws RuntimeException("gitea down")
|
||||||
|
|
||||||
h.executor.startBuild("main", "abc123", h.workingDir)
|
h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
|
|
||||||
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
||||||
}
|
}
|
||||||
@@ -488,8 +488,8 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
|
|
||||||
h.executor.startBuild("branch-a", "sha-a", h.workingDir)
|
h.executor.startBuild(h.repo, "branch-a", "sha-a")
|
||||||
h.executor.startBuild("branch-b", "sha-b", h.workingDir)
|
h.executor.startBuild(h.repo, "branch-b", "sha-b")
|
||||||
|
|
||||||
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.PENDING
|
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.PENDING
|
||||||
|
|
||||||
@@ -503,8 +503,8 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("with maxConcurrent 2 two branches build at the same time") {
|
test("with maxConcurrent 2 two branches build at the same time") {
|
||||||
val h = harness("sleep 10", maxConcurrent = 2)
|
val h = harness("sleep 10", maxConcurrent = 2)
|
||||||
|
|
||||||
val buildA = h.executor.startBuild("branch-a", "sha-a", h.workingDir)
|
val buildA = h.executor.startBuild(h.repo, "branch-a", "sha-a")
|
||||||
val buildB = h.executor.startBuild("branch-b", "sha-b", h.workingDir)
|
val buildB = h.executor.startBuild(h.repo, "branch-b", "sha-b")
|
||||||
|
|
||||||
eventually(10.seconds) {
|
eventually(10.seconds) {
|
||||||
h.repository.latestFor("branch-a")?.status shouldBe BuildStatus.RUNNING
|
h.repository.latestFor("branch-a")?.status shouldBe BuildStatus.RUNNING
|
||||||
@@ -522,8 +522,8 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("a second build of the same branch waits even when a slot is free") {
|
test("a second build of the same branch waits even when a slot is free") {
|
||||||
val h = harness("sleep 1", maxConcurrent = 2)
|
val h = harness("sleep 1", maxConcurrent = 2)
|
||||||
|
|
||||||
val first = h.executor.startBuild("main", "sha-1", h.workingDir)
|
val first = h.executor.startBuild(h.repo, "main", "sha-1")
|
||||||
val second = h.executor.startBuild("main", "sha-2", h.workingDir)
|
val second = h.executor.startBuild(h.repo, "main", "sha-2")
|
||||||
|
|
||||||
eventually(30.seconds) {
|
eventually(30.seconds) {
|
||||||
h.repository
|
h.repository
|
||||||
@@ -539,8 +539,8 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
test("cancel only affects the addressed build, other branches keep running") {
|
test("cancel only affects the addressed build, other branches keep running") {
|
||||||
val h = harness("sleep 10", maxConcurrent = 2)
|
val h = harness("sleep 10", maxConcurrent = 2)
|
||||||
|
|
||||||
val buildA = h.executor.startBuild("branch-a", "sha-a", h.workingDir)
|
val buildA = h.executor.startBuild(h.repo, "branch-a", "sha-a")
|
||||||
val buildB = h.executor.startBuild("branch-b", "sha-b", h.workingDir)
|
val buildB = h.executor.startBuild(h.repo, "branch-b", "sha-b")
|
||||||
eventually(10.seconds) {
|
eventually(10.seconds) {
|
||||||
h.repository.latestFor("branch-a")?.status shouldBe BuildStatus.RUNNING
|
h.repository.latestFor("branch-a")?.status shouldBe BuildStatus.RUNNING
|
||||||
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.RUNNING
|
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.RUNNING
|
||||||
|
|||||||
@@ -35,11 +35,18 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun rootfsUnpacked(rootfs: String = "/srv/buildenv.tar.zst"): Path =
|
private fun imageName(rootfs: String = "/srv/buildenv.tar.zst"): String = "werkator-buildenv-${rootfs.sha12()}"
|
||||||
repoDir
|
|
||||||
.resolve(BwrapBuildRunner.BUILDENV_DIR)
|
/** The image is already loaded: `werkdock images` lists it, so no load runs. */
|
||||||
.resolve(rootfs.sha12())
|
private fun givenImageLoaded(rootfs: String = "/srv/buildenv.tar.zst") {
|
||||||
.resolve(BwrapBuildRunner.ROOTFS_DIR)
|
every { commandRunner.runOrThrow(listOf("werkdock", "images"), repoDir, any(), any()) } returns
|
||||||
|
GitCommandResult(0, imageName(rootfs) + "\n", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun givenImageMissing() {
|
||||||
|
every { commandRunner.runOrThrow(listOf("werkdock", "images"), repoDir, any(), any()) } returns
|
||||||
|
GitCommandResult(0, "some-other-image\n", "")
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
beforeEach {
|
beforeEach {
|
||||||
@@ -54,104 +61,92 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("unpacks the rootfs on demand and assembles the exact bwrap command") {
|
test("assembles the exact werkdock run command for a loaded image") {
|
||||||
every {
|
givenImageLoaded()
|
||||||
commandRunner.runOrThrow(
|
|
||||||
listOf("tar", "--no-same-owner", "-xf", "/srv/buildenv.tar.zst", "-C", rootfsUnpacked().toString()),
|
|
||||||
repoDir,
|
|
||||||
any(),
|
|
||||||
any(),
|
|
||||||
)
|
|
||||||
} returns
|
|
||||||
GitCommandResult(0, "", "")
|
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
||||||
|
|
||||||
val args = captured.single()
|
captured.single() shouldBe
|
||||||
val rootfsDir = args[args.indexOf("--ro-bind") + 1]
|
|
||||||
args shouldBe
|
|
||||||
listOf(
|
listOf(
|
||||||
"bwrap",
|
"werkdock",
|
||||||
"--unshare-user",
|
"run",
|
||||||
"--unshare-pid",
|
"--rm",
|
||||||
"--die-with-parent",
|
"-v",
|
||||||
"--uid",
|
"$repoDir:$repoDir",
|
||||||
"0",
|
"-v",
|
||||||
"--gid",
|
"$workspace:$workspace",
|
||||||
"0",
|
"-v",
|
||||||
"--ro-bind",
|
"${repoDir.resolve(".git/werkator/buildenv/home")}:/root",
|
||||||
rootfsUnpacked().toString(),
|
"-e",
|
||||||
"/",
|
"branch=main",
|
||||||
"--bind",
|
"-w",
|
||||||
repoDir.toString(),
|
|
||||||
repoDir.toString(),
|
|
||||||
"--bind",
|
|
||||||
workspace.toString(),
|
|
||||||
workspace.toString(),
|
|
||||||
"--bind",
|
|
||||||
repoDir.resolve(".git/werkator/buildenv/home").toString(),
|
|
||||||
"/root",
|
|
||||||
"--ro-bind",
|
|
||||||
"/etc/resolv.conf",
|
|
||||||
"/etc/resolv.conf",
|
|
||||||
"--proc",
|
|
||||||
"/proc",
|
|
||||||
"--dev",
|
|
||||||
"/dev",
|
|
||||||
"--tmpfs",
|
|
||||||
"/tmp",
|
|
||||||
"--setenv",
|
|
||||||
"HOME",
|
|
||||||
"/root",
|
|
||||||
"--setenv",
|
|
||||||
"branch",
|
|
||||||
"main",
|
|
||||||
"--chdir",
|
|
||||||
workspace.toString(),
|
workspace.toString(),
|
||||||
|
imageName(),
|
||||||
"/bin/sh",
|
"/bin/sh",
|
||||||
"-c",
|
"-c",
|
||||||
"./gradlew test",
|
"./gradlew test",
|
||||||
)
|
)
|
||||||
Files.isDirectory(rootfsUnpacked()) shouldBe true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("binds a relative workspace path at its absolute location") {
|
test("loads the image once when werkdock does not know it yet") {
|
||||||
// bwrap creates mountpoints for bind destinations inside the sandbox;
|
givenImageMissing()
|
||||||
// a relative path would land in the read-only rootfs and fail with
|
|
||||||
// "Can't mkdir parents ...: Read-only file system" (seen on the webspace).
|
|
||||||
every {
|
every {
|
||||||
commandRunner.runOrThrow(
|
commandRunner.runOrThrow(
|
||||||
listOf("tar", "--no-same-owner", "-xf", "/srv/buildenv.tar.zst", "-C", rootfsUnpacked().toString()),
|
listOf("werkdock", "load", "-i", "/srv/buildenv.tar.zst", "--name", imageName()),
|
||||||
repoDir,
|
repoDir,
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
)
|
)
|
||||||
} returns
|
} returns GitCommandResult(0, "", "")
|
||||||
GitCommandResult(0, "", "")
|
|
||||||
|
|
||||||
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
||||||
|
|
||||||
|
verify {
|
||||||
|
commandRunner.runOrThrow(
|
||||||
|
listOf("werkdock", "load", "-i", "/srv/buildenv.tar.zst", "--name", imageName()),
|
||||||
|
repoDir,
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("does not load an image werkdock already has") {
|
||||||
|
givenImageLoaded()
|
||||||
|
|
||||||
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
||||||
|
|
||||||
|
verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("uses the configured werkdock binary path") {
|
||||||
|
every { commandRunner.runOrThrow(listOf("/opt/bin/werkdock", "images"), repoDir, any(), any()) } returns
|
||||||
|
GitCommandResult(0, imageName() + "\n", "")
|
||||||
|
val branchConfig =
|
||||||
|
BranchConfig(
|
||||||
|
bwrap = BwrapConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst", werkdock = "/opt/bin/werkdock"),
|
||||||
|
)
|
||||||
|
|
||||||
|
runner.start("./gradlew test", workspace, emptyMap(), repoDir, branchConfig)
|
||||||
|
|
||||||
|
captured.single().first() shouldBe "/opt/bin/werkdock"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("mounts a relative workspace path at its absolute location") {
|
||||||
|
givenImageLoaded()
|
||||||
val relativeWorkspace = repoDir.relativize(workspace)
|
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, bwrapBranchConfig())
|
||||||
|
|
||||||
val args = captured.single()
|
val args = captured.single()
|
||||||
val absolute = workspace.toAbsolutePath().normalize().toString()
|
val absolute = workspace.toAbsolutePath().normalize().toString()
|
||||||
val bindIdx = args.withIndex().filter { it.value == "--bind" }.map { it.index }
|
args shouldContainElement "-v"
|
||||||
// first bind is the repo dir (mountpoint base), second is the workspace
|
args[args.indexOf("-w") + 1] shouldBe absolute
|
||||||
args[bindIdx[1] + 1] shouldBe absolute
|
args.count { it == "$absolute:$absolute" } shouldBe 1
|
||||||
args[bindIdx[1] + 2] shouldBe absolute
|
|
||||||
args[args.indexOf("--chdir") + 1] shouldBe absolute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("does not re-unpack an already prepared rootfs") {
|
test("adds bwrap env after the branch environment") {
|
||||||
Files.createDirectories(rootfsUnpacked())
|
givenImageLoaded()
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
|
||||||
|
|
||||||
verify(exactly = 0) { commandRunner.runOrThrow(match { it.first() == "tar" }, any(), any(), any()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
test("adds bwrap env and passes the branch environment through") {
|
|
||||||
Files.createDirectories(rootfsUnpacked())
|
|
||||||
|
|
||||||
runner.start(
|
runner.start(
|
||||||
"./gradlew test",
|
"./gradlew test",
|
||||||
@@ -162,39 +157,43 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val args = captured.single()
|
val args = captured.single()
|
||||||
args[args.indexOf("branch") - 1] shouldBe "--setenv"
|
args[args.indexOf("branch=main") - 1] shouldBe "-e"
|
||||||
args[args.indexOf("branch") + 1] shouldBe "main"
|
args[args.indexOf("FOO=bar") - 1] shouldBe "-e"
|
||||||
args[args.indexOf("FOO") - 1] shouldBe "--setenv"
|
args.indexOf("branch=main") shouldBe args.indexOf("FOO=bar") - 2
|
||||||
args[args.indexOf("FOO") + 1] shouldBe "bar"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("exposes git metadata read-only with the werkator dir masked for a worktree workspace") {
|
test("exposes git metadata read-only with the werkator dir masked, in mount order") {
|
||||||
val gitDir = repoDir.resolve(".git")
|
val gitDir = repoDir.resolve(".git")
|
||||||
val adminDir = gitDir.resolve("worktrees/workspace")
|
val adminDir = gitDir.resolve("worktrees/workspace")
|
||||||
Files.createDirectories(adminDir)
|
Files.createDirectories(adminDir)
|
||||||
Files.createDirectories(gitDir.resolve("werkator"))
|
Files.createDirectories(gitDir.resolve("werkator"))
|
||||||
Files.createDirectories(workspace)
|
Files.createDirectories(workspace)
|
||||||
Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n")
|
Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n")
|
||||||
Files.createDirectories(rootfsUnpacked())
|
givenImageLoaded()
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
||||||
|
|
||||||
val args = captured.single()
|
val args = captured.single()
|
||||||
args[args.indexOf(gitDir.toString()) - 1] shouldBe "--ro-bind"
|
args[args.indexOf("$gitDir:$gitDir:ro") - 1] shouldBe "-v"
|
||||||
args[args.indexOf("$gitDir/werkator") - 1] shouldBe "--tmpfs"
|
args[args.indexOf("$gitDir/werkator") - 1] shouldBe "--tmpfs"
|
||||||
args[args.indexOf(adminDir.toString()) - 1] shouldBe "--bind"
|
args[args.indexOf("$adminDir:$adminDir") - 1] shouldBe "-v"
|
||||||
|
// order: ro .git, tmpfs mask, admin dir, then the workspace bind that
|
||||||
|
// shadows the mask at its own path
|
||||||
|
val roGit = args.indexOf("$gitDir:$gitDir:ro")
|
||||||
|
val mask = args.indexOf("$gitDir/werkator")
|
||||||
|
val admin = args.indexOf("$adminDir:$adminDir")
|
||||||
|
val workspaceBind = args.indexOf("$workspace:$workspace")
|
||||||
|
(roGit < mask && mask < admin && admin < workspaceBind) shouldBe true
|
||||||
}
|
}
|
||||||
|
|
||||||
test("mounts no git metadata when the workspace is not a worktree") {
|
test("mounts no git metadata when the workspace is not a worktree") {
|
||||||
Files.createDirectories(rootfsUnpacked())
|
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, bwrapBranchConfig())
|
||||||
|
|
||||||
val args = captured.single()
|
val args = captured.single()
|
||||||
val gitDir = repoDir.resolve(".git")
|
val gitDir = repoDir.resolve(".git")
|
||||||
// the sandbox's own /tmp tmpfs is always present; the point is that no tmpfs
|
|
||||||
// masks .git/werkator and no worktree admin dir is bound
|
|
||||||
args.none { it == "$gitDir/werkator" } shouldBe true
|
args.none { it == "$gitDir/werkator" } shouldBe true
|
||||||
args.none { it.contains("worktrees/") } shouldBe true
|
args.none { it.contains("worktrees/") } shouldBe true
|
||||||
}
|
}
|
||||||
@@ -210,16 +209,17 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
exception.message shouldContain "bwrap.rootfs"
|
exception.message shouldContain "bwrap.rootfs"
|
||||||
}
|
}
|
||||||
|
|
||||||
test("downloads a URL rootfs once before unpacking") {
|
test("downloads a URL rootfs once before loading it") {
|
||||||
val url = "https://example.test/buildenv.tar.zst"
|
val url = "https://example.test/buildenv.tar.zst"
|
||||||
val downloadTarget =
|
val downloadTarget =
|
||||||
repoDir
|
repoDir
|
||||||
.resolve(BwrapBuildRunner.BUILDENV_DIR)
|
.resolve(BwrapBuildRunner.BUILDENV_DIR)
|
||||||
.resolve(url.sha12())
|
.resolve(url.sha12())
|
||||||
.resolve("buildenv.tar.zst")
|
.resolve("buildenv.tar.zst")
|
||||||
|
givenImageMissing()
|
||||||
every { commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any()) } returns
|
every { commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any()) } returns
|
||||||
GitCommandResult(0, "", "")
|
GitCommandResult(0, "", "")
|
||||||
every { commandRunner.runOrThrow(match { it.first() == "tar" }, any(), any(), any()) } returns
|
every { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) } returns
|
||||||
GitCommandResult(0, "", "")
|
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, bwrapBranchConfig(rootfs = url))
|
||||||
@@ -227,10 +227,19 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
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())
|
||||||
}
|
}
|
||||||
verify { commandRunner.runOrThrow(match { it.first() == "tar" }, repoDir, any(), any()) }
|
verify {
|
||||||
|
commandRunner.runOrThrow(
|
||||||
|
listOf("werkdock", "load", "-i", downloadTarget.toString(), "--name", "werkator-buildenv-${url.sha12()}"),
|
||||||
|
repoDir,
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private infix fun List<String>.shouldContainElement(element: String) = (element in this) shouldBe true
|
||||||
|
|
||||||
private fun String.sha12(): String =
|
private fun String.sha12(): String =
|
||||||
java.security.MessageDigest
|
java.security.MessageDigest
|
||||||
.getInstance("SHA-256")
|
.getInstance("SHA-256")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.hoennig.werkator.commands
|
|||||||
|
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
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.kotest.matchers.string.shouldContain
|
import io.kotest.matchers.string.shouldContain
|
||||||
@@ -18,11 +19,11 @@ class BuildCommandTest : FunSpec() {
|
|||||||
private val gitService = mockk<GitService>()
|
private val gitService = mockk<GitService>()
|
||||||
private val consoleBuildRunner = mockk<ConsoleBuildRunner>()
|
private val consoleBuildRunner = mockk<ConsoleBuildRunner>()
|
||||||
private val dir: Path = Paths.get(".")
|
private val dir: Path = Paths.get(".")
|
||||||
|
private val repo = RepoContext("test", dir, mockk(), mockk())
|
||||||
|
|
||||||
private fun command(fragment: String? = null) =
|
private fun command(fragment: String? = null) =
|
||||||
BuildCommand(gitService, consoleBuildRunner).apply {
|
BuildCommand(gitService, consoleBuildRunner, repo).apply {
|
||||||
branchFragment = fragment
|
branchFragment = fragment
|
||||||
workingDir = dir
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -35,13 +36,13 @@ class BuildCommandTest : FunSpec() {
|
|||||||
every { gitService.currentBranch(dir) } returns "main"
|
every { gitService.currentBranch(dir) } returns "main"
|
||||||
every { gitService.localHeadCommit("main", dir) } returns "local-head"
|
every { gitService.localHeadCommit("main", dir) } returns "local-head"
|
||||||
every { gitService.hasNewCommits("main", dir) } returns false
|
every { gitService.hasNewCommits("main", dir) } returns false
|
||||||
every { consoleBuildRunner.buildAndStream("main", "local-head", dir) } returns BuildStatus.SUCCESS
|
every { consoleBuildRunner.buildAndStream(repo, "main", "local-head") } returns BuildStatus.SUCCESS
|
||||||
|
|
||||||
var exitCode = -1
|
var exitCode = -1
|
||||||
captureConsole { exitCode = command().call() }
|
captureConsole { exitCode = command().call() }
|
||||||
|
|
||||||
exitCode shouldBe 0
|
exitCode shouldBe 0
|
||||||
verify { consoleBuildRunner.buildAndStream("main", "local-head", dir) }
|
verify { consoleBuildRunner.buildAndStream(repo, "main", "local-head") }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("builds origin's head when the branch has new commits on origin") {
|
test("builds origin's head when the branch has new commits on origin") {
|
||||||
@@ -49,20 +50,20 @@ class BuildCommandTest : FunSpec() {
|
|||||||
every { gitService.localHeadCommit("main", dir) } returns "local-head"
|
every { gitService.localHeadCommit("main", dir) } returns "local-head"
|
||||||
every { gitService.hasNewCommits("main", dir) } returns true
|
every { gitService.hasNewCommits("main", dir) } returns true
|
||||||
every { gitService.originHeadCommit("main", dir) } returns "origin-head"
|
every { gitService.originHeadCommit("main", dir) } returns "origin-head"
|
||||||
every { consoleBuildRunner.buildAndStream("main", "origin-head", dir) } returns BuildStatus.SUCCESS
|
every { consoleBuildRunner.buildAndStream(repo, "main", "origin-head") } returns BuildStatus.SUCCESS
|
||||||
|
|
||||||
var exitCode = -1
|
var exitCode = -1
|
||||||
captureConsole { exitCode = command().call() }
|
captureConsole { exitCode = command().call() }
|
||||||
|
|
||||||
exitCode shouldBe 0
|
exitCode shouldBe 0
|
||||||
verify { consoleBuildRunner.buildAndStream("main", "origin-head", dir) }
|
verify { consoleBuildRunner.buildAndStream(repo, "main", "origin-head") }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("a failing build exits with code 1") {
|
test("a failing build exits with code 1") {
|
||||||
every { gitService.currentBranch(dir) } returns "main"
|
every { gitService.currentBranch(dir) } returns "main"
|
||||||
every { gitService.localHeadCommit("main", dir) } returns "local-head"
|
every { gitService.localHeadCommit("main", dir) } returns "local-head"
|
||||||
every { gitService.hasNewCommits("main", dir) } returns false
|
every { gitService.hasNewCommits("main", dir) } returns false
|
||||||
every { consoleBuildRunner.buildAndStream("main", "local-head", dir) } returns BuildStatus.FAILED
|
every { consoleBuildRunner.buildAndStream(repo, "main", "local-head") } returns BuildStatus.FAILED
|
||||||
|
|
||||||
var exitCode = -1
|
var exitCode = -1
|
||||||
captureConsole { exitCode = command().call() }
|
captureConsole { exitCode = command().call() }
|
||||||
@@ -75,13 +76,13 @@ class BuildCommandTest : FunSpec() {
|
|||||||
every { gitService.originBranches(dir) } returns listOf("main", "feature/x")
|
every { gitService.originBranches(dir) } returns listOf("main", "feature/x")
|
||||||
every { gitService.localHeadCommit("feature/x", dir) } returns null
|
every { gitService.localHeadCommit("feature/x", dir) } returns null
|
||||||
every { gitService.originHeadCommit("feature/x", dir) } returns "origin-head"
|
every { gitService.originHeadCommit("feature/x", dir) } returns "origin-head"
|
||||||
every { consoleBuildRunner.buildAndStream("feature/x", "origin-head", dir) } returns BuildStatus.SUCCESS
|
every { consoleBuildRunner.buildAndStream(repo, "feature/x", "origin-head") } returns BuildStatus.SUCCESS
|
||||||
|
|
||||||
var exitCode = -1
|
var exitCode = -1
|
||||||
captureConsole { exitCode = command(fragment = "x").call() }
|
captureConsole { exitCode = command(fragment = "x").call() }
|
||||||
|
|
||||||
exitCode shouldBe 0
|
exitCode shouldBe 0
|
||||||
verify { consoleBuildRunner.buildAndStream("feature/x", "origin-head", dir) }
|
verify { consoleBuildRunner.buildAndStream(repo, "feature/x", "origin-head") }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("an ambiguous fragment lists the candidates and exits with code 2") {
|
test("an ambiguous fragment lists the candidates and exits with code 2") {
|
||||||
@@ -126,7 +127,7 @@ class BuildCommandTest : FunSpec() {
|
|||||||
every { gitService.currentBranch(dir) } returns "main"
|
every { gitService.currentBranch(dir) } returns "main"
|
||||||
every { gitService.localHeadCommit("main", dir) } returns "local-head"
|
every { gitService.localHeadCommit("main", dir) } returns "local-head"
|
||||||
every { gitService.hasNewCommits("main", dir) } returns false
|
every { gitService.hasNewCommits("main", dir) } returns false
|
||||||
every { consoleBuildRunner.buildAndStream("main", "local-head", dir) } returns BuildStatus.SUCCESS
|
every { consoleBuildRunner.buildAndStream(repo, "main", "local-head") } returns BuildStatus.SUCCESS
|
||||||
|
|
||||||
var exitCode = -1
|
var exitCode = -1
|
||||||
val console = captureConsole { exitCode = command().call() }
|
val console = captureConsole { exitCode = command().call() }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import de.hoennig.werkator.build.BuildResult
|
|||||||
import de.hoennig.werkator.build.BuildResultRepository
|
import de.hoennig.werkator.build.BuildResultRepository
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
import de.hoennig.werkator.build.RunningBuild
|
import de.hoennig.werkator.build.RunningBuild
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
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.kotest.matchers.string.shouldContain
|
import io.kotest.matchers.string.shouldContain
|
||||||
@@ -23,9 +24,10 @@ class ConsoleBuildRunnerTest : FunSpec() {
|
|||||||
private val artifactStore = mockk<ArtifactStore>()
|
private val artifactStore = mockk<ArtifactStore>()
|
||||||
|
|
||||||
private lateinit var tempDir: Path
|
private lateinit var tempDir: Path
|
||||||
|
private lateinit var repo: RepoContext
|
||||||
|
|
||||||
private fun runner() =
|
private fun runner() =
|
||||||
ConsoleBuildRunner(buildExecutor, repository, artifactStore).apply {
|
ConsoleBuildRunner(buildExecutor).apply {
|
||||||
pollIntervalMillis = 1
|
pollIntervalMillis = 1
|
||||||
persistTimeoutMillis = 100
|
persistTimeoutMillis = 100
|
||||||
}
|
}
|
||||||
@@ -56,6 +58,7 @@ class ConsoleBuildRunnerTest : FunSpec() {
|
|||||||
beforeEach {
|
beforeEach {
|
||||||
clearMocks(buildExecutor, repository, artifactStore)
|
clearMocks(buildExecutor, repository, artifactStore)
|
||||||
tempDir = Files.createTempDirectory("werkator-console-build-test")
|
tempDir = Files.createTempDirectory("werkator-console-build-test")
|
||||||
|
repo = RepoContext("test", tempDir, repository, artifactStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach {
|
afterEach {
|
||||||
@@ -66,7 +69,7 @@ class ConsoleBuildRunnerTest : FunSpec() {
|
|||||||
val stagingDir = Files.createDirectory(tempDir.resolve("staging"))
|
val stagingDir = Files.createDirectory(tempDir.resolve("staging"))
|
||||||
val build = runningBuild(stagingDir)
|
val build = runningBuild(stagingDir)
|
||||||
Files.writeString(build.liveLogFile, "compiling ...\ntests green\n")
|
Files.writeString(build.liveLogFile, "compiling ...\ntests green\n")
|
||||||
every { buildExecutor.startBuild("main", "0123456789abcdef", tempDir) } returns build
|
every { buildExecutor.startBuild(repo, "main", "0123456789abcdef") } returns build
|
||||||
// the terminal status arrives together with the finished persist (staging gone)
|
// the terminal status arrives together with the finished persist (staging gone)
|
||||||
every { repository.history() } answers {
|
every { repository.history() } answers {
|
||||||
stagingDir.toFile().deleteRecursively()
|
stagingDir.toFile().deleteRecursively()
|
||||||
@@ -75,7 +78,7 @@ class ConsoleBuildRunnerTest : FunSpec() {
|
|||||||
every { artifactStore.artifactDir("main-key") } returns null
|
every { artifactStore.artifactDir("main-key") } returns null
|
||||||
|
|
||||||
var status: BuildStatus? = null
|
var status: BuildStatus? = null
|
||||||
val console = captureConsole { status = runner().buildAndStream("main", "0123456789abcdef", tempDir) }
|
val console = captureConsole { status = runner().buildAndStream(repo, "main", "0123456789abcdef") }
|
||||||
|
|
||||||
status shouldBe BuildStatus.SUCCESS
|
status shouldBe BuildStatus.SUCCESS
|
||||||
console.stdout shouldContain "compiling ...\ntests green\n"
|
console.stdout shouldContain "compiling ...\ntests green\n"
|
||||||
@@ -87,12 +90,12 @@ class ConsoleBuildRunnerTest : FunSpec() {
|
|||||||
val build = runningBuild(stagingDir)
|
val build = runningBuild(stagingDir)
|
||||||
val persistedDir = Files.createDirectory(tempDir.resolve("persisted"))
|
val persistedDir = Files.createDirectory(tempDir.resolve("persisted"))
|
||||||
Files.writeString(persistedDir.resolve(BuildExecutor.LIVE_LOG_FILE), "full build output\n")
|
Files.writeString(persistedDir.resolve(BuildExecutor.LIVE_LOG_FILE), "full build output\n")
|
||||||
every { buildExecutor.startBuild("main", "0123456789abcdef", tempDir) } returns build
|
every { buildExecutor.startBuild(repo, "main", "0123456789abcdef") } returns build
|
||||||
every { repository.history() } returns listOf(result(BuildStatus.FAILED))
|
every { repository.history() } returns listOf(result(BuildStatus.FAILED))
|
||||||
every { artifactStore.artifactDir("main-key") } returns persistedDir
|
every { artifactStore.artifactDir("main-key") } returns persistedDir
|
||||||
|
|
||||||
var status: BuildStatus? = null
|
var status: BuildStatus? = null
|
||||||
val console = captureConsole { status = runner().buildAndStream("main", "0123456789abcdef", tempDir) }
|
val console = captureConsole { status = runner().buildAndStream(repo, "main", "0123456789abcdef") }
|
||||||
|
|
||||||
status shouldBe BuildStatus.FAILED
|
status shouldBe BuildStatus.FAILED
|
||||||
console.stdout shouldContain "full build output"
|
console.stdout shouldContain "full build output"
|
||||||
@@ -103,11 +106,11 @@ class ConsoleBuildRunnerTest : FunSpec() {
|
|||||||
val stagingDir = Files.createDirectory(tempDir.resolve("staging"))
|
val stagingDir = Files.createDirectory(tempDir.resolve("staging"))
|
||||||
val build = runningBuild(stagingDir)
|
val build = runningBuild(stagingDir)
|
||||||
Files.writeString(build.liveLogFile, "some output\n")
|
Files.writeString(build.liveLogFile, "some output\n")
|
||||||
every { buildExecutor.startBuild("main", "0123456789abcdef", tempDir) } returns build
|
every { buildExecutor.startBuild(repo, "main", "0123456789abcdef") } returns build
|
||||||
every { repository.history() } returns listOf(result(BuildStatus.SUCCESS, duration = null))
|
every { repository.history() } returns listOf(result(BuildStatus.SUCCESS, duration = null))
|
||||||
|
|
||||||
var status: BuildStatus? = null
|
var status: BuildStatus? = null
|
||||||
val console = captureConsole { status = runner().buildAndStream("main", "0123456789abcdef", tempDir) }
|
val console = captureConsole { status = runner().buildAndStream(repo, "main", "0123456789abcdef") }
|
||||||
|
|
||||||
status shouldBe BuildStatus.SUCCESS
|
status shouldBe BuildStatus.SUCCESS
|
||||||
console.stdout shouldContain "some output"
|
console.stdout shouldContain "some output"
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.hoennig.werkator.commands
|
||||||
|
|
||||||
|
import de.hoennig.werkator.git.GitService
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.string.shouldMatch
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.nio.file.Files
|
||||||
|
|
||||||
|
class ControlTokenCommandTest : FunSpec() {
|
||||||
|
private val gitService = mockk<GitService>()
|
||||||
|
private val command = ControlTokenCommand(gitService)
|
||||||
|
|
||||||
|
init {
|
||||||
|
test("creates the token like the server would and prints the same one on a re-run") {
|
||||||
|
val tempDir = Files.createTempDirectory("werkator-token-test")
|
||||||
|
command.workingDir = tempDir
|
||||||
|
every { gitService.getTopLevel(any()) } returns tempDir
|
||||||
|
|
||||||
|
command.call() shouldBe 0
|
||||||
|
|
||||||
|
val tokenFile = tempDir.resolve(".git/werkator/control-token")
|
||||||
|
val token = tokenFile.toFile().readText().trim()
|
||||||
|
token shouldMatch Regex("[0-9a-f]{48}")
|
||||||
|
|
||||||
|
command.call() shouldBe 0
|
||||||
|
tokenFile.toFile().readText().trim() shouldBe token
|
||||||
|
}
|
||||||
|
|
||||||
|
test("fails with exit code 2 outside a repository") {
|
||||||
|
every { gitService.getTopLevel(any()) } throws IllegalStateException("not a git repository")
|
||||||
|
|
||||||
|
command.call() shouldBe 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,10 @@ class InitCommandTest : FunSpec() {
|
|||||||
private val initCommand =
|
private val initCommand =
|
||||||
InitCommand(
|
InitCommand(
|
||||||
gitService,
|
gitService,
|
||||||
|
// default (null) BuildProperties provider: a relaxed ObjectProvider mock
|
||||||
|
// returns a raw Object under type erasure and breaks the version check
|
||||||
de.hoennig.werkator.config
|
de.hoennig.werkator.config
|
||||||
.ConfigLoader(mockk(relaxed = true)),
|
.ConfigLoader(),
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -122,6 +124,47 @@ class InitCommandTest : FunSpec() {
|
|||||||
projectConfig.toFile().readText() shouldBe "existing: content"
|
projectConfig.toFile().readText() shouldBe "existing: content"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("--apply installs the fragment as the applied layer and the effective config sees it") {
|
||||||
|
val tempDir = Files.createTempDirectory("werkator-init-test")
|
||||||
|
initCommand.workingDir = tempDir
|
||||||
|
val fragment = tempDir.resolve("mih34.yml")
|
||||||
|
fragment.toFile().writeText("server:\n port: 18088\n")
|
||||||
|
initCommand.apply = fragment
|
||||||
|
|
||||||
|
every { gitService.getTopLevel(tempDir) } returns tempDir
|
||||||
|
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/my-org/my-repo.git"
|
||||||
|
|
||||||
|
initCommand.run()
|
||||||
|
|
||||||
|
tempDir
|
||||||
|
.resolve(de.hoennig.werkator.config.ConfigFiles.APPLIED)
|
||||||
|
.toFile()
|
||||||
|
.shouldExist()
|
||||||
|
de.hoennig.werkator.config
|
||||||
|
.ConfigLoader()
|
||||||
|
.load(tempDir)
|
||||||
|
.server.port shouldBe 18088
|
||||||
|
initCommand.apply = null
|
||||||
|
}
|
||||||
|
|
||||||
|
test("--apply with an invalid fragment installs nothing") {
|
||||||
|
val tempDir = Files.createTempDirectory("werkator-init-test")
|
||||||
|
initCommand.workingDir = tempDir
|
||||||
|
val fragment = tempDir.resolve("typo.yml")
|
||||||
|
fragment.toFile().writeText("server:\n prot: 18088\n")
|
||||||
|
initCommand.apply = fragment
|
||||||
|
|
||||||
|
every { gitService.getTopLevel(tempDir) } returns tempDir
|
||||||
|
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/my-org/my-repo.git"
|
||||||
|
|
||||||
|
initCommand.run()
|
||||||
|
|
||||||
|
Files
|
||||||
|
.exists(tempDir.resolve(de.hoennig.werkator.config.ConfigFiles.APPLIED))
|
||||||
|
.shouldBeFalse()
|
||||||
|
initCommand.apply = null
|
||||||
|
}
|
||||||
|
|
||||||
test("--systemd generates unit and environment file with install instructions") {
|
test("--systemd generates unit and environment file with install instructions") {
|
||||||
val tempDir = Files.createTempDirectory("werkator-init-test")
|
val tempDir = Files.createTempDirectory("werkator-init-test")
|
||||||
initCommand.workingDir = tempDir
|
initCommand.workingDir = tempDir
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import de.hoennig.werkator.build.BuildResult
|
|||||||
import de.hoennig.werkator.build.BuildResultRepository
|
import de.hoennig.werkator.build.BuildResultRepository
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
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.kotest.matchers.string.shouldContain
|
import io.kotest.matchers.string.shouldContain
|
||||||
@@ -22,8 +23,9 @@ class RetryCommandTest : FunSpec() {
|
|||||||
private val repository = mockk<BuildResultRepository>()
|
private val repository = mockk<BuildResultRepository>()
|
||||||
private val consoleBuildRunner = mockk<ConsoleBuildRunner>()
|
private val consoleBuildRunner = mockk<ConsoleBuildRunner>()
|
||||||
private val dir: Path = Paths.get(".")
|
private val dir: Path = Paths.get(".")
|
||||||
|
private val repo = RepoContext("test", dir, repository, mockk())
|
||||||
|
|
||||||
private fun command() = RetryCommand(gitService, repository, consoleBuildRunner).apply { workingDir = dir }
|
private fun command() = RetryCommand(gitService, consoleBuildRunner, repo)
|
||||||
|
|
||||||
private fun result(
|
private fun result(
|
||||||
branch: String,
|
branch: String,
|
||||||
@@ -52,21 +54,21 @@ class RetryCommandTest : FunSpec() {
|
|||||||
)
|
)
|
||||||
every { gitService.originHeadCommit("main", dir) } returns "head-main"
|
every { gitService.originHeadCommit("main", dir) } returns "head-main"
|
||||||
every { gitService.originHeadCommit("feature/y", dir) } returns "head-y"
|
every { gitService.originHeadCommit("feature/y", dir) } returns "head-y"
|
||||||
every { consoleBuildRunner.buildAndStream(any(), any(), dir, any()) } returns BuildStatus.SUCCESS
|
every { consoleBuildRunner.buildAndStream(repo, any(), any(), any()) } returns BuildStatus.SUCCESS
|
||||||
|
|
||||||
var exitCode = -1
|
var exitCode = -1
|
||||||
captureConsole { exitCode = command().call() }
|
captureConsole { exitCode = command().call() }
|
||||||
|
|
||||||
exitCode shouldBe 0
|
exitCode shouldBe 0
|
||||||
verify { consoleBuildRunner.buildAndStream("main", "head-main", dir, "default") }
|
verify { consoleBuildRunner.buildAndStream(repo, "main", "head-main", "default") }
|
||||||
verify { consoleBuildRunner.buildAndStream("feature/y", "head-y", dir, "default") }
|
verify { consoleBuildRunner.buildAndStream(repo, "feature/y", "head-y", "default") }
|
||||||
verify(exactly = 0) { consoleBuildRunner.buildAndStream("feature/ok", any(), dir, any()) }
|
verify(exactly = 0) { consoleBuildRunner.buildAndStream(repo, "feature/ok", any(), any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("exits with code 1 when a retried build fails again") {
|
test("exits with code 1 when a retried build fails again") {
|
||||||
every { repository.latestPerName() } returns listOf(result("main", BuildStatus.FAILED))
|
every { repository.latestPerName() } returns listOf(result("main", BuildStatus.FAILED))
|
||||||
every { gitService.originHeadCommit("main", dir) } returns "head-main"
|
every { gitService.originHeadCommit("main", dir) } returns "head-main"
|
||||||
every { consoleBuildRunner.buildAndStream("main", "head-main", dir) } returns BuildStatus.FAILED
|
every { consoleBuildRunner.buildAndStream(repo, "main", "head-main") } returns BuildStatus.FAILED
|
||||||
|
|
||||||
var exitCode = -1
|
var exitCode = -1
|
||||||
captureConsole { exitCode = command().call() }
|
captureConsole { exitCode = command().call() }
|
||||||
|
|||||||
@@ -91,5 +91,11 @@ class SystemdServiceFilesTest : FunSpec() {
|
|||||||
content shouldContain "#JAVA_OPTS="
|
content shouldContain "#JAVA_OPTS="
|
||||||
content shouldContain ".werkator.yml"
|
content shouldContain ".werkator.yml"
|
||||||
}
|
}
|
||||||
|
test("the htaccess proxies everything to the configured localhost port") {
|
||||||
|
val content = SystemdServiceFiles.htaccessContent(18088)
|
||||||
|
|
||||||
|
content shouldContain "DirectoryIndex disabled"
|
||||||
|
content shouldContain "RewriteRule .* http://127.0.0.1:18088%{REQUEST_URI} [proxy]"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,63 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
"./gradlew fromBranch"
|
"./gradlew fromBranch"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("the applied instance fragment layers above the project config and below the machine config") {
|
||||||
|
val dir = Files.createTempDirectory("werkator-test")
|
||||||
|
dir.resolve(".werkator.yml").toFile().writeText("server:\n port: 1000\n publicBaseUrl: \"https://project/\"\n")
|
||||||
|
Files.createDirectories(dir.resolve(".git/werkator"))
|
||||||
|
dir.resolve(ConfigFiles.APPLIED).toFile().writeText("server:\n port: 2000\n bindAddress: 0.0.0.0\n")
|
||||||
|
dir
|
||||||
|
.resolve(".git/werkator/.werkator.yml")
|
||||||
|
.toFile()
|
||||||
|
.writeText("server:\n port: 3000\n")
|
||||||
|
|
||||||
|
val server = loader.load(dir).server
|
||||||
|
|
||||||
|
// machine wins over applied wins over project; untouched keys fall through
|
||||||
|
server.port shouldBe 3000
|
||||||
|
server.bindAddress shouldBe "0.0.0.0"
|
||||||
|
server.publicBaseUrl shouldBe "https://project/"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("applyInstanceFragment installs a valid fragment verbatim, and re-applying replaces it") {
|
||||||
|
val dir = Files.createTempDirectory("werkator-test")
|
||||||
|
val fragment = dir.resolve("mih34.yml")
|
||||||
|
fragment.toFile().writeText("# instance mih34\nserver:\n port: 18088\n")
|
||||||
|
|
||||||
|
val target = loader.applyInstanceFragment(dir, fragment)
|
||||||
|
|
||||||
|
target shouldBe dir.resolve(ConfigFiles.APPLIED)
|
||||||
|
// verbatim copy: the comment survives
|
||||||
|
target.toFile().readText() shouldContain "# instance mih34"
|
||||||
|
loader.load(dir).server.port shouldBe 18088
|
||||||
|
|
||||||
|
fragment.toFile().writeText("server:\n port: 19099\n")
|
||||||
|
loader.applyInstanceFragment(dir, fragment)
|
||||||
|
loader.load(dir).server.port shouldBe 19099
|
||||||
|
}
|
||||||
|
|
||||||
|
test("applyInstanceFragment refuses an unknown key loudly instead of installing a silent no-op") {
|
||||||
|
val dir = Files.createTempDirectory("werkator-test")
|
||||||
|
val fragment = dir.resolve("typo.yml")
|
||||||
|
fragment.toFile().writeText("server:\n prot: 18088\n")
|
||||||
|
|
||||||
|
val exception =
|
||||||
|
shouldThrow<IllegalArgumentException> {
|
||||||
|
loader.applyInstanceFragment(dir, fragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
exception.message shouldContain "typo.yml"
|
||||||
|
Files.exists(dir.resolve(ConfigFiles.APPLIED)).shouldBeFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("applyInstanceFragment refuses a missing or empty fragment") {
|
||||||
|
val dir = Files.createTempDirectory("werkator-test")
|
||||||
|
|
||||||
|
shouldThrow<IllegalArgumentException> {
|
||||||
|
loader.applyInstanceFragment(dir, dir.resolve("absent.yml"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test("reads executor.maxConcurrent and defaults it to 1") {
|
test("reads executor.maxConcurrent and defaults it to 1") {
|
||||||
val dir = Files.createTempDirectory("werkator-test")
|
val dir = Files.createTempDirectory("werkator-test")
|
||||||
loader.load(dir).executor.maxConcurrent shouldBe 1
|
loader.load(dir).executor.maxConcurrent shouldBe 1
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package de.hoennig.werkator.repo
|
||||||
|
|
||||||
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
class RepoContextsTest : FunSpec() {
|
||||||
|
private val contexts = RepoContexts(ConfigLoader())
|
||||||
|
|
||||||
|
init {
|
||||||
|
test("a context is named after its directory and keeps its state inside the repository") {
|
||||||
|
val dir = Files.createTempDirectory("werkator-repo-context-test").resolve("werkbaum")
|
||||||
|
Files.createDirectories(dir)
|
||||||
|
|
||||||
|
val repo = contexts.open(dir)
|
||||||
|
|
||||||
|
repo.name shouldBe "werkbaum"
|
||||||
|
repo.workingDir shouldBe dir
|
||||||
|
repo.artifactStore
|
||||||
|
.rootDir()
|
||||||
|
.fileName
|
||||||
|
.toString() shouldBe
|
||||||
|
de.hoennig.werkator.build.ArtifactKeys
|
||||||
|
.repoKey(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the current directory resolves to the same name as its absolute path") {
|
||||||
|
contexts.open(Paths.get(".")).name shouldBe RepoContexts.defaultName(Paths.get(".").toAbsolutePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a filesystem root has no basename and gets the fallback name") {
|
||||||
|
RepoContexts.defaultName(Paths.get("/")) shouldBe "repository"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the name can be overridden per entry, as the registry will do") {
|
||||||
|
contexts.open(Paths.get("."), name = "custom").name shouldBe "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import de.hoennig.werkator.build.BuildResult
|
|||||||
import de.hoennig.werkator.build.BuildResultRepository
|
import de.hoennig.werkator.build.BuildResultRepository
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
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.every
|
import io.mockk.every
|
||||||
@@ -14,7 +15,15 @@ import java.time.Instant
|
|||||||
class BranchListingTest : FunSpec() {
|
class BranchListingTest : FunSpec() {
|
||||||
private val gitService = mockk<GitService>()
|
private val gitService = mockk<GitService>()
|
||||||
private val repository = mockk<BuildResultRepository>()
|
private val repository = mockk<BuildResultRepository>()
|
||||||
private val listing = BranchListing(gitService, repository)
|
private val repo =
|
||||||
|
RepoContext(
|
||||||
|
"test",
|
||||||
|
java.nio.file.Paths
|
||||||
|
.get("."),
|
||||||
|
repository,
|
||||||
|
mockk(),
|
||||||
|
)
|
||||||
|
private val listing = BranchListing(gitService)
|
||||||
|
|
||||||
private val mainResult =
|
private val mainResult =
|
||||||
BuildResult(
|
BuildResult(
|
||||||
@@ -38,7 +47,7 @@ class BranchListingTest : FunSpec() {
|
|||||||
every { repository.latestFor(any()) } returns null
|
every { repository.latestFor(any()) } returns null
|
||||||
every { repository.latestGreenFor(any()) } returns null
|
every { repository.latestGreenFor(any()) } returns null
|
||||||
|
|
||||||
val rows = listing.branches()
|
val rows = listing.branches(repo)
|
||||||
|
|
||||||
// no bare "master" row next to master@release — it would read as "never built"
|
// no bare "master" row next to master@release — it would read as "never built"
|
||||||
rows.map { it.name } shouldBe listOf("master@release", "idle")
|
rows.map { it.name } shouldBe listOf("master@release", "idle")
|
||||||
@@ -56,7 +65,7 @@ class BranchListingTest : FunSpec() {
|
|||||||
every { repository.latestFor(any()) } returns null
|
every { repository.latestFor(any()) } returns null
|
||||||
every { repository.latestGreenFor(any()) } returns null
|
every { repository.latestGreenFor(any()) } returns null
|
||||||
|
|
||||||
listing.branches().map { it.branch } shouldBe
|
listing.branches(repo).map { it.branch } shouldBe
|
||||||
listOf("main", "develop", "zz-flat", "aa/nested", "feature/x")
|
listOf("main", "develop", "zz-flat", "aa/nested", "feature/x")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +77,7 @@ class BranchListingTest : FunSpec() {
|
|||||||
every { repository.latestFor("feature/x") } returns null
|
every { repository.latestFor("feature/x") } returns null
|
||||||
every { repository.latestGreenFor("feature/x") } returns null
|
every { repository.latestGreenFor("feature/x") } returns null
|
||||||
|
|
||||||
val branches = listing.branches()
|
val branches = listing.branches(repo)
|
||||||
|
|
||||||
branches[0].branch shouldBe "main"
|
branches[0].branch shouldBe "main"
|
||||||
branches[0].status shouldBe "success"
|
branches[0].status shouldBe "success"
|
||||||
@@ -90,7 +99,7 @@ class BranchListingTest : FunSpec() {
|
|||||||
every { repository.latestGreenFor("feature/x") } returns
|
every { repository.latestGreenFor("feature/x") } returns
|
||||||
mainResult.copy(branch = "feature/x", name = "feature/x", artifactKey = "green-key")
|
mainResult.copy(branch = "feature/x", name = "feature/x", artifactKey = "green-key")
|
||||||
|
|
||||||
val branches = listing.branches()
|
val branches = listing.branches(repo)
|
||||||
|
|
||||||
branches[0].status shouldBe "failed"
|
branches[0].status shouldBe "failed"
|
||||||
branches[0].latestGreenUrl shouldBe null
|
branches[0].latestGreenUrl shouldBe null
|
||||||
@@ -107,7 +116,7 @@ class BranchListingTest : FunSpec() {
|
|||||||
every { repository.latestGreenFor("develop") } returns null
|
every { repository.latestGreenFor("develop") } returns null
|
||||||
every { repository.latestPerName() } returns listOf(mainResult, nightly)
|
every { repository.latestPerName() } returns listOf(mainResult, nightly)
|
||||||
|
|
||||||
val rows = listing.branches()
|
val rows = listing.branches(repo)
|
||||||
|
|
||||||
rows.map { it.name } shouldBe listOf("main", "main@nightly", "develop")
|
rows.map { it.name } shouldBe listOf("main", "main@nightly", "develop")
|
||||||
rows[1].branch shouldBe "main"
|
rows[1].branch shouldBe "main"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import de.hoennig.werkator.build.BuildResultRepository
|
|||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
import de.hoennig.werkator.build.RunningBuild
|
import de.hoennig.werkator.build.RunningBuild
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -50,6 +51,9 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var branchListing: BranchListing
|
lateinit var branchListing: BranchListing
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var repo: RepoContext
|
||||||
|
|
||||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||||
|
|
||||||
private val successResult =
|
private val successResult =
|
||||||
@@ -74,7 +78,8 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
beforeEach {
|
beforeEach {
|
||||||
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing)
|
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing, repo)
|
||||||
|
every { repo.workingDir } returns tempDir
|
||||||
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
|
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
|
||||||
every { repository.latestGreenFor(any()) } returns null
|
every { repository.latestGreenFor(any()) } returns null
|
||||||
}
|
}
|
||||||
@@ -152,7 +157,7 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
test("restart enqueues the branch's last recorded commit, also for branch names with slashes") {
|
test("restart enqueues the branch's last recorded commit, also for branch names with slashes") {
|
||||||
val liveLogFile = tempDir.resolve("restart.log")
|
val liveLogFile = tempDir.resolve("restart.log")
|
||||||
every { repository.latestFor("feature/topic") } returns successResult.copy(branch = "feature/topic", name = "feature/topic")
|
every { repository.latestFor("feature/topic") } returns successResult.copy(branch = "feature/topic", name = "feature/topic")
|
||||||
every { buildExecutor.startBuild("feature/topic", successResult.commit) } returns
|
every { buildExecutor.startBuild(repo, "feature/topic", successResult.commit) } returns
|
||||||
runningBuild(liveLogFile).copy(branch = "feature/topic", name = "feature/topic")
|
runningBuild(liveLogFile).copy(branch = "feature/topic", name = "feature/topic")
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
@@ -164,14 +169,14 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
.andExpect(jsonPath("$.status").value("pending"))
|
.andExpect(jsonPath("$.status").value("pending"))
|
||||||
.andExpect(jsonPath("$.artifactKey").value("main-abc123-running"))
|
.andExpect(jsonPath("$.artifactKey").value("main-abc123-running"))
|
||||||
|
|
||||||
verify { buildExecutor.startBuild("feature/topic", successResult.commit) }
|
verify { buildExecutor.startBuild(repo, "feature/topic", successResult.commit) }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("restart of a named build re-runs its build definition on its real branch") {
|
test("restart of a named build re-runs its build definition on its real branch") {
|
||||||
val liveLogFile = tempDir.resolve("named-restart.log")
|
val liveLogFile = tempDir.resolve("named-restart.log")
|
||||||
every { repository.latestFor("main@pitest") } returns
|
every { repository.latestFor("main@pitest") } returns
|
||||||
successResult.copy(build = "pitest", name = "main@pitest")
|
successResult.copy(build = "pitest", name = "main@pitest")
|
||||||
every { buildExecutor.startBuild("main", successResult.commit, build = "pitest") } returns
|
every { buildExecutor.startBuild(repo, "main", successResult.commit, build = "pitest") } returns
|
||||||
runningBuild(liveLogFile).copy(build = "pitest", name = "main@pitest")
|
runningBuild(liveLogFile).copy(build = "pitest", name = "main@pitest")
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
@@ -183,14 +188,14 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
.andExpect(jsonPath("$.name").value("main@pitest"))
|
.andExpect(jsonPath("$.name").value("main@pitest"))
|
||||||
|
|
||||||
// the re-run resolves its settings from the current config by the build name
|
// the re-run resolves its settings from the current config by the build name
|
||||||
verify { buildExecutor.startBuild("main", successResult.commit, build = "pitest") }
|
verify { buildExecutor.startBuild(repo, "main", successResult.commit, build = "pitest") }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("restart with atOriginHead builds the branch as it is now, not the recorded commit") {
|
test("restart with atOriginHead builds the branch as it is now, not the recorded commit") {
|
||||||
val liveLogFile = tempDir.resolve("head-restart.log")
|
val liveLogFile = tempDir.resolve("head-restart.log")
|
||||||
every { repository.latestFor("main") } returns successResult
|
every { repository.latestFor("main") } returns successResult
|
||||||
every { gitService.originHeadCommit("main", any()) } returns "newhead1"
|
every { gitService.originHeadCommit("main", any()) } returns "newhead1"
|
||||||
every { buildExecutor.startBuild("main", "newhead1") } returns runningBuild(liveLogFile)
|
every { buildExecutor.startBuild(repo, "main", "newhead1") } returns runningBuild(liveLogFile)
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
.perform(
|
.perform(
|
||||||
@@ -201,15 +206,15 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
).andExpect(status().isAccepted)
|
).andExpect(status().isAccepted)
|
||||||
|
|
||||||
// the recorded commit is deliberately not used: a branches row stands for a branch
|
// the recorded commit is deliberately not used: a branches row stands for a branch
|
||||||
verify { buildExecutor.startBuild("main", "newhead1") }
|
verify { buildExecutor.startBuild(repo, "main", "newhead1") }
|
||||||
verify(exactly = 0) { buildExecutor.startBuild("main", successResult.commit) }
|
verify(exactly = 0) { buildExecutor.startBuild(repo, "main", successResult.commit) }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("restart with atOriginHead keeps the recorded build definition and its real branch") {
|
test("restart with atOriginHead keeps the recorded build definition and its real branch") {
|
||||||
val liveLogFile = tempDir.resolve("head-named.log")
|
val liveLogFile = tempDir.resolve("head-named.log")
|
||||||
every { repository.latestFor("main@pitest") } returns successResult.copy(build = "pitest", name = "main@pitest")
|
every { repository.latestFor("main@pitest") } returns successResult.copy(build = "pitest", name = "main@pitest")
|
||||||
every { gitService.originHeadCommit("main", any()) } returns "newhead2"
|
every { gitService.originHeadCommit("main", any()) } returns "newhead2"
|
||||||
every { buildExecutor.startBuild("main", "newhead2", build = "pitest") } returns
|
every { buildExecutor.startBuild(repo, "main", "newhead2", build = "pitest") } returns
|
||||||
runningBuild(liveLogFile).copy(build = "pitest", name = "main@pitest")
|
runningBuild(liveLogFile).copy(build = "pitest", name = "main@pitest")
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
@@ -220,7 +225,7 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
.header(BuildsApiController.TOKEN_HEADER, "secret"),
|
.header(BuildsApiController.TOKEN_HEADER, "secret"),
|
||||||
).andExpect(status().isAccepted)
|
).andExpect(status().isAccepted)
|
||||||
|
|
||||||
verify { buildExecutor.startBuild("main", "newhead2", build = "pitest") }
|
verify { buildExecutor.startBuild(repo, "main", "newhead2", build = "pitest") }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("restart with atOriginHead of a branch gone from origin is refused by name") {
|
test("restart with atOriginHead of a branch gone from origin is refused by name") {
|
||||||
@@ -242,7 +247,7 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
val liveLogFile = tempDir.resolve("first-build.log")
|
val liveLogFile = tempDir.resolve("first-build.log")
|
||||||
every { repository.latestFor("fresh") } returns null
|
every { repository.latestFor("fresh") } returns null
|
||||||
every { gitService.originHeadCommit("fresh", any()) } returns successResult.commit
|
every { gitService.originHeadCommit("fresh", any()) } returns successResult.commit
|
||||||
every { buildExecutor.startBuild("fresh", successResult.commit) } returns
|
every { buildExecutor.startBuild(repo, "fresh", successResult.commit) } returns
|
||||||
runningBuild(liveLogFile).copy(branch = "fresh", name = "fresh")
|
runningBuild(liveLogFile).copy(branch = "fresh", name = "fresh")
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
@@ -250,7 +255,7 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
.andExpect(status().isAccepted)
|
.andExpect(status().isAccepted)
|
||||||
.andExpect(jsonPath("$.status").value("pending"))
|
.andExpect(jsonPath("$.status").value("pending"))
|
||||||
|
|
||||||
verify { buildExecutor.startBuild("fresh", successResult.commit) }
|
verify { buildExecutor.startBuild(repo, "fresh", successResult.commit) }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("restart of a branch without recorded builds and without origin counterpart answers 404") {
|
test("restart of a branch without recorded builds and without origin counterpart answers 404") {
|
||||||
@@ -290,7 +295,7 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
.header(BuildsApiController.TOKEN_HEADER, "wrong"),
|
.header(BuildsApiController.TOKEN_HEADER, "wrong"),
|
||||||
).andExpect(status().isForbidden)
|
).andExpect(status().isForbidden)
|
||||||
|
|
||||||
verify(exactly = 0) { buildExecutor.startBuild(any(), any(), any()) }
|
verify(exactly = 0) { buildExecutor.startBuild(any(), any(), any(), any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("cancel answers 202 for a cancellable build and 404 otherwise") {
|
test("cancel answers 202 for a cancellable build and 404 otherwise") {
|
||||||
@@ -317,7 +322,7 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
.perform(delete("/api/builds/some-key").param("token", "secret"))
|
.perform(delete("/api/builds/some-key").param("token", "secret"))
|
||||||
.andExpect(status().isForbidden)
|
.andExpect(status().isForbidden)
|
||||||
|
|
||||||
verify(exactly = 0) { buildExecutor.startBuild(any(), any(), any()) }
|
verify(exactly = 0) { buildExecutor.startBuild(any(), any(), any(), any()) }
|
||||||
verify(exactly = 0) { buildExecutor.cancel(any()) }
|
verify(exactly = 0) { buildExecutor.cancel(any()) }
|
||||||
verify(exactly = 0) { repository.delete(any()) }
|
verify(exactly = 0) { repository.delete(any()) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import de.hoennig.werkator.config.ConfigLoader
|
|||||||
import de.hoennig.werkator.config.WerkatorConfig
|
import de.hoennig.werkator.config.WerkatorConfig
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -22,6 +23,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
|||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@@ -65,6 +67,9 @@ class PermanentBranchRoutesTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var branchPermalinks: BranchPermalinks
|
lateinit var branchPermalinks: BranchPermalinks
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var repo: RepoContext
|
||||||
|
|
||||||
private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test")
|
private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test")
|
||||||
|
|
||||||
private val greenBuild =
|
private val greenBuild =
|
||||||
@@ -89,7 +94,9 @@ class PermanentBranchRoutesTest : FunSpec() {
|
|||||||
metricsCollector,
|
metricsCollector,
|
||||||
branchListing,
|
branchListing,
|
||||||
branchPermalinks,
|
branchPermalinks,
|
||||||
|
repo,
|
||||||
)
|
)
|
||||||
|
every { repo.workingDir } returns Paths.get(".")
|
||||||
every { configLoader.load(any()) } returns WerkatorConfig()
|
every { configLoader.load(any()) } returns WerkatorConfig()
|
||||||
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig()
|
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig()
|
||||||
every { gitService.showFileAtCommit(any(), any(), any()) } returns null
|
every { gitService.showFileAtCommit(any(), any(), any()) } returns null
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import de.hoennig.werkator.git.GitService
|
|||||||
import de.hoennig.werkator.metrics.MetricAggregate
|
import de.hoennig.werkator.metrics.MetricAggregate
|
||||||
import de.hoennig.werkator.metrics.SystemMetrics
|
import de.hoennig.werkator.metrics.SystemMetrics
|
||||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
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.kotest.matchers.string.shouldContain
|
import io.kotest.matchers.string.shouldContain
|
||||||
@@ -36,6 +37,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
|||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@@ -73,6 +75,9 @@ class UiControllerTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var branchPermalinks: BranchPermalinks
|
lateinit var branchPermalinks: BranchPermalinks
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var repo: RepoContext
|
||||||
|
|
||||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||||
|
|
||||||
private val emptySystemMetrics =
|
private val emptySystemMetrics =
|
||||||
@@ -113,7 +118,9 @@ class UiControllerTest : FunSpec() {
|
|||||||
metricsCollector,
|
metricsCollector,
|
||||||
branchListing,
|
branchListing,
|
||||||
branchPermalinks,
|
branchPermalinks,
|
||||||
|
repo,
|
||||||
)
|
)
|
||||||
|
every { repo.workingDir } returns Paths.get(".")
|
||||||
every { configLoader.load(any()) } returns
|
every { configLoader.load(any()) } returns
|
||||||
WerkatorConfig(
|
WerkatorConfig(
|
||||||
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
|
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
|
||||||
@@ -291,6 +298,30 @@ class UiControllerTest : FunSpec() {
|
|||||||
).andExpect(content().string(not(containsString("reports/tests/test/packages/index.html"))))
|
).andExpect(content().string(not(containsString("reports/tests/test/packages/index.html"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("artifact index lists plain files outside reports/ and keeps logs and report files out of that list") {
|
||||||
|
val artifactDir = Files.createDirectories(tempDir.resolve("files-view-key"))
|
||||||
|
Files.writeString(artifactDir.resolve("build.stdout.log"), "out")
|
||||||
|
Files.createDirectories(artifactDir.resolve("werkdock/dist"))
|
||||||
|
Files.writeString(artifactDir.resolve("werkdock/dist/werkdock"), "elf")
|
||||||
|
Files.createDirectories(artifactDir.resolve("reports/tests"))
|
||||||
|
Files.writeString(artifactDir.resolve("reports/tests/index.html"), "<html></html>")
|
||||||
|
every { repository.history() } returns listOf(successResult)
|
||||||
|
every { artifactStore.artifactDir("files-view-key") } returns artifactDir
|
||||||
|
|
||||||
|
val page =
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/builds/files-view-key"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(
|
||||||
|
content().string(containsString("""/artifacts/files-view-key/werkdock/dist/werkdock" target="_blank"""")),
|
||||||
|
).andReturn()
|
||||||
|
.response.contentAsString
|
||||||
|
// stored at its own path, not below reports/; the log stays in the
|
||||||
|
// logs section and is not repeated in the files list
|
||||||
|
page shouldNotContain "reports/werkdock"
|
||||||
|
(page.split("/artifacts/files-view-key/build.stdout.log").size - 1) shouldBe 1
|
||||||
|
}
|
||||||
|
|
||||||
test("the artifact page shows the command of the build's own definition, not the plain branch command") {
|
test("the artifact page shows the command of the build's own definition, not the plain branch command") {
|
||||||
val pitestResult =
|
val pitestResult =
|
||||||
successResult.copy(
|
successResult.copy(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import de.hoennig.werkator.config.TriggerConfig
|
|||||||
import de.hoennig.werkator.config.WatcherConfig
|
import de.hoennig.werkator.config.WatcherConfig
|
||||||
import de.hoennig.werkator.config.WerkatorConfig
|
import de.hoennig.werkator.config.WerkatorConfig
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import io.kotest.assertions.throwables.shouldThrow
|
import io.kotest.assertions.throwables.shouldThrow
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.booleans.shouldBeFalse
|
import io.kotest.matchers.booleans.shouldBeFalse
|
||||||
@@ -62,12 +63,11 @@ class WatcherTest : FunSpec() {
|
|||||||
val artifactStore = mockk<ArtifactStore>()
|
val artifactStore = mockk<ArtifactStore>()
|
||||||
val startedBuilds = CopyOnWriteArrayList<Pair<String, String>>()
|
val startedBuilds = CopyOnWriteArrayList<Pair<String, String>>()
|
||||||
val configLoader = mockk<ConfigLoader>()
|
val configLoader = mockk<ConfigLoader>()
|
||||||
|
val repo = RepoContext("test", workingDir, repository, artifactStore)
|
||||||
val watcher =
|
val watcher =
|
||||||
Watcher(
|
Watcher(
|
||||||
gitService = gitService,
|
gitService = gitService,
|
||||||
buildExecutor = buildExecutor,
|
buildExecutor = buildExecutor,
|
||||||
repository = repository,
|
|
||||||
artifactStore = artifactStore,
|
|
||||||
configLoader = configLoader,
|
configLoader = configLoader,
|
||||||
clock = Clock.fixed(noon, ZoneOffset.UTC),
|
clock = Clock.fixed(noon, ZoneOffset.UTC),
|
||||||
)
|
)
|
||||||
@@ -91,8 +91,8 @@ class WatcherTest : FunSpec() {
|
|||||||
every { gitService.fastForwardLocalBranches(any()) } returns emptyList()
|
every { gitService.fastForwardLocalBranches(any()) } returns emptyList()
|
||||||
every { buildExecutor.currentBuilds() } returns emptyList()
|
every { buildExecutor.currentBuilds() } returns emptyList()
|
||||||
every { buildExecutor.startBuild(any(), any(), any(), any()) } answers {
|
every { buildExecutor.startBuild(any(), any(), any(), any()) } answers {
|
||||||
val branch = firstArg<String>()
|
val branch = secondArg<String>()
|
||||||
val commit = secondArg<String>()
|
val commit = thirdArg<String>()
|
||||||
startedBuilds += branch to commit
|
startedBuilds += branch to commit
|
||||||
runningBuild(branch, commit)
|
runningBuild(branch, commit)
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ class WatcherTest : FunSpec() {
|
|||||||
val harness = Harness()
|
val harness = Harness()
|
||||||
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable")
|
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable")
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.watcher
|
harness.watcher
|
||||||
.state()
|
.state()
|
||||||
@@ -170,7 +170,7 @@ class WatcherTest : FunSpec() {
|
|||||||
verify(exactly = 0) { harness.artifactStore.prune(any()) }
|
verify(exactly = 0) { harness.artifactStore.prune(any()) }
|
||||||
|
|
||||||
every { harness.gitService.fetchOrigin(any()) } returns Unit
|
every { harness.gitService.fetchOrigin(any()) } returns Unit
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.watcher
|
harness.watcher
|
||||||
.state()
|
.state()
|
||||||
@@ -183,19 +183,19 @@ class WatcherTest : FunSpec() {
|
|||||||
val logged = captureWatcherLog()
|
val logged = captureWatcherLog()
|
||||||
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable")
|
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable")
|
||||||
|
|
||||||
repeat(5) { harness.watcher.poll(harness.workingDir) }
|
repeat(5) { harness.watcher.poll(harness.repo) }
|
||||||
|
|
||||||
// one wrong token used to write a warning every ten seconds, 297 of them in an hour
|
// one wrong token used to write a warning every ten seconds, 297 of them in an hour
|
||||||
logged().filter { it.contains("fetching origin failed") } shouldHaveSize 1
|
logged().filter { it.contains("fetching origin failed") } shouldHaveSize 1
|
||||||
|
|
||||||
every { harness.gitService.fetchOrigin(any()) } returns Unit
|
every { harness.gitService.fetchOrigin(any()) } returns Unit
|
||||||
repeat(3) { harness.watcher.poll(harness.workingDir) }
|
repeat(3) { harness.watcher.poll(harness.repo) }
|
||||||
|
|
||||||
logged().filter { it.contains("fetching origin succeeded again") } shouldHaveSize 1
|
logged().filter { it.contains("fetching origin succeeded again") } shouldHaveSize 1
|
||||||
|
|
||||||
// a different failure is a different message and is worth saying again
|
// a different failure is a different message and is worth saying again
|
||||||
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("host is down")
|
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("host is down")
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
logged().filter { it.contains("fetching origin failed") } shouldHaveSize 2
|
logged().filter { it.contains("fetching origin failed") } shouldHaveSize 2
|
||||||
}
|
}
|
||||||
@@ -209,7 +209,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||||
every { harness.gitService.originHeadCommit("feature/new", any()) } returns "commit-feature"
|
every { harness.gitService.originHeadCommit("feature/new", any()) } returns "commit-feature"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds shouldContainExactly
|
harness.startedBuilds shouldContainExactly
|
||||||
listOf("main" to "commit-main", "feature/new" to "commit-feature")
|
listOf("main" to "commit-main", "feature/new" to "commit-feature")
|
||||||
@@ -223,7 +223,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||||
every { harness.gitService.fastForwardLocalBranches(any()) } returns listOf("main")
|
every { harness.gitService.fastForwardLocalBranches(any()) } returns listOf("main")
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
// syncing the ref before the decision would hide the very commit being enqueued here
|
// syncing the ref before the decision would hide the very commit being enqueued here
|
||||||
harness.startedBuilds shouldContainExactly listOf("main" to "commit-main")
|
harness.startedBuilds shouldContainExactly listOf("main" to "commit-main")
|
||||||
@@ -238,7 +238,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||||
every { harness.gitService.fastForwardLocalBranches(any()) } throws RuntimeException("ref locked")
|
every { harness.gitService.fastForwardLocalBranches(any()) } throws RuntimeException("ref locked")
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.watcher
|
harness.watcher
|
||||||
.state()
|
.state()
|
||||||
@@ -251,7 +251,7 @@ class WatcherTest : FunSpec() {
|
|||||||
val harness = Harness(WerkatorConfig(watcher = WatcherConfig(fastForwardLocalRefs = false)))
|
val harness = Harness(WerkatorConfig(watcher = WatcherConfig(fastForwardLocalRefs = false)))
|
||||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
verify(exactly = 0) { harness.gitService.fastForwardLocalBranches(any()) }
|
verify(exactly = 0) { harness.gitService.fastForwardLocalBranches(any()) }
|
||||||
}
|
}
|
||||||
@@ -264,7 +264,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-new"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-new"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds.shouldBeEmpty()
|
harness.startedBuilds.shouldBeEmpty()
|
||||||
}
|
}
|
||||||
@@ -280,7 +280,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-2"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-2"
|
||||||
every { harness.gitService.originHeadCommit("feature/other", any()) } returns "commit-3"
|
every { harness.gitService.originHeadCommit("feature/other", any()) } returns "commit-3"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds shouldContainExactly listOf("feature/other" to "commit-3")
|
harness.startedBuilds shouldContainExactly listOf("feature/other" to "commit-3")
|
||||||
harness.watcher.state().queuedBranches shouldContainExactly listOf("main")
|
harness.watcher.state().queuedBranches shouldContainExactly listOf("main")
|
||||||
@@ -294,11 +294,11 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
harness.startedBuilds.shouldBeEmpty()
|
harness.startedBuilds.shouldBeEmpty()
|
||||||
|
|
||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-def"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-def"
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds shouldContainExactly listOf("main" to "commit-def")
|
harness.startedBuilds shouldContainExactly listOf("main" to "commit-def")
|
||||||
}
|
}
|
||||||
@@ -306,7 +306,7 @@ class WatcherTest : FunSpec() {
|
|||||||
test("poll filters new origin branches by the configured newBranchMaxAge") {
|
test("poll filters new origin branches by the configured newBranchMaxAge") {
|
||||||
val harness = Harness(WerkatorConfig(watcher = WatcherConfig(newBranchMaxAge = "12h")))
|
val harness = Harness(WerkatorConfig(watcher = WatcherConfig(newBranchMaxAge = "12h")))
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
verify { harness.gitService.newOriginBranches(Duration.ofHours(12), any()) }
|
verify { harness.gitService.newOriginBranches(Duration.ofHours(12), any()) }
|
||||||
}
|
}
|
||||||
@@ -319,7 +319,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.originHeadCommit("feature/no-pr", any()) } returns "commit-solo"
|
every { harness.gitService.originHeadCommit("feature/no-pr", any()) } returns "commit-solo"
|
||||||
every { harness.gitService.pullRequestHeads(any()) } returns setOf("commit-pr")
|
every { harness.gitService.pullRequestHeads(any()) } returns setOf("commit-pr")
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds shouldContainExactly listOf("feature/pr" to "commit-pr")
|
harness.startedBuilds shouldContainExactly listOf("feature/pr" to "commit-pr")
|
||||||
}
|
}
|
||||||
@@ -330,7 +330,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.newOriginBranches(any(), any()) } returns listOf("feature/x")
|
every { harness.gitService.newOriginBranches(any(), any()) } returns listOf("feature/x")
|
||||||
every { harness.gitService.originHeadCommit("feature/x", any()) } returns "commit-x"
|
every { harness.gitService.originHeadCommit("feature/x", any()) } returns "commit-x"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds shouldContainExactly listOf("feature/x" to "commit-x")
|
harness.startedBuilds shouldContainExactly listOf("feature/x" to "commit-x")
|
||||||
verify(exactly = 0) { harness.gitService.pullRequestHeads(any()) }
|
verify(exactly = 0) { harness.gitService.pullRequestHeads(any()) }
|
||||||
@@ -348,7 +348,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.newOriginBranches(any(), any()) } returns listOf("feature/no-pr")
|
every { harness.gitService.newOriginBranches(any(), any()) } returns listOf("feature/no-pr")
|
||||||
every { harness.gitService.originHeadCommit("feature/no-pr", any()) } returns "commit-solo"
|
every { harness.gitService.originHeadCommit("feature/no-pr", any()) } returns "commit-solo"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds shouldContainExactly listOf("feature/no-pr" to "commit-solo")
|
harness.startedBuilds shouldContainExactly listOf("feature/no-pr" to "commit-solo")
|
||||||
verify(exactly = 0) { harness.gitService.pullRequestHeads(any()) }
|
verify(exactly = 0) { harness.gitService.pullRequestHeads(any()) }
|
||||||
@@ -370,7 +370,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds shouldContainExactly listOf("main" to "commit-main")
|
harness.startedBuilds shouldContainExactly listOf("main" to "commit-main")
|
||||||
}
|
}
|
||||||
@@ -394,7 +394,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds.shouldBeEmpty()
|
harness.startedBuilds.shouldBeEmpty()
|
||||||
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeFalse()
|
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeFalse()
|
||||||
@@ -406,12 +406,12 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds shouldContainExactly listOf("main" to "commit-abc")
|
harness.startedBuilds shouldContainExactly listOf("main" to "commit-abc")
|
||||||
// the deprecated branch schedule rebuilds the branch's own pool with the default build
|
// the deprecated branch schedule rebuilds the branch's own pool with the default build
|
||||||
verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), BuildDefinition.DEFAULT) }
|
verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-abc", BuildDefinition.DEFAULT) }
|
||||||
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
|
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,13 +434,13 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
||||||
every { harness.gitService.originHeadCommit("release/1.x", any()) } returns "commit-rel"
|
every { harness.gitService.originHeadCommit("release/1.x", any()) } returns "commit-rel"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
// glob selector: main and release/1.x fire once, feature/x is not selected
|
// glob selector: main and release/1.x fire once, feature/x is not selected
|
||||||
harness.startedBuilds shouldContainExactlyInAnyOrder
|
harness.startedBuilds shouldContainExactlyInAnyOrder
|
||||||
listOf("main" to "commit-abc", "release/1.x" to "commit-rel")
|
listOf("main" to "commit-abc", "release/1.x" to "commit-rel")
|
||||||
verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), "pitest") }
|
verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-abc", "pitest") }
|
||||||
harness.autoBuildState().isTriggered("main@pitest", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
|
harness.autoBuildState().isTriggered("main@pitest", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,10 +460,10 @@ class WatcherTest : FunSpec() {
|
|||||||
"dormant" to noon.minus(Duration.ofDays(10)),
|
"dormant" to noon.minus(Duration.ofDays(10)),
|
||||||
)
|
)
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds shouldContainExactly listOf("active" to "commit-act")
|
harness.startedBuilds shouldContainExactly listOf("active" to "commit-act")
|
||||||
verify { harness.buildExecutor.startBuild("active", "commit-act", any(), "pitest") }
|
verify { harness.buildExecutor.startBuild(harness.repo, "active", "commit-act", "pitest") }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("an onPush build definition builds the changed branches it selects") {
|
test("an onPush build definition builds the changed branches it selects") {
|
||||||
@@ -480,13 +480,13 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||||
every { harness.gitService.originHeadCommit("feature/x", any()) } returns "commit-feat"
|
every { harness.gitService.originHeadCommit("feature/x", any()) } returns "commit-feat"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
// the implicit default build covers both branches; lint only selects main
|
// the implicit default build covers both branches; lint only selects main
|
||||||
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), BuildDefinition.DEFAULT) }
|
verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-main", BuildDefinition.DEFAULT) }
|
||||||
verify { harness.buildExecutor.startBuild("feature/x", "commit-feat", any(), BuildDefinition.DEFAULT) }
|
verify { harness.buildExecutor.startBuild(harness.repo, "feature/x", "commit-feat", BuildDefinition.DEFAULT) }
|
||||||
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), "lint") }
|
verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-main", "lint") }
|
||||||
verify(exactly = 0) { harness.buildExecutor.startBuild("feature/x", "commit-feat", any(), "lint") }
|
verify(exactly = 0) { harness.buildExecutor.startBuild(harness.repo, "feature/x", "commit-feat", "lint") }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("builds.default with onPush false disables the implicit on-push build") {
|
test("builds.default with onPush false disables the implicit on-push build") {
|
||||||
@@ -497,7 +497,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds.shouldBeEmpty()
|
harness.startedBuilds.shouldBeEmpty()
|
||||||
}
|
}
|
||||||
@@ -509,9 +509,9 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), BuildDefinition.DEFAULT) }
|
verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-main", BuildDefinition.DEFAULT) }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("a build definition committed on a branch fires for that branch, without any entry in the primary config") {
|
test("a build definition committed on a branch fires for that branch, without any entry in the primary config") {
|
||||||
@@ -528,10 +528,10 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp"
|
every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp"
|
||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds shouldContainExactly listOf("experiment" to "commit-exp")
|
harness.startedBuilds shouldContainExactly listOf("experiment" to "commit-exp")
|
||||||
verify { harness.buildExecutor.startBuild("experiment", "commit-exp", any(), "pitest") }
|
verify { harness.buildExecutor.startBuild(harness.repo, "experiment", "commit-exp", "pitest") }
|
||||||
harness
|
harness
|
||||||
.autoBuildState()
|
.autoBuildState()
|
||||||
.isTriggered("experiment@pitest", LocalDate.parse("2026-07-07"), "11:00")
|
.isTriggered("experiment@pitest", LocalDate.parse("2026-07-07"), "11:00")
|
||||||
@@ -550,7 +550,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer
|
every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer
|
||||||
every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp"
|
every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds shouldContainExactly listOf("experiment" to "commit-exp")
|
harness.startedBuilds shouldContainExactly listOf("experiment" to "commit-exp")
|
||||||
}
|
}
|
||||||
@@ -569,7 +569,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer
|
every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer
|
||||||
every { harness.gitService.originHeadCommit(any(), any()) } returns "commit-any"
|
every { harness.gitService.originHeadCommit(any(), any()) } returns "commit-any"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
// the definition selects main, but it is only known on experiment — so nothing is built
|
// the definition selects main, but it is only known on experiment — so nothing is built
|
||||||
harness.startedBuilds.shouldBeEmpty()
|
harness.startedBuilds.shouldBeEmpty()
|
||||||
@@ -580,12 +580,12 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||||
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-1")
|
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-1")
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
verify(exactly = 1) { harness.gitService.showFileAtCommit("commit-1", Watcher.CONFIG_FILE, any()) }
|
verify(exactly = 1) { harness.gitService.showFileAtCommit("commit-1", Watcher.CONFIG_FILE, any()) }
|
||||||
|
|
||||||
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-2")
|
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-2")
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
verify(exactly = 1) { harness.gitService.showFileAtCommit("commit-2", Watcher.CONFIG_FILE, any()) }
|
verify(exactly = 1) { harness.gitService.showFileAtCommit("commit-2", Watcher.CONFIG_FILE, any()) }
|
||||||
}
|
}
|
||||||
@@ -596,7 +596,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-1")
|
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-1")
|
||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-1"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-1"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
harness.startedBuilds.shouldBeEmpty()
|
harness.startedBuilds.shouldBeEmpty()
|
||||||
|
|
||||||
// the machine config gains a scheduled build while the branch stays where it is:
|
// the machine config gains a scheduled build while the branch stays where it is:
|
||||||
@@ -608,9 +608,9 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.configLoader.load(any()) } returns edited
|
every { harness.configLoader.load(any()) } returns edited
|
||||||
every { harness.configLoader.loadWithBranchLayer(any(), anyNullable()) } returns edited
|
every { harness.configLoader.loadWithBranchLayer(any(), anyNullable()) } returns edited
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
verify { harness.buildExecutor.startBuild("main", "commit-1", any(), "nightly") }
|
verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-1", "nightly") }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("an unreadable branch config falls back to the primary definitions instead of failing the poll") {
|
test("an unreadable branch config falls back to the primary definitions instead of failing the poll") {
|
||||||
@@ -624,13 +624,13 @@ class WatcherTest : FunSpec() {
|
|||||||
RuntimeException("mapping problem")
|
RuntimeException("mapping problem")
|
||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.watcher
|
harness.watcher
|
||||||
.state()
|
.state()
|
||||||
.lastPollError
|
.lastPollError
|
||||||
.shouldBeNull()
|
.shouldBeNull()
|
||||||
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), BuildDefinition.DEFAULT) }
|
verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-main", BuildDefinition.DEFAULT) }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("an auto-build slot stays untriggered while the branch is still building") {
|
test("an auto-build slot stays untriggered while the branch is still building") {
|
||||||
@@ -639,7 +639,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds.shouldBeEmpty()
|
harness.startedBuilds.shouldBeEmpty()
|
||||||
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeFalse()
|
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeFalse()
|
||||||
@@ -655,7 +655,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { harness.gitService.originHeadCommit("feature/a", any()) } returns "commit-2"
|
every { harness.gitService.originHeadCommit("feature/a", any()) } returns "commit-2"
|
||||||
every { harness.gitService.originHeadCommit("queued", any()) } returns "commit-3"
|
every { harness.gitService.originHeadCommit("queued", any()) } returns "commit-3"
|
||||||
|
|
||||||
harness.watcher.recoverOnStartup(harness.workingDir)
|
harness.watcher.recoverOnStartup(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds shouldContainExactlyInAnyOrder
|
harness.startedBuilds shouldContainExactlyInAnyOrder
|
||||||
listOf("main" to "commit-1", "feature/a" to "commit-2", "queued" to "commit-3")
|
listOf("main" to "commit-1", "feature/a" to "commit-2", "queued" to "commit-3")
|
||||||
@@ -671,7 +671,7 @@ class WatcherTest : FunSpec() {
|
|||||||
harness.seed("main", BuildStatus.INTERRUPTED, commit = "commit-2")
|
harness.seed("main", BuildStatus.INTERRUPTED, commit = "commit-2")
|
||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-2"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-2"
|
||||||
|
|
||||||
harness.watcher.recoverOnStartup(harness.workingDir)
|
harness.watcher.recoverOnStartup(harness.repo)
|
||||||
|
|
||||||
harness.startedBuilds shouldContainExactly listOf("main" to "commit-2")
|
harness.startedBuilds shouldContainExactly listOf("main" to "commit-2")
|
||||||
}
|
}
|
||||||
@@ -681,17 +681,17 @@ class WatcherTest : FunSpec() {
|
|||||||
harness.seed("main", BuildStatus.INTERRUPTED, commit = "commit-1", build = "pitest")
|
harness.seed("main", BuildStatus.INTERRUPTED, commit = "commit-1", build = "pitest")
|
||||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-1"
|
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-1"
|
||||||
|
|
||||||
harness.watcher.recoverOnStartup(harness.workingDir)
|
harness.watcher.recoverOnStartup(harness.repo)
|
||||||
|
|
||||||
// otherwise a restart mid-nightly-build would repeat it as a regular build in the wrong pool
|
// otherwise a restart mid-nightly-build would repeat it as a regular build in the wrong pool
|
||||||
verify { harness.buildExecutor.startBuild("main", "commit-1", any(), "pitest") }
|
verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-1", "pitest") }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("startup recovery closes out an orphaned PENDING build of a branch gone from origin") {
|
test("startup recovery closes out an orphaned PENDING build of a branch gone from origin") {
|
||||||
val harness = Harness()
|
val harness = Harness()
|
||||||
val orphan = harness.seed("gone", BuildStatus.PENDING, commit = "commit-1")
|
val orphan = harness.seed("gone", BuildStatus.PENDING, commit = "commit-1")
|
||||||
|
|
||||||
harness.watcher.recoverOnStartup(harness.workingDir)
|
harness.watcher.recoverOnStartup(harness.repo)
|
||||||
|
|
||||||
// PENDING is prune-immune; left as-is, the gone branch could never be pruned
|
// PENDING is prune-immune; left as-is, the gone branch could never be pruned
|
||||||
harness.startedBuilds.shouldBeEmpty()
|
harness.startedBuilds.shouldBeEmpty()
|
||||||
@@ -709,7 +709,7 @@ class WatcherTest : FunSpec() {
|
|||||||
val removedWorktree = harness.worktreeDir("gone")
|
val removedWorktree = harness.worktreeDir("gone")
|
||||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.repository.history().map { it.branch } shouldContainExactly listOf("main")
|
harness.repository.history().map { it.branch } shouldContainExactly listOf("main")
|
||||||
verify {
|
verify {
|
||||||
@@ -728,7 +728,7 @@ class WatcherTest : FunSpec() {
|
|||||||
keeping.seed("main", BuildStatus.FAILED, commit = "commit-2")
|
keeping.seed("main", BuildStatus.FAILED, commit = "commit-2")
|
||||||
every { keeping.gitService.originBranches(any()) } returns listOf("main")
|
every { keeping.gitService.originBranches(any()) } returns listOf("main")
|
||||||
|
|
||||||
keeping.watcher.poll(keeping.workingDir)
|
keeping.watcher.poll(keeping.repo)
|
||||||
|
|
||||||
keeping.repository.history().map { it.status } shouldContainExactly
|
keeping.repository.history().map { it.status } shouldContainExactly
|
||||||
listOf(BuildStatus.FAILED, BuildStatus.SUCCESS)
|
listOf(BuildStatus.FAILED, BuildStatus.SUCCESS)
|
||||||
@@ -739,7 +739,7 @@ class WatcherTest : FunSpec() {
|
|||||||
dropping.seed("main", BuildStatus.FAILED, commit = "commit-2")
|
dropping.seed("main", BuildStatus.FAILED, commit = "commit-2")
|
||||||
every { dropping.gitService.originBranches(any()) } returns listOf("main")
|
every { dropping.gitService.originBranches(any()) } returns listOf("main")
|
||||||
|
|
||||||
dropping.watcher.poll(dropping.workingDir)
|
dropping.watcher.poll(dropping.repo)
|
||||||
|
|
||||||
dropping.repository.history().map { it.status } shouldContainExactly listOf(BuildStatus.FAILED)
|
dropping.repository.history().map { it.status } shouldContainExactly listOf(BuildStatus.FAILED)
|
||||||
}
|
}
|
||||||
@@ -751,7 +751,7 @@ class WatcherTest : FunSpec() {
|
|||||||
harness.seed("main", BuildStatus.FAILED, commit = "commit-2")
|
harness.seed("main", BuildStatus.FAILED, commit = "commit-2")
|
||||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
harness.repository.history().map { it.commit } shouldContainExactly listOf("commit-2")
|
harness.repository.history().map { it.commit } shouldContainExactly listOf("commit-2")
|
||||||
}
|
}
|
||||||
@@ -762,7 +762,7 @@ class WatcherTest : FunSpec() {
|
|||||||
val busyWorktree = harness.worktreeDir("busy")
|
val busyWorktree = harness.worktreeDir("busy")
|
||||||
every { harness.gitService.originBranches(any()) } returns listOf("busy")
|
every { harness.gitService.originBranches(any()) } returns listOf("busy")
|
||||||
|
|
||||||
harness.watcher.poll(harness.workingDir)
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
Files.exists(busyWorktree).shouldBeTrue()
|
Files.exists(busyWorktree).shouldBeTrue()
|
||||||
}
|
}
|
||||||
@@ -772,14 +772,14 @@ class WatcherTest : FunSpec() {
|
|||||||
val fetches = CountDownLatch(2)
|
val fetches = CountDownLatch(2)
|
||||||
every { harness.gitService.fetchOrigin(any()) } answers { fetches.countDown() }
|
every { harness.gitService.fetchOrigin(any()) } answers { fetches.countDown() }
|
||||||
|
|
||||||
harness.watcher.start(harness.workingDir)
|
harness.watcher.start(harness.repo)
|
||||||
|
|
||||||
fetches.await(5, TimeUnit.SECONDS).shouldBeTrue()
|
fetches.await(5, TimeUnit.SECONDS).shouldBeTrue()
|
||||||
harness.watcher
|
harness.watcher
|
||||||
.state()
|
.state()
|
||||||
.running
|
.running
|
||||||
.shouldBeTrue()
|
.shouldBeTrue()
|
||||||
shouldThrow<IllegalStateException> { harness.watcher.start(harness.workingDir) }
|
shouldThrow<IllegalStateException> { harness.watcher.start(harness.repo) }
|
||||||
|
|
||||||
harness.watcher.stop()
|
harness.watcher.stop()
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,15 @@
|
|||||||
# - Only the final `tar --zstd` writes to stdout; every build step is
|
# - Only the final `tar --zstd` writes to stdout; every build step is
|
||||||
# redirected to stderr, so the archive coming out of `docker run` is pure.
|
# redirected to stderr, so the archive coming out of `docker run` is pure.
|
||||||
#
|
#
|
||||||
# Usage: build-bwrap-rootfs.sh [--release trixie] [--mirror URL] [--out path]
|
# Usage: build-bwrap-rootfs.sh [--release trixie] [--mirror URL] [--out path] [--pkgs-extra "PKG..."]
|
||||||
# --release Debian release/architecture tail, default "trixie"
|
# --release Debian release/architecture tail, default "trixie"
|
||||||
# --mirror apt mirror for debootstrap, default http://deb.debian.org/debian
|
# --mirror apt mirror for debootstrap, default http://deb.debian.org/debian
|
||||||
# --out output archive path, default ./werkator-buildenv-<release>.tar.zst
|
# --out output archive path, default ./werkator-buildenv-<release>.tar.zst
|
||||||
|
# --pkgs-extra additional apt packages on top of the base list, e.g.
|
||||||
|
# "golang-go nodejs npm" for Go and Node builds. Name the
|
||||||
|
# archive after its content (--out): the bwrap runtime keys the
|
||||||
|
# unpacked environment by the archive SOURCE PATH, so a changed
|
||||||
|
# content needs a changed name to take effect.
|
||||||
#
|
#
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -45,11 +50,13 @@ usage() {
|
|||||||
|
|
||||||
release="trixie"
|
release="trixie"
|
||||||
out=""
|
out=""
|
||||||
|
pkgs_extra=""
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--release) release="${2:?missing value for --release}"; shift 2 ;;
|
--release) release="${2:?missing value for --release}"; shift 2 ;;
|
||||||
--mirror) mirror="${2:?missing value for --mirror}"; shift 2 ;;
|
--mirror) mirror="${2:?missing value for --mirror}"; shift 2 ;;
|
||||||
--out) out="${2:?missing value for --out}"; shift 2 ;;
|
--out) out="${2:?missing value for --out}"; shift 2 ;;
|
||||||
|
--pkgs-extra) pkgs_extra="${2:?missing value for --pkgs-extra}"; shift 2 ;;
|
||||||
-*) die "unknown option: $1" ;;
|
-*) die "unknown option: $1" ;;
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
@@ -62,8 +69,11 @@ command -v docker >/dev/null 2>&1 || die "docker is required to build the rootfs
|
|||||||
# Rootfs content: Werkator's own build needs a JDK 21 toolchain (Gradle
|
# Rootfs content: Werkator's own build needs a JDK 21 toolchain (Gradle
|
||||||
# toolchain resolution), git, ca-certificates for HTTPS, locales for git, and
|
# toolchain resolution), git, ca-certificates for HTTPS, locales for git, and
|
||||||
# curl/unzip/xz-utils/zstd for the Gradle wrapper and general build hygiene.
|
# curl/unzip/xz-utils/zstd for the Gradle wrapper and general build hygiene.
|
||||||
|
# The headless JDK on purpose: it skips the X11/fontconfig library stack
|
||||||
|
# (~200 MB) and still supports headless AWT, which is all a CI build needs.
|
||||||
# Keep this list additive — project-specific tooling goes on top of this base.
|
# Keep this list additive — project-specific tooling goes on top of this base.
|
||||||
PKGS="openjdk-21-jdk git ca-certificates locales procps file curl unzip xz-utils zstd"
|
PKGS="openjdk-21-jdk-headless git ca-certificates locales procps file curl unzip xz-utils zstd"
|
||||||
|
[ -z "$pkgs_extra" ] || PKGS="$PKGS $pkgs_extra"
|
||||||
|
|
||||||
# The chroot step runs inside the freshly debootstrapped rootfs; passed into
|
# The chroot step runs inside the freshly debootstrapped rootfs; passed into
|
||||||
# the container as base64 so no nested heredoc corrupts the piped script.
|
# the container as base64 so no nested heredoc corrupts the piped script.
|
||||||
@@ -76,6 +86,12 @@ apt-get clean
|
|||||||
rm -f /etc/localtime
|
rm -f /etc/localtime
|
||||||
locale-gen en_US.UTF-8 de_DE.UTF-8 >/dev/null 2>&1 || true
|
locale-gen en_US.UTF-8 de_DE.UTF-8 >/dev/null 2>&1 || true
|
||||||
update-locale LANG=en_US.UTF-8 >/dev/null 2>&1 || true
|
update-locale LANG=en_US.UTF-8 >/dev/null 2>&1 || true
|
||||||
|
# Trim what a build environment never reads: translated message catalogs
|
||||||
|
# except en/de (the generated locales in /usr/lib/locale stay untouched),
|
||||||
|
# man pages, package docs, and the apt package lists (apt still works after
|
||||||
|
# an apt-get update, should anyone ever need it inside the sandbox).
|
||||||
|
find /usr/share/locale -mindepth 1 -maxdepth 1 ! -name "en*" ! -name "de*" -exec rm -rf {} +
|
||||||
|
rm -rf /usr/share/man/* /usr/share/doc/* /var/lib/apt/lists/* /var/cache/apt
|
||||||
' | base64 -w0)"
|
' | base64 -w0)"
|
||||||
|
|
||||||
# The outer script runs inside the Debian container as root. Build noise goes
|
# The outer script runs inside the Debian container as root. Build noise goes
|
||||||
@@ -98,7 +114,7 @@ chmod +x /b/rootfs/inner.sh
|
|||||||
chroot /b/rootfs /bin/bash /inner.sh
|
chroot /b/rootfs /bin/bash /inner.sh
|
||||||
umount /b/rootfs/proc; umount /b/rootfs/sys; umount /b/rootfs/dev
|
umount /b/rootfs/proc; umount /b/rootfs/sys; umount /b/rootfs/dev
|
||||||
exec 1>&3
|
exec 1>&3
|
||||||
tar --zstd --exclude=proc --exclude=sys --exclude=dev -C /b/rootfs -cf - .
|
tar --zstd --anchored --exclude=./proc --exclude=./sys --exclude=./dev -C /b/rootfs -cf - .
|
||||||
' | base64 -w0)"
|
' | base64 -w0)"
|
||||||
|
|
||||||
echo "building ${release} rootfs (downloads packages, takes a while; log below)..."
|
echo "building ${release} rootfs (downloads packages, takes a while; log below)..."
|
||||||
|
|||||||
+228
-163
@@ -5,58 +5,75 @@
|
|||||||
# the second the command. All connection and deployment values come from the
|
# the second the command. All connection and deployment values come from the
|
||||||
# `.env` file in the repository root — never as command line parameters.
|
# `.env` file in the repository root — never as command line parameters.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Commands name their role (step 21 session D): `instance-*` manages the
|
||||||
# tools/remote werkator check-prerequisites
|
# BUILDER — the installed Werkator instance and its werkdock sandbox tool —
|
||||||
# tools/remote werkator install
|
# while `repo-*` acts on the BUILT, the repository the instance watches.
|
||||||
# tools/remote werkator build # WERKATOR_BRANCH to override, default main
|
# Werkator is never built on the target: the instance is installed from the
|
||||||
# tools/remote werkator start
|
# locally built runtime bundle (ADR 0006), and builds of the watched
|
||||||
# tools/remote port-forward start # background tunnel to the Werkator UI
|
# repository are the running instance's job (or `bin/werkator build` on the
|
||||||
# tools/remote port-forward stop
|
# host — the werkator CLI, not this script).
|
||||||
# tools/remote werkator control-token
|
|
||||||
#
|
#
|
||||||
# Required in .env:
|
# Usage (step 23: `--env-file` selects the target instance, default `.env`):
|
||||||
|
# tools/remote [--env-file FILE] werkator check-prerequisites werkdock doctor on the host
|
||||||
|
# tools/remote [--env-file FILE] werkator instance-install first-time: upload + unpack bundle and werkdock
|
||||||
|
# tools/remote [--env-file FILE] werkator instance-update redeploy bundle + werkdock, restart the service
|
||||||
|
# tools/remote [--env-file FILE] werkator instance-start apply fragment, Apache proxy, systemd unit
|
||||||
|
# tools/remote [--env-file FILE] werkator repo-init clone the watched repo, init --apply, rootfs
|
||||||
|
# tools/remote [--env-file FILE] werkator control-token
|
||||||
|
# tools/remote [--env-file FILE] port-forward start background tunnel to the Werkator UI
|
||||||
|
# tools/remote [--env-file FILE] port-forward stop
|
||||||
|
#
|
||||||
|
# The env file carries TRANSPORT values only; everything that is Werkator
|
||||||
|
# configuration travels as a YAML fragment in the config schema, named by
|
||||||
|
# WERKATOR_INIT_CONFIG and installed remotely via `werkator init --apply`
|
||||||
|
# (docs/plan/23-init-owns-the-files.md). Pair the files per instance, e.g.
|
||||||
|
# `.env.mih34` + `.env.mih34.yml` (both gitignored).
|
||||||
|
#
|
||||||
|
# Required in the env file:
|
||||||
# WERKATOR_REMOTE user@host to operate on, e.g. mih34-werkator@mih34.hostsharing.net
|
# WERKATOR_REMOTE user@host to operate on, e.g. mih34-werkator@mih34.hostsharing.net
|
||||||
# WERKATOR_PATH target directory on that host, e.g. /home/storage/mih34/users/werkator
|
# WERKATOR_PATH target directory on that host, e.g. /home/storage/mih34/users/werkator
|
||||||
#
|
#
|
||||||
# Required for `start`:
|
# Required for `instance-start`:
|
||||||
# WERKATOR_PORT the localhost port assigned by Hostsharing (eigener Serverdienst)
|
# WERKATOR_DOMAIN the domain served by the managed Apache (docroot location for the
|
||||||
# WERKATOR_DOMAIN the domain served by the managed Apache, e.g. ci.example.de
|
# generated .htaccess); the port lives in the fragment (server.port)
|
||||||
#
|
#
|
||||||
# Required for `port-forward`:
|
# Required for `port-forward`:
|
||||||
# WERKATOR_LOCAL_PORT the local port the browser uses
|
# WERKATOR_LOCAL_PORT the local port the browser uses
|
||||||
# Optional in .env:
|
# Optional in the env file:
|
||||||
# WERKATOR_BRANCH branch for `build` (default: main)
|
# WERKATOR_INIT_CONFIG the init fragment to apply (repo-init, instance-start)
|
||||||
# WERKATOR_MEMORY_MAX systemd MemoryMax for the unit, e.g. 1G (start)
|
# WERKATOR_REPO_URL https clone URL of the watched repository
|
||||||
# WERKATOR_TASKS_MAX systemd TasksMax for the unit, e.g. 512 (start)
|
# (default: https://github.com/mhoennig/werkator.git)
|
||||||
# WERKATOR_ROOTFS rootfs archive path
|
# WERKATOR_ROOTFS rootfs archive path for repo-init
|
||||||
# (default: <repo>/build/werkator-buildenv-trixie.tar.zst)
|
# (default: <repo>/build/werkator-buildenv-trixie-java-go-node.tar.zst)
|
||||||
#
|
#
|
||||||
# Install layout on the host:
|
# Install layout on the host:
|
||||||
# $WERKATOR_PATH/werkator/ the repository clone
|
# $WERKATOR_PATH/werkator/ the watched repository (clone)
|
||||||
# $WERKATOR_PATH/.werkator/ runtime bundle + rootfs archive
|
# $WERKATOR_PATH/.werkator/werkator/ the unpacked runtime bundle
|
||||||
#
|
# $WERKATOR_PATH/.werkator/bin/ the werkdock binary
|
||||||
# `install` performs, in order:
|
# $WERKATOR_PATH/.werkator/*.tar.* uploaded bundle and rootfs archives
|
||||||
# 1. check-prerequisites (bwrap capability + disk/quota, aborts on FAIL)
|
|
||||||
# 2. ensure SSH access (ssh-copy-id on first use; asks for the password)
|
|
||||||
# 3. upload artifacts (runtime bundle, built locally if missing, + rootfs)
|
|
||||||
# 4. clone the repository (needs the host SSH key registered at GitHub once —
|
|
||||||
# the script prints the key and waits)
|
|
||||||
# 5. `werkator init` + machine-local bwrap configuration
|
|
||||||
#
|
#
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
ENV_FILE=""
|
||||||
|
if [ "${1:-}" = "--env-file" ]; then
|
||||||
|
ENV_FILE="${2:?missing value for --env-file}"
|
||||||
|
shift 2
|
||||||
|
fi
|
||||||
|
|
||||||
REPO="${1:-}"
|
REPO="${1:-}"
|
||||||
COMMAND="${2:-}"
|
COMMAND="${2:-}"
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
PREREQ_SCRIPT="$REPO_ROOT/tools/werkator-build-prerequisites.sh"
|
|
||||||
RUNTIME_BUNDLE="$REPO_ROOT/build/distributions/werkator-runtime-linux-x64.tar.gz"
|
RUNTIME_BUNDLE="$REPO_ROOT/build/distributions/werkator-runtime-linux-x64.tar.gz"
|
||||||
|
WERKDOCK_BINARY="$REPO_ROOT/werkdock/dist/werkdock"
|
||||||
PID_FILE="/tmp/werkator-port-forward-$(id -u).pid"
|
PID_FILE="/tmp/werkator-port-forward-$(id -u).pid"
|
||||||
LOG_FILE="/tmp/werkator-port-forward-$(id -u).log"
|
LOG_FILE="/tmp/werkator-port-forward-$(id -u).log"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
sed -n '3,32p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
awk 'NR > 2 && !/^#/ { exit } NR > 2 { sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +81,7 @@ require_env() {
|
|||||||
local missing=0
|
local missing=0
|
||||||
for name in "$@"; do
|
for name in "$@"; do
|
||||||
if [ -z "${!name:-}" ]; then
|
if [ -z "${!name:-}" ]; then
|
||||||
echo "ERROR: $name is not set — define it in $REPO_ROOT/.env" >&2
|
echo "ERROR: $name is not set — define it in $ENV_FILE" >&2
|
||||||
missing=1
|
missing=1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -73,16 +90,21 @@ require_env() {
|
|||||||
|
|
||||||
[ -n "$REPO" ] && [ -n "$COMMAND" ] || usage
|
[ -n "$REPO" ] && [ -n "$COMMAND" ] || usage
|
||||||
|
|
||||||
# Load the connection and deployment values; explicit environment wins, the
|
# Load the transport values; explicit environment wins, the selected env file
|
||||||
# .env in the repository root fills the rest.
|
# (default: the .env in the repository root) fills the rest.
|
||||||
|
ENV_FILE="${ENV_FILE:-$REPO_ROOT/.env}"
|
||||||
set -a
|
set -a
|
||||||
[ -f "$REPO_ROOT/.env" ] && source "$REPO_ROOT/.env"
|
[ -f "$ENV_FILE" ] && source "$ENV_FILE"
|
||||||
set +a
|
set +a
|
||||||
|
|
||||||
require_env WERKATOR_REMOTE WERKATOR_PATH
|
require_env WERKATOR_REMOTE WERKATOR_PATH
|
||||||
HOST="$WERKATOR_REMOTE"
|
HOST="$WERKATOR_REMOTE"
|
||||||
TARGET_DIR="$WERKATOR_PATH"
|
TARGET_DIR="$WERKATOR_PATH"
|
||||||
ROOTFS="${WERKATOR_ROOTFS:-$REPO_ROOT/build/werkator-buildenv-trixie.tar.zst}"
|
ROOTFS="${WERKATOR_ROOTFS:-$REPO_ROOT/build/werkator-buildenv-trixie-java-go-node.tar.zst}"
|
||||||
|
REPO_URL="${WERKATOR_REPO_URL:-https://github.com/mhoennig/werkator.git}"
|
||||||
|
MACHINE_CONFIG="$TARGET_DIR/werkator/.git/werkator/.werkator.yml"
|
||||||
|
WERKATOR_BIN="$TARGET_DIR/.werkator/werkator/bin/werkator"
|
||||||
|
UNIT="werkator-werkator.service"
|
||||||
|
|
||||||
ssh_present() {
|
ssh_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
|
||||||
@@ -94,165 +116,197 @@ ensure_ssh() {
|
|||||||
else
|
else
|
||||||
echo "==> No key-based SSH access yet; running ssh-copy-id (password prompt expected)"
|
echo "==> No key-based SSH access yet; running ssh-copy-id (password prompt expected)"
|
||||||
ssh-copy-id "$HOST"
|
ssh-copy-id "$HOST"
|
||||||
ssh_present || { echo "ERROR: SSH access still not working after ssh-copy-id" >&2; exit 1; }
|
ssh_present || die "SSH access still not working after ssh-copy-id"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run the prerequisites script remotely by piping it over stdin; TARGET_DIR and
|
# Werkdock owns the host checks (`werkdock doctor` ports the old prerequisites
|
||||||
# ROOTFS_ARCHIVE are passed as arguments to `bash -s --`.
|
# script); the binary is uploaded first, so the check works pre-install.
|
||||||
check_prerequisites() {
|
check_prerequisites() {
|
||||||
echo "==> Checking prerequisites on $HOST (target dir: $TARGET_DIR)"
|
ensure_werkdock_binary
|
||||||
local rootfs_remote="$TARGET_DIR/.werkator/$(basename "$ROOTFS")"
|
echo "==> Uploading werkdock and running its doctor on $HOST (target dir: $TARGET_DIR)"
|
||||||
if ! ssh "$HOST" "WERKATOR_SSH_TARGET='$HOST' bash -s -- '$TARGET_DIR' '$rootfs_remote'" < "$PREREQ_SCRIPT"; then
|
ssh "$HOST" "mkdir -p '$TARGET_DIR/.werkator/bin'"
|
||||||
echo "ERROR: prerequisites failed on $HOST — install aborted" >&2
|
scp -q "$WERKDOCK_BINARY" "$HOST:$TARGET_DIR/.werkator/bin/werkdock.new"
|
||||||
exit 1
|
ssh "$HOST" "mv '$TARGET_DIR/.werkator/bin/werkdock.new' '$TARGET_DIR/.werkator/bin/werkdock' && chmod 755 '$TARGET_DIR/.werkator/bin/werkdock'"
|
||||||
|
if ! ssh "$HOST" "'$TARGET_DIR/.werkator/bin/werkdock' doctor '$TARGET_DIR'"; then
|
||||||
|
die "werkdock doctor failed on $HOST — install aborted"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_local_artifacts() {
|
ensure_werkdock_binary() {
|
||||||
|
if [ ! -f "$WERKDOCK_BINARY" ]; then
|
||||||
|
echo "==> werkdock binary not found; building it locally (go build)"
|
||||||
|
(cd "$REPO_ROOT/werkdock" && CGO_ENABLED=0 go build -o dist/werkdock .)
|
||||||
|
fi
|
||||||
|
[ -f "$WERKDOCK_BINARY" ] || die "werkdock binary missing: $WERKDOCK_BINARY"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uploads the init fragment named by WERKATOR_INIT_CONFIG and echoes its remote
|
||||||
|
# path; empty when no fragment is configured.
|
||||||
|
upload_fragment() {
|
||||||
|
[ -n "${WERKATOR_INIT_CONFIG:-}" ] || { echo ""; return 0; }
|
||||||
|
[ -f "$WERKATOR_INIT_CONFIG" ] || die "init fragment missing: $WERKATOR_INIT_CONFIG"
|
||||||
|
local remote="$TARGET_DIR/.werkator/$(basename "$WERKATOR_INIT_CONFIG")"
|
||||||
|
scp -q "$WERKATOR_INIT_CONFIG" "$HOST:$remote"
|
||||||
|
echo "$remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The instance artifacts are built locally (ADR 0006): the runtime bundle via
|
||||||
|
# Gradle, the werkdock binary via the Go toolchain. Both are rebuilt when
|
||||||
|
# missing, never on the target.
|
||||||
|
ensure_instance_artifacts() {
|
||||||
if [ ! -f "$RUNTIME_BUNDLE" ]; then
|
if [ ! -f "$RUNTIME_BUNDLE" ]; then
|
||||||
echo "==> Runtime bundle not found; building it locally (./gradlew runtimeBundle)"
|
echo "==> Runtime bundle not found; building it locally (./gradlew runtimeBundle)"
|
||||||
(cd "$REPO_ROOT" && ./gradlew runtimeBundle --console=plain -q)
|
(cd "$REPO_ROOT" && ./gradlew runtimeBundle --console=plain -q)
|
||||||
fi
|
fi
|
||||||
[ -f "$RUNTIME_BUNDLE" ] || { echo "ERROR: runtime bundle missing: $RUNTIME_BUNDLE" >&2; exit 1; }
|
[ -f "$RUNTIME_BUNDLE" ] || die "runtime bundle missing: $RUNTIME_BUNDLE"
|
||||||
[ -f "$ROOTFS" ] || {
|
ensure_werkdock_binary
|
||||||
echo "ERROR: rootfs archive missing: $ROOTFS" >&2
|
|
||||||
echo " build it with tools/build-bwrap-rootfs.sh or set WERKATOR_ROOTFS" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_github_access() {
|
# Uploads and unpacks the instance artifacts. The previous runtime stays as
|
||||||
# `ssh -T git@github.com` exits 1 even on success ("does not provide shell
|
# werkator.prev for one deployment as the rollback asset.
|
||||||
# access") — neutralize remotely, then match on the greeting text.
|
deploy_instance() {
|
||||||
if ssh "$HOST" 'ssh -o BatchMode=yes -o ConnectTimeout=10 -T git@github.com 2>&1 || true' | grep -q "successfully authenticated"; then
|
echo "==> Uploading runtime bundle and werkdock binary"
|
||||||
echo "==> GitHub SSH access from $HOST: ok"
|
ssh "$HOST" "mkdir -p '$TARGET_DIR/.werkator/bin'"
|
||||||
return 0
|
scp -q "$RUNTIME_BUNDLE" "$HOST:$TARGET_DIR/.werkator/"
|
||||||
fi
|
scp -q "$WERKDOCK_BINARY" "$HOST:$TARGET_DIR/.werkator/bin/werkdock.new"
|
||||||
echo
|
echo "==> Unpacking"
|
||||||
echo "==> The host cannot reach GitHub via SSH yet."
|
ssh "$HOST" "set -e
|
||||||
echo " Add THIS public key to GitHub (Settings > SSH and GPG keys > New SSH key):"
|
cd '$TARGET_DIR/.werkator'
|
||||||
ssh "$HOST" 'cat ~/.ssh/id_*.pub 2>/dev/null' || {
|
mv bin/werkdock.new bin/werkdock && chmod 755 bin/werkdock
|
||||||
echo "ERROR: no public key on the host; create one with ssh-keygen -t ed25519" >&2
|
rm -rf werkator.prev
|
||||||
exit 1
|
[ ! -d werkator ] || mv werkator werkator.prev
|
||||||
}
|
tar xzf '$(basename "$RUNTIME_BUNDLE")'
|
||||||
read -r -p " Press Enter once the key is registered at GitHub... "
|
'./werkator/bin/werkator' --version
|
||||||
ssh "$HOST" 'ssh -o BatchMode=yes -T git@github.com 2>&1 || true' | grep -q "successfully authenticated" || {
|
'./bin/werkdock' version"
|
||||||
echo "ERROR: GitHub authentication from $HOST still failing" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
echo "==> GitHub SSH access from $HOST: ok"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
install() {
|
instance_install() {
|
||||||
ensure_ssh
|
ensure_ssh
|
||||||
check_prerequisites
|
check_prerequisites
|
||||||
ensure_local_artifacts
|
ensure_instance_artifacts
|
||||||
|
deploy_instance
|
||||||
|
echo
|
||||||
|
echo "==> Instance installed."
|
||||||
|
echo " Runtime: $WERKATOR_BIN"
|
||||||
|
echo " werkdock: $TARGET_DIR/.werkator/bin/werkdock"
|
||||||
|
echo " Next: tools/remote werkator repo-init, then instance-start"
|
||||||
|
}
|
||||||
|
|
||||||
echo "==> Uploading runtime bundle and rootfs archive"
|
# Refuse to swap the runtime under a running build; FORCE=1 overrides.
|
||||||
ssh "$HOST" "mkdir -p '$TARGET_DIR/.werkator'"
|
require_idle() {
|
||||||
scp -q "$RUNTIME_BUNDLE" "$HOST:$TARGET_DIR/.werkator/"
|
local port
|
||||||
scp -q "$ROOTFS" "$HOST:$TARGET_DIR/.werkator/"
|
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)"
|
||||||
|
[ -n "$port" ] || return 0
|
||||||
|
local current
|
||||||
|
current="$(ssh "$HOST" "curl -s --max-time 5 http://127.0.0.1:$port/api/builds/current" || true)"
|
||||||
|
if [ -n "$current" ] && [ "$current" != "[]" ]; then
|
||||||
|
[ "${FORCE:-}" = "1" ] || die "a build is running on $HOST — retry when idle, or FORCE=1 to override"
|
||||||
|
echo "==> WARNING: deploying although a build is running (FORCE=1)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
echo "==> Unpacking runtime bundle"
|
instance_update() {
|
||||||
ssh "$HOST" "tar xzf '$TARGET_DIR/.werkator/$(basename "$RUNTIME_BUNDLE")' -C '$TARGET_DIR/.werkator'"
|
ensure_ssh
|
||||||
ssh "$HOST" "'$TARGET_DIR/.werkator/werkator/bin/werkator' --version"
|
ensure_instance_artifacts
|
||||||
|
require_idle
|
||||||
|
local was_active=0
|
||||||
|
if ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user is-active --quiet '$UNIT'"; then
|
||||||
|
was_active=1
|
||||||
|
fi
|
||||||
|
if [ "$was_active" = "1" ]; then
|
||||||
|
echo "==> Stopping $UNIT"
|
||||||
|
ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user stop '$UNIT'"
|
||||||
|
fi
|
||||||
|
deploy_instance
|
||||||
|
if [ "$was_active" = "1" ]; then
|
||||||
|
echo "==> Starting $UNIT"
|
||||||
|
ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user start '$UNIT' && sleep 3 && systemctl --user is-active '$UNIT'"
|
||||||
|
else
|
||||||
|
echo "==> Service was not running; not started (use instance-start for the first start)"
|
||||||
|
fi
|
||||||
|
echo "==> Instance updated."
|
||||||
|
}
|
||||||
|
|
||||||
ensure_github_access
|
# Sets up the WATCHED repository: an anonymous https clone (a private origin
|
||||||
|
# gets its credentials via git.account/git.token in the machine config that
|
||||||
|
# `werkator init` creates), the werkator init with the instance fragment
|
||||||
|
# applied, and the rootfs archive for the sandbox builds. All configuration
|
||||||
|
# writing is init's — this script transports and invokes (step 23).
|
||||||
|
repo_init() {
|
||||||
|
ensure_ssh
|
||||||
|
[ -f "$ROOTFS" ] || die "rootfs archive missing: $ROOTFS — build it with tools/build-bwrap-rootfs.sh or set WERKATOR_ROOTFS"
|
||||||
|
ssh "$HOST" "test -x '$WERKATOR_BIN'" || die "no instance on $HOST — run instance-install first"
|
||||||
|
|
||||||
echo "==> Cloning the repository"
|
echo "==> Cloning the watched repository"
|
||||||
if ssh "$HOST" "test -d '$TARGET_DIR/werkator/.git'"; then
|
if ssh "$HOST" "test -d '$TARGET_DIR/werkator/.git'"; then
|
||||||
echo " (already cloned, skipping)"
|
echo " (already cloned, skipping)"
|
||||||
else
|
else
|
||||||
ssh "$HOST" "git clone git@github.com:mhoennig/werkator.git '$TARGET_DIR/werkator'"
|
ssh "$HOST" "git clone '$REPO_URL' '$TARGET_DIR/werkator'"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Running werkator init"
|
echo "==> Uploading the rootfs archive (skipped when unchanged)"
|
||||||
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$TARGET_DIR/.werkator/werkator/bin/werkator' init"
|
local rootfs_remote="$TARGET_DIR/.werkator/$(basename "$ROOTFS")"
|
||||||
|
local local_sha remote_sha
|
||||||
|
local_sha="$(sha256sum "$ROOTFS" | cut -d' ' -f1)"
|
||||||
|
remote_sha="$(ssh "$HOST" "sha256sum '$rootfs_remote' 2>/dev/null | cut -d' ' -f1" || true)"
|
||||||
|
if [ "$local_sha" = "$remote_sha" ]; then
|
||||||
|
echo " (already on the host, skipping)"
|
||||||
|
else
|
||||||
|
scp -q "$ROOTFS" "$HOST:$rootfs_remote"
|
||||||
|
remote_sha="$(ssh "$HOST" "sha256sum '$rootfs_remote' | cut -d' ' -f1")"
|
||||||
|
[ "$local_sha" = "$remote_sha" ] || die "rootfs upload checksum mismatch"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Writing machine-local bwrap configuration"
|
echo "==> Running werkator init${WERKATOR_INIT_CONFIG:+ --apply $(basename "${WERKATOR_INIT_CONFIG}")}"
|
||||||
ssh "$HOST" "grep -q '^ bwrap:' '$TARGET_DIR/werkator/.git/werkator/.werkator.yml' 2>/dev/null" || ssh "$HOST" "cat >> '$TARGET_DIR/werkator/.git/werkator/.werkator.yml' <<'CFG'
|
local fragment_remote
|
||||||
|
fragment_remote="$(upload_fragment)"
|
||||||
# Build in the bubblewrap sandbox instead of natively (Step 17 / ADR 0007).
|
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' init ${fragment_remote:+--apply '$fragment_remote'}"
|
||||||
# Both keys are pinned: read from this machine config even if a branch sets
|
|
||||||
# its own values in a committed .werkator.yml.
|
|
||||||
builds:
|
|
||||||
default:
|
|
||||||
bwrap:
|
|
||||||
enabled: true
|
|
||||||
rootfs: $TARGET_DIR/.werkator/$(basename "$ROOTFS")
|
|
||||||
CFG"
|
|
||||||
|
|
||||||
echo "==> Verifying the effective configuration"
|
echo "==> Verifying the effective configuration"
|
||||||
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$TARGET_DIR/.werkator/werkator/bin/werkator' config:print 2>/dev/null | grep -A3 'bwrap:' | head -4"
|
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' config:print 2>/dev/null | grep -A4 'bwrap:' | head -5"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "==> Install complete."
|
echo "==> Repository ready."
|
||||||
echo " Repo: $TARGET_DIR/werkator"
|
echo " Repo: $TARGET_DIR/werkator"
|
||||||
echo " Runtime: $TARGET_DIR/.werkator/werkator/bin/werkator"
|
echo " Next: fill git.account/git.token in $MACHINE_CONFIG if the origin is private,"
|
||||||
echo " Next: tools/remote werkator build"
|
echo " then tools/remote werkator instance-start"
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
# Start the server as a systemd user unit behind the managed Apache. All
|
||||||
|
# configuration comes from the instance fragment (server.port, publicBaseUrl,
|
||||||
|
# systemd limits); init generates the units AND the .htaccess — this script
|
||||||
|
# only places and activates them (step 23).
|
||||||
|
instance_start() {
|
||||||
ensure_ssh
|
ensure_ssh
|
||||||
local branch="${WERKATOR_BRANCH:-main}"
|
require_env WERKATOR_DOMAIN
|
||||||
echo "==> Running one initial build of branch '$branch' on $HOST (in the bwrap sandbox)"
|
|
||||||
ssh -t "$HOST" "cd '$TARGET_DIR/werkator' && '$TARGET_DIR/.werkator/werkator/bin/werkator' build '$branch'"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Start the server as a systemd user unit behind the managed Apache.
|
echo "==> Applying the instance fragment and generating the host integration (init --systemd)"
|
||||||
# WERKATOR_MEMORY_MAX / WERKATOR_TASKS_MAX (optional) are written into the
|
local fragment_remote
|
||||||
# machine config so `init --systemd` bakes them into the unit.
|
fragment_remote="$(upload_fragment)"
|
||||||
start() {
|
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' init ${fragment_remote:+--apply '$fragment_remote'} --systemd"
|
||||||
ensure_ssh
|
|
||||||
require_env WERKATOR_PORT WERKATOR_DOMAIN
|
local htaccess_src="$TARGET_DIR/werkator/.git/werkator/werkator.htaccess"
|
||||||
local machine="$TARGET_DIR/werkator/.git/werkator/.werkator.yml"
|
|
||||||
local unit="werkator-$(basename "$TARGET_DIR/werkator").service"
|
|
||||||
local htaccess="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/.htaccess"
|
local htaccess="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/.htaccess"
|
||||||
|
if ssh "$HOST" "test -f '$htaccess_src'"; then
|
||||||
echo "==> Writing server settings to the machine config"
|
echo "==> Placing the generated Apache reverse proxy at $htaccess"
|
||||||
if ssh "$HOST" "grep -q '^server:' '$machine' 2>/dev/null"; then
|
ssh "$HOST" "mkdir -p '$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www' && cp '$htaccess_src' '$htaccess'"
|
||||||
# re-run: update port and publicBaseUrl in place (systemd limits stay as written)
|
|
||||||
ssh "$HOST" "sed -i 's/^ port: .*/ port: $WERKATOR_PORT/; s|^ publicBaseUrl: .*| publicBaseUrl: \"https://$WERKATOR_DOMAIN/\"|' '$machine'"
|
|
||||||
else
|
else
|
||||||
ssh "$HOST" "cat >> '$machine' <<'CFG'
|
echo "==> No werkator.htaccess generated (no server.publicBaseUrl configured) — skipping the Apache proxy"
|
||||||
|
|
||||||
# Web access: the managed Apache terminates TLS and proxies to the localhost
|
|
||||||
# port assigned by Hostsharing (eigener Serverdienst); TLS is the domain's
|
|
||||||
# Let's Encrypt certificate, so Werkator itself stays on 127.0.0.1.
|
|
||||||
server:
|
|
||||||
port: $WERKATOR_PORT
|
|
||||||
bindAddress: 127.0.0.1
|
|
||||||
publicBaseUrl: \"https://$WERKATOR_DOMAIN/\"
|
|
||||||
nginx:
|
|
||||||
enabled: false
|
|
||||||
systemd:
|
|
||||||
memoryMax: \"${WERKATOR_MEMORY_MAX:-}\"
|
|
||||||
tasksMax: \"${WERKATOR_TASKS_MAX:-}\"
|
|
||||||
CFG"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Writing the Apache reverse proxy to $htaccess"
|
|
||||||
ssh "$HOST" "mkdir -p '$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www' && cat > '$htaccess' <<'HT'
|
|
||||||
DirectoryIndex disabled
|
|
||||||
RewriteEngine On
|
|
||||||
RewriteBase /
|
|
||||||
RewriteRule .* http://127.0.0.1:$WERKATOR_PORT%{REQUEST_URI} [proxy]
|
|
||||||
HT"
|
|
||||||
|
|
||||||
echo "==> Generating the systemd user unit (init --systemd)"
|
|
||||||
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$TARGET_DIR/.werkator/werkator/bin/werkator' init --systemd"
|
|
||||||
|
|
||||||
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 '$TARGET_DIR/werkator/.git/werkator/$UNIT' ~/.config/systemd/user/ && \
|
||||||
ln -sf '$TARGET_DIR/werkator/.git/werkator/werkator-docker-prune.service' ~/.config/systemd/user/ && \
|
ln -sf '$TARGET_DIR/werkator/.git/werkator/werkator-docker-prune.service' ~/.config/systemd/user/ && \
|
||||||
ln -sf '$TARGET_DIR/werkator/.git/werkator/werkator-docker-prune.timer' ~/.config/systemd/user/ && \
|
ln -sf '$TARGET_DIR/werkator/.git/werkator/werkator-docker-prune.timer' ~/.config/systemd/user/ && \
|
||||||
systemctl --user daemon-reload && systemctl --user restart '$unit' && systemctl --user status '$unit' --no-pager -l | head -12"
|
XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user daemon-reload && \
|
||||||
|
XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user restart '$UNIT' && \
|
||||||
|
XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user status '$UNIT' --no-pager -l | head -12"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "==> Server started. Verify: https://$WERKATOR_DOMAIN/"
|
echo "==> Server started. Verify: https://$WERKATOR_DOMAIN/"
|
||||||
echo " Logs: ssh $HOST -- systemctl --user status '$unit'"
|
echo " Logs: ssh $HOST -- systemctl --user status '$UNIT'"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Background SSH tunnel to the Werkator server, so the browser reaches the UI
|
# Background SSH tunnel to the Werkator server, so the browser reaches the UI
|
||||||
@@ -260,9 +314,11 @@ HT"
|
|||||||
# `start` runs ssh -N -L detached with a pid file; `stop` kills it.
|
# `start` runs ssh -N -L detached with a pid file; `stop` kills it.
|
||||||
port_forward() {
|
port_forward() {
|
||||||
require_env WERKATOR_LOCAL_PORT
|
require_env WERKATOR_LOCAL_PORT
|
||||||
|
# the effective port, wherever it is configured (machine config or applied
|
||||||
|
# fragment) — config:print is the single answer, not this script's parser
|
||||||
local remote_port
|
local remote_port
|
||||||
remote_port="$(ssh "$HOST" "awk '/^server:/{f=1;next} f && /^ port:/{print \$2; exit}' '$TARGET_DIR/werkator/.git/werkator/.werkator.yml'")"
|
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 '"')"
|
||||||
[ -n "$remote_port" ] || { echo "ERROR: no server.port in the machine config — run 'tools/remote werkator start' first" >&2; exit 1; }
|
[ -n "$remote_port" ] || die "no server.port configured — run 'tools/remote werkator instance-start' first"
|
||||||
|
|
||||||
case "$COMMAND" in
|
case "$COMMAND" in
|
||||||
start)
|
start)
|
||||||
@@ -299,14 +355,11 @@ port_forward() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# Print the control token guarding the mutating build endpoints. If the server
|
# Print the control token guarding the mutating build endpoints — the werkator
|
||||||
# has not created it yet (it does so on first use), generate one in place — the
|
# CLI owns creation and format (step 23), this script only invokes it.
|
||||||
# server reads the file lazily, so a pre-created token is equivalent.
|
|
||||||
control_token() {
|
control_token() {
|
||||||
ensure_ssh
|
ensure_ssh
|
||||||
local token_file="$TARGET_DIR/werkator/.git/werkator/control-token"
|
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' control-token"
|
||||||
ssh "$HOST" "if [ -f '$token_file' ]; then cat '$token_file'; else \
|
|
||||||
umask 077 && head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \\n' > '$token_file' && cat '$token_file'; fi"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "$REPO" in
|
case "$REPO" in
|
||||||
@@ -319,18 +372,30 @@ case "$REPO" in
|
|||||||
ensure_ssh
|
ensure_ssh
|
||||||
check_prerequisites
|
check_prerequisites
|
||||||
;;
|
;;
|
||||||
install)
|
instance-install)
|
||||||
install
|
instance_install
|
||||||
;;
|
;;
|
||||||
build)
|
instance-update)
|
||||||
build
|
instance_update
|
||||||
;;
|
;;
|
||||||
start)
|
instance-start)
|
||||||
start
|
instance_start
|
||||||
|
;;
|
||||||
|
repo-init)
|
||||||
|
repo_init
|
||||||
;;
|
;;
|
||||||
control-token)
|
control-token)
|
||||||
control_token
|
control_token
|
||||||
;;
|
;;
|
||||||
|
install)
|
||||||
|
die "'install' was the self-build prototype; use instance-install + repo-init (step 21 session D)"
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
die "'build' (the self-build) is retired; the instance builds pushes itself, or run '$WERKATOR_BIN build <branch>' on the host"
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
die "'start' is now 'instance-start' — commands name their role (builder vs built)"
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "ERROR: unknown command: $COMMAND" >&2
|
echo "ERROR: unknown command: $COMMAND" >&2
|
||||||
usage
|
usage
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Verify the bwrap (bubblewrap) build precondition on a target host before
|
|
||||||
# running Werkator's bwrap build runtime there (step 17 / ADR 0007).
|
|
||||||
#
|
|
||||||
# The whole "build Werkator inside bubblewrap on a Managed Webspace" approach
|
|
||||||
# hinges on one hard precondition: unprivileged user namespaces with a uid-0
|
|
||||||
# mapping and read-only root binds must work. This script runs the exact
|
|
||||||
# command line recorded in docs/plan/17-bwrap-build-runtime.md, checks the
|
|
||||||
# expected signals, and additionally verifies the disk/quota situation:
|
|
||||||
# a bwrap build unpacks the rootfs (a zstd archive expands to several GiB)
|
|
||||||
# plus a Gradle distribution and per-branch caches, so the host needs both
|
|
||||||
# raw free space and enough group-quota headroom.
|
|
||||||
#
|
|
||||||
# Run this ON the target host (the webspace), no root needed.
|
|
||||||
#
|
|
||||||
# Optional: the rootfs archive to size the disk/quota check against, e.g.
|
|
||||||
# werkator-build-prerequisites.sh /path/to/werkator-buildenv-trixie.tar.zst
|
|
||||||
# When omitted, the check runs against a conservative default footprint.
|
|
||||||
#
|
|
||||||
# Usage: werkator-build-prerequisites.sh [TARGET_DIR] [ROOTFS_ARCHIVE]
|
|
||||||
#
|
|
||||||
# TARGET_DIR is the directory the build workspace will live in (default: $HOME).
|
|
||||||
# The check verifies it sits on the home filesystem and has enough free space.
|
|
||||||
# ROOTFS_ARCHIVE, when given, is the rootfs archive that will be used there.
|
|
||||||
#
|
|
||||||
# Output is one PASS/FAIL line per check plus a final RESULT line, e.g.:
|
|
||||||
# PASS: bwrap version: bubblewrap 0.8.0
|
|
||||||
# PASS: build runs as root inside the namespace (uid 0)
|
|
||||||
# PASS: uid_map maps root back to the unprivileged user (uid 120957)
|
|
||||||
# PASS: read-only root bind is enforced
|
|
||||||
# PASS: at least 5 GiB free space on the build working filesystem
|
|
||||||
# FAIL: group quota headroom below the 5 GiB build footprint ...
|
|
||||||
# RESULT: FAIL (4/5) — Werkator bubblewrap builds are not usable on this host.
|
|
||||||
#
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
target_dir_arg="${1:-}"
|
|
||||||
rootfs_arg="${2:-}"
|
|
||||||
|
|
||||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
|
||||||
|
|
||||||
# Disk footprint a bwrap build needs headroom for, in 1K blocks: unpacked
|
|
||||||
# rootfs (zstd expands roughly 3-4x), Gradle distribution + per-branch cache,
|
|
||||||
# build output and artifacts. ~5 GiB.
|
|
||||||
MIN_FREE_BLOCKS=$((5 * 1024 * 1024))
|
|
||||||
|
|
||||||
# Reference filesystem: the one the invoking user's home directory lives on.
|
|
||||||
# Builds (repo clone, buildenv, caches) must run there — other mounts, such as
|
|
||||||
# a slow mass-storage volume, are rejected.
|
|
||||||
HOME_FS="$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {print $1}')"
|
|
||||||
|
|
||||||
pass=0
|
|
||||||
fail=0
|
|
||||||
result() { # result PASS|FAIL "message"
|
|
||||||
echo "$1: $2"
|
|
||||||
if [ "$1" = "PASS" ]; then pass=$((pass+1)); else fail=$((fail+1)); fi
|
|
||||||
}
|
|
||||||
|
|
||||||
command -v bwrap >/dev/null 2>&1 || die "bwrap is not installed on this host"
|
|
||||||
|
|
||||||
output="$(bwrap --unshare-user --unshare-pid --die-with-parent --uid 0 --gid 0 \
|
|
||||||
--ro-bind / / --dev /dev --proc /proc --tmpfs /tmp \
|
|
||||||
sh -c 'id -u && cat /proc/self/uid_map && (touch /usr/ro-test 2>&1 || true)' 2>&1)" ||
|
|
||||||
die "bwrap invocation failed (no user namespace support?): $output"
|
|
||||||
|
|
||||||
# Signal 0: bwrap itself is usable (version as a visible marker).
|
|
||||||
result PASS "bwrap version: $(bwrap --version 2>&1)"
|
|
||||||
|
|
||||||
# Signal 1: runs as root (uid 0) inside the namespace.
|
|
||||||
first="$(printf '%s\n' "$output" | sed -n '1p')"
|
|
||||||
if [ "$first" = "0" ]; then
|
|
||||||
result PASS "build runs as root inside the namespace (uid 0)"
|
|
||||||
else
|
|
||||||
result FAIL "expected uid 0 inside the namespace, got: $first"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Signal 2: uid_map maps root to the invoking unprivileged user.
|
|
||||||
uid_line="$(printf '%s\n' "$output" | sed -n '2p')"
|
|
||||||
self_uid="$(id -u)"
|
|
||||||
if printf '%s\n' "$uid_line" | grep -E "^[[:space:]]*0[[:space:]]+${self_uid}[[:space:]]+1" >/dev/null; then
|
|
||||||
result PASS "uid_map maps root back to the unprivileged user (uid $self_uid)"
|
|
||||||
else
|
|
||||||
result FAIL "expected uid_map '0 $self_uid 1', got: $uid_line"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Signal 3: the read-only root bind is enforced (a write to /usr fails).
|
|
||||||
if printf '%s\n' "$output" | grep -qi "read-only file system"; then
|
|
||||||
result PASS "read-only root bind is enforced"
|
|
||||||
else
|
|
||||||
result FAIL "the read-only root bind did not reject a write to /usr"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Disk / quota checks ------------------------------------------------
|
|
||||||
|
|
||||||
target_dir="${target_dir_arg:-$HOME}"
|
|
||||||
target_dir="$(realpath -m "$target_dir")"
|
|
||||||
min_gib=$((MIN_FREE_BLOCKS / 1024 / 1024))
|
|
||||||
|
|
||||||
if [ -n "$rootfs_arg" ] && [ ! -f "$rootfs_arg" ]; then
|
|
||||||
echo "WARNING: rootfs archive not found: $rootfs_arg (continuing without it)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
target_fs="$(df -Pk "$target_dir" 2>/dev/null | awk 'NR==2 {print $1}')"
|
|
||||||
df_output="$(df -Pk "$target_dir" 2>/dev/null | awk 'NR==2 {print int($4) " " $6}')"
|
|
||||||
if [ -n "$df_output" ]; then
|
|
||||||
avail_k="${df_output%% *}"
|
|
||||||
mount="${df_output##* }"
|
|
||||||
if [ -n "$HOME_FS" ] && [ "$target_fs" != "$HOME_FS" ]; then
|
|
||||||
# An explicitly chosen foreign filesystem is allowed (e.g. for testing)
|
|
||||||
# but flagged: builds there will be slow.
|
|
||||||
echo "WARNING: target dir is on $target_fs (mounted at $mount), not the home filesystem ($HOME_FS) — builds will run on slower storage"
|
|
||||||
fi
|
|
||||||
if [ "${avail_k:-0}" -lt "$MIN_FREE_BLOCKS" ]; then
|
|
||||||
result FAIL "less than ${min_gib} GiB free space on the build working filesystem ($mount)"
|
|
||||||
else
|
|
||||||
result PASS "at least ${min_gib} GiB free space on the build working filesystem ($mount, device $target_fs)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "WARNING: could not measure free space on $target_dir — only the quota check below applies"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if quota_output="$(quota -g 2>/dev/null)" && [ -n "$quota_output" ]; then
|
|
||||||
quota_ok=1
|
|
||||||
quota_seen=0
|
|
||||||
detail=""
|
|
||||||
while read -r fs blocks quota_limit; do
|
|
||||||
quota_seen=1
|
|
||||||
# Only the quota of the target filesystem counts — other volumes may
|
|
||||||
# legitimately be full or unquota'd without affecting the build.
|
|
||||||
if [ -n "$target_fs" ] && [ "$(basename "$fs")" != "$(basename "$target_fs")" ] && [ "$fs" != "$target_fs" ]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
headroom=$((quota_limit - blocks))
|
|
||||||
if [ "$headroom" -lt "$MIN_FREE_BLOCKS" ]; then
|
|
||||||
quota_ok=0
|
|
||||||
detail+=" $(basename "$fs"): $(awk -v b="$headroom" 'BEGIN{printf "%.1f", b/1024/1024}') GiB free of quota;"
|
|
||||||
fi
|
|
||||||
done < <(printf '%s\n' "$quota_output" | awk '
|
|
||||||
NF==1 && $1 ~ /^\// { pending_fs=$1; next }
|
|
||||||
$1 ~ /^\// && $2 ~ /^[0-9]+$/ { print $1, $2, $4; pending_fs=""; next }
|
|
||||||
$1 ~ /^[0-9]+[*]?/ && pending_fs != "" { gsub(/\*/, "", $1); print pending_fs, $1, $3; pending_fs="" }')
|
|
||||||
if [ "$quota_seen" -eq 0 ]; then
|
|
||||||
echo "WARNING: quota tooling present but no group quota lines could be parsed — only free space was checked"
|
|
||||||
elif [ "$quota_ok" -eq 1 ]; then
|
|
||||||
result PASS "group quota headroom covers the ${min_gib} GiB build footprint"
|
|
||||||
else
|
|
||||||
result FAIL "group quota headroom below the ${min_gib} GiB build footprint (rootfs + Gradle cache); raise the quota before building.$detail"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "WARNING: no readable group quota tooling on this host — only free space was checked"
|
|
||||||
fi
|
|
||||||
|
|
||||||
total=$((pass + fail))
|
|
||||||
echo
|
|
||||||
if [ "$fail" -eq 0 ]; then
|
|
||||||
echo "RESULT: PASS ($pass/$total) — Werkator bubblewrap builds are usable on this host."
|
|
||||||
echo "Next: install the Werkator instance with: tools/remote werkator install ${WERKATOR_SSH_TARGET:-<user>@<host>} '$target_dir'"
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo "RESULT: FAIL ($pass/$total) — Werkator bubblewrap builds are not usable on this host."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/werkdock
|
||||||
|
/dist/
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Werkdock
|
||||||
|
|
||||||
|
A docker-like sandbox CLI over `bwrap` — filesystem isolation only.
|
||||||
|
A dock is the enclosed basin in which ships are built: the dock gate controls what passes, the water outside is shared with the whole harbor.
|
||||||
|
Accordingly, network, uid, `/proc`, `/dev`, and `/tmp` come from the host by contract; that is what makes Werkdock work without root on a Hostsharing Managed Webspace.
|
||||||
|
|
||||||
|
Semantics — docker-compatible as far as the filesystem-only contract allows (see [RFC 0002](docs/rfcs/0002-docker-compatible-surface.md)):
|
||||||
|
|
||||||
|
- An *image* is a rootfs archive; an *instance* is an unpacked, writable directory tree and corresponds to a docker container.
|
||||||
|
- `werkdock run [flags] IMAGE [CMD...]` creates an instance and executes in the sandbox with uid 0 mapped to the calling user; verbs and flags follow docker, unsupported docker flags fail loudly.
|
||||||
|
- `werkdock doctor` checks the host: user-namespace capability, disk and quota headroom.
|
||||||
|
- A daemon speaking the Docker Engine API subset (for Testcontainers) is designed for but deferred.
|
||||||
|
|
||||||
|
## Disk Footprint
|
||||||
|
|
||||||
|
Werkdock's storage model is coarser than Docker's on the image side and cheaper on the instance side:
|
||||||
|
|
||||||
|
- An image is a flat, complete directory tree — there are no layers, and nothing is shared between images.
|
||||||
|
A JDK+Go+Node build image is roughly 2 GiB unpacked, plus its compressed archive (~0.5 GiB) as long as that is kept around.
|
||||||
|
- An instance costs (almost) nothing: the rootfs is bound read-only into every sandbox, writable are only tmpfs (`/tmp`, `/root`) and the caller's binds.
|
||||||
|
Ten parallel runs in one image add zero filesystem copies; what grows per project are its own caches in bound volumes.
|
||||||
|
- Consequence: prefer ONE fat image shared by all projects over per-project images.
|
||||||
|
- Watch out for orphans: consumers that key an unpacked environment by the archive's source path (Werkator's bwrap runtime does) leave the old tree behind on every path change; pruning is manual until `rmi`/`prune` verbs exist.
|
||||||
|
- Future options that would remove the flat-tree cost, in their own RFCs when they come due: composable toolchain mounts — a slim base plus per-toolchain prefix binds, no overlayfs needed ([RFC 0003](docs/rfcs/0003-composable-toolchain-mounts.md), candidate) — overlayfs layers (the kernel allows it unprivileged in a user namespace since 5.11; the webspaces' bwrap 0.8.0 cannot yet), or hardlink deduplication between image versions in the store (the ostree principle, no root needed).
|
||||||
|
|
||||||
|
## Build and Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./... # all tests; sandbox integration tests skip without bwrap/userns
|
||||||
|
go vet ./... && gofmt -l . # quality gates (gofmt must print nothing)
|
||||||
|
CGO_ENABLED=0 go build . # one static linux binary, ~3 MB
|
||||||
|
```
|
||||||
|
|
||||||
|
First steps on a host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
werkdock doctor # can this host run sandboxes?
|
||||||
|
werkdock load -i rootfs.tar.zst # import a rootfs archive as an image
|
||||||
|
werkdock run --rm -v /repo:/repo -w /repo IMAGE sh -c './gradlew build'
|
||||||
|
```
|
||||||
|
|
||||||
|
Status: bootstrap.
|
||||||
|
The implementation language is Go, decided in [RFC 0001](docs/rfcs/0001-implementation-language.md).
|
||||||
|
Werkdock grows in this subdirectory of the Werkator repository and moves to its own repository once it stands on its own.
|
||||||
|
It must stay self-contained: no imports from Werkator code, no Gradle coupling to the Werkator build.
|
||||||
|
The roadmap is session B of [docs/plan/21-werkdock-extraction-and-webspace-install.md](../docs/plan/21-werkdock-extraction-and-webspace-install.md).
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# RFC 0001: Implementation Language for Werkdock
|
||||||
|
|
||||||
|
**Status:**
|
||||||
|
- proposed: 2026-09-01
|
||||||
|
- accepted: 2026-09-01
|
||||||
|
- rejected: -
|
||||||
|
|
||||||
|
**Proposal:** Werkdock is implemented in **Go** — as a single static binary, stdlib-only, with the sandbox engine behind an interface so bwrap can later be replaced by native namespaces.
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
Werkdock is a docker-like sandbox CLI over `bwrap`, filesystem isolation only (see [README](../../README.md) and Werkator plan step 21).
|
||||||
|
Three hard requirements drive the language choice:
|
||||||
|
|
||||||
|
1. **Distribution to a Managed Webspace without root** — the tool must arrive and run with no package installation and no runtime dependency on the host.
|
||||||
|
2. **The work is process and filesystem orchestration** — spawning `bwrap`/`tar`/`zstd` with streamed logs and forwarded signals, assembling mount arguments, `doctor` checks.
|
||||||
|
3. **Self-contained and testable** — no code sharing and no build coupling with Werkator; the integration is `werkdock run` as a CLI call, like git and docker.
|
||||||
|
|
||||||
|
Two further criteria matter in this project:
|
||||||
|
|
||||||
|
- **AI-generated code quality** — the tool is developed AI-assisted; languages where generated code is reliably correct and idiomatic reduce review load.
|
||||||
|
- **Security** — Werkdock assembles mount arguments and uid mappings from user input; language safety and a small supply chain count.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
bash, Python 3, Kotlin Native, Rust, Go.
|
||||||
|
|
||||||
|
Scoring: −2 (unsuitable) to +2 (ideal), unweighted sum.
|
||||||
|
|
||||||
|
| Criterion | bash | Python 3 | Kotlin Native | Rust | Go |
|
||||||
|
|---|---:|---:|---:|---:|---:|
|
||||||
|
| Distribution to webspace (no root) | +2 | +1 | −1 | +2 | +2 |
|
||||||
|
| Fit for process/FS orchestration | +1 | +2 | 0 | +2 | +2 |
|
||||||
|
| Testability | −2 | +2 | +1 | +2 | +2 |
|
||||||
|
| Robustness/maintainability as it grows | −2 | +1 | +1 | +2 | +2 |
|
||||||
|
| Closeness to the maintainer's stack (Kotlin dev) | 0 | +1 | +2 | −1 | +1 |
|
||||||
|
| Genre references to learn from | −1 | 0 | −1 | +1 | +2 |
|
||||||
|
| Future: own namespaces instead of bwrap | −2 | −1 | 0 | +2 | +1 |
|
||||||
|
| Toolchain/build effort | +2 | +2 | −2 | 0 | +2 |
|
||||||
|
| AI-generated code quality | −1 | +2 | 0 | +1 | +2 |
|
||||||
|
| Security | −2 | +1 | +1 | +2 | +2 |
|
||||||
|
| **Sum** | **−5** | **+11** | **+1** | **+13** | **+18** |
|
||||||
|
|
||||||
|
The ranking is robust against re-weighting: Go scores below +1 in no criterion — it wins by absence of weaknesses, not by one outlier.
|
||||||
|
|
||||||
|
### bash
|
||||||
|
|
||||||
|
Out on principle: the Werkator repository exists because a grown bash CI script became unmaintainable.
|
||||||
|
A tool with subcommands, image/instance state, and doctor checks starts beyond the bash comfort zone.
|
||||||
|
AI generates bash fluently but with the classic silent defects (quoting, word splitting, unchecked exit codes), and the missing test story means nobody notices.
|
||||||
|
Security −2 is earned: injection via word splitting in exactly the kind of code Werkdock writes — user-supplied paths assembled into mount arguments.
|
||||||
|
The existing scripts serve as specification, not as foundation.
|
||||||
|
|
||||||
|
### Python 3
|
||||||
|
|
||||||
|
The best "no new compiler" candidate: present on every Debian webspace, the stdlib suffices (unpacking `tar.zst` shells out to `zstd` anyway), excellent testability, excellent AI generation.
|
||||||
|
Weaknesses: version drift across hosts (3.11/3.13), no static type check at runtime, and the tool runs as a tamperable source file on the host interpreter instead of as a binary.
|
||||||
|
|
||||||
|
### Kotlin Native
|
||||||
|
|
||||||
|
Loses despite maximum stack closeness, and not narrowly — the weakness sits exactly where Werkdock lives:
|
||||||
|
|
||||||
|
- **The stdlib gap hits the tool's core.** Kotlin never had its own system libraries; on the JVM it delegates file, process, and IO work to the JDK. On Native that platform library is gone and only `platform.posix` remains. Werkdock's central operation — spawning processes with log streaming, signal forwarding, and exit codes — means hand-written `fork`/`execvp`/`waitpid` over cinterop.
|
||||||
|
- **Kotlin Native was built for iOS, not for CLI tools.** The driver was Kotlin Multiplatform (no JVM allowed on iPhone); the kotlinx ecosystem grew what mobile apps need. Mobile apps never spawn child processes, so no official process API exists.
|
||||||
|
- **AI drifts to the JVM.** The Kotlin training corpus is overwhelmingly JVM/Android; models reliably propose `ProcessBuilder` and `java.nio`, which do not exist on Native.
|
||||||
|
- **Distribution is build-machine-bound.** Unlike the jlink bundle (which copies Temurin's prebuilt binaries, glibc floor 2.15, measured in Werkator ADR 0006), Kotlin Native compiles locally, so the binary's glibc floor is the build machine's.
|
||||||
|
- **The expected payoff never materializes.** There is no shared code and no shared build graph with Werkator by design; "same language" buys only developer familiarity — and JVM-library-free Native Kotlin feels more foreign than Go does after a week.
|
||||||
|
|
||||||
|
The honest variant of language consistency — Kotlin/JVM plus a jlink bundle like Werkator itself — was not on the ballot and would be disproportionate: a ~66 MB bundle for a sandbox helper copied to foreign webspaces, against one static Go binary.
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
|
||||||
|
Technically the strongest language for the genre and the best if Werkdock one day opens namespaces itself (direct syscalls, `youki` as a memory-safe sandbox reference).
|
||||||
|
Price: the steepest learning curve for a Kotlin developer and the slowest progress; AI-generated Rust needs iterations at the borrow checker, which the compiler at least enforces loudly.
|
||||||
|
|
||||||
|
### Go
|
||||||
|
|
||||||
|
The sweet spot:
|
||||||
|
|
||||||
|
- The container world Werkdock imitates is written in Go — docker CLI, podman, runc — so every subproblem has a proven, readable reference.
|
||||||
|
- One static binary (`CGO_ENABLED=0`) is the perfect webspace distribution; cross-compilation is a `GOOS`/`GOARCH` pair; builds take seconds.
|
||||||
|
- Testing is built in; `gofmt` knows exactly one style, which makes AI-generated Go above-average correct on the first attempt.
|
||||||
|
- The stdlib covers everything the tool does (`os/exec`, `os`, `io`, `archive/tar`), keeping the dependency list near zero — the smallest supply chain in the field.
|
||||||
|
- Coming from Kotlin, Go is productive within days: garbage collector, familiar concepts, deliberately small language.
|
||||||
|
|
||||||
|
## The Namespace Future, Concretely
|
||||||
|
|
||||||
|
Own namespaces instead of shelling out to `bwrap` are a real option, and Go keeps it open:
|
||||||
|
|
||||||
|
- The webspace kernel provably allows unprivileged user namespaces — Debian's `bwrap` has not been setuid since bookworm and uses nothing else.
|
||||||
|
- Go needs no cgo for it: namespaces are created when spawning the child via `SysProcAttr` (`Cloneflags`, `UidMappings`/`GidMappings`), with the usual re-exec pattern (`werkdock run` starts itself as a hidden init subcommand inside the fresh namespaces, sets up mounts, then execs the payload).
|
||||||
|
- The concrete payoff: since kernel 5.11, overlayfs mounts are allowed inside a user namespace unprivileged — the webspace runs 6.1, but its `bubblewrap 0.8.0` has no `--overlay` (added in 0.9.0). Own namespace code could provide the throwaway writable layer per build today.
|
||||||
|
- The counterweight: `bwrap` is hardened, Flatpak-tested code, and if the platform ever adopts an AppArmor userns restriction (as Ubuntu 24.04 did), the distribution's `bwrap` would likely stay permitted while a brought-along binary gets its `clone()` refused.
|
||||||
|
|
||||||
|
Consequence for the design, independent of the engine question's outcome: the sandbox engine sits behind an interface from the start — engine 1 is `bwrap` (present, proven, invocation logic exists), engine 2 can later be native namespaces.
|
||||||
|
|
||||||
|
## Concrete Proposal
|
||||||
|
|
||||||
|
1. **Language**: Go, current stable toolchain, pinned in `go.mod` (`toolchain` directive).
|
||||||
|
2. **Module**: `werkdock` as its own Go module in this subdirectory — no Gradle involvement, `go build` / `go test` / `go vet` are the whole toolchain.
|
||||||
|
3. **Dependency policy**: stdlib-only; any third-party dependency needs an RFC.
|
||||||
|
4. **Distribution**: one static linux/amd64 binary, built with `CGO_ENABLED=0`; other architectures are a build-matrix entry away if ever needed.
|
||||||
|
5. **Style and quality gates**: `gofmt` (enforced), `go vet`, table-driven tests with the built-in `testing` package.
|
||||||
|
6. **Architecture**: CLI semantics (`run`, images, instances, `doctor`) decoupled from a sandbox engine interface; `bwrap` is the first engine, native namespaces a possible second.
|
||||||
|
7. **External processes**: `bwrap`, `tar`, `zstd` are called as CLIs via `os/exec` — the same pattern Werkator uses for git and docker.
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
Accepted on 2026-09-01: Werkdock is implemented in Go, under the terms of the concrete proposal above.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# RFC 0002: Docker-Compatible Surface
|
||||||
|
|
||||||
|
**Status:**
|
||||||
|
- proposed: 2026-09-01
|
||||||
|
- accepted: 2026-09-01 (level 1 as the shape of the CLI; levels 2 and 3 deferred indefinitely)
|
||||||
|
- rejected: -
|
||||||
|
|
||||||
|
**Proposal:** Werkdock's user-facing surface follows Docker wherever the filesystem-only contract allows: level 1 is a docker-compatible CLI (verbs, flags, exit codes), level 2 is pulling OCI images from registries, level 3 is a daemon offering the Docker Engine REST API subset that Testcontainers needs.
|
||||||
|
Level 1 is built in session B; levels 2 and 3 are designed for but deferred.
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
The requirement (2026-09-01): the CLI — and a daemon API, if one is needed — shall be docker-compatible as far as possible, also to enable integrating Testcontainers later.
|
||||||
|
|
||||||
|
Docker compatibility is not one thing; it comes in three separable levels, and Testcontainers forces a position on each:
|
||||||
|
|
||||||
|
1. **CLI compatibility** — `werkdock run` takes the flags a docker user already knows. Cheap, pure design discipline, and it makes every docker tutorial partially applicable.
|
||||||
|
2. **Image compatibility** — a werkdock image today is a self-built rootfs archive; docker images are OCI images from registries. Pulling and flattening OCI images makes the world's images usable.
|
||||||
|
3. **API compatibility** — Testcontainers never invokes the CLI; it speaks the Docker Engine REST API over a unix socket (`DOCKER_HOST`). Podman achieves Testcontainers support exactly this way (`podman system service`). Without this level there is no Testcontainers, regardless of the CLI.
|
||||||
|
|
||||||
|
## What Testcontainers Actually Needs
|
||||||
|
|
||||||
|
From observing docker-java/testcontainers-java against real daemons:
|
||||||
|
|
||||||
|
- `/version` and `/info` handshakes; then image pull (level 2 is a prerequisite), container create/start/inspect/logs/wait/remove.
|
||||||
|
- Port mapping: create requests an exposed container port with an empty host port, inspect must answer with the mapped ephemeral host port (`NetworkSettings.Ports`).
|
||||||
|
- The Ryuk reaper container (disableable via `TESTCONTAINERS_RYUK_DISABLED=true`).
|
||||||
|
|
||||||
|
The port mapping is the crux for Werkdock: with filesystem-only isolation there is no network namespace, the payload binds host ports directly.
|
||||||
|
Two consequences:
|
||||||
|
|
||||||
|
- "Mapping" degenerates to identity — inspect reports the port the service actually bound. Workable for sequential CI use.
|
||||||
|
- Two containers wanting the same fixed port collide, exactly as with docker's `--network=host`.
|
||||||
|
|
||||||
|
The honest way out, if Testcontainers support ever becomes serious: unprivileged network namespaces are available inside a user namespace (rootless podman does networking this way, via a userspace stack — pasta/slirp4netns).
|
||||||
|
That would be a deliberate, opt-in extension of the filesystem-only contract, decided in its own RFC — not implied by this one.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
* Docker-compatible from the start on all three levels — rejected: level 3 without a consumer is speculation, and the Ryuk/port semantics need real Testcontainers runs to validate against.
|
||||||
|
* Own CLI idioms (`werkdock run <instance> -- <cmd>` as sketched in plan step 21), compatibility later — rejected: retrofitting docker semantics onto a shipped CLI breaks users; the compatibility must shape the surface from day one.
|
||||||
|
* Docker-compatible CLI now, API-ready architecture, levels 2 and 3 deferred — chosen.
|
||||||
|
|
||||||
|
## Concrete Proposal
|
||||||
|
|
||||||
|
### Level 1 — CLI (session B)
|
||||||
|
|
||||||
|
Verbs and flags follow docker; unsupported docker flags fail loudly with a reason, never silently no-op:
|
||||||
|
|
||||||
|
| Werkdock | Docker equivalent | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `werkdock run [flags] IMAGE [CMD...]` | `docker run` | creates an instance from the image, runs CMD |
|
||||||
|
| `werkdock create` / `start` / `stop` / `rm` | same | instance lifecycle |
|
||||||
|
| `werkdock ps [-a]` | same | running/all instances |
|
||||||
|
| `werkdock images` / `rmi` | same | local image store |
|
||||||
|
| `werkdock load -i FILE` | `docker load` | imports a rootfs archive as an image |
|
||||||
|
| `werkdock exec INSTANCE CMD...` | `docker exec` | additional process in a running sandbox |
|
||||||
|
| `werkdock logs [-f] INSTANCE` | `docker logs` | |
|
||||||
|
| `werkdock inspect NAME` | `docker inspect` | JSON, docker-shaped where fields apply |
|
||||||
|
| `werkdock doctor` | *(none)* | host capability and quota check; `info` aliases the summary |
|
||||||
|
|
||||||
|
Supported `run` flags from the start: `-v/--volume` (bind mounts), `-e/--env`, `-w/--workdir`, `--rm`, `--name`, `-d/--detach`, `--entrypoint`.
|
||||||
|
Refused with explanation: everything that promises isolation Werkdock does not provide (`-p/--publish`, `--network`, `--memory`, `--cpus`, `--user` beyond the fixed uid-0 mapping).
|
||||||
|
|
||||||
|
Semantic shift against the step-21 sketch: `run` takes an **image** (docker semantics), not a pre-unpacked instance; instances are created per run and correspond to docker containers.
|
||||||
|
`--rm` deletes the instance tree afterwards; without it, `ps -a`/`start` see it again.
|
||||||
|
|
||||||
|
### Level 2 — OCI images (deferred, designed for)
|
||||||
|
|
||||||
|
`werkdock pull IMAGE[:TAG]` fetches from an OCI registry (Docker Hub et al.) and flattens the layers into a rootfs.
|
||||||
|
This is HTTP + JSON + tar with whiteout handling — implementable within the stdlib-only policy (RFC 0001), but a substantial work package (registry auth token dance included).
|
||||||
|
Until then, `werkdock load` and the self-built rootfs archives carry the image store.
|
||||||
|
|
||||||
|
### Level 3 — daemon API (deferred, designed for)
|
||||||
|
|
||||||
|
`werkdock daemon` serves the Docker Engine API subset from "What Testcontainers Actually Needs" on a unix socket; consumers set `DOCKER_HOST=unix://$XDG_RUNTIME_DIR/werkdock.sock`.
|
||||||
|
Architecture consequence now: the CLI must not own the lifecycle logic — verbs are thin frontends over the same internal service the daemon would expose, and instance state lives on disk in a format both can read.
|
||||||
|
Ryuk stays disabled in documentation until proven.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Plan step 21 session B and the README change their CLI sketch to the docker-shaped surface above.
|
||||||
|
- The engine interface from RFC 0001 is unaffected — compatibility shapes the surface, engines stay swappable behind it.
|
||||||
|
- Testcontainers remains a stated goal, not a claim: it is validated the day level 3 exists, and the port-collision limitation is documented until a network-namespace RFC changes it.
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
Decided 2026-09-01: level 1 shapes the CLI — verbs and flags follow docker, unsupported flags fail loudly.
|
||||||
|
Levels 2 and 3 (OCI pull, daemon API, Testcontainers) are deferred indefinitely; nothing in the code may make them harder, nothing is built for them now.
|
||||||
|
The immediate goal is narrower than level 1's full verb list: `doctor`, `load`, and `run` — enough for the sandbox builds of Werkator, Werkbaum, and Werkdock itself; the remaining verbs follow with need.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# RFC 0003: Composable Toolchain Mounts
|
||||||
|
|
||||||
|
**Status:**
|
||||||
|
- proposed: 2026-09-01 (as a candidate — comes due when more than one toolchain combination is needed)
|
||||||
|
- accepted: -
|
||||||
|
- rejected: -
|
||||||
|
|
||||||
|
**Proposal:** Instead of baking every toolchain combination into its own flat image, werkdock composes a sandbox at run time: a slim base image plus per-toolchain artifacts from the store, mounted read-only under their own prefixes.
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
Werkdock images are flat trees without layers (see the Disk Footprint section of the README): every toolchain combination is a full archive, built, uploaded, and unpacked as a whole.
|
||||||
|
The pain is concrete: adding Go and Node to the JDK build environment meant rebuilding and re-uploading a ~600 MB archive whose JDK half did not change.
|
||||||
|
Docker solves this with content-addressed layers over overlayfs — which needs either root or an overlay-capable bwrap (0.9+), neither available on the target webspaces today.
|
||||||
|
|
||||||
|
## The Key Insight
|
||||||
|
|
||||||
|
Overlayfs is only needed when trees must merge *at the same paths*.
|
||||||
|
Toolchains that live under their own prefix need no merging at all — and the official tarball distributions do exactly that:
|
||||||
|
|
||||||
|
- Go unpacks to `/usr/local/go`
|
||||||
|
- Node unpacks to `/usr/local/node-<version>`
|
||||||
|
- Temurin JDKs unpack to `/usr/local/jdk-<version>`
|
||||||
|
|
||||||
|
So composition is plain bind mounts, available in every bwrap version, no root, no overlayfs:
|
||||||
|
|
||||||
|
```
|
||||||
|
werkdock run --rm --with jdk-21 --with go-1.24 --with node-20 base sh -c '...'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sketch
|
||||||
|
|
||||||
|
- The base image shrinks to what apt must provide (debootstrap minbase, git, ca-certificates, locales — roughly 300 MB unpacked).
|
||||||
|
- A *toolchain* is a store artifact beside images: an unpacked tarball plus a small manifest naming its mount prefix and the environment it needs (`PATH` entries, `JAVA_HOME`, `GOROOT`, ...).
|
||||||
|
- `--with NAME` adds a read-only bind of the toolchain at its prefix and applies its manifest environment; order follows the flags, like `-v`.
|
||||||
|
- Deduplication falls out for free: each toolchain is stored once, every combination costs zero additional disk.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
- Only tarball-distributed toolchains fit; apt-installed ones spread across `/usr` and cannot be prefix-mounted.
|
||||||
|
For JDK, Go, and Node the official tarballs exist; toolchains without one stay in the base image.
|
||||||
|
- `--with` is a werkdock extension beyond the docker-compatible surface (RFC 0002) — docker has no counterpart.
|
||||||
|
It is additive: level-1 compatibility of the remaining CLI is untouched.
|
||||||
|
|
||||||
|
## Considered Alternatives
|
||||||
|
|
||||||
|
- On-target image building (apt/mmdebstrap on the webspace, unprivileged): technically possible via user namespaces, but slow, network-bound per build, and a relapse into the self-build drift step 21 corrects — build locally, install artifacts.
|
||||||
|
- Letting package managers fill a persistent home cache (Gradle toolchains, Go modules): works today as a side effect, but unhermetic and network-dependent on cold caches.
|
||||||
|
- Overlayfs layers or hardlink dedup between image versions: the general solutions, still worthwhile later, but blocked on bwrap 0.9+ (overlay) or more store machinery (dedup) — composition needs neither.
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
Pending — to be decided when a second toolchain combination is actually needed (for example Werkbaum pinning its own Node version).
|
||||||
|
Until then the one fat image (RFC 0002 outcome, plan step 21) stays the deliberate choice.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module werkdock
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Package cli parses werkdock's docker-shaped command line (RFC 0002)
|
||||||
|
// and dispatches to the internal packages. Exit codes follow docker:
|
||||||
|
// 125 for werkdock's own errors, otherwise the sandboxed command's code
|
||||||
|
// is passed through.
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is replaced at release time; the dev default marks unreleased
|
||||||
|
// builds.
|
||||||
|
var Version = "0.1.0-dev"
|
||||||
|
|
||||||
|
const exitCLIError = 125
|
||||||
|
|
||||||
|
// Main runs the CLI and returns the process exit code.
|
||||||
|
func Main(args []string) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
usage(os.Stderr)
|
||||||
|
return exitCLIError
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "run":
|
||||||
|
return runCmd(args[1:])
|
||||||
|
case "load":
|
||||||
|
return loadCmd(args[1:])
|
||||||
|
case "images":
|
||||||
|
return imagesCmd(args[1:])
|
||||||
|
case "doctor":
|
||||||
|
return doctorCmd(args[1:])
|
||||||
|
case "version", "--version":
|
||||||
|
fmt.Printf("werkdock %s\n", Version)
|
||||||
|
return 0
|
||||||
|
case "help", "--help", "-h":
|
||||||
|
usage(os.Stdout)
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "werkdock: unknown command %q\n\n", args[0])
|
||||||
|
usage(os.Stderr)
|
||||||
|
return exitCLIError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage(w io.Writer) {
|
||||||
|
fmt.Fprint(w, `werkdock — a docker-like sandbox CLI over bwrap, filesystem isolation only.
|
||||||
|
Network, uid, /proc, /dev, and /tmp come from the host by contract.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
werkdock run [flags] IMAGE COMMAND [ARG...] run a command in a sandbox
|
||||||
|
werkdock load -i ARCHIVE [--name NAME] import a rootfs archive as an image
|
||||||
|
werkdock images list loaded images, one name per line
|
||||||
|
werkdock doctor [TARGET_DIR] check whether this host can run sandboxes
|
||||||
|
werkdock version print the version
|
||||||
|
|
||||||
|
Run flags:
|
||||||
|
-v, --volume SRC:DEST[:ro] bind mount (repeatable; -v and --tmpfs apply in flag order)
|
||||||
|
--tmpfs DEST empty tmpfs at DEST (repeatable)
|
||||||
|
-e, --env KEY=VALUE set an environment variable (KEY alone copies it from the host)
|
||||||
|
-w, --workdir DIR working directory inside the sandbox (default /)
|
||||||
|
--rm remove the instance afterwards (currently required)
|
||||||
|
|
||||||
|
The store lives in $WERKDOCK_HOME (default ~/.werkdock).
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(err error) int {
|
||||||
|
fmt.Fprintf(os.Stderr, "werkdock: %v\n", err)
|
||||||
|
return exitCLIError
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"werkdock/internal/doctor"
|
||||||
|
"werkdock/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func doctorCmd(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(io.Discard)
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
targetDir := ""
|
||||||
|
switch len(fs.Args()) {
|
||||||
|
case 0:
|
||||||
|
st, err := store.Default()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
targetDir = st.Root
|
||||||
|
// The store may not exist yet; measure its closest existing
|
||||||
|
// ancestor, which sits on the same filesystem.
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(targetDir); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(targetDir)
|
||||||
|
if parent == targetDir {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
targetDir = parent
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
targetDir = fs.Args()[0]
|
||||||
|
default:
|
||||||
|
return fail(fmt.Errorf("unexpected argument %q", fs.Args()[1]))
|
||||||
|
}
|
||||||
|
report := doctor.Run(targetDir, os.Getuid(), runCombined)
|
||||||
|
report.Render(os.Stdout)
|
||||||
|
if report.OK() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCombined(name string, args ...string) (string, error) {
|
||||||
|
out, err := exec.Command(name, args...).CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"werkdock/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// imagesCmd prints the loaded image names, one per line — machine-usable
|
||||||
|
// (Werkator checks image existence through it) and close enough to
|
||||||
|
// `docker images --format '{{.Repository}}'`.
|
||||||
|
func imagesCmd(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("images", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(io.Discard)
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
if len(fs.Args()) != 0 {
|
||||||
|
return fail(fmt.Errorf("unexpected argument %q", fs.Args()[0]))
|
||||||
|
}
|
||||||
|
st, err := store.Default()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
names, err := st.List()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
fmt.Println(name)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"werkdock/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadCmd(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("load", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(io.Discard)
|
||||||
|
var input, name string
|
||||||
|
fs.StringVar(&input, "i", "", "rootfs archive to import")
|
||||||
|
fs.StringVar(&input, "input", "", "rootfs archive to import")
|
||||||
|
fs.StringVar(&name, "name", "", "image name (default: derived from the archive file name)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
if input == "" {
|
||||||
|
return fail(errors.New("load needs -i ARCHIVE"))
|
||||||
|
}
|
||||||
|
if len(fs.Args()) != 0 {
|
||||||
|
return fail(fmt.Errorf("unexpected argument %q", fs.Args()[0]))
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = store.ImageNameFromArchive(input)
|
||||||
|
}
|
||||||
|
st, err := store.Default()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
if err := st.Load(input, name); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Loaded image: %s\n", name)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"werkdock/internal/engine"
|
||||||
|
"werkdock/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runOptions is the parsed form of `werkdock run` flags, separated from
|
||||||
|
// execution so the parsing is testable and a later daemon can reuse it.
|
||||||
|
type runOptions struct {
|
||||||
|
Mounts []engine.Mount
|
||||||
|
Env []engine.EnvVar
|
||||||
|
Workdir string
|
||||||
|
Remove bool
|
||||||
|
Image string
|
||||||
|
Command []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCmd(args []string) int {
|
||||||
|
opts, err := parseRun(args, os.Getenv)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
st, err := store.Default()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
rootfs, err := st.RootFS(opts.Image)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
spec := engine.RunSpec{
|
||||||
|
RootFS: rootfs,
|
||||||
|
Mounts: hostMounts(opts.Mounts),
|
||||||
|
Env: opts.Env,
|
||||||
|
Workdir: opts.Workdir,
|
||||||
|
Command: opts.Command,
|
||||||
|
}
|
||||||
|
eng := &engine.Bwrap{}
|
||||||
|
code, err := eng.Run(spec)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostMounts prepends the host mounts the contract prescribes: DNS comes
|
||||||
|
// from the host, so /etc/resolv.conf is bound read-only when it exists —
|
||||||
|
// before the user mounts, so an explicit mount over /etc wins.
|
||||||
|
func hostMounts(mounts []engine.Mount) []engine.Mount {
|
||||||
|
var all []engine.Mount
|
||||||
|
if fi, err := os.Stat("/etc/resolv.conf"); err == nil && fi.Mode().IsRegular() {
|
||||||
|
all = append(all, engine.Mount{Mode: engine.MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"})
|
||||||
|
}
|
||||||
|
return append(all, mounts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRun parses the docker-shaped run flags. Docker flags whose
|
||||||
|
// promise werkdock cannot keep are registered and refused with a
|
||||||
|
// reason — never silently ignored (RFC 0002).
|
||||||
|
func parseRun(args []string, getenv func(string) string) (*runOptions, error) {
|
||||||
|
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(io.Discard)
|
||||||
|
var envs stringList
|
||||||
|
opts := &runOptions{}
|
||||||
|
// -v and --tmpfs collect into ONE ordered list: bwrap layers mounts in
|
||||||
|
// order, so a tmpfs between two binds (the git-metadata mask) must stay
|
||||||
|
// between them.
|
||||||
|
volumes := &mountFlag{mounts: &opts.Mounts}
|
||||||
|
tmpfs := &mountFlag{mounts: &opts.Mounts, tmpfs: true}
|
||||||
|
fs.Var(volumes, "v", "bind mount SRC:DEST[:ro]")
|
||||||
|
fs.Var(volumes, "volume", "bind mount SRC:DEST[:ro]")
|
||||||
|
fs.Var(tmpfs, "tmpfs", "empty tmpfs at DEST")
|
||||||
|
fs.Var(&envs, "e", "environment variable KEY=VALUE")
|
||||||
|
fs.Var(&envs, "env", "environment variable KEY=VALUE")
|
||||||
|
fs.StringVar(&opts.Workdir, "w", "", "working directory inside the sandbox")
|
||||||
|
fs.StringVar(&opts.Workdir, "workdir", "", "working directory inside the sandbox")
|
||||||
|
fs.BoolVar(&opts.Remove, "rm", false, "remove the instance afterwards")
|
||||||
|
refuse(fs, "p", "werkdock has no network isolation; the sandbox binds host ports directly")
|
||||||
|
refuse(fs, "publish", "werkdock has no network isolation; the sandbox binds host ports directly")
|
||||||
|
refuse(fs, "network", "the network is the host's by contract; there is nothing to configure")
|
||||||
|
refuse(fs, "memory", "werkdock does not manage resources; use the host's limits (e.g. systemd)")
|
||||||
|
refuse(fs, "cpus", "werkdock does not manage resources; use the host's limits (e.g. systemd)")
|
||||||
|
refuse(fs, "user", "the sandbox always runs uid 0 mapped to the calling user")
|
||||||
|
refuse(fs, "d", "detached instances are not implemented yet")
|
||||||
|
refuse(fs, "detach", "detached instances are not implemented yet")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !opts.Remove {
|
||||||
|
return nil, errors.New("persistent instances are not implemented yet; run with --rm")
|
||||||
|
}
|
||||||
|
rest := fs.Args()
|
||||||
|
if len(rest) == 0 {
|
||||||
|
return nil, errors.New("no image specified")
|
||||||
|
}
|
||||||
|
if len(rest) == 1 {
|
||||||
|
return nil, errors.New("no command specified (werkdock images carry no default command yet)")
|
||||||
|
}
|
||||||
|
opts.Image = rest[0]
|
||||||
|
opts.Command = rest[1:]
|
||||||
|
for _, e := range envs {
|
||||||
|
opts.Env = append(opts.Env, parseEnv(e, getenv))
|
||||||
|
}
|
||||||
|
if opts.Workdir != "" && !filepath.IsAbs(opts.Workdir) {
|
||||||
|
return nil, fmt.Errorf("workdir must be an absolute path: %s", opts.Workdir)
|
||||||
|
}
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVolume(v string) (engine.Mount, error) {
|
||||||
|
parts := strings.Split(v, ":")
|
||||||
|
if len(parts) < 2 || len(parts) > 3 {
|
||||||
|
return engine.Mount{}, fmt.Errorf("invalid volume %q, expected SRC:DEST[:ro]", v)
|
||||||
|
}
|
||||||
|
mount := engine.Mount{Mode: engine.MountBind, Source: parts[0], Dest: parts[1]}
|
||||||
|
if len(parts) == 3 {
|
||||||
|
switch parts[2] {
|
||||||
|
case "ro":
|
||||||
|
mount.Mode = engine.MountRoBind
|
||||||
|
case "rw":
|
||||||
|
// docker accepts :rw as the explicit default; so do we
|
||||||
|
default:
|
||||||
|
return engine.Mount{}, fmt.Errorf("invalid volume option %q in %q, only 'ro' and 'rw' are supported", parts[2], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(mount.Source) {
|
||||||
|
return engine.Mount{}, fmt.Errorf("volume source must be an absolute path: %s", mount.Source)
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(mount.Dest) {
|
||||||
|
return engine.Mount{}, fmt.Errorf("volume destination must be an absolute path: %s", mount.Dest)
|
||||||
|
}
|
||||||
|
return mount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mountFlag appends -v/--volume and --tmpfs values to one shared,
|
||||||
|
// ordered mount list.
|
||||||
|
type mountFlag struct {
|
||||||
|
mounts *[]engine.Mount
|
||||||
|
tmpfs bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *mountFlag) String() string { return "" }
|
||||||
|
|
||||||
|
func (f *mountFlag) Set(v string) error {
|
||||||
|
if f.tmpfs {
|
||||||
|
if !filepath.IsAbs(v) {
|
||||||
|
return fmt.Errorf("tmpfs destination must be an absolute path: %s", v)
|
||||||
|
}
|
||||||
|
*f.mounts = append(*f.mounts, engine.Mount{Mode: engine.MountTmpfs, Dest: v})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mount, err := parseVolume(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*f.mounts = append(*f.mounts, mount)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEnv(e string, getenv func(string) string) engine.EnvVar {
|
||||||
|
if key, value, found := strings.Cut(e, "="); found {
|
||||||
|
return engine.EnvVar{Key: key, Value: value}
|
||||||
|
}
|
||||||
|
return engine.EnvVar{Key: e, Value: getenv(e)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringList collects a repeatable flag's values in order.
|
||||||
|
type stringList []string
|
||||||
|
|
||||||
|
func (s *stringList) String() string { return strings.Join(*s, ",") }
|
||||||
|
|
||||||
|
func (s *stringList) Set(v string) error {
|
||||||
|
*s = append(*s, v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refusedFlag rejects a known docker flag with the reason werkdock
|
||||||
|
// cannot honor it.
|
||||||
|
type refusedFlag struct {
|
||||||
|
name string
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *refusedFlag) String() string { return "" }
|
||||||
|
|
||||||
|
func (f *refusedFlag) Set(string) error {
|
||||||
|
return fmt.Errorf("flag -%s is not supported: %s", f.name, f.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *refusedFlag) IsBoolFlag() bool { return true }
|
||||||
|
|
||||||
|
func refuse(fs *flag.FlagSet, name, reason string) {
|
||||||
|
fs.Var(&refusedFlag{name: name, reason: reason}, name, reason)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"werkdock/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
func noEnv(string) string { return "" }
|
||||||
|
|
||||||
|
func TestParseRunSupportedFlags(t *testing.T) {
|
||||||
|
opts, err := parseRun([]string{
|
||||||
|
"--rm",
|
||||||
|
"-v", "/repo:/repo",
|
||||||
|
"--volume", "/cache:/root/.gradle:ro",
|
||||||
|
"-e", "CI=true",
|
||||||
|
"-w", "/repo",
|
||||||
|
"buildenv", "sh", "-c", "./gradlew build",
|
||||||
|
}, noEnv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if opts.Image != "buildenv" {
|
||||||
|
t.Errorf("image: got %q", opts.Image)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(opts.Command, []string{"sh", "-c", "./gradlew build"}) {
|
||||||
|
t.Errorf("command: got %q", opts.Command)
|
||||||
|
}
|
||||||
|
wantMounts := []engine.Mount{
|
||||||
|
{Mode: engine.MountBind, Source: "/repo", Dest: "/repo"},
|
||||||
|
{Mode: engine.MountRoBind, Source: "/cache", Dest: "/root/.gradle"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(opts.Mounts, wantMounts) {
|
||||||
|
t.Errorf("mounts: got %+v", opts.Mounts)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(opts.Env, []engine.EnvVar{{Key: "CI", Value: "true"}}) {
|
||||||
|
t.Errorf("env: got %+v", opts.Env)
|
||||||
|
}
|
||||||
|
if opts.Workdir != "/repo" {
|
||||||
|
t.Errorf("workdir: got %q", opts.Workdir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRunCopiesBareEnvKeysFromTheHost(t *testing.T) {
|
||||||
|
getenv := func(key string) string {
|
||||||
|
if key == "LANG" {
|
||||||
|
return "C.UTF-8"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
opts, err := parseRun([]string{"--rm", "-e", "LANG", "img", "true"}, getenv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(opts.Env, []engine.EnvVar{{Key: "LANG", Value: "C.UTF-8"}}) {
|
||||||
|
t.Errorf("env: got %+v", opts.Env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRunRefusesDockerFlagsLoudly(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
args []string
|
||||||
|
wantReason string
|
||||||
|
}{
|
||||||
|
{[]string{"--rm", "-p", "8080:80", "img", "true"}, "no network isolation"},
|
||||||
|
{[]string{"--rm", "--network", "host", "img", "true"}, "network is the host's"},
|
||||||
|
{[]string{"--rm", "--memory", "1g", "img", "true"}, "does not manage resources"},
|
||||||
|
{[]string{"--rm", "--user", "1000", "img", "true"}, "uid 0 mapped to the calling user"},
|
||||||
|
{[]string{"--rm", "-d", "img", "true"}, "not implemented yet"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(strings.Join(tt.args, " "), func(t *testing.T) {
|
||||||
|
_, err := parseRun(tt.args, noEnv)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantReason) {
|
||||||
|
t.Errorf("got %v, want refusal containing %q", err, tt.wantReason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRunRequiresRmForNow(t *testing.T) {
|
||||||
|
_, err := parseRun([]string{"img", "true"}, noEnv)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "--rm") {
|
||||||
|
t.Errorf("got %v, want the --rm requirement", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRunValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{"no image", []string{"--rm"}, "no image specified"},
|
||||||
|
{"no command", []string{"--rm", "img"}, "no command specified"},
|
||||||
|
{"volume without dest", []string{"--rm", "-v", "/only-src", "img", "true"}, "expected SRC:DEST"},
|
||||||
|
{"volume with bad option", []string{"--rm", "-v", "/a:/b:cached", "img", "true"}, "only 'ro' and 'rw' are supported"},
|
||||||
|
{"relative tmpfs dest", []string{"--rm", "--tmpfs", "rel", "img", "true"}, "absolute"},
|
||||||
|
{"relative volume source", []string{"--rm", "-v", "rel:/b", "img", "true"}, "absolute"},
|
||||||
|
{"relative volume dest", []string{"--rm", "-v", "/a:rel", "img", "true"}, "absolute"},
|
||||||
|
{"relative workdir", []string{"--rm", "-w", "rel", "img", "true"}, "absolute"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := parseRun(tt.args, noEnv)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Errorf("got %v, want it to contain %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRunKeepsMountFlagOrderAcrossVolumeAndTmpfs(t *testing.T) {
|
||||||
|
// The git-metadata mask depends on it: ro-bind .git, tmpfs over
|
||||||
|
// .git/werkator, then the workspace bind — in exactly this order.
|
||||||
|
opts, err := parseRun([]string{
|
||||||
|
"--rm",
|
||||||
|
"-v", "/r/.git:/r/.git:ro",
|
||||||
|
"--tmpfs", "/r/.git/werkator",
|
||||||
|
"-v", "/r/ws:/r/ws",
|
||||||
|
"img", "true",
|
||||||
|
}, noEnv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []engine.Mount{
|
||||||
|
{Mode: engine.MountRoBind, Source: "/r/.git", Dest: "/r/.git"},
|
||||||
|
{Mode: engine.MountTmpfs, Dest: "/r/.git/werkator"},
|
||||||
|
{Mode: engine.MountBind, Source: "/r/ws", Dest: "/r/ws"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(opts.Mounts, want) {
|
||||||
|
t.Errorf("mounts: got %+v", opts.Mounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRunAcceptsTheExplicitRwVolumeOption(t *testing.T) {
|
||||||
|
opts, err := parseRun([]string{"--rm", "-v", "/a:/b:rw", "img", "true"}, noEnv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(opts.Mounts, []engine.Mount{{Mode: engine.MountBind, Source: "/a", Dest: "/b"}}) {
|
||||||
|
t.Errorf("mounts: got %+v", opts.Mounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRunStopsFlagParsingAtTheImage(t *testing.T) {
|
||||||
|
// Docker semantics: everything after the image belongs to the
|
||||||
|
// command, even if it looks like a flag.
|
||||||
|
opts, err := parseRun([]string{"--rm", "img", "ls", "-la", "/tmp"}, noEnv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(opts.Command, []string{"ls", "-la", "/tmp"}) {
|
||||||
|
t.Errorf("command: got %q", opts.Command)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
// Package doctor checks whether this host can run werkdock sandboxes:
|
||||||
|
// unprivileged user namespaces with a uid-0 mapping and enforced
|
||||||
|
// read-only root binds, the required CLI tools, and disk/quota headroom
|
||||||
|
// for the build footprint. It is a port of Werkator's
|
||||||
|
// werkator-build-prerequisites.sh, with the same PASS/FAIL output.
|
||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MinFreeKiB is the disk footprint a sandbox build needs headroom for:
|
||||||
|
// unpacked rootfs (zstd expands roughly 3-4x), toolchain caches, build
|
||||||
|
// output. ~5 GiB, in KiB.
|
||||||
|
const MinFreeKiB = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
// Runner executes a command and returns its combined output; injected
|
||||||
|
// so the evaluation logic is testable against captured fixtures.
|
||||||
|
type Runner func(name string, args ...string) (string, error)
|
||||||
|
|
||||||
|
// Report is the outcome of all checks.
|
||||||
|
type Report struct {
|
||||||
|
Checks []Check
|
||||||
|
Warnings []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check is one PASS/FAIL line.
|
||||||
|
type Check struct {
|
||||||
|
OK bool
|
||||||
|
Msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Report) pass(format string, a ...any) {
|
||||||
|
r.Checks = append(r.Checks, Check{OK: true, Msg: fmt.Sprintf(format, a...)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Report) fail(format string, a ...any) {
|
||||||
|
r.Checks = append(r.Checks, Check{OK: false, Msg: fmt.Sprintf(format, a...)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Report) warn(format string, a ...any) {
|
||||||
|
r.Warnings = append(r.Warnings, fmt.Sprintf(format, a...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK reports whether no check failed.
|
||||||
|
func (r *Report) OK() bool {
|
||||||
|
for _, c := range r.Checks {
|
||||||
|
if !c.OK {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes all checks against targetDir (where images and build
|
||||||
|
// workspaces will live).
|
||||||
|
func Run(targetDir string, selfUID int, run Runner) *Report {
|
||||||
|
r := &Report{}
|
||||||
|
sandboxChecks(r, selfUID, run)
|
||||||
|
toolChecks(r)
|
||||||
|
diskChecks(r, targetDir, run)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// sandboxProbe is the command run inside the sandbox; its three output
|
||||||
|
// lines are the signals evaluated below.
|
||||||
|
const sandboxProbe = "id -u && cat /proc/self/uid_map && (touch /usr/ro-test 2>&1 || true)"
|
||||||
|
|
||||||
|
func sandboxChecks(r *Report, selfUID int, run Runner) {
|
||||||
|
if _, err := exec.LookPath("bwrap"); err != nil {
|
||||||
|
r.fail("bwrap is not installed on this host")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
version, err := run("bwrap", "--version")
|
||||||
|
if err != nil {
|
||||||
|
r.fail("bwrap --version failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.pass("bwrap version: %s", strings.TrimSpace(version))
|
||||||
|
out, err := run("bwrap",
|
||||||
|
"--unshare-user", "--unshare-pid", "--die-with-parent",
|
||||||
|
"--uid", "0", "--gid", "0",
|
||||||
|
"--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp",
|
||||||
|
"sh", "-c", sandboxProbe)
|
||||||
|
if err != nil {
|
||||||
|
r.fail("bwrap invocation failed (no user namespace support?): %s", strings.TrimSpace(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
EvaluateSandbox(r, out, selfUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateSandbox checks the three signals of the sandbox probe output:
|
||||||
|
// uid 0 inside, a uid_map back to the unprivileged user, and an
|
||||||
|
// enforced read-only root bind.
|
||||||
|
func EvaluateSandbox(r *Report, output string, selfUID int) {
|
||||||
|
lines := strings.Split(strings.TrimRight(output, "\n"), "\n")
|
||||||
|
line := func(i int) string {
|
||||||
|
if i < len(lines) {
|
||||||
|
return strings.TrimSpace(lines[i])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if line(0) == "0" {
|
||||||
|
r.pass("build runs as root inside the namespace (uid 0)")
|
||||||
|
} else {
|
||||||
|
r.fail("expected uid 0 inside the namespace, got: %s", line(0))
|
||||||
|
}
|
||||||
|
mapRe := regexp.MustCompile(`^\s*0\s+` + strconv.Itoa(selfUID) + `\s+1`)
|
||||||
|
if mapRe.MatchString(line(1)) {
|
||||||
|
r.pass("uid_map maps root back to the unprivileged user (uid %d)", selfUID)
|
||||||
|
} else {
|
||||||
|
r.fail("expected uid_map '0 %d 1', got: %s", selfUID, line(1))
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(output), "read-only file system") {
|
||||||
|
r.pass("read-only root bind is enforced")
|
||||||
|
} else {
|
||||||
|
r.fail("the read-only root bind did not reject a write to /usr")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolChecks(r *Report) {
|
||||||
|
if _, err := exec.LookPath("tar"); err != nil {
|
||||||
|
r.fail("tar is not installed — required to unpack images")
|
||||||
|
} else {
|
||||||
|
r.pass("tar is available")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("zstd"); err != nil {
|
||||||
|
r.warn("zstd is not installed — .tar.zst images cannot be unpacked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diskChecks(r *Report, targetDir string, run Runner) {
|
||||||
|
minGiB := MinFreeKiB / 1024 / 1024
|
||||||
|
homeFS := ""
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
if out, err := run("df", "-Pk", home); err == nil {
|
||||||
|
homeFS, _, _ = ParseDF(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := run("df", "-Pk", targetDir)
|
||||||
|
if err != nil {
|
||||||
|
r.warn("could not measure free space on %s — only the quota check applies", targetDir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
device, availKiB, mount := ParseDF(out)
|
||||||
|
if device == "" {
|
||||||
|
r.warn("could not measure free space on %s — only the quota check applies", targetDir)
|
||||||
|
} else {
|
||||||
|
if homeFS != "" && device != homeFS {
|
||||||
|
r.warn("target dir is on %s (mounted at %s), not the home filesystem (%s) — builds will run on slower storage", device, mount, homeFS)
|
||||||
|
}
|
||||||
|
if availKiB < MinFreeKiB {
|
||||||
|
r.fail("less than %d GiB free space on the build working filesystem (%s)", minGiB, mount)
|
||||||
|
} else {
|
||||||
|
r.pass("at least %d GiB free space on the build working filesystem (%s, device %s)", minGiB, mount, device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quotaOut, err := run("quota", "-g")
|
||||||
|
if err != nil || strings.TrimSpace(quotaOut) == "" {
|
||||||
|
r.warn("no readable group quota tooling on this host — only free space was checked")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lines := ParseQuota(quotaOut)
|
||||||
|
if len(lines) == 0 {
|
||||||
|
r.warn("quota tooling present but no group quota lines could be parsed — only free space was checked")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok := true
|
||||||
|
detail := ""
|
||||||
|
for _, q := range lines {
|
||||||
|
// Only the quota of the target filesystem counts — other
|
||||||
|
// volumes may legitimately be full without affecting builds.
|
||||||
|
if device != "" && filepath.Base(q.FS) != filepath.Base(device) && q.FS != device {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
headroom := q.Limit - q.Blocks
|
||||||
|
if headroom < MinFreeKiB {
|
||||||
|
ok = false
|
||||||
|
detail += fmt.Sprintf(" %s: %.1f GiB free of quota;", filepath.Base(q.FS), float64(headroom)/1024/1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
r.pass("group quota headroom covers the %d GiB build footprint", minGiB)
|
||||||
|
} else {
|
||||||
|
r.fail("group quota headroom below the %d GiB build footprint; raise the quota before building.%s", minGiB, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDF extracts device, available KiB, and mount point from
|
||||||
|
// `df -Pk DIR` output.
|
||||||
|
func ParseDF(output string) (device string, availKiB int64, mount string) {
|
||||||
|
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||||
|
if len(lines) < 2 {
|
||||||
|
return "", 0, ""
|
||||||
|
}
|
||||||
|
fields := strings.Fields(lines[1])
|
||||||
|
if len(fields) < 6 {
|
||||||
|
return "", 0, ""
|
||||||
|
}
|
||||||
|
avail, err := strconv.ParseInt(fields[3], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, ""
|
||||||
|
}
|
||||||
|
return fields[0], avail, fields[5]
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuotaLine is one filesystem's group quota: used blocks and the hard
|
||||||
|
// limit, both in KiB.
|
||||||
|
type QuotaLine struct {
|
||||||
|
FS string
|
||||||
|
Blocks int64
|
||||||
|
Limit int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseQuota parses `quota -g` output, including the wrapped form where
|
||||||
|
// a long device name stands alone on its own line and the numbers
|
||||||
|
// follow on the next. A '*' suffix on the blocks value (over soft
|
||||||
|
// quota) is ignored.
|
||||||
|
func ParseQuota(output string) []QuotaLine {
|
||||||
|
var result []QuotaLine
|
||||||
|
pendingFS := ""
|
||||||
|
for _, raw := range strings.Split(output, "\n") {
|
||||||
|
fields := strings.Fields(raw)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(fields) == 1 && strings.HasPrefix(fields[0], "/") {
|
||||||
|
pendingFS = fields[0]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(fields[0], "/") && len(fields) >= 4 {
|
||||||
|
if blocks, limit, ok := quotaNumbers(fields[1], fields[3]); ok {
|
||||||
|
result = append(result, QuotaLine{FS: fields[0], Blocks: blocks, Limit: limit})
|
||||||
|
pendingFS = ""
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pendingFS != "" && len(fields) >= 3 {
|
||||||
|
if blocks, limit, ok := quotaNumbers(fields[0], fields[2]); ok {
|
||||||
|
result = append(result, QuotaLine{FS: pendingFS, Blocks: blocks, Limit: limit})
|
||||||
|
pendingFS = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func quotaNumbers(blocksField, limitField string) (int64, int64, bool) {
|
||||||
|
blocks, err := strconv.ParseInt(strings.TrimSuffix(blocksField, "*"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
limit, err := strconv.ParseInt(limitField, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return blocks, limit, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render writes the report in the PASS/FAIL format of the original
|
||||||
|
// prerequisites script, ending with a RESULT line.
|
||||||
|
func (r *Report) Render(w io.Writer) {
|
||||||
|
for _, c := range r.Checks {
|
||||||
|
status := "PASS"
|
||||||
|
if !c.OK {
|
||||||
|
status = "FAIL"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s: %s\n", status, c.Msg)
|
||||||
|
}
|
||||||
|
for _, warning := range r.Warnings {
|
||||||
|
fmt.Fprintf(w, "WARNING: %s\n", warning)
|
||||||
|
}
|
||||||
|
passed := 0
|
||||||
|
for _, c := range r.Checks {
|
||||||
|
if c.OK {
|
||||||
|
passed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
if r.OK() {
|
||||||
|
fmt.Fprintf(w, "RESULT: PASS (%d/%d) — werkdock sandboxes are usable on this host.\n", passed, len(r.Checks))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "RESULT: FAIL (%d/%d) — werkdock sandboxes are not usable on this host.\n", passed, len(r.Checks))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEvaluateSandboxAllSignalsPass(t *testing.T) {
|
||||||
|
r := &Report{}
|
||||||
|
output := "0\n 0 120957 1\ntouch: cannot touch '/usr/ro-test': Read-only file system\n"
|
||||||
|
EvaluateSandbox(r, output, 120957)
|
||||||
|
if !r.OK() {
|
||||||
|
t.Errorf("expected all signals to pass, got %+v", r.Checks)
|
||||||
|
}
|
||||||
|
if len(r.Checks) != 3 {
|
||||||
|
t.Errorf("expected 3 checks, got %d", len(r.Checks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateSandboxFailures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
output string
|
||||||
|
selfUID int
|
||||||
|
wantFail string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"not root inside",
|
||||||
|
"1000\n 0 120957 1\nRead-only file system\n",
|
||||||
|
120957,
|
||||||
|
"expected uid 0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uid_map maps someone else",
|
||||||
|
"0\n 0 999999 1\nRead-only file system\n",
|
||||||
|
120957,
|
||||||
|
"expected uid_map",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"writable root bind",
|
||||||
|
"0\n 0 120957 1\n",
|
||||||
|
120957,
|
||||||
|
"did not reject a write",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := &Report{}
|
||||||
|
EvaluateSandbox(r, tt.output, tt.selfUID)
|
||||||
|
found := false
|
||||||
|
for _, c := range r.Checks {
|
||||||
|
if !c.OK && strings.Contains(c.Msg, tt.wantFail) {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected a failing check containing %q, got %+v", tt.wantFail, r.Checks)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDF(t *testing.T) {
|
||||||
|
output := "Filesystem 1024-blocks Used Available Capacity Mounted on\n" +
|
||||||
|
"/dev/mapper/vg0-home 959786032 447013936 463941300 50% /home\n"
|
||||||
|
device, avail, mount := ParseDF(output)
|
||||||
|
if device != "/dev/mapper/vg0-home" || avail != 463941300 || mount != "/home" {
|
||||||
|
t.Errorf("got %q %d %q", device, avail, mount)
|
||||||
|
}
|
||||||
|
if d, a, m := ParseDF("garbage"); d != "" || a != 0 || m != "" {
|
||||||
|
t.Errorf("expected empty result for garbage, got %q %d %q", d, a, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseQuotaPlainAndWrappedLines(t *testing.T) {
|
||||||
|
output := `Disk quotas for group g123456 (gid 123456):
|
||||||
|
Filesystem blocks quota limit grace files quota limit grace
|
||||||
|
/dev/vdb1 123456 900000 1000000 1234 0 0
|
||||||
|
/dev/mapper/very-long-device-name-that-wraps
|
||||||
|
654321* 4500000 5000000 4321 0 0
|
||||||
|
`
|
||||||
|
want := []QuotaLine{
|
||||||
|
{FS: "/dev/vdb1", Blocks: 123456, Limit: 1000000},
|
||||||
|
{FS: "/dev/mapper/very-long-device-name-that-wraps", Blocks: 654321, Limit: 5000000},
|
||||||
|
}
|
||||||
|
if got := ParseQuota(output); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("got %+v\nwant %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseQuotaIgnoresUnparsableOutput(t *testing.T) {
|
||||||
|
if got := ParseQuota("no quotas here\n"); len(got) != 0 {
|
||||||
|
t.Errorf("expected no lines, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeRunner serves canned outputs keyed by command name.
|
||||||
|
func fakeRunner(outputs map[string]string) Runner {
|
||||||
|
return func(name string, args ...string) (string, error) {
|
||||||
|
return outputs[name], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskChecksFailOnQuotaHeadroomOfTheTargetFilesystem(t *testing.T) {
|
||||||
|
r := &Report{}
|
||||||
|
// 1 GiB quota headroom on the home device, plenty on another one.
|
||||||
|
outputs := map[string]string{
|
||||||
|
"df": "Filesystem 1024-blocks Used Available Capacity Mounted on\n" +
|
||||||
|
"/dev/vdb1 100000000 10000000 90000000 10% /home\n",
|
||||||
|
"quota": "Disk quotas for group g1 (gid 1):\n" +
|
||||||
|
" Filesystem blocks quota limit grace\n" +
|
||||||
|
"/dev/vdb1 4000000 5000000 5048576 - - -\n" +
|
||||||
|
"/dev/other 0 0 99999999 - - -\n",
|
||||||
|
}
|
||||||
|
diskChecks(r, "/home/user", fakeRunner(outputs))
|
||||||
|
if r.OK() {
|
||||||
|
t.Fatalf("expected the quota check to fail, got %+v", r.Checks)
|
||||||
|
}
|
||||||
|
failing := ""
|
||||||
|
for _, c := range r.Checks {
|
||||||
|
if !c.OK {
|
||||||
|
failing = c.Msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(failing, "quota headroom below") || !strings.Contains(failing, "vdb1") {
|
||||||
|
t.Errorf("unexpected failure message: %s", failing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskChecksPassWithSpaceAndQuota(t *testing.T) {
|
||||||
|
r := &Report{}
|
||||||
|
outputs := map[string]string{
|
||||||
|
"df": "Filesystem 1024-blocks Used Available Capacity Mounted on\n" +
|
||||||
|
"/dev/vdb1 100000000 10000000 90000000 10% /home\n",
|
||||||
|
"quota": "Disk quotas for group g1 (gid 1):\n" +
|
||||||
|
" Filesystem blocks quota limit grace\n" +
|
||||||
|
"/dev/vdb1 1000000 90000000 99000000 - - -\n",
|
||||||
|
}
|
||||||
|
diskChecks(r, "/home/user", fakeRunner(outputs))
|
||||||
|
if !r.OK() {
|
||||||
|
t.Errorf("expected disk checks to pass, got %+v", r.Checks)
|
||||||
|
}
|
||||||
|
if len(r.Checks) != 2 {
|
||||||
|
t.Errorf("expected free-space and quota checks, got %+v", r.Checks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderEndsWithTheResultLine(t *testing.T) {
|
||||||
|
r := &Report{}
|
||||||
|
r.pass("all good")
|
||||||
|
r.warn("just saying")
|
||||||
|
var out strings.Builder
|
||||||
|
r.Render(&out)
|
||||||
|
rendered := out.String()
|
||||||
|
if !strings.Contains(rendered, "PASS: all good\n") ||
|
||||||
|
!strings.Contains(rendered, "WARNING: just saying\n") ||
|
||||||
|
!strings.Contains(rendered, "RESULT: PASS (1/1)") {
|
||||||
|
t.Errorf("unexpected rendering:\n%s", rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bwrap runs a RunSpec through the bwrap CLI — filesystem isolation
|
||||||
|
// only; network, uid mapping target, /proc, /dev, and /tmp come from
|
||||||
|
// the host by contract.
|
||||||
|
//
|
||||||
|
// The invocation is a port of Werkator's BwrapBuildRunner, including
|
||||||
|
// the parts hardened on a real Hostsharing webspace: bind mountpoints
|
||||||
|
// are pre-created inside the rootfs (a plain host directory), because
|
||||||
|
// bwrap cannot mkdir them against the read-only root bind.
|
||||||
|
type Bwrap struct {
|
||||||
|
// Path of the bwrap binary; empty means "bwrap" via PATH.
|
||||||
|
Path string
|
||||||
|
// Stdio of the sandboxed command; nil fields default to the
|
||||||
|
// werkdock process's own.
|
||||||
|
Stdout io.Writer
|
||||||
|
Stderr io.Writer
|
||||||
|
Stdin io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPATH is the PATH inside the sandbox; the environment is
|
||||||
|
// cleared (docker semantics), so a sane default must be set explicitly.
|
||||||
|
const DefaultPATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
|
||||||
|
// Argv assembles the full bwrap command line for spec.
|
||||||
|
//
|
||||||
|
// Mount order: the rootfs first; then /proc, /dev, and the tmpfs
|
||||||
|
// mounts for /tmp and /root, BEFORE the user binds, so a bind whose
|
||||||
|
// destination lies below them lands inside instead of being shadowed;
|
||||||
|
// then the user binds in the given order.
|
||||||
|
func (b *Bwrap) Argv(spec RunSpec) ([]string, error) {
|
||||||
|
if spec.RootFS == "" {
|
||||||
|
return nil, errors.New("rootfs must be set")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(spec.RootFS) {
|
||||||
|
return nil, fmt.Errorf("rootfs must be an absolute path: %s", spec.RootFS)
|
||||||
|
}
|
||||||
|
if len(spec.Command) == 0 {
|
||||||
|
return nil, errors.New("no command specified")
|
||||||
|
}
|
||||||
|
bin := b.Path
|
||||||
|
if bin == "" {
|
||||||
|
bin = "bwrap"
|
||||||
|
}
|
||||||
|
args := []string{
|
||||||
|
bin,
|
||||||
|
"--unshare-user",
|
||||||
|
"--unshare-pid",
|
||||||
|
"--die-with-parent",
|
||||||
|
"--uid", "0",
|
||||||
|
"--gid", "0",
|
||||||
|
"--ro-bind", spec.RootFS, "/",
|
||||||
|
"--proc", "/proc",
|
||||||
|
"--dev", "/dev",
|
||||||
|
"--tmpfs", "/tmp",
|
||||||
|
"--tmpfs", "/root",
|
||||||
|
}
|
||||||
|
for _, m := range spec.Mounts {
|
||||||
|
if !filepath.IsAbs(m.Dest) {
|
||||||
|
return nil, fmt.Errorf("mount destination must be an absolute path: %s", m.Dest)
|
||||||
|
}
|
||||||
|
switch m.Mode {
|
||||||
|
case MountBind:
|
||||||
|
args = append(args, "--bind", m.Source, m.Dest)
|
||||||
|
case MountRoBind:
|
||||||
|
args = append(args, "--ro-bind", m.Source, m.Dest)
|
||||||
|
case MountTmpfs:
|
||||||
|
args = append(args, "--tmpfs", m.Dest)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown mount mode %d for %s", m.Mode, m.Dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args = append(args,
|
||||||
|
"--clearenv",
|
||||||
|
"--setenv", "HOME", "/root",
|
||||||
|
"--setenv", "PATH", DefaultPATH,
|
||||||
|
)
|
||||||
|
for _, e := range spec.Env {
|
||||||
|
args = append(args, "--setenv", e.Key, e.Value)
|
||||||
|
}
|
||||||
|
workdir := spec.Workdir
|
||||||
|
if workdir == "" {
|
||||||
|
workdir = "/"
|
||||||
|
}
|
||||||
|
args = append(args, "--chdir", workdir, "--")
|
||||||
|
args = append(args, spec.Command...)
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureMountpoints pre-creates the mountpoints of spec inside the
|
||||||
|
// rootfs directory. bwrap creates mountpoints against the sandbox view,
|
||||||
|
// which is the read-only rootfs bind — every destination missing from
|
||||||
|
// the rootfs fails with "Read-only file system". The rootfs directory
|
||||||
|
// itself is a plain host directory, so the mountpoints are created
|
||||||
|
// there; bwrap then finds them and has nothing left to mkdir.
|
||||||
|
//
|
||||||
|
// Anything that already exists in the rootfs is left alone (e.g.
|
||||||
|
// /etc/resolv.conf is a file many rootfs archives ship). A bind whose
|
||||||
|
// source is a regular file gets a file mountpoint, not a directory.
|
||||||
|
func EnsureMountpoints(spec RunSpec) error {
|
||||||
|
for _, dest := range []string{"/proc", "/dev", "/tmp", "/root"} {
|
||||||
|
if err := ensureDir(spec.RootFS, dest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range spec.Mounts {
|
||||||
|
target, err := rootfsPath(spec.RootFS, m.Dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(target); err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.Mode == MountTmpfs {
|
||||||
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src, err := os.Stat(m.Source)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bind source %s: %w", m.Source, err)
|
||||||
|
}
|
||||||
|
if src.Mode().IsRegular() {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDir(rootfs, dest string) error {
|
||||||
|
target, err := rootfsPath(rootfs, dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, statErr := os.Lstat(target); statErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return os.MkdirAll(target, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rootfsPath resolves dest inside rootfs and refuses destinations that
|
||||||
|
// escape it — werkdock assembles mounts from user input, so this must
|
||||||
|
// hold even for hostile paths.
|
||||||
|
func rootfsPath(rootfs, dest string) (string, error) {
|
||||||
|
root := filepath.Clean(rootfs)
|
||||||
|
target := filepath.Join(root, dest)
|
||||||
|
prefix := root
|
||||||
|
if !strings.HasSuffix(prefix, string(filepath.Separator)) {
|
||||||
|
prefix += string(filepath.Separator)
|
||||||
|
}
|
||||||
|
if target != root && !strings.HasPrefix(target, prefix) {
|
||||||
|
return "", fmt.Errorf("bind destination escapes the rootfs: %s", dest)
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes spec and returns the command's exit code; bwrap
|
||||||
|
// propagates the child's code, so the caller can pass it through.
|
||||||
|
func (b *Bwrap) Run(spec RunSpec) (int, error) {
|
||||||
|
argv, err := b.Argv(spec)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := EnsureMountpoints(spec); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
cmd := exec.Command(argv[0], argv[1:]...)
|
||||||
|
cmd.Stdout = b.Stdout
|
||||||
|
if cmd.Stdout == nil {
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
}
|
||||||
|
cmd.Stderr = b.Stderr
|
||||||
|
if cmd.Stderr == nil {
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
}
|
||||||
|
cmd.Stdin = b.Stdin
|
||||||
|
err = cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if errors.As(err, &exit) {
|
||||||
|
return exit.ExitCode(), nil
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestArgvAssemblesTheHardenedInvocation(t *testing.T) {
|
||||||
|
b := &Bwrap{}
|
||||||
|
spec := RunSpec{
|
||||||
|
RootFS: "/store/images/buildenv/rootfs",
|
||||||
|
Mounts: []Mount{
|
||||||
|
{Mode: MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"},
|
||||||
|
{Mode: MountRoBind, Source: "/repo/.git", Dest: "/repo/.git"},
|
||||||
|
{Mode: MountTmpfs, Dest: "/repo/.git/werkator"},
|
||||||
|
{Mode: MountBind, Source: "/repo", Dest: "/repo"},
|
||||||
|
{Mode: MountBind, Source: "/cache", Dest: "/root/.gradle"},
|
||||||
|
},
|
||||||
|
Env: []EnvVar{{Key: "CI", Value: "true"}, {Key: "TERM", Value: "dumb"}},
|
||||||
|
Workdir: "/repo",
|
||||||
|
Command: []string{"/bin/sh", "-c", "./gradlew build"},
|
||||||
|
}
|
||||||
|
argv, err := b.Argv(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"bwrap",
|
||||||
|
"--unshare-user", "--unshare-pid", "--die-with-parent",
|
||||||
|
"--uid", "0", "--gid", "0",
|
||||||
|
"--ro-bind", "/store/images/buildenv/rootfs", "/",
|
||||||
|
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", "/root",
|
||||||
|
"--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf",
|
||||||
|
"--ro-bind", "/repo/.git", "/repo/.git",
|
||||||
|
"--tmpfs", "/repo/.git/werkator",
|
||||||
|
"--bind", "/repo", "/repo",
|
||||||
|
"--bind", "/cache", "/root/.gradle",
|
||||||
|
"--clearenv",
|
||||||
|
"--setenv", "HOME", "/root",
|
||||||
|
"--setenv", "PATH", DefaultPATH,
|
||||||
|
"--setenv", "CI", "true",
|
||||||
|
"--setenv", "TERM", "dumb",
|
||||||
|
"--chdir", "/repo", "--",
|
||||||
|
"/bin/sh", "-c", "./gradlew build",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(argv, want) {
|
||||||
|
t.Errorf("argv mismatch:\n got %q\nwant %q", argv, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArgvValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
spec RunSpec
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{"missing rootfs", RunSpec{Command: []string{"true"}}, "rootfs must be set"},
|
||||||
|
{"relative rootfs", RunSpec{RootFS: "rootfs", Command: []string{"true"}}, "absolute"},
|
||||||
|
{"missing command", RunSpec{RootFS: "/r"}, "no command specified"},
|
||||||
|
{
|
||||||
|
"relative mount dest",
|
||||||
|
RunSpec{RootFS: "/r", Mounts: []Mount{{Mode: MountBind, Source: "/s", Dest: "work"}}, Command: []string{"true"}},
|
||||||
|
"absolute",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := (&Bwrap{}).Argv(tt.spec)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Errorf("got error %v, want it to contain %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArgvDefaultsWorkdirToRoot(t *testing.T) {
|
||||||
|
argv, err := (&Bwrap{}).Argv(RunSpec{RootFS: "/r", Command: []string{"true"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
joined := strings.Join(argv, " ")
|
||||||
|
if !strings.Contains(joined, "--chdir / --") {
|
||||||
|
t.Errorf("expected default workdir /, got: %s", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureMountpointsCreatesMissingAndSkipsExisting(t *testing.T) {
|
||||||
|
rootfs := t.TempDir()
|
||||||
|
// The rootfs ships /etc/resolv.conf as a file with content — it
|
||||||
|
// must be left alone.
|
||||||
|
if err := os.MkdirAll(filepath.Join(rootfs, "etc"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shipped := filepath.Join(rootfs, "etc", "resolv.conf")
|
||||||
|
if err := os.WriteFile(shipped, []byte("nameserver 127.0.0.53\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
srcFile := filepath.Join(srcDir, "hosts")
|
||||||
|
if err := os.WriteFile(srcFile, []byte("127.0.0.1 localhost\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
spec := RunSpec{
|
||||||
|
RootFS: rootfs,
|
||||||
|
Mounts: []Mount{
|
||||||
|
{Mode: MountRoBind, Source: "/etc", Dest: "/etc/resolv.conf"}, // exists: skipped (source type irrelevant)
|
||||||
|
{Mode: MountBind, Source: srcDir, Dest: "/repo/workspace"}, // missing dir mountpoint
|
||||||
|
{Mode: MountBind, Source: srcFile, Dest: "/etc/hosts.werkdock"}, // missing file mountpoint
|
||||||
|
{Mode: MountTmpfs, Dest: "/repo/.git/werkator"}, // tmpfs mountpoint, no source
|
||||||
|
},
|
||||||
|
Command: []string{"true"},
|
||||||
|
}
|
||||||
|
if err := EnsureMountpoints(spec); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, dir := range []string{"proc", "dev", "tmp", "root", "repo/workspace", "repo/.git/werkator"} {
|
||||||
|
fi, err := os.Stat(filepath.Join(rootfs, dir))
|
||||||
|
if err != nil || !fi.IsDir() {
|
||||||
|
t.Errorf("expected directory mountpoint %s in the rootfs: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(filepath.Join(rootfs, "etc", "hosts.werkdock"))
|
||||||
|
if err != nil || !fi.Mode().IsRegular() {
|
||||||
|
t.Errorf("expected file mountpoint etc/hosts.werkdock in the rootfs: %v", err)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(shipped)
|
||||||
|
if err != nil || string(content) != "nameserver 127.0.0.53\n" {
|
||||||
|
t.Errorf("shipped rootfs file was modified: %q, %v", content, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureMountpointsRefusesEscapingDestinations(t *testing.T) {
|
||||||
|
spec := RunSpec{
|
||||||
|
RootFS: t.TempDir(),
|
||||||
|
Mounts: []Mount{{Mode: MountBind, Source: "/tmp", Dest: "/../outside"}},
|
||||||
|
Command: []string{"true"},
|
||||||
|
}
|
||||||
|
err := EnsureMountpoints(spec)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "escapes the rootfs") {
|
||||||
|
t.Errorf("got %v, want an escape refusal", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunInsideRealSandbox is the gated integration test: it runs only
|
||||||
|
// where bwrap and unprivileged user namespaces actually work. The host
|
||||||
|
// / serves as the read-only rootfs, so nothing is unpacked and (all
|
||||||
|
// mountpoints existing) nothing is written.
|
||||||
|
func TestRunInsideRealSandbox(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("bwrap"); err != nil {
|
||||||
|
t.Skip("bwrap not installed")
|
||||||
|
}
|
||||||
|
if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil {
|
||||||
|
t.Skipf("unprivileged user namespaces not usable here: %v", err)
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
b := &Bwrap{Stdout: &stdout, Stderr: &stderr}
|
||||||
|
code, err := b.Run(RunSpec{
|
||||||
|
RootFS: "/",
|
||||||
|
Command: []string{"id", "-u"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run failed: %v (stderr: %s)", err, stderr.String())
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code %d, stderr: %s", code, stderr.String())
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(stdout.String()); got != "0" {
|
||||||
|
t.Errorf("expected uid 0 inside the sandbox, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPassesTheExitCodeThrough(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("bwrap"); err != nil {
|
||||||
|
t.Skip("bwrap not installed")
|
||||||
|
}
|
||||||
|
if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil {
|
||||||
|
t.Skipf("unprivileged user namespaces not usable here: %v", err)
|
||||||
|
}
|
||||||
|
b := &Bwrap{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}}
|
||||||
|
code, err := b.Run(RunSpec{RootFS: "/", Command: []string{"sh", "-c", "exit 42"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if code != 42 {
|
||||||
|
t.Errorf("expected exit code 42, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Package engine executes sandboxed commands. The CLI verbs are thin
|
||||||
|
// frontends over this package, so a later daemon can expose the same
|
||||||
|
// logic without duplicating it (RFC 0002).
|
||||||
|
package engine
|
||||||
|
|
||||||
|
// MountMode distinguishes the mount kinds a RunSpec can carry.
|
||||||
|
type MountMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MountBind is a read-write bind mount.
|
||||||
|
MountBind MountMode = iota
|
||||||
|
// MountRoBind is a read-only bind mount.
|
||||||
|
MountRoBind
|
||||||
|
// MountTmpfs is an empty tmpfs at Dest; Source is unused.
|
||||||
|
MountTmpfs
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mount is one mount, applied in order; later mounts shadow earlier
|
||||||
|
// ones at their own path, exactly as bwrap layers them — the order of
|
||||||
|
// -v and --tmpfs flags is therefore significant and preserved.
|
||||||
|
type Mount struct {
|
||||||
|
Mode MountMode
|
||||||
|
Source string
|
||||||
|
Dest string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvVar is one environment variable; order is preserved.
|
||||||
|
type EnvVar struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunSpec describes one sandboxed command, independent of the engine
|
||||||
|
// that executes it.
|
||||||
|
type RunSpec struct {
|
||||||
|
// RootFS is the absolute path to the unpacked image rootfs,
|
||||||
|
// bound read-only at /.
|
||||||
|
RootFS string
|
||||||
|
Mounts []Mount
|
||||||
|
Env []EnvVar
|
||||||
|
Workdir string
|
||||||
|
Command []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine runs a RunSpec and reports the command's exit code.
|
||||||
|
// bwrap is the first engine; native namespaces may become a second
|
||||||
|
// (RFC 0001).
|
||||||
|
type Engine interface {
|
||||||
|
Run(spec RunSpec) (int, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Package store is the on-disk image store. An image is a rootfs
|
||||||
|
// archive unpacked under the store root; instance state will live here
|
||||||
|
// too once persistent instances exist, in a format both the CLI and a
|
||||||
|
// later daemon can read (RFC 0002).
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is rooted at $WERKDOCK_HOME, defaulting to ~/.werkdock.
|
||||||
|
type Store struct {
|
||||||
|
Root string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageMeta is written as image.json beside each image's rootfs.
|
||||||
|
type ImageMeta struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
|
||||||
|
|
||||||
|
// Default resolves the store root from the environment.
|
||||||
|
func Default() (Store, error) {
|
||||||
|
if root := os.Getenv("WERKDOCK_HOME"); root != "" {
|
||||||
|
return Store{Root: root}, nil
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return Store{}, fmt.Errorf("cannot resolve the store root: %w", err)
|
||||||
|
}
|
||||||
|
return Store{Root: filepath.Join(home, ".werkdock")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Store) imageDir(name string) string {
|
||||||
|
return filepath.Join(s.Root, "images", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RootFS resolves an image name to its unpacked rootfs directory.
|
||||||
|
func (s Store) RootFS(name string) (string, error) {
|
||||||
|
if !nameRe.MatchString(name) {
|
||||||
|
return "", fmt.Errorf("invalid image name: %q", name)
|
||||||
|
}
|
||||||
|
rootfs := filepath.Join(s.imageDir(name), "rootfs")
|
||||||
|
if fi, err := os.Stat(rootfs); err != nil || !fi.IsDir() {
|
||||||
|
return "", fmt.Errorf("no such image: %s (load it with: werkdock load -i ARCHIVE --name %s)", name, name)
|
||||||
|
}
|
||||||
|
return rootfs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns the names of all loaded images, sorted; half-written
|
||||||
|
// `.tmp` directories from an interrupted load are not images.
|
||||||
|
func (s Store) List() ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(filepath.Join(s.Root, "images"))
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() && nameRe.MatchString(e.Name()) && !strings.HasSuffix(e.Name(), ".tmp") {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load imports a rootfs archive as an image. The archive is unpacked
|
||||||
|
// with the tar CLI (compression auto-detected; .tar.zst needs the zstd
|
||||||
|
// binary, which doctor checks) into a temporary directory and renamed
|
||||||
|
// into place, so a failed load leaves no half image behind.
|
||||||
|
func (s Store) Load(archive, name string) error {
|
||||||
|
if !nameRe.MatchString(name) {
|
||||||
|
return fmt.Errorf("invalid image name: %q (allowed: lowercase letters, digits, '.', '_', '-')", name)
|
||||||
|
}
|
||||||
|
// ".tmp" is the staging suffix of this very function — a legal-looking
|
||||||
|
// image name ending in it would collide with interrupted loads.
|
||||||
|
if strings.HasSuffix(name, ".tmp") {
|
||||||
|
return fmt.Errorf("invalid image name: %q (the .tmp suffix is reserved for staging)", name)
|
||||||
|
}
|
||||||
|
archiveAbs, err := filepath.Abs(archive)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(archiveAbs); err != nil {
|
||||||
|
return fmt.Errorf("archive: %w", err)
|
||||||
|
}
|
||||||
|
dir := s.imageDir(name)
|
||||||
|
if _, err := os.Stat(dir); err == nil {
|
||||||
|
return fmt.Errorf("image %q already exists (remove %s to replace it)", name, dir)
|
||||||
|
}
|
||||||
|
tmp := dir + ".tmp"
|
||||||
|
if err := os.RemoveAll(tmp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rootfs := filepath.Join(tmp, "rootfs")
|
||||||
|
if err := os.MkdirAll(rootfs, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd := exec.Command("tar", "--no-same-owner", "-xf", archiveAbs, "-C", rootfs)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
_ = os.RemoveAll(tmp)
|
||||||
|
return fmt.Errorf("unpacking %s failed: %w\n%s", archiveAbs, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
meta, err := json.MarshalIndent(ImageMeta{Name: name, Source: archiveAbs, CreatedAt: time.Now().UTC()}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
_ = os.RemoveAll(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmp, "image.json"), append(meta, '\n'), 0o644); err != nil {
|
||||||
|
_ = os.RemoveAll(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, dir); err != nil {
|
||||||
|
_ = os.RemoveAll(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageNameFromArchive derives a default image name from an archive
|
||||||
|
// file name by stripping the compression and tar extensions:
|
||||||
|
// "werkator-buildenv-trixie.tar.zst" becomes "werkator-buildenv-trixie".
|
||||||
|
func ImageNameFromArchive(archive string) string {
|
||||||
|
name := filepath.Base(archive)
|
||||||
|
for {
|
||||||
|
ext := filepath.Ext(name)
|
||||||
|
switch strings.ToLower(ext) {
|
||||||
|
case ".tar", ".gz", ".tgz", ".zst", ".xz", ".bz2":
|
||||||
|
name = strings.TrimSuffix(name, ext)
|
||||||
|
default:
|
||||||
|
return strings.ToLower(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeTestArchive builds a minimal rootfs .tar.gz with the stdlib, so
|
||||||
|
// the tests need no zstd; Load unpacks it with the system tar.
|
||||||
|
func writeTestArchive(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gz := gzip.NewWriter(f)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
if err := tw.WriteHeader(&tar.Header{Name: "etc/", Mode: 0o755, Typeflag: tar.TypeDir}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := []byte("hello from the rootfs\n")
|
||||||
|
if err := tw.WriteHeader(&tar.Header{Name: "etc/hello", Mode: 0o644, Size: int64(len(content))}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := tw.Write(content); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, c := range []interface{ Close() error }{tw, gz, f} {
|
||||||
|
if err := c.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadUnpacksArchiveIntoTheStore(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("tar"); err != nil {
|
||||||
|
t.Skip("tar not installed")
|
||||||
|
}
|
||||||
|
st := Store{Root: t.TempDir()}
|
||||||
|
archive := filepath.Join(t.TempDir(), "mini-rootfs.tar.gz")
|
||||||
|
writeTestArchive(t, archive)
|
||||||
|
if err := st.Load(archive, "mini"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rootfs, err := st.RootFS("mini")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(filepath.Join(rootfs, "etc", "hello"))
|
||||||
|
if err != nil || string(content) != "hello from the rootfs\n" {
|
||||||
|
t.Errorf("unpacked file: %q, %v", content, err)
|
||||||
|
}
|
||||||
|
metaRaw, err := os.ReadFile(filepath.Join(st.Root, "images", "mini", "image.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var meta ImageMeta
|
||||||
|
if err := json.Unmarshal(metaRaw, &meta); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta.Name != "mini" || meta.Source == "" || meta.CreatedAt.IsZero() {
|
||||||
|
t.Errorf("image.json incomplete: %+v", meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRefusesAnExistingImageName(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("tar"); err != nil {
|
||||||
|
t.Skip("tar not installed")
|
||||||
|
}
|
||||||
|
st := Store{Root: t.TempDir()}
|
||||||
|
archive := filepath.Join(t.TempDir(), "mini.tar.gz")
|
||||||
|
writeTestArchive(t, archive)
|
||||||
|
if err := st.Load(archive, "mini"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := st.Load(archive, "mini")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||||
|
t.Errorf("got %v, want an already-exists refusal", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadLeavesNoHalfImageOnFailure(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("tar"); err != nil {
|
||||||
|
t.Skip("tar not installed")
|
||||||
|
}
|
||||||
|
st := Store{Root: t.TempDir()}
|
||||||
|
broken := filepath.Join(t.TempDir(), "broken.tar.gz")
|
||||||
|
if err := os.WriteFile(broken, []byte("this is not a tar archive"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.Load(broken, "broken"); err == nil {
|
||||||
|
t.Fatal("expected the load to fail")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(st.Root, "images", "broken")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("expected no image directory, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(st.Root, "images", "broken.tmp")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("expected no leftover tmp directory, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRootFSValidation(t *testing.T) {
|
||||||
|
st := Store{Root: t.TempDir()}
|
||||||
|
if _, err := st.RootFS("no-such-image"); err == nil || !strings.Contains(err.Error(), "no such image") {
|
||||||
|
t.Errorf("got %v, want a no-such-image error", err)
|
||||||
|
}
|
||||||
|
if _, err := st.RootFS("../escape"); err == nil || !strings.Contains(err.Error(), "invalid image name") {
|
||||||
|
t.Errorf("got %v, want an invalid-name error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListNamesLoadedImagesAndIgnoresTmpLeftovers(t *testing.T) {
|
||||||
|
st := Store{Root: t.TempDir()}
|
||||||
|
if names, err := st.List(); err != nil || names != nil {
|
||||||
|
t.Fatalf("empty store: got %v, %v", names, err)
|
||||||
|
}
|
||||||
|
for _, dir := range []string{"beta", "alpha", "broken.tmp"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(st.Root, "images", dir), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names, err := st.List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(names) != 2 || names[0] != "alpha" || names[1] != "beta" {
|
||||||
|
t.Errorf("got %v, want [alpha beta]", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageNameFromArchive(t *testing.T) {
|
||||||
|
tests := []struct{ in, want string }{
|
||||||
|
{"werkator-buildenv-trixie.tar.zst", "werkator-buildenv-trixie"},
|
||||||
|
{"/path/to/Base.TAR.GZ", "base"},
|
||||||
|
{"rootfs.tgz", "rootfs"},
|
||||||
|
{"plain", "plain"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := ImageNameFromArchive(tt.in); got != tt.want {
|
||||||
|
t.Errorf("ImageNameFromArchive(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"werkdock/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(cli.Main(os.Args[1:]))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user