Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec07269cca | ||
|
|
b8600bb556 | ||
|
|
0f9f119687 | ||
|
|
5c64391f5f | ||
|
|
cd6f915231 | ||
|
|
092183ca30 | ||
|
|
0761a274db | ||
|
|
5c73c4cc21 | ||
|
|
16543f038b | ||
|
|
3ccc901d1b | ||
|
|
11f6f9bd26 | ||
|
|
9221550a2a | ||
|
|
3b095fd04a | ||
|
|
6521c47092 | ||
|
|
ed89911871 | ||
|
|
ca9c23d55b | ||
|
|
06967c551e | ||
|
|
108fabeae6 | ||
|
|
61c235313f | ||
|
|
be965617cd | ||
|
|
7eac1c342d | ||
|
|
623266e060 | ||
|
|
22a1b28824 | ||
|
|
4d76a93927 | ||
|
|
4f2ad3b244 | ||
|
|
7028ca8ca3 | ||
|
|
0012c17cb7 | ||
|
|
fe11a339ad | ||
|
|
e61adfbbaa | ||
|
|
c0fd1de954 | ||
|
|
ac53b5f211 | ||
|
|
06f4052848 | ||
|
|
f11b53bdb4 | ||
|
|
c1629cace3 | ||
|
|
80a14a7467 | ||
|
|
91e1dd47a3 | ||
|
|
c82f2a965c | ||
|
|
4304dd7c4b | ||
|
|
6ca67a6a37 | ||
|
|
6fba43c627 | ||
|
|
58799ed0ed | ||
|
|
b66d26a03a | ||
|
|
a5027acfaf | ||
|
|
b16c9c2e27 | ||
|
|
ab2a4133e8 |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: architecture
|
name: architecture
|
||||||
description: Detailed Werkator subsystem architecture — CLI wiring and exit codes, server mode, web UI, configuration system, git access, build execution (native, Docker, and bwrap), watcher poll cycle, and system metrics. Use when designing or modifying code in the commands, config, git, gitea, build, artifacts, watcher, metrics, or server packages, or when a question goes beyond the overview in AGENTS.md.
|
description: Detailed Werkator subsystem architecture — CLI wiring and exit codes, server mode, web UI, configuration system, git access, build execution (native, Docker, and the werkdock sandbox), watcher poll cycle, and system metrics. Use when designing or modifying code in the commands, config, git, gitea, build, artifacts, watcher, metrics, or server packages, or when a question goes beyond the overview in AGENTS.md.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Werkator Architecture
|
# Werkator Architecture
|
||||||
@@ -49,7 +49,7 @@ Werkator is configured by three YAML files, deep-merged by `ConfigLoader` (later
|
|||||||
|
|
||||||
Every lookup falls back to the pre-rename name (`ConfigFiles`): `.gittally.yml`, and `.git/gittally/.gittally.yml` for the machine layer. Current name first, and where both exist the old one is ignored rather than merged — a missing config is not an error, so an un-renamed installation would otherwise start on defaults without a single failure.
|
Every lookup falls back to the pre-rename name (`ConfigFiles`): `.gittally.yml`, and `.git/gittally/.gittally.yml` for the machine layer. Current name first, and where both exist the old one is ignored rather than merged — a missing config is not an error, so an un-renamed installation would otherwise start on defaults without a single failure.
|
||||||
|
|
||||||
On top of those comes the **branch layer**: the `.werkator.yml` committed on a branch, applied by `loadWithBranchLayer` (the watcher passes the content read via `git show`, `loadForWorktree` the file in the build worktree). A branch describes its own CI and wins over both layers — the whole `builds` section — so a configuration can be tried out on a branch without touching other branches' builds. `stripPinned` removes what is not a description of this branch's build: `git`, `server`, `gitea`, `executor`, `watcher`, and — inside every `builds` definition as well as every legacy `branches` entry — `requirePullRequest`, `statusContext`, `docker.enabled`/`docker.network`, and `bwrap.enabled`/`bwrap.rootfs`/`bwrap.werkdock`.
|
On top of those comes the **branch layer**: the `.werkator.yml` committed on a branch, applied by `loadWithBranchLayer` (the watcher passes the content read via `git show`, `loadForWorktree` the file in the build worktree). A branch describes its own CI and wins over both layers — the whole `builds` section — so a configuration can be tried out on a branch without touching other branches' builds. `stripPinned` removes what is not a description of this branch's build: `git`, `server`, `gitea`, `executor`, `watcher`, and — inside every `builds` definition as well as every legacy `branches` entry — `requirePullRequest`, `statusContext`, `docker.enabled`/`docker.network`, and `werkdock.enabled`/`werkdock.rootfs`/`werkdock.binary` (the section was called `bwrap` until v1.2.0; `renameLegacySandbox` maps the old name onto the new one on every raw layer, before merging).
|
||||||
|
|
||||||
Each file is version-checked before merging (`werkator.version.since`/`below`, `ConfigVersions.verdict`), so the message can name the file to fix: `since` is hard in both directions — too old a Werkator, or a file written before `ConfigVersions.FORMAT_BROKE_IN` and read after it — while `below` only warns. There is no format version (`apiVersion`) on purpose: only one configuration generation is supported, and the declared version exists to make the incompatibility nameable.
|
Each file is version-checked before merging (`werkator.version.since`/`below`, `ConfigVersions.verdict`), so the message can name the file to fix: `since` is hard in both directions — too old a Werkator, or a file written before `ConfigVersions.FORMAT_BROKE_IN` and read after it — while `below` only warns. There is no format version (`apiVersion`) on purpose: only one configuration generation is supported, and the declared version exists to make the incompatibility nameable.
|
||||||
|
|
||||||
@@ -63,23 +63,25 @@ Three places must stay in sync when config keys change: the `WerkatorConfig` dat
|
|||||||
|
|
||||||
## Repository Context
|
## 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.
|
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 (running the pre-rename state-dir migration for that repository on the way); `RepoRegistry` opens one per entry of the instance configuration's `repositories` — or the current directory without a registry — lazily on first use and loudly: a non-repository entry or a duplicate name aborts the start naming the home file, a repository whose config must not be read (`ConfigException`) is skipped with an error. `RepoConfiguration` provides `registry.current()` (the cwd when served, else the first entry) as the `RepoContext` bean; the controllers no longer take it — they resolve per request from the `{repo}` path segment, and `registry.current()` is what the unscoped routes mean. The `--repo` mixin (`RepoOption`) selects by name in `build`, `retry`, and `status`. Git access and config loading stay path-based services taking `repo.workingDir`; the instance configuration (`~/.werkator.yml`, `ConfigLoader.homeDir`/`WERKATOR_HOME`, bound as `InstanceConfig`) is folded in by `ConfigLoader.loadRaw` itself — its `defaults` below every repository layer, its `server`/`executor`/`watcher.pollInterval` overlaid on top and stripped from the repository files with one warning — so every consumer of `load(dir)` sees the instance values without knowing the file. The context object is the identity (executor pools, watcher memory are keyed by it), so exactly one is opened per repository — `RunningBuild` carries it too, so `currentBuilds()` says which repository a running build belongs to: the current-builds view and API serve only the served repository's builds, and the watcher's worktree pruning is protected by its own repository's builds alone. Routes, pages, and artifact files are repository-scoped (session D): every mapping exists twice, `/api/repos/<name>/…` and `/repos/<name>/…` beside the unscoped form, an unknown name is a 404, and the link prefix follows the number of served repositories — one repository keeps its existing URLs, several make every link name its repository and replace the repository name in the page title with a drop-down that switches repositories, keeping the current view.
|
||||||
|
|
||||||
## Build Execution
|
## Build Execution
|
||||||
|
|
||||||
`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.
|
`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 — with the build's artifact page as `target_url` when `server.publicBaseUrl` is set, repository-scoped through `RepoLinks` — 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 build behind the `BuildRunner` interface: `DispatchingBuildRunner` (`@Primary`) routes to native `ProcessBuildRunner` (the default), to `DockerBuildRunner` when `docker.enabled`, or to `BwrapBuildRunner` when `bwrap.enabled` — docker and bwrap are mutually exclusive per build and rejected in `buildSettings`, never picked silently. The Docker runner shells out to the `docker` CLI (no SDK): it (re)builds the configured image when the Dockerfile inputs changed (tracked via the `org.werkator.build-inputs-sha256` image label), maintains a per-repo Gradle cache volume, mounts the worktree and the Docker socket into a labelled (`org.hoennig.werkator`) `--rm --init` container, and repairs workspace ownership in-container after each command (under a rootless daemon the container runs as root, which is the host user, and the repair degenerates to `0:0`). Git works inside the container: the primary `.git` is mounted read-only with `.git/werkator/` masked by an empty tmpfs (credential isolation) and the worktree's admin dir mounted read-write (`gitMetadataMounts`). The returned `Process` is the attached `docker run` client, so log streaming and termination work exactly like native builds.
|
The runtime is selected per build behind the `BuildRunner` interface: `DispatchingBuildRunner` (`@Primary`) routes to native `ProcessBuildRunner` (the default), to `DockerBuildRunner` when `docker.enabled`, or to `WerkdockBuildRunner` when `werkdock.enabled` — docker and werkdock are mutually exclusive per build and rejected in `buildSettings`, never picked silently. The Docker runner shells out to the `docker` CLI (no SDK): it (re)builds the configured image when the Dockerfile inputs changed (tracked via the `org.werkator.build-inputs-sha256` image label), maintains a per-repo Gradle cache volume, mounts the worktree and the Docker socket into a labelled (`org.hoennig.werkator`) `--rm --init` container, and repairs workspace ownership in-container after each command (under a rootless daemon the container runs as root, which is the host user, and the repair degenerates to `0:0`). Git works inside the container: the primary `.git` is mounted read-only with `.git/werkator/` masked by an empty tmpfs (credential isolation) and the worktree's admin dir mounted read-write (`gitMetadataMounts`). The returned `Process` is the attached `docker run` client, so log streaming and termination work exactly like native builds.
|
||||||
|
|
||||||
`BwrapBuildRunner` (ADR 0008) is the third runtime, for hosts without root and without Docker — Hostsharing Managed Webspaces. It shells out to the `bwrap` CLI (no library): a prepared rootfs archive (`bwrap.rootfs`, built by `tools/build-bwrap-rootfs.sh`) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`, with uid 0 inside mapped to the calling user; isolation is filesystem-only — network, uid, `/proc`, `/dev` are the host's by contract. It reuses the Docker runner's `gitMetadataMounts`; mount order matters (repo dir read-write before the metadata mounts and the workspace), and bind mountpoints missing from the rootfs are pre-created there, since the rootfs is a plain host directory while bwrap cannot mkdir against the read-only sandbox root. `bwrap.enabled`/`bwrap.rootfs` are pinned like the docker sandbox policy. The returned `Process` is the attached `bwrap` process, so streaming and cancellation are unchanged. 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.
|
`WerkdockBuildRunner` (ADR 0008) is the third runtime, for hosts without root and without Docker — Hostsharing Managed Webspaces. It shells out to the `bwrap` CLI (no library): a prepared rootfs archive (`werkdock.rootfs`, built by `tools/build-bwrap-rootfs.sh`) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`, with uid 0 inside mapped to the calling user; isolation is filesystem-only — network, uid, `/proc`, `/dev` are the host's by contract. It reuses the Docker runner's `gitMetadataMounts`; mount order matters (repo dir read-write before the metadata mounts and the workspace), and bind mountpoints missing from the rootfs are pre-created there, since the rootfs is a plain host directory while bwrap cannot mkdir against the read-only sandbox root. `werkdock.enabled`/`werkdock.rootfs` are pinned like the docker sandbox policy. The returned `Process` is the attached `bwrap` process, so streaming and cancellation are unchanged. The generic sandbox machinery is the standalone tool [Werkdock](https://git.javagil.de/mi/werkdock) (plan step 21: grown in `werkdock/`, consumed via the CLI since session C, its own repository since session E); the runner delegates to the `werkdock` CLI and this repository no longer carries its source.
|
||||||
|
|
||||||
## Watcher
|
## Watcher
|
||||||
|
|
||||||
`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.
|
`Watcher` replaces the legacy blocking main loop with a non-blocking fixed-delay poll cycle over the registry's contexts (`start(repos)`, `pollAll(repos)`, `poll(repo)` for one; `recoverOnStartup(repo)` per repository, each in its own guard). Every repository is polled in its own guard — a crash or an unreachable origin is that repository's report in `WatcherState.repositories`, and the next one is polled regardless; the top-level `WatcherState` fields aggregate the reports, reading exactly as before with one repository and prefixed by the repository name with several. What the watcher remembers per repository — the logged fetch error, the deprecation warning, the cached branch definitions — lives in a `RepoWatch` keyed by context. Per repository the cycle is: 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
|
||||||
|
|
||||||
`SystemMetricsCollector` samples CPU (`/proc/stat` deltas), RAM (`/proc/meminfo`), disk, and repository size every 60s, but only after `ServerMetricsLifecycle` (server profile) calls `start()` — like the watcher, nothing is scheduled in CLI runs or tests. Min/max/avg aggregation state persists as JSON in the artifact root (`ArtifactStore.rootDir()`), so restarts continue the series. Unavailable sources (e.g. no `/proc` outside Linux) yield null metrics served as HTTP 200 by `GET /api/system` — the `/system` page shows `n/a`, never an error.
|
`SystemMetricsCollector` samples CPU (`/proc/stat` deltas), RAM (`/proc/meminfo`), disk, and repository size every 60s, but only after `ServerMetricsLifecycle` (server profile) calls `start()` — like the watcher, nothing is scheduled in CLI runs or tests. Min/max/avg aggregation state persists as JSON in the artifact root (`ArtifactStore.rootDir()`), so restarts continue the series. Unavailable sources (e.g. no `/proc` outside Linux) yield null metrics served as HTTP 200 by `GET /api/system` — the `/system` page shows `n/a`, never an error.
|
||||||
|
|
||||||
|
The disk metric is the tightest of the user quota, the group quota, and the volume itself (`DiskQuota`, PR#16): `readDisk()` shells out `quota -u -g --no-wrap --raw-grace` next to the `Files.getFileStore` read, parses it into candidate budgets, and `bindingDiskSpace` picks whichever has the smallest headroom — the volume by default, a quota on a Hostsharing Managed Webspace where it is the actual limit (`werkdock doctor` already reads the same number in Go, as a one-off pre-build check). `diskTotalGib`/`diskUsedGib`/`diskFreeGib` always come from that one binding source; `SystemMetrics.diskSource` names it (`UiFormats.diskTotal`/`werkator.js`'s `formatDiskTotal` render the same info-line suffix), and a source change resets the disk min/max/avg (`PersistedMetricsState.diskSourceKey`) so a stale volume-sized ceiling never survives a newly detected quota.
|
||||||
|
|||||||
@@ -36,14 +36,3 @@ 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
|
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ All production code lives under `de.hoennig.werkator`, with sub-packages `comman
|
|||||||
- `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.
|
- Everything repository-scoped (results, artifacts, worktrees, git and config access) goes through a `RepoContext`, never through an implicit current directory: the executor serializes per (context, branch) under one global `maxConcurrent`, the watcher polls every context in its own guard. `RepoRegistry` opens one context per entry of the instance configuration `~/.werkator.yml` (ADR 0009), or the current directory without one; the instance-level keys (`server`, `executor`, `watcher.pollInterval`) and the `defaults` block are folded into every repository's effective config by `ConfigLoader` itself, so no consumer reads the home file directly. Server routes carry the repository as `/repos/<name>/…` and `/api/repos/<name>/…`, with the unscoped form permanently meaning the served repository; the pages stay per repository and a drop-down in the page title switches between them.
|
||||||
- When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`.
|
- When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`.
|
||||||
- Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config.
|
- Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config.
|
||||||
- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and bubblewrap (`bwrap.enabled`, `bwrap.rootfs`, `bwrap.werkdock`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone.
|
- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and werkdock (`werkdock.enabled`, `werkdock.rootfs`, `werkdock.binary`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone.
|
||||||
- A build definition carries the complete description of its build, split in two: the `trigger` block (`onPush`, `atTimes`, `branches`, `activeWithin`) says when and for which branches it runs, everything else what it does. `builds.default` is the base every other definition inherits its settings — never its `trigger` — from. The split is structural so that a selector added to `TriggerConfig` later is non-inheritable by construction; writing a trigger key flat is refused, never ignored, because ignoring it leaves a build that silently stops running. A `!` prefix in `trigger.branches` excludes and always wins.
|
- A build definition carries the complete description of its build, split in two: the `trigger` block (`onPush`, `atTimes`, `branches`, `activeWithin`) says when and for which branches it runs, everything else what it does. `builds.default` is the base every other definition inherits its settings — never its `trigger` — from. The split is structural so that a selector added to `TriggerConfig` later is non-inheritable by construction; writing a trigger key flat is refused, never ignored, because ignoring it leaves a build that silently stops running. A `!` prefix in `trigger.branches` excludes and always wins.
|
||||||
- The inheritance is applied after all layers are merged: that order is what makes a build a branch invents inherit the host's sandbox policy instead of the data-class default, so the pinning also holds for a build the host has never heard of. Pinned are `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, `bwrap.rootfs`, and `bwrap.werkdock`. Docker and bwrap are mutually exclusive per branch — enabling both is rejected at start.
|
- The inheritance is applied after all layers are merged: that order is what makes a build a branch invents inherit the host's sandbox policy instead of the data-class default, so the pinning also holds for a build the host has never heard of. Pinned are `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `werkdock.enabled`, `werkdock.rootfs`, and `werkdock.binary`. Docker and werkdock are mutually exclusive per branch — enabling both is rejected at start. The section was called `bwrap` until v1.2.0 and is still read under that name, with a warning; the hard refusal waits for the release that sets `ConfigVersions.FORMAT_BROKE_IN`.
|
||||||
- `builds` or the legacy `branches`, never both: `branches` is read only while the merged config defines no build at all (`builds.maxConcurrent` is not one), and ignored with a warning as soon as one exists. The section is deprecated and goes away once the repositories have migrated; then `ConfigVersions.FORMAT_BROKE_IN` gets set and a leftover `branches:` key must be rejected by name — the version check alone cannot catch a file that declares no version.
|
- `builds` or the legacy `branches`, never both: `branches` is read only while the merged config defines no build at all (`builds.maxConcurrent` is not one), and ignored with a warning as soon as one exists. The section is deprecated and goes away once the repositories have migrated; then `ConfigVersions.FORMAT_BROKE_IN` gets set and a leftover `branches:` key must be rejected by name — the version check alone cannot catch a file that declares no version.
|
||||||
- Web UI: server-rendered Thymeleaf plus one hand-written `static/werkator.js` — no SPA framework, no frontend build pipeline; every fetch has a timeout and an explicit error badge; `UiFormats` and `werkator.js` must produce identical display formats.
|
- Web UI: server-rendered Thymeleaf plus one hand-written `static/werkator.js` — no SPA framework, no frontend build pipeline; every fetch has a timeout and an explicit error badge; `UiFormats` and `werkator.js` must produce identical display formats.
|
||||||
- Git and Docker access shells out to the CLIs (`GitCommandRunner`, `docker`) — no JGit, no Docker SDK.
|
- Git and Docker access shells out to the CLIs (`GitCommandRunner`, `docker`) — no JGit, no Docker SDK.
|
||||||
@@ -68,6 +68,7 @@ Keep sentences short.
|
|||||||
- `docs/deployment.md` — running Werkator as a systemd user service behind an existing reverse proxy (`init --systemd` generates the unit).
|
- `docs/deployment.md` — running Werkator as a systemd user service behind an existing reverse proxy (`init --systemd` generates the unit).
|
||||||
- `docs/werkator-migrationsplan.md` — renaming a running installation from GitTally to Werkator: what the name fallback covers and what has to be moved by hand.
|
- `docs/werkator-migrationsplan.md` — renaming a running installation from GitTally to Werkator: what the name fallback covers and what has to be moved by hand.
|
||||||
- `docs/plan/` — the step-by-step rewrite plan; `docs/plan/README.md` explains how to execute a step, `docs/plan/00-legacy-analysis.md` summarizes the legacy bash script.
|
- `docs/plan/` — the step-by-step rewrite plan; `docs/plan/README.md` explains how to execute a step, `docs/plan/00-legacy-analysis.md` summarizes the legacy bash script.
|
||||||
|
- `docs/rfcs/` — requests for comments: proposals that are larger than one PR and not yet a decision (an accepted RFC becomes an ADR or a plan step).
|
||||||
- `docs/prs/` — one document per pull request; every PR needs one. IMPORTANT: Before opening or finishing a pull request, load the [pr-doc skill](.claude/skills/pr-doc/SKILL.md) and write the PR-doc.
|
- `docs/prs/` — one document per pull request; every PR needs one. IMPORTANT: Before opening or finishing a pull request, load the [pr-doc skill](.claude/skills/pr-doc/SKILL.md) and write the PR-doc.
|
||||||
|
|
||||||
## Key Architectural Decisions
|
## Key Architectural Decisions
|
||||||
|
|||||||
@@ -8,6 +8,28 @@ Lightweight, declarative and highly opinionated software build system (CI/CD).
|
|||||||
- [docs/bootstrapping.md](docs/bootstrapping.md) — initializing a repository with `init`
|
- [docs/bootstrapping.md](docs/bootstrapping.md) — initializing a repository with `init`
|
||||||
- [docs/deployment.md](docs/deployment.md) — running Werkator as a systemd service behind a reverse proxy
|
- [docs/deployment.md](docs/deployment.md) — running Werkator as a systemd service behind a reverse proxy
|
||||||
|
|
||||||
|
## Adding a Gitea Repository
|
||||||
|
|
||||||
|
One instance serves several repositories (`docs/deployment.md`, ADR 0009).
|
||||||
|
From the workstation, clone and initialise, then register:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/remote --env-file .env.<instance> werkator repo-add https://gitea.example.org/<owner>/<repo>.git [<name>]
|
||||||
|
```
|
||||||
|
|
||||||
|
It prints the registry entry: add it to `~/.werkator.yml` under `repositories:`, then restart the service.
|
||||||
|
The optional `[<name>]` overrides the directory basename: it becomes the route segment (`/repos/<name>/…`) and the UI switcher entry, so it must be unique.
|
||||||
|
Needed only when the clone directory name is wrong or collides — e.g. `michael.hoennig.de.git` checked out as `michael.hoennig.de`, or two forges serving a repo of the same name.
|
||||||
|
A **public** repository needs nothing else: the clone runs anonymously.
|
||||||
|
A **private** repository needs shared credentials once on the host, in `~/.werkator.yml` of the service user, before cloning:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
defaults:
|
||||||
|
git:
|
||||||
|
account: <gitea-user>
|
||||||
|
token: <token> # Gitea → Settings → Applications → Generate Token, scope read:repository
|
||||||
|
```
|
||||||
|
|
||||||
## Developer Setup
|
## Developer Setup
|
||||||
|
|
||||||
Source `.envrc` to add `tools/` to your `PATH`, or install [direnv](#direnv) to have this done automatically on `cd`:
|
Source `.envrc` to add `tools/` to your `PATH`, or install [direnv](#direnv) to have this done automatically on `cd`:
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ plugins {
|
|||||||
group = "de.hoennig"
|
group = "de.hoennig"
|
||||||
// bump at least the patch version for every deployment — and only then, not per commit —
|
// bump at least the patch version for every deployment — and only then, not per commit —
|
||||||
// so the UI footer (BuildProperties), --version and the release notes identify what is
|
// so the UI footer (BuildProperties), --version and the release notes identify what is
|
||||||
// actually running; a deployment bundles whatever was committed since the last one
|
// actually running; a deployment bundles whatever was committed since the last one.
|
||||||
version = "1.0.1"
|
// ReleaseVersionConsistencyTest fails the build if this and the top releases.html entry disagree.
|
||||||
|
version = "1.2.0"
|
||||||
|
|
||||||
java {
|
java {
|
||||||
toolchain {
|
toolchain {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ Then, you have to configure *Werkator* by amending this config file according to
|
|||||||
|
|
||||||
`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).
|
`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.
|
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).
|
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, together with the static `werkator-maintenance.html` its `ErrorDocument`s fall back to while the service restarts).
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Werkator is configured via YAML files. Settings are merged from several sources
|
|||||||
|
|
||||||
| Layer | Path | Committed to Git | Purpose |
|
| Layer | Path | Committed to Git | Purpose |
|
||||||
|--------------------------|----------------------------|------------------|----------------------------------------------|
|
|--------------------------|----------------------------|------------------|----------------------------------------------|
|
||||||
|
| Instance config | `~/.werkator.yml` | No | The repository registry and the instance-level settings; optional `defaults` below every repository (see below) |
|
||||||
| 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` |
|
| 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 |
|
||||||
@@ -70,7 +71,7 @@ above, giving the precedence **branch > repo install > project**. It takes prece
|
|||||||
everything that describes how this branch is built: the whole `builds` section — its own
|
everything that describes how this branch is built: the whole `builds` section — its own
|
||||||
definitions and its overrides of the definitions from the project config, with
|
definitions and its overrides of the definitions from the project config, with
|
||||||
`buildCommand`, `cleanCommand`, `artifactDirs`, log file names, and
|
`buildCommand`, `cleanCommand`, `artifactDirs`, log file names, and
|
||||||
`docker.image`/`dockerfile`/`context`/`env` and `bwrap.env` inside them. That is how a new configuration is tried out: change it on a branch, and
|
`docker.image`/`dockerfile`/`context`/`env` and `werkdock.env` inside them. That is how a new configuration is tried out: change it on a branch, and
|
||||||
no other branch's builds are affected.
|
no other branch's builds are affected.
|
||||||
|
|
||||||
The branch layer is used in both places where it matters: the watcher reads the committed
|
The branch layer is used in both places where it matters: the watcher reads the committed
|
||||||
@@ -98,7 +99,7 @@ single branch may decide it:
|
|||||||
- the repository-side settings: the whole `gitea`, `executor`, and `watcher` sections;
|
- the repository-side settings: the whole `gitea`, `executor`, and `watcher` sections;
|
||||||
- the trust gate: `requirePullRequest`, and the Gitea status context: `statusContext`;
|
- the trust gate: `requirePullRequest`, and the Gitea status context: `statusContext`;
|
||||||
- the container sandbox policy: `docker.enabled`/`docker.network` and
|
- the container sandbox policy: `docker.enabled`/`docker.network` and
|
||||||
`bwrap.enabled`/`bwrap.rootfs`/`bwrap.werkdock` — host-pinned as
|
`werkdock.enabled`/`werkdock.rootfs`/`werkdock.binary` — host-pinned as
|
||||||
long as only the host's configuration sets them, master-pinned once the committed
|
long as only the host's configuration sets them, master-pinned once the committed
|
||||||
configuration does.
|
configuration does.
|
||||||
|
|
||||||
@@ -115,6 +116,53 @@ The pinned settings are stripped wherever they appear, in a build definition as
|
|||||||
a legacy `branches` entry. The deprecated `branches` section itself is read from the repo
|
a legacy `branches` entry. The deprecated `branches` section itself is read from the repo
|
||||||
install/project config only, and only while nothing defines a build at all.
|
install/project config only, and only while nothing defines a build at all.
|
||||||
|
|
||||||
|
## `~/.werkator.yml` — the instance configuration
|
||||||
|
|
||||||
|
One Werkator instance serves a *set* of repositories (ADR 0009): one service, one port, one UI, one watcher loop.
|
||||||
|
The set and everything shared by it live in `.werkator.yml` in the home directory of the user running Werkator — one instance per OS user.
|
||||||
|
The file name is deliberately the same everywhere; only the location carries the meaning: home is the instance, the repository root is the project, `.git` is the machine.
|
||||||
|
`WERKATOR_HOME` overrides the directory the file is looked up in.
|
||||||
|
|
||||||
|
Without this file Werkator serves the current working directory exactly as before.
|
||||||
|
With it, the registry wins over the current directory: `werkator server` serves the registered repositories wherever it is started.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
werkator:
|
||||||
|
version:
|
||||||
|
since: "0.9.16" # like every other config file
|
||||||
|
|
||||||
|
repositories: # the registry; each entry is one served repository
|
||||||
|
- path: ~/repos/werkator # absolute, or relative to the home directory (~ expands)
|
||||||
|
- path: ~/repos/werkbaum
|
||||||
|
name: baum # optional; default is the directory basename
|
||||||
|
|
||||||
|
server: # the instance's server section — port, bind address, public URL, nginx
|
||||||
|
port: 18080
|
||||||
|
executor:
|
||||||
|
maxConcurrent: 2 # the global cap over all repositories
|
||||||
|
watcher:
|
||||||
|
pollInterval: 10s # one loop, one delay
|
||||||
|
|
||||||
|
defaults: # optional: repository-level keys merged BELOW every repository's own layers
|
||||||
|
git:
|
||||||
|
account: ci-bot
|
||||||
|
token: "…" # secrets may then live here instead of in each repository
|
||||||
|
gitea:
|
||||||
|
baseUrl: https://git.example.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Key ownership once this file exists:
|
||||||
|
|
||||||
|
- **Instance-level** — read from this file alone: the whole `server` section, `executor.maxConcurrent`, and `watcher.pollInterval`.
|
||||||
|
A repository file still carrying one of them is ignored on that key, with one warning naming both files; it is never merged silently.
|
||||||
|
- **Repository defaults** — the `defaults` block, in the repository config schema: merged below each repository's project config, applied fragment, machine config, and branch layer, so a repository's own value always wins.
|
||||||
|
Pinning is unchanged: home defaults and the repository's machine config are both host-side layers, and a branch still cannot reach a pinned key.
|
||||||
|
- **Repository-level** — everything else stays in the repository's own files: `gitea.*`, `git.*` credentials, `builds`, retention, the other `watcher` keys (`pullRequestGate`, `newBranchMaxAge`, `fastForwardLocalRefs`), and the sandbox policy.
|
||||||
|
|
||||||
|
Repository names are the `--repo` selector of `werkator build`, `retry`, and `status` (without it a command means the current directory when served, otherwise the first registered repository) and will become the route segment of the web UI.
|
||||||
|
Two entries resolving to the same name abort the start, as does an entry that is not a git repository.
|
||||||
|
A repository whose configuration this Werkator must not read (see the version declaration) is skipped with an error; the others are served.
|
||||||
|
|
||||||
## Inspect the Effective Config
|
## Inspect the Effective Config
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -358,9 +406,9 @@ That is how a branch gets a build of its own without being built by the default
|
|||||||
`activeWithin` (e.g. `24h`) additionally keeps only branches whose origin head commit is younger than the duration — useful to run a nightly deep check over all recently active branches.
|
`activeWithin` (e.g. `24h`) additionally keeps only branches whose origin head commit is younger than the duration — useful to run a nightly deep check over all recently active branches.
|
||||||
Both parts combine as an intersection.
|
Both parts combine as an intersection.
|
||||||
|
|
||||||
Settings: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, `requirePullRequest`, `statusContext`, and `docker` and `bwrap` with all their keys.
|
Settings: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, `requirePullRequest`, `statusContext`, and `docker` and `werkdock` with all their keys.
|
||||||
A definition carries the complete description of its build; unset keys fall back to `builds.default` and then to Werkator's own defaults.
|
A definition carries the complete description of its build; unset keys fall back to `builds.default` and then to Werkator's own defaults.
|
||||||
`requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, `bwrap.rootfs`, and `bwrap.werkdock` are pinned (master-pinned, see [the branch layer](#the-branch-layer-a-branch-describes-its-own-ci)): they are read from the repo install/project config even when a branch sets them in its own committed config.
|
`requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `werkdock.enabled`, `werkdock.rootfs`, and `werkdock.binary` are pinned (master-pinned, see [the branch layer](#the-branch-layer-a-branch-describes-its-own-ci)): they are read from the repo install/project config even when a branch sets them in its own committed config.
|
||||||
Inheritance from `builds.default` covers the settings only — the `trigger` block says when and where *this* build runs and is never inherited.
|
Inheritance from `builds.default` covers the settings only — the `trigger` block says when and where *this* build runs and is never inherited.
|
||||||
Definitions are part of the branch layer: a branch may add its own and override those from the project config, for its own builds only.
|
Definitions are part of the branch layer: a branch may add its own and override those from the project config, for its own builds only.
|
||||||
Because the inheritance is applied after all layers are merged, a build a branch invents still inherits the host's `builds.default` — its sandbox policy included, which is what keeps the pinning effective for a build the host has never heard of.
|
Because the inheritance is applied after all layers are merged, a build a branch invents still inherits the host's `builds.default` — its sandbox policy included, which is what keeps the pinning effective for a build the host has never heard of.
|
||||||
@@ -416,22 +464,25 @@ Note that the rest of `.git` — including `.git/config` — is visible to build
|
|||||||
The Docker socket is mounted into the container and `DOCKER_HOST`/`TESTCONTAINERS_*` variables are set, so Testcontainers-based builds work inside the container.
|
The Docker socket is mounted into the container and `DOCKER_HOST`/`TESTCONTAINERS_*` variables are set, so Testcontainers-based builds work inside the container.
|
||||||
All Werkator containers carry `org.hoennig.werkator` labels; stale build containers of the repository are removed before the first Docker build after a restart.
|
All Werkator containers carry `org.hoennig.werkator` labels; stale build containers of the repository are removed before the first Docker build after a restart.
|
||||||
|
|
||||||
### Notes on `builds.<name>.bwrap`
|
### Notes on `builds.<name>.werkdock`
|
||||||
|
|
||||||
With `bwrap.enabled`, Werkator runs the build in a bubblewrap sandbox instead of native execution.
|
With `werkdock.enabled`, Werkator runs the build in a bubblewrap sandbox instead of native execution.
|
||||||
This is the third runtime, for hosts without root and without a Docker daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md` and ADR 0008.
|
This is the third runtime, for hosts without root and without a Docker daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md` and ADR 0008.
|
||||||
Since step 21 session C the sandbox is executed by the `werkdock` CLI (`bwrap.werkdock`, default: resolved via `PATH`) — Werkator no longer invokes `bwrap` itself; `bwrap` must be installed for werkdock.
|
Since step 21 session C the sandbox is executed by the `werkdock` CLI (`werkdock.binary`, default: resolved via `PATH`) — Werkator no longer invokes `bwrap` itself; `bwrap` must be installed for werkdock.
|
||||||
|
The section was called `bwrap` and its binary key `bwrap.werkdock` until v1.2.0; both are still read, with a warning naming the file, so an installation can be migrated at its next configuration edit rather than at the next update.
|
||||||
`werkdock doctor` checks the host's capability (it replaced the retired `tools/werkator-build-prerequisites.sh` in step 23).
|
`werkdock doctor` checks the host's capability (it replaced the retired `tools/werkator-build-prerequisites.sh` in step 23).
|
||||||
|
|
||||||
`bwrap.rootfs` names the prepared root filesystem archive — a Debian-base rootfs with the build tools (JDK, git, locales, project-specific tooling) built elsewhere, since `debootstrap` is unavailable on the target.
|
`werkdock.rootfs` names the prepared root filesystem archive — a Debian-base rootfs with the build tools (JDK, git, locales, project-specific tooling) built elsewhere, since `debootstrap` is unavailable on the target.
|
||||||
It is a local path or an `http(s)` URL; a URL is downloaded once into `.git/werkator/buildenv/`.
|
It is a local path or an `http(s)` URL; a URL is downloaded once into `.git/werkator/buildenv/`.
|
||||||
Build the archive with `tools/build-bwrap-rootfs.sh` on any machine with Docker.
|
Build the archive with `tools/build-bwrap-rootfs.sh` on any machine with Docker.
|
||||||
The archive is loaded once per source as the werkdock image `werkator-buildenv-<hash>` into werkdock's store (`$WERKDOCK_HOME`, default `~/.werkdock`) — shared by every repository of this OS user; the hash derives from the source string, so a changed `rootfs` loads a fresh image and stale ones can be removed from the store.
|
The archive is imported once per source as the werkdock image `werkator-buildenv-<hash>` into werkdock's store (`$WERKDOCK_HOME`, default `~/.werkdock`) — shared by every repository of this OS user; the hash derives from the source string, so a changed `rootfs` imports a fresh image and stale ones can be removed from the store.
|
||||||
|
Werkator uses `werkdock import ARCHIVE IMAGE` (docker import semantics) and falls back to the older `werkdock load -i ARCHIVE --name IMAGE` when the installed werkdock does not know the verb yet, so Werkator and werkdock can be updated in either order.
|
||||||
|
The image keeps its untagged name (it equals `werkator-buildenv-<hash>:latest` in werkdock's docker-style naming), so an existing store needs no re-import.
|
||||||
Per-repo Gradle caches persist in `.git/werkator/buildenv/home`, bound as `/root`.
|
Per-repo Gradle caches persist in `.git/werkator/buildenv/home`, bound as `/root`.
|
||||||
`bwrap.env` adds environment variables inside the sandbox; the environment is otherwise cleared (docker semantics) — the server's environment does not leak in.
|
`werkdock.env` adds environment variables inside the sandbox; the environment is otherwise cleared (docker semantics) — the server's environment does not leak in.
|
||||||
Files created inside the sandbox are owned by the host user, because uid 0 maps back to the unprivileged webspace user.
|
Files created inside the sandbox are owned by the host user, because uid 0 maps back to the unprivileged webspace user.
|
||||||
|
|
||||||
`docker` and `bwrap` are mutually exclusive per branch: enabling both is rejected at start, not silently picked.
|
`docker` and `werkdock` are mutually exclusive per branch: enabling both is rejected at start, not silently picked.
|
||||||
Git works inside the sandbox exactly as inside the Docker container: the primary `.git` is mounted read-only with `.git/werkator/` masked, so builds can run read-only git commands but never reach the machine config or the control token.
|
Git works inside the sandbox exactly as inside the Docker container: the primary `.git` is mounted read-only with `.git/werkator/` masked, so builds can run read-only git commands but never reach the machine config or the control token.
|
||||||
|
|
||||||
## `.git/werkator/.werkator.yml` (not committed)
|
## `.git/werkator/.werkator.yml` (not committed)
|
||||||
|
|||||||
@@ -81,6 +81,61 @@ User services stop at logout unless lingering is enabled once per user:
|
|||||||
loginctl enable-linger "$USER"
|
loginctl enable-linger "$USER"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Serving Several Repositories
|
||||||
|
|
||||||
|
One instance serves a *set* of repositories (ADR 0009): one service, one port, one UI, one watcher loop, one control token.
|
||||||
|
Adding a repository is editing a registry entry — never a data migration, because everything repository-specific already lives inside the repository (`.git/werkator/`: machine config with secrets, build results, auto-build slots, worktrees).
|
||||||
|
|
||||||
|
1. **Clone it** on the host, next to the ones already served:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/<owner>/<repo>.git ~/repos/<repo>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Prepare it** like any watched repository — the machine config with the git credentials, and the sandbox policy if the host needs one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/repos/<repo> && java -jar ~/bin/werkator.jar init
|
||||||
|
# fill in git.account and git.token in .git/werkator/.werkator.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Credentials shared by every repository of the same forge can live once in the instance file's `defaults` block instead (`docs/configuration.md`); they merge below each repository's own layers, so a repository may still override them.
|
||||||
|
The repository's own `.werkator.yml` — its builds — is committed and comes with the clone.
|
||||||
|
|
||||||
|
3. **Register it** in `~/.werkator.yml` of the user running the service:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
repositories:
|
||||||
|
- path: ~/repos/werkator
|
||||||
|
- path: ~/repos/<repo>
|
||||||
|
name: <short-name> # optional; default is the directory basename
|
||||||
|
```
|
||||||
|
|
||||||
|
The name is the route segment (`/repos/<name>/…`) and the UI's switcher entry, so it must be unique: a duplicate aborts the start naming this file, and so does an entry that is no git repository.
|
||||||
|
A repository whose configuration Werkator must not read (a version violation) is skipped with an error — the others keep building.
|
||||||
|
|
||||||
|
Steps 1 and 2 are mechanical and can be done from the workstation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/remote --env-file .env.<instance> werkator repo-add https://github.com/<owner>/<repo>.git [<name>]
|
||||||
|
```
|
||||||
|
|
||||||
|
It clones the repository next to the ones already served, runs `init` in it, and **prints** the registry entry.
|
||||||
|
It does not write `~/.werkator.yml`: that file is the instance's own — port, global concurrency, possibly shared credentials — and a script editing it in place would rewrite the operator's configuration behind their back.
|
||||||
|
Cloning and initialising is mechanical; registering is a decision.
|
||||||
|
A private `https` origin authenticates with the shared `defaults.git.account`/`defaults.git.token` of `~/.werkator.yml` (the token travels via a one-shot `GIT_ASKPASS` on the host, never in a URL or process list); enter those once before cloning a private repository — without them only public origins clone.
|
||||||
|
|
||||||
|
4. **Restart** the service; startup recovery re-enqueues what was in flight:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user restart werkator-<repo-name>.service
|
||||||
|
```
|
||||||
|
|
||||||
|
The service unit keeps the name it was installed with — it is the instance's unit, not one repository's.
|
||||||
|
With one registered repository every URL stays what it was; with several, the pages and the API carry the repository (`/repos/<name>/…`, `/api/repos/<name>/…`), the unscoped paths keep meaning the repository the instance was started in, and the navigation shows a switcher.
|
||||||
|
The watcher polls every repository in its own guard: an unreachable origin is that repository's report in the health banner, and the others are polled regardless.
|
||||||
|
`executor.maxConcurrent` is the global cap across all of them, and builds take slots in enqueue order (FIFO).
|
||||||
|
|
||||||
## Operating the Service
|
## Operating the Service
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -130,6 +185,11 @@ ssh <user>@<host>
|
|||||||
The tarball unpacks to a `werkator/` directory, so it must not be extracted over `~/opt` directly — unpack it in `/tmp` and move it into place, as above.
|
The tarball unpacks to a `werkator/` directory, so it must not be extracted over `~/opt` directly — unpack it in `/tmp` and move it into place, as above.
|
||||||
Rollback is the reverse: stop, remove the new directory (or jar), move `.bak` back, start.
|
Rollback is the reverse: stop, remove the new directory (or jar), move `.bak` back, start.
|
||||||
|
|
||||||
|
`tools/remote --env-file .env.<instance> werkator instance-update` does the same sequence for any host, not only the webspace layout it was written for.
|
||||||
|
Three optional keys in the env file name what differs (see the script's header): `WERKATOR_REPO_DIR` (directory of the watched repository, which also names the systemd unit), `WERKATOR_INSTALL_DIR` (where the runtime bundle is unpacked), and `WERKATOR_SANDBOX` (`werkdock`, the default, or `docker` — a Docker host has no werkdock binary and no rootfs archive to upload).
|
||||||
|
Their defaults are the layout `instance-install` creates, so an env file that names none of them behaves exactly as before.
|
||||||
|
The upload happens before the service is stopped and every artifact is checksum-verified after the transfer, so a dropped connection costs the transfer and not the running service.
|
||||||
|
|
||||||
Then check `https://<public-url>/` for the new version in the footer, and `journalctl --user -u werkator-<repo-name>.service -n 50` for a clean start.
|
Then check `https://<public-url>/` for the new version in the footer, and `journalctl --user -u werkator-<repo-name>.service -n 50` for a clean start.
|
||||||
Config file changes are not needed for an update; new keys take their defaults.
|
Config file changes are not needed for an update; new keys take their defaults.
|
||||||
|
|
||||||
@@ -265,12 +325,12 @@ See [configuration.md](configuration.md) for all `server.nginx.*` keys.
|
|||||||
## Hostsharing Managed Webspace
|
## 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.
|
The third deployment variant (plan step 21, verified live on a real webspace): no root, no Docker daemon, no own reverse proxy.
|
||||||
Werkator runs as a systemd *user* service on the assigned localhost port ("eigener Serverdienst"), the platform's managed Apache terminates TLS and proxies via `.htaccess`, and builds run in the bubblewrap sandbox executed by the [werkdock](../werkdock/README.md) CLI (ADR 0008, step 21 session C).
|
Werkator runs as a systemd *user* service on the assigned localhost port ("eigener Serverdienst"), the platform's managed Apache terminates TLS and proxies via `.htaccess`, and builds run in the bubblewrap sandbox executed by the [werkdock](https://git.javagil.de/mi/werkdock) CLI (ADR 0008, step 21 session C).
|
||||||
|
|
||||||
Werkator is never built on the webspace: the runtime bundle and the werkdock binary are built locally and uploaded (ADR 0006).
|
Werkator is never built on the webspace: the runtime bundle and the werkdock binary are built locally and uploaded (ADR 0006).
|
||||||
All steps are driven by `tools/remote`; commands name their role — `instance-*` manages the installed Werkator, `repo-*` the repository it watches.
|
All steps are driven by `tools/remote`; commands name their role — `instance-*` manages the installed Werkator, `repo-*` the repository it watches.
|
||||||
Each instance is a pair of files (step 23): a transport env file selected with `--env-file` (default `.env`), and a YAML fragment in the configuration schema, named by its `WERKATOR_INIT_CONFIG` key and installed remotely via `werkator init --apply` — e.g. `.env.mih34` + `.env.mih34.yml`, both gitignored.
|
Each instance is a pair of files (step 23): a transport env file selected with `--env-file` (default `.env`), and a YAML fragment in the configuration schema, named by its `WERKATOR_INIT_CONFIG` key and installed remotely via `werkator init --apply` — e.g. `.env.mih34` + `.env.mih34.yml`, both gitignored.
|
||||||
The fragment carries the Werkator configuration (`server.port`, `publicBaseUrl`, systemd limits, `builds.default.bwrap.*`); the env file only says where and how to reach the host.
|
The fragment carries the Werkator configuration (`server.port`, `publicBaseUrl`, systemd limits, `builds.default.werkdock.*`); the env file only says where and how to reach the host.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/remote --env-file .env.mih34 werkator check-prerequisites # uploads werkdock, runs its doctor
|
tools/remote --env-file .env.mih34 werkator check-prerequisites # uploads werkdock, runs its doctor
|
||||||
@@ -281,7 +341,8 @@ tools/remote --env-file .env.mih34 port-forward start # browser tunne
|
|||||||
```
|
```
|
||||||
|
|
||||||
Layout on the host: the watched repository at `$WERKATOR_PATH/werkator/`, the unpacked runtime at `$WERKATOR_PATH/.werkator/werkator/`, the werkdock binary at `$WERKATOR_PATH/.werkator/bin/werkdock`.
|
Layout on the host: the watched repository at `$WERKATOR_PATH/werkator/`, the unpacked runtime at `$WERKATOR_PATH/.werkator/werkator/`, the werkdock binary at `$WERKATOR_PATH/.werkator/bin/werkdock`.
|
||||||
The rootfs archive is loaded once per source into werkdock's image store (`~/.werkdock`), shared by every repository of the user.
|
That is the default, not a requirement: `WERKATOR_REPO_DIR`, `WERKATOR_INSTALL_DIR` and `WERKATOR_SANDBOX` bend it to an installation that predates the script, see [Updating an Existing Installation](#updating-an-existing-installation).
|
||||||
|
The rootfs archive is imported once per source into werkdock's image store (`~/.werkdock`), shared by every repository of the user.
|
||||||
Fill `git.account`/`git.token` in the machine config when the origin is private, and make the user's services survive logout with `loginctl enable-linger`.
|
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):
|
Updates are one command, refused while a build runs (`FORCE=1` overrides):
|
||||||
@@ -291,3 +352,6 @@ tools/remote --env-file .env.mih34 werkator instance-update
|
|||||||
```
|
```
|
||||||
|
|
||||||
The previous runtime stays as `.werkator/werkator.prev` for one deployment as the rollback asset.
|
The previous runtime stays as `.werkator/werkator.prev` for one deployment as the rollback asset.
|
||||||
|
For the few seconds `instance-update` restarts the service, nothing listens on its port; the generated `.htaccess` maps that refused connection (`ErrorDocument 502/503/504`) to a static `werkator-maintenance.html` placed next to it by `instance-start`, so the page reads "Werkator is restarting" instead of Apache's default error page or a hung request.
|
||||||
|
|
||||||
|
The `/system` page's disk metric is quota-aware (PR#16): on a Managed Webspace the binding limit is usually the package's group quota, not the free space of the shared host volume, so `diskTotalGib` there is the quota's soft limit — the info line names it (`group quota <package>, hard limit … GiB`) instead of showing the host's full disk size.
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ Deviations and decisions:
|
|||||||
- The legacy `generation` field was not ported; it only guarded the legacy JS against monitor restarts.
|
- The legacy `generation` field was not ported; it only guarded the legacy JS against monitor restarts.
|
||||||
- The CPU count comes from `Runtime.availableProcessors()` instead of `nproc`.
|
- The CPU count comes from `Runtime.availableProcessors()` instead of `nproc`.
|
||||||
|
|
||||||
|
**Implementation note (PR#16, 2026-09-03):** the disk metric is now the tightest of the user quota, the group quota, and the volume, not the volume alone — `DiskQuota` parses `quota -u -g --no-wrap --raw-grace` and `SystemMetricsCollector.readDisk()` picks whichever candidate has the smallest headroom. On hosts without a binding quota (Docker hosts, developer machines) nothing changes; on a Hostsharing Managed Webspace the group quota is usually the real limit, so `diskTotalGib` there is the quota's soft limit instead of the host volume's size, and the info line names the source. A source change (volume → quota, or one quota subject to another) resets `diskUsedGib`/`diskFreeGib`'s min/max/avg so a stale ceiling from the previous source never survives.
|
||||||
|
|
||||||
Manual smoke test (2026-07-07): scratch repository with a bare origin, server on port 18986, observed through a real browser tab (via a TCP proxy, so the tab outlived backend restarts).
|
Manual smoke test (2026-07-07): scratch repository with a bare origin, server on port 18986, observed through a real browser tab (via a TCP proxy, so the tab outlived backend restarts).
|
||||||
The first sample rendered RAM/disk/repo values immediately with CPU `n/a` and the totals line (`8 cores`, RAM/disk GiB, updated time).
|
The first sample rendered RAM/disk/repo values immediately with CPU `n/a` and the totals line (`8 cores`, RAM/disk GiB, updated time).
|
||||||
After the next 60s poll the open tab updated in place without reload: the updated time ticked, CPU used appeared (1.58 cores, idle 6.42 = 8 total), and min/max diverged.
|
After the next 60s poll the open tab updated in place without reload: the updated time ticked, CPU used appeared (1.58 cores, idle 6.42 = 8 total), and min/max diverged.
|
||||||
|
|||||||
@@ -85,6 +85,17 @@ Bring intent 1 to the webspace: build locally, install the bundle — Werkator n
|
|||||||
Session D's replacement must name the role in every command and in the script's vocabulary (e.g. `instance install`/`instance update` vs `repo build`), and prefer delegating built-side operations to the `werkator` CLI instead of reimplementing them.
|
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.
|
- `docs/deployment.md` gains "Hostsharing Managed Webspace" as the third deployment variant — step 17 required this to be written from a verified setup, and the branch's live run provides exactly that.
|
||||||
|
|
||||||
|
### E — Werkdock moves to its own repository (2026-09-03)
|
||||||
|
|
||||||
|
Sessions B–D left Werkdock self-contained by design ("no imports from Werkator code, no Gradle coupling"), and session C reduced the coupling to a binary on the `PATH`.
|
||||||
|
What remained was the directory move the plan promised from the start.
|
||||||
|
|
||||||
|
- The nine commits below `werkdock/` are lifted with `git subtree split -P werkdock`, so the history survives the move; the paths lose the prefix.
|
||||||
|
- The build definition `werkdock` leaves this repository's `.werkator.yml` and becomes the `default` build of the new repository's own `.werkator.yml` — the same commands, minus the `cd werkdock` prefix.
|
||||||
|
- `tools/remote` no longer builds the binary from a subdirectory: `WERKDOCK_REPO` names the checkout (default: a sibling of this repository), `WERKDOCK_BINARY` the built binary within it, and a missing checkout fails loudly with the clone URL instead of a bare "file not found".
|
||||||
|
- Werkator's own configuration is untouched: `bwrap.werkdock` still names the executing binary and is still pinned — a branch must not substitute it (AGENTS.md).
|
||||||
|
- The instance registers the new repository like any other (`tools/remote werkator repo-add`), so Werkdock is built and tested by the same Werkator that runs on its binary.
|
||||||
|
|
||||||
## Session Notes
|
## 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).
|
- 2026-09-01: The fat build image exists and is live on mih34: `tools/build-bwrap-rootfs.sh` gained `--pkgs-extra`, the archive `werkator-buildenv-trixie-java-go-node.tar.zst` (515 MB, JDK 21 + Go + Node/npm) was built locally, uploaded checksum-verified, and the machine config switched to it (deduplicating nine identical bwrap blocks the install prototype had appended).
|
||||||
@@ -103,3 +114,4 @@ Bring intent 1 to the webspace: build locally, install the bundle — Werkator n
|
|||||||
- Session B: the `werkdock/` subdirectory holds a self-contained tool in which `werkdock doctor`, an image build, and `werkdock run` work on a Managed Webspace without any Werkator involvement.
|
- Session 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 C: `./gradlew build` green with `BwrapBuildRunner` delegating to `werkdock`; the pinned-key tests and the metadata-masking tests unchanged and green.
|
||||||
- Session D: a fresh Managed Webspace reaches a running, HTTPS-reachable Werkator via `tools/remote werkator install` + `start` without ever compiling on the target; `docs/deployment.md` documents it.
|
- Session D: a fresh Managed Webspace reaches a running, HTTPS-reachable Werkator via `tools/remote werkator install` + `start` without ever compiling on the target; `docs/deployment.md` documents it.
|
||||||
|
- Session E: `werkdock/` is gone from this repository, the new repository builds and tests green on its own, `tools/remote` installs the binary from the sibling checkout, and the instance watches both repositories.
|
||||||
|
|||||||
@@ -55,16 +55,20 @@ The pinning model is untouched: pinned keys still come from each repo's machine
|
|||||||
|
|
||||||
### C — The registry and N repositories
|
### 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).
|
- ~~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).~~ — done 2026-09-02: `InstanceConfig` binds `~/.werkator.yml` (`ConfigLoader.homeDir`, `WERKATOR_HOME` overrides); `RepoRegistry` opens the contexts. The instance keys and the `defaults` block are applied inside `ConfigLoader.loadRaw`, so every `load(dir)` consumer sees them without knowing the file — the repository's copies of instance keys are dropped with one warning naming both files.
|
||||||
- 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.
|
- ~~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.~~ — done 2026-09-02: `pollAll(repos)`, one guard per repository, `WatcherState.repositories`; the top-level fields aggregate (unchanged with one repository, name-prefixed with several). Isolation proven by test (one unreachable origin, the other still enqueues).
|
||||||
- Startup recovery per repo; auto-build slots stay in each repo's `.git/werkator/`.
|
- ~~Startup recovery per repo; auto-build slots stay in each repo's `.git/werkator/`.~~ — done: `start(repos)` recovers each in its own guard; slots unchanged.
|
||||||
- CLI commands gain an optional repo selector and default to the current working directory, so `werkator status` inside a repo behaves as today.
|
- ~~CLI commands gain an optional repo selector and default to the current working directory, so `werkator status` inside a repo behaves as today.~~ — done: `--repo <name>` (`RepoOption` mixin) on `build`, `retry`, `status`; default is the cwd when served, else the first registered repository.
|
||||||
|
- Also done: the pre-rename state-dir migration runs per opened repository; the metrics page's repository size sums the registered repositories (the disk metric is the first one's file store).
|
||||||
|
- Carried over to session D: ~~`RunningBuild` still carries no repository (the "current builds" view and the worktree pruning cannot tell repositories apart)~~ — done 2026-09-03: `RunningBuild.repo` is the context (identity comparison), the current-builds view and API filter to the served repository, and the worktree pruning is protected by its own repository's running builds alone; the controllers still serve `registry.current()` only; `docs/deployment.md` gets the registry setup with session E.
|
||||||
|
|
||||||
### D — Server, API, and UI scoping
|
### 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.
|
- ~~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.~~ — done 2026-09-03 (PR #13): every route of the builds API, the pages, and the artifact files is mapped twice; the unscoped form is not an alias with an expiry date but the permanent way to say "the served repository", and an unknown name is a 404 in each controller's own shape.
|
||||||
- Latest/branches/history views group by repo or gain a repo column; one instance-wide metrics page; one control token.
|
- ~~Latest/branches/history views group by repo or gain a repo column; one instance-wide metrics page; one control token.~~ — done 2026-09-03, decided against the column: the pages stay per repository and the page title gains a **repository switcher** (a drop-down in place of the repository name, since 2026-09-03; first a link row beside the menu) (a row's actions need the repository anyway, branches come from one origin, artifacts from one store — and with one repository a column is noise). Metrics page and control token stay instance-wide as planned.
|
||||||
- Gitea status links use the repo-scoped URLs.
|
- ~~Gitea status links use the repo-scoped URLs~~ — done 2026-09-03: the permanent artifact links take the prefix (`BranchPermalinks.permanentUrl`; the key is a hash of the build name alone, so two repositories both having `main` would share one URL), and the **commit status now carries a target URL at all** — `server.publicBaseUrl` was documented as "used for all links posted to Gitea" while the executor posted `targetUrl = null`. It is the build's artifact page, repository-scoped.
|
||||||
|
- Also done: `RunningBuild` carries its `RepoContext` (the carry-over from session C), so the current-builds views and the watcher's worktree pruning tell repositories apart, and `cancel` refuses a key that is not recorded in the named repository.
|
||||||
|
- ~~`docs/deployment.md` gets the registry setup~~ — done 2026-09-03: section "Serving Several Repositories" (clone, prepare, register, restart) with the name rules, the per-repository guard, and what the URLs look like with one repository and with several.
|
||||||
|
|
||||||
### E — Rollout on mih34: Werkbaum joins
|
### E — Rollout on mih34: Werkbaum joins
|
||||||
|
|
||||||
@@ -74,7 +78,7 @@ The pinning model is untouched: pinned keys still come from each repo's machine
|
|||||||
|
|
||||||
## Open Questions
|
## 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.
|
- ~~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.~~ — decided 2026-09-02: FIFO. The executor's slot semaphore is already fair, so builds take slots in enqueue order across repositories; the watcher enqueues in registry order within one cycle, which is a fixed and inspectable bias rather than a scheduler. Round-robin per repository only when a real queue shows starvation.
|
||||||
- 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 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.
|
- ~~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.
|
||||||
|
|
||||||
@@ -82,6 +86,6 @@ The pinning model is untouched: pinned keys still come from each repo's machine
|
|||||||
|
|
||||||
- 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 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 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 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).~~ — done 2026-09-02 (`WatcherTest`: "one repository's unreachable origin neither stops nor silences the other").
|
||||||
- Session D: both repos browsable in one UI; single-repo installations keep their existing URLs.
|
- 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.
|
- Session E: mih34 builds Werkator and Werkbaum from one service; `docs/deployment.md` describes the registry setup.
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ Added for running Werkator on Hostsharing Managed Webspaces (2026-08-10):
|
|||||||
|
|
||||||
Added to correct the bwrap prototype's drift toward self-building on the webspace (2026-09-01):
|
Added to correct the bwrap prototype's drift toward self-building on the webspace (2026-09-01):
|
||||||
|
|
||||||
- [ ] `21-werkdock-extraction-and-webspace-install.md` — roadmap in four sessions: close step 17's open ends, grow the sandbox tooling into **Werkdock** (a docker-like filesystem-only sandbox CLI, developed in the `werkdock/` subdirectory, later its own repository), let Werkator consume it, and replace the webspace self-build with the local-build-plus-install path of ADR 0006
|
- [ ] `21-werkdock-extraction-and-webspace-install.md` — roadmap in four sessions: close step 17's open ends, grow the sandbox tooling into **Werkdock** (a docker-like filesystem-only sandbox CLI, developed in the `werkdock/` subdirectory, extracted into [its own repository](https://git.javagil.de/mi/werkdock) in session E), let Werkator consume it, and replace the webspace self-build with the local-build-plus-install path of ADR 0006
|
||||||
|
|
||||||
Added after step 21 session D exposed that `tools/remote` re-implements configuration Werkator owns (2026-09-01):
|
Added after step 21 session D exposed that `tools/remote` re-implements configuration Werkator owns (2026-09-01):
|
||||||
|
|
||||||
@@ -113,5 +113,5 @@ Step 17 depends on 11, 15, and 16, and starts with a hard precondition check on
|
|||||||
Step 18 depends on nothing in code but on the watched repository having migrated — its precondition check is a hard gate, not a formality.
|
Step 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 21 depends on 17; its sessions B and C grow Werkdock in the `werkdock/` subdirectory, session D supersedes the self-build prototype in `tools/remote`, and session E moves Werkdock into its own repository.
|
||||||
Step 23 depends on 21 session D; its per-instance file convention (transport env + init fragment) also feeds step 22's instance setup and should land before Werkbaum rolls out.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
> **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 #11 gave every repository-scoped code path an explicit `RepoContext`, but exactly one exists: the current working directory.
|
||||||
|
ADR 0009 wants one instance to serve a *set* of repositories from a registry in the instance configuration, with the instance-level settings (server, global concurrency, poll interval) owned by that file and optional defaults shared by every repository — and the watcher must poll every repository in a way that one unreachable origin cannot starve or silence the others.
|
||||||
|
Step 22 session C is that: the registry, N contexts, the multiplexed watcher, and a `--repo` selector for the CLI.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Repository-scoped routes, API paths, and UI grouping (session D): the controllers still serve the current repository only.
|
||||||
|
- The rollout on mih34 with Werkbaum and the deployment documentation of the registry (session E).
|
||||||
|
- A per-repository concurrency cap below the global one, and round-robin fairness across repositories (decided FIFO, see The Solution).
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: the instance configuration `~/.werkator.yml`
|
||||||
|
|
||||||
|
#### Background
|
||||||
|
|
||||||
|
- The file lives in the home directory of the user running Werkator — one instance per OS user; `WERKATOR_HOME` overrides the directory.
|
||||||
|
- It carries the registry (`repositories`), the instance-level keys (`server`, `executor.maxConcurrent`, `watcher.pollInterval`), and an optional `defaults` block in the repository config schema.
|
||||||
|
|
||||||
|
#### Scenario#12.01: The defaults block sits below every repository's own layers
|
||||||
|
|
||||||
|
So that one `git.account`/`git.token` or one `gitea.baseUrl` can be written once for every repository of the same forge, while a repository's own value always wins.
|
||||||
|
|
||||||
|
- **Given** a home config with `defaults`
|
||||||
|
- **When** a repository's effective config is loaded
|
||||||
|
- **Then** every key the repository's layers do not set comes from `defaults`, and every key they do set stays the repository's.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [the home config carries the registry, and its defaults sit below every repository layer](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#12.02: Instance-level keys come from the home file alone
|
||||||
|
|
||||||
|
So that a `server.port` left in a repository's machine config can never silently compete with the instance's.
|
||||||
|
|
||||||
|
- **Given** a home config and a repository file still carrying `server`, `executor`, or `watcher.pollInterval`
|
||||||
|
- **When** the repository's effective config is loaded
|
||||||
|
- **Then** the whole `server` section, `executor` and `watcher.pollInterval` are the home file's
|
||||||
|
- **and** the repository's copies are dropped with one warning naming both files
|
||||||
|
- **and** the other `watcher` keys (the gates) stay the repository's.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [with a home config the instance keys come from it alone, and a repository's copies are ignored](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
|
||||||
|
- [the home config is version-checked like every other file](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
|
||||||
|
|
||||||
|
### Feature: the registry
|
||||||
|
|
||||||
|
#### Scenario#12.03: Every entry becomes a context, the start fails loudly on what cannot be served
|
||||||
|
|
||||||
|
So that an instance serving the wrong set never comes up looking healthy.
|
||||||
|
|
||||||
|
- **Given** a home config with `repositories`
|
||||||
|
- **When** the registry is opened
|
||||||
|
- **Then** each entry yields a `RepoContext` named after its directory unless the entry names it
|
||||||
|
- **and** an entry that is not a git repository or two entries resolving to the same name abort the start with a message naming the home file
|
||||||
|
- **and** a repository whose configuration this Werkator must not read is skipped with an error while the others are served
|
||||||
|
- **and** without a home config the registry is the current directory alone, exactly as before.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [RepoRegistryTest](../../src/test/kotlin/de/hoennig/werkator/repo/RepoRegistryTest.kt) (all five tests)
|
||||||
|
|
||||||
|
### Feature: the watcher over N repositories
|
||||||
|
|
||||||
|
#### Scenario#12.04: One repository's failure neither stops nor silences the others
|
||||||
|
|
||||||
|
So that a wrong token in one repository cannot stall the builds of every other one.
|
||||||
|
|
||||||
|
- **Given** two registered repositories, one with an unreachable origin
|
||||||
|
- **When** a poll cycle runs
|
||||||
|
- **Then** the other repository's due branches are enqueued
|
||||||
|
- **and** the state reports the failure under the failing repository's name, and per repository in `repositories`
|
||||||
|
- **and** a repository whose poll crashes reports it the same way and the cycle continues.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [one repository's unreachable origin neither stops nor silences the other](../../src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt)
|
||||||
|
- [a repository whose poll crashes reports it by name and the cycle goes on](../../src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt)
|
||||||
|
- the existing single-repository `WatcherTest` tests, proving the top-level state fields read as before
|
||||||
|
|
||||||
|
### Feature: the CLI selects a repository
|
||||||
|
|
||||||
|
#### Scenario#12.05: `--repo <name>` selects a registered repository; without it a command means the current directory
|
||||||
|
|
||||||
|
So that `werkator status` inside a repository behaves as it always did, and a registered repository can be addressed from anywhere.
|
||||||
|
|
||||||
|
- **Given** a registry with two repositories
|
||||||
|
- **When** `werkator status --repo second` runs
|
||||||
|
- **Then** the second repository's results are printed
|
||||||
|
- **and** an unknown name is a usage error (exit code 2) naming the registered repositories.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [--repo selects a registered repository; an unknown name is a usage error naming the registered ones](../../src/test/kotlin/de/hoennig/werkator/commands/StatusCommandTest.kt)
|
||||||
|
- [BuildCommandTest](../../src/test/kotlin/de/hoennig/werkator/commands/BuildCommandTest.kt), [RetryCommandTest](../../src/test/kotlin/de/hoennig/werkator/commands/RetryCommandTest.kt) (the default: the registry's current repository)
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
`InstanceConfig` binds the home file; `ConfigLoader` gained `homeDir`, `instanceFile()`, and `loadInstance()`.
|
||||||
|
The instance keys and the `defaults` block are folded in by `ConfigLoader.loadRaw` itself — `defaults` below the repository layers, `server`/`executor`/`watcher.pollInterval` overlaid on top and stripped from the repository files with one warning — so every existing consumer of `load(dir)` (the server command, the executor's slot count, the watcher's interval) sees the instance values without knowing the file exists.
|
||||||
|
`RepoRegistry` opens the contexts lazily on first use; `RepoConfiguration` provides `registry.current()` as the `RepoContext` bean the still-unscoped controllers use.
|
||||||
|
`Watcher.start(repos)` recovers each repository in its own guard, and `pollAll(repos)` polls each in its own guard, aggregating the per-repository reports into `WatcherState` (new `repositories` list; the top-level fields unchanged with one repository, name-prefixed with several, so the health banner needs no change).
|
||||||
|
`RepoOption` is a picocli mixin shared by `build`, `retry`, and `status`.
|
||||||
|
The pre-rename state-dir migration moved from the CLI runner into `RepoContexts.open`, so it runs per served repository; the metrics collector sums the registered repositories' sizes.
|
||||||
|
Fairness across repositories is decided FIFO: the executor's slot semaphore is already fair, and the watcher enqueues in registry order — a fixed, inspectable bias instead of a scheduler; round-robin only when a real queue shows starvation.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- `RunningBuild` still carries no repository, so the current-builds view and the worktree pruning cannot tell repositories apart — session D, with the routes.
|
||||||
|
- With several repositories the `queuedBranches` list mixes their branch names unprefixed; session D scopes it with the UI.
|
||||||
|
|
||||||
|
## Additional Changes
|
||||||
|
|
||||||
|
- `docs/configuration.md`: the instance configuration documented, the layer table gained the home file.
|
||||||
|
- Architecture skill and AGENTS.md: registry, instance config folding, watcher multiplexing.
|
||||||
|
- `docs/plan/22-multi-repo.md`: session C ticked, fairness decided, carry-overs to session D.
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- PR #11 (`RepoContext` refactor).
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- Session D: server/API/UI repo scoping.
|
||||||
|
- Session E: rollout on mih34 with Werkbaum, registry setup in `docs/deployment.md`.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
> **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 #12 gave the instance a registry of repositories, but the server still served exactly one of them.
|
||||||
|
Every route — API, pages, artifact files — worked on `registry.current()`, so a second registered repository was built and polled, yet invisible and unreachable.
|
||||||
|
Two consequences went beyond "not browsable": the current-builds views listed the running builds of *all* repositories while looking their status up in *one* repository's results, and `cancel` addressed a build by key across the whole instance.
|
||||||
|
Step 22 session D is the repository dimension in the server: routes, links, and the UI.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- The rollout on the instance and the deployment documentation of the registry (session E).
|
||||||
|
- Merging several repositories into one table: the pages stay per repository (see The Solution).
|
||||||
|
- A per-repository control token or per-repository metrics: one instance, one token, one metrics page (ADR 0009).
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: every route carries the repository
|
||||||
|
|
||||||
|
#### Background
|
||||||
|
|
||||||
|
- The instance serves a registry of repositories (ADR 0009); the *served* repository is `RepoRegistry.current()` — the current working directory when it is served, else the first entry.
|
||||||
|
- The prefix is `/repos/<name>` for the pages and `/api/repos/<name>` for the API, where `<name>` is the registry entry's short name.
|
||||||
|
|
||||||
|
#### Scenario#13.01: The repository-scoped API answers for the named repository
|
||||||
|
|
||||||
|
So that a second registered repository is reachable at all.
|
||||||
|
|
||||||
|
- **Given** an instance serving a repository named `test`
|
||||||
|
- **When** `GET /api/repos/test/builds/latest` is requested
|
||||||
|
- **Then** the answer holds that repository's builds
|
||||||
|
- **and** `GET /api/builds/latest` still answers the same, because the unscoped form means the served repository
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [BuildsApiControllerTest — "the repository-scoped routes answer for the named repository and 404 for an unknown name"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#13.02: A name the instance does not serve is a miss, not an error
|
||||||
|
|
||||||
|
So that a typo in a URL reads like every other miss of this API.
|
||||||
|
|
||||||
|
- **Given** an instance that serves no repository named `no-such-repo`
|
||||||
|
- **When** `GET /api/repos/no-such-repo/builds/latest` is requested
|
||||||
|
- **Then** the answer is 404 with `{"error": "no repository named 'no-such-repo'"}`
|
||||||
|
- **and** the page `/repos/no-such-repo` answers 404 as well
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [BuildsApiControllerTest — "the repository-scoped routes answer for the named repository and 404 for an unknown name"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||||
|
- [UiControllerTest — "a page of a repository this instance does not serve answers 404"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#13.03: A single-repository installation keeps its existing URLs
|
||||||
|
|
||||||
|
So that no bookmark, no posted Gitea link, and no operator habit breaks on an installation that has exactly what it had before.
|
||||||
|
|
||||||
|
- **Given** an instance serving exactly one repository
|
||||||
|
- **When** any page is rendered
|
||||||
|
- **Then** every link it contains is unscoped (`/branches`, `/history`, `/api/builds/latest`)
|
||||||
|
- **and** no repository switcher is shown, because there is nothing to switch
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [UiControllerTest — "with one served repository the pages keep their existing URLs and show no switcher"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#13.04: With several repositories every link names its repository
|
||||||
|
|
||||||
|
So that a click inside a repository's page stays inside that repository.
|
||||||
|
|
||||||
|
- **Given** an instance serving the repositories `test` and `other`
|
||||||
|
- **When** the page `/repos/test` is rendered
|
||||||
|
- **Then** its navigation links, its `data-api`, and its `werkator-repo-base` meta carry `/repos/test`
|
||||||
|
- **and** the switcher offers `/repos/other`
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [UiControllerTest — "with several served repositories every link names its repository and the switcher appears"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#13.05: A repository-named route never reaches another repository
|
||||||
|
|
||||||
|
So that the repository in the path is a boundary, not a label.
|
||||||
|
|
||||||
|
- **Given** a build whose artifact key is not recorded in the repository named in the route
|
||||||
|
- **When** that build is cancelled through `/api/repos/test/builds/<key>/cancel`
|
||||||
|
- **Then** the answer is 404
|
||||||
|
- **and** the executor is not asked to cancel anything
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [BuildsApiControllerTest — "cancel does not reach a build of another repository"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||||
|
- [BuildsApiControllerTest — "current answers only the served repository's builds"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||||
|
- [WatcherTest — "a running build of another repository does not keep this repository's worktree"](../../src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt)
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
Every controller resolves its `RepoContext` per request instead of holding the served one as a bean: `repoOf(name)` is `registry.current()` without a name and `registry.byName(name)` with one, and an unknown name throws `UnknownRepositoryException`, which each controller turns into its own 404 shape.
|
||||||
|
Each route is mapped twice — scoped and unscoped — so the unscoped form is not a transitional alias but the permanent way to say "the served repository".
|
||||||
|
|
||||||
|
`RunningBuild` carries its `RepoContext`, so the executor's instance-global `currentBuilds()` can be filtered: the current-builds view and API show their own repository's builds, and the watcher's worktree pruning is protected by its own repository's builds alone.
|
||||||
|
`cancel` additionally verifies that the artifact key is recorded in the named repository — a queued or running build always has its PENDING/RUNNING result there.
|
||||||
|
|
||||||
|
The pages stay **per repository** instead of merging every repository's rows into one table with a repository column: a row's actions need the repository anyway, branches come from one origin and artifacts from one store, and with the single repository most installations have, such a column is pure noise.
|
||||||
|
What makes the instance one UI is the switcher in the navigation.
|
||||||
|
|
||||||
|
The link prefix follows the *number of served repositories*, not the route a page was reached through — with one repository the installation keeps its existing URLs, with several every link names its repository.
|
||||||
|
`werkator.js` reads that prefix once from a `werkator-repo-base` meta and builds its action and artifact URLs from it; the paths rendered into the DOM already carry it.
|
||||||
|
`BranchPermalinks.permanentUrl` takes the prefix too: the permanent key is a hash of the build name alone, so two repositories both having `main` would otherwise share one permanent URL.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- The metrics page and the control token stay instance-wide (ADR 0009); a per-repository token is not planned.
|
||||||
|
- `/api/watcher` stays unscoped — its state already carries the per-repository reports, and the UI banner is instance-wide.
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- PR #12 — the repository registry (step 22 session C).
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- Step 22 session E — the rollout: the registry with Werkator and Werkbaum under one service, and `docs/deployment.md`.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
> **WARNING:** This document describes only the change applied in this PR.
|
||||||
|
> It may already be outdated once the next PR is merged.
|
||||||
|
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
Werkdock was grown inside this repository on purpose — plan step 21, sessions B and C — and the plan said from the first line that it "moves to its own repository later".
|
||||||
|
Everything that move needs has been true since session C: the tool has no imports from Werkator code, no Gradle coupling, and Werkator reaches it the way it reaches `git` — as a binary on the `PATH`, named by the pinned key `bwrap.werkdock`.
|
||||||
|
|
||||||
|
What kept it here was three references, not a dependency:
|
||||||
|
`.werkator.yml` carried a second build definition for a Go module that has nothing to do with the Kotlin build around it,
|
||||||
|
`tools/remote` built the binary out of the subdirectory,
|
||||||
|
and the documentation described a directory that was about to stop existing.
|
||||||
|
|
||||||
|
The instance makes the cost visible: every Werkator branch built a `<branch>@werkdock` artifact, so Werkdock was rebuilt for changes that could not possibly affect it, and its own history was invisible under Werkator's branch names.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Changing Werkdock itself: the extracted repository holds the same nine commits, byte for byte, only without the path prefix.
|
||||||
|
- Changing how Werkator uses Werkdock: `bwrap.enabled`, `bwrap.rootfs` and `bwrap.werkdock` stay as they are, still pinned, still resolved via `PATH` at run time.
|
||||||
|
- Publishing releases of the binary: `tools/remote` keeps installing the locally built one (ADR 0006 — never built on the target).
|
||||||
|
- Registering the new repository on the instance: that is one `tools/remote werkator repo-add` plus a registry entry, done in the rollout, not in this branch.
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
**The history moves with the files.**
|
||||||
|
`git subtree split -P werkdock` lifts the nine commits that touched the subdirectory into a root-level history; the new repository starts from that, so `git log` and `git blame` keep working across the move.
|
||||||
|
A plain copy would have made this PR cheaper and the tool's past unreadable.
|
||||||
|
|
||||||
|
**The build definition follows, and becomes the only one.**
|
||||||
|
What was `builds.werkdock` here is `builds.default` in the new repository — same commands, minus the `cd werkdock` prefix, same `gofmt`-twice idiom (the first call names the unformatted files, the second fails the build on them).
|
||||||
|
This repository's `.werkator.yml` is back to one definition, and a Werkator branch no longer rebuilds a Go module it cannot influence.
|
||||||
|
|
||||||
|
**`tools/remote` asks for a checkout instead of a subdirectory.**
|
||||||
|
`WERKDOCK_REPO` names it (default: a sibling of this repository — the usual layout when you work on both), `WERKDOCK_BINARY` the built binary within it, and both are overridable from the environment like every other transport value.
|
||||||
|
A missing checkout now fails with the clone URL in the message rather than a bare "file not found": the script cannot fix the situation itself any more, so it says what would.
|
||||||
|
|
||||||
|
**The documentation stops describing a subdirectory.**
|
||||||
|
`docs/deployment.md` links the repository instead of `../werkdock/README.md`, the plan index and the architecture skill say the extraction happened, and plan step 21 gains session E with what was decided and what it left alone.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `./gradlew ktlintFormat build` green in this repository without `werkdock/`.
|
||||||
|
- The extracted repository builds and tests green on its own — `gofmt`, `go vet`, `go test ./...` (four packages), `go build` — which is the acceptance criterion the plan set for a self-contained tool.
|
||||||
|
- `bash -n tools/remote`, and the sibling-checkout default resolved against a real checkout.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
**A branch cannot remove a build definition — only add or override one.**
|
||||||
|
Observed on this very branch: the instance built `21e-werkdock-own-repo@werkdock` and it failed, although the branch's committed `.werkator.yml` no longer defines `werkdock`.
|
||||||
|
The cause is not a bug in the watcher — it reads the branch layer, and no fallback warning was logged — but the merge itself:
|
||||||
|
`withBranchLayer` does `deepMerge(loadRaw(workingDir), stripPinned(branchLayer))`, so a definition present in the project layer and absent on the branch survives the merge.
|
||||||
|
`AGENTS.md` and the architecture skill describe the branch layer as winning "including the whole `builds` section", which reads as replacement.
|
||||||
|
|
||||||
|
The mismatch resolves itself on merge, but one commit later than one would guess, and the watcher log shows exactly where:
|
||||||
|
at the merge commit `ac53b5f` both `default` and `werkdock` were still enqueued, at the next commit only `default`.
|
||||||
|
The project layer is the `.werkator.yml` in the *watched clone's working tree*, so the definition survives until that tree has advanced past its removal — the merge itself still gets one last stray build.
|
||||||
|
It does not block this PR.
|
||||||
|
It is a decision, not an oversight to fix in passing: either `builds` is replaced as a whole (then a branch can retire a build, and a branch that only adds one must repeat the others), or the merge stays and the two documents are corrected to say that removal is not expressible on a branch.
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
> **WARNING:** This document describes only the change applied in this PR.
|
||||||
|
> It may already be outdated once the next PR is merged.
|
||||||
|
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
|
||||||
|
|
||||||
|
## Related Links
|
||||||
|
|
||||||
|
- The live system page: <https://werkator.javagil.de/system> (instance `mih09-werkator` on `mih09.hostsharing.net`, a Hostsharing Managed Webspace).
|
||||||
|
- [Step 09: System Metrics](../plan/09-system-metrics.md) — where the disk metric comes from.
|
||||||
|
- [ADR 0008](../adrs/0008-2026-09-01.bwrap-build-runtime.md) and [`werkdock doctor`](../../werkdock/internal/doctor/doctor.go) — the one place that already reads the group quota, as a one-off check before the first build.
|
||||||
|
- Hostsharing-internal: the package's quota is set in HSAdmin; the values below were read on the host with `quota(1)`.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
The system page shows the disk of the *host*, not the disk the instance can use.
|
||||||
|
On `mih09` it reads `Disk total: 70.99 GiB`, `Disk used 37.13 GiB`, `Disk free 33.86 GiB` — the numbers of the whole `/` volume, shared by every package on that machine.
|
||||||
|
What actually limits Werkator there is a **group quota** of the package `mih09`: 8 GiB soft limit, 12 GiB hard limit, 1.04 GiB used, measured on 2026-09-03 (see [Attachments](#measured-on-mih09-2026-09-03)).
|
||||||
|
So the page promises 33 GiB of headroom where 7 GiB exist, and its warn/critical highlighting (80 %/90 % of the total) can never fire before a build fails with "Disk quota exceeded".
|
||||||
|
|
||||||
|
The disk metric is read via `java.nio.file.FileStore` with `df` semantics (step 09).
|
||||||
|
That is right on a host Werkator owns (vm4006, a Docker host) and wrong on every host where a user or group quota is the binding limit — the Hostsharing Managed Webspace being the deployment variant we now run in production.
|
||||||
|
`werkdock doctor` already knows this: it checks the group-quota headroom before the first build, because the free space of the volume said nothing (a 1 GiB quota blocked a build mid-flight on `h68`, step 17).
|
||||||
|
The system page should show the same truth, continuously.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Inode (file-count) quotas: `quota(1)` reports them, but the Gradle caches on `mih09` use 14 226 of 16.7 M files; a follow-up if it ever matters.
|
||||||
|
- Alerting or refusing to start a build on a full quota — the page only shows; `werkdock doctor` keeps the one-off pre-build check.
|
||||||
|
- A configuration switch: the quota is detected, never declared (see Open Questions).
|
||||||
|
- Changing the JSON field names of `GET /api/system` or the metric rows of the page: `diskTotalGib`, `diskUsedGib`, `diskFreeGib` keep their names and their meaning "the budget the instance can fill".
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: the disk metric is the tightest budget — user quota, group quota, or the volume
|
||||||
|
|
||||||
|
#### Background
|
||||||
|
|
||||||
|
- `quota(1)` prints one block per subject (`-u` the user, `-g` its groups), each with one line per filesystem: `blocks` (1 KiB units, currently used), `quota` (soft limit), `limit` (hard limit), grace, then the same four for files.
|
||||||
|
A subject without quota prints `… : none`.
|
||||||
|
A `*` after `blocks` marks "over the soft limit".
|
||||||
|
- Three **candidates** can limit what a directory may still take: the user quota, the group quota (each the lines of the directory's filesystem) and the volume itself (the file store, `df` semantics).
|
||||||
|
Each has a headroom: `soft − blocks` for a quota, the usable space for the volume.
|
||||||
|
- The **binding candidate** is the one with the smallest headroom; its numbers are the disk metric — total, used and free come from one source, never mixed.
|
||||||
|
On `mih09` today that is the group quota (6.96 GiB left against 33.86 GiB on the volume); a user quota, once the webspace introduces one, joins the comparison without any change.
|
||||||
|
- For a quota the **total** is its **soft** limit, the hard limit is shown alongside (see Open Questions for the choice).
|
||||||
|
- Where no quota line matches the directory's filesystem, the volume is the only candidate — Docker hosts and developer machines render exactly as today.
|
||||||
|
- The directory is the first served repository's, as for the file-store metric today.
|
||||||
|
|
||||||
|
#### Scenario#16.01: A group quota replaces the volume numbers
|
||||||
|
|
||||||
|
So that the operator of a Managed Webspace sees the budget the package can fill, not the size of the host's disk.
|
||||||
|
|
||||||
|
- **Given** `quota -u -g` reports no user quota and a group quota on the repository's filesystem with 8 GiB soft limit, 12 GiB hard limit and 1.04 GiB used
|
||||||
|
- **and** the file store reports 71 GiB total and 34 GiB free
|
||||||
|
- **When** a sample is taken
|
||||||
|
- **Then** `diskTotalGib` is 8.00, `diskUsedGib` 1.04 and `diskFreeGib` 6.96
|
||||||
|
- **and** the snapshot names the quota: group `mih09`, its filesystem, and the hard limit 12.00 GiB.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [SystemMetricsCollectorTest — "a group quota on the repository's filesystem replaces the file-store disk numbers"](../../src/test/kotlin/de/hoennig/werkator/metrics/SystemMetricsCollectorTest.kt)
|
||||||
|
- [DiskQuotaTest — "the mih09 output parses into one group line per filesystem and no user line"](../../src/test/kotlin/de/hoennig/werkator/metrics/DiskQuotaTest.kt) (fixture: the attachment below)
|
||||||
|
|
||||||
|
#### Scenario#16.02: The tightest of user quota, group quota and volume binds
|
||||||
|
|
||||||
|
So that neither a user quota below the group's, nor a nearly full host volume below both, is hidden by the wider budgets.
|
||||||
|
|
||||||
|
- **Given** a user quota with 2 GiB headroom and a group quota with 7 GiB headroom on the same filesystem
|
||||||
|
- **and** the volume has 34 GiB usable
|
||||||
|
- **When** a sample is taken
|
||||||
|
- **Then** the user quota binds: its soft limit is the total, its blocks the used value
|
||||||
|
- **and** with the user quota reporting `none`, the group quota binds
|
||||||
|
- **and** with the volume down to 1 GiB usable, the volume binds and the metric shows the file-store numbers, quotas or not.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [DiskQuotaTest — "among user quota, group quota and volume the smallest headroom binds"](../../src/test/kotlin/de/hoennig/werkator/metrics/DiskQuotaTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#16.03: Only the quota of the repository's filesystem counts
|
||||||
|
|
||||||
|
So that a full quota on another volume (on `mih09`: `/dev/sdb1`) does not shrink the metric of the volume Werkator writes to.
|
||||||
|
|
||||||
|
- **Given** quota lines for two filesystems
|
||||||
|
- **and** the repository directory's file store is named like the first one
|
||||||
|
- **When** the binding quota is chosen
|
||||||
|
- **Then** the second filesystem's lines are ignored
|
||||||
|
- **and** the name is matched exactly, or by its last path segment when the file store reports a resolved device path (`/dev/sdb1` vs `/dev/disk/by-id/…`).
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [DiskQuotaTest — "only the lines of the directory's file store are considered, matched exactly or by device name"](../../src/test/kotlin/de/hoennig/werkator/metrics/DiskQuotaTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#16.04: Without a quota the volume stays the source
|
||||||
|
|
||||||
|
So that hosts without quota tooling, without a quota, or with an unreadable `quota` output render exactly as before.
|
||||||
|
|
||||||
|
- **Given** `quota` is absent, fails, prints `none` for user and group, or prints only other filesystems
|
||||||
|
- **When** a sample is taken
|
||||||
|
- **Then** `diskTotalGib`, `diskUsedGib` and `diskFreeGib` are the file-store values
|
||||||
|
- **and** the snapshot names the volume as the source and no quota
|
||||||
|
- **and** an absent or failing `quota` is logged once, not every 60 s, like every other source.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [SystemMetricsCollectorTest — "without a quota the volume stays the disk source"](../../src/test/kotlin/de/hoennig/werkator/metrics/SystemMetricsCollectorTest.kt)
|
||||||
|
- [SystemMetricsCollectorTest — "unreadable sources degrade to null metrics, never fail the sample"](../../src/test/kotlin/de/hoennig/werkator/metrics/SystemMetricsCollectorTest.kt) (existing, extended by the quota source)
|
||||||
|
|
||||||
|
#### Scenario#16.05: A changed disk source restarts the disk series
|
||||||
|
|
||||||
|
So that the min/max/avg of a 1 GiB quota metric are not poisoned by the 37 GiB volume history of the previous binary — the max would otherwise read 37.13 GiB forever.
|
||||||
|
|
||||||
|
- **Given** a persisted aggregation state whose disk series were recorded from the file store
|
||||||
|
- **When** the first sample after the update finds a binding quota
|
||||||
|
- **Then** the `diskUsedGib` and `diskFreeGib` series start afresh
|
||||||
|
- **and** every other series continues
|
||||||
|
- **and** the state records the disk source, so the next restart continues the quota series.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [SystemMetricsCollectorTest — "a changed disk source restarts the disk series and keeps the others"](../../src/test/kotlin/de/hoennig/werkator/metrics/SystemMetricsCollectorTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#16.06: The page says which budget it shows
|
||||||
|
|
||||||
|
So that `Disk total: 8.00 GiB` on a 71 GiB host is not mistaken for a broken metric.
|
||||||
|
|
||||||
|
- **Given** a snapshot with a binding group quota
|
||||||
|
- **When** the system page renders or polls
|
||||||
|
- **Then** the info line reads `Disk total: 8.00 GiB (group quota mih09, hard limit 12.00 GiB)`
|
||||||
|
- **and** with a binding user quota `Disk total: 4.00 GiB (user quota mih09-werkator, hard limit 6.00 GiB)`
|
||||||
|
- **and** with the volume binding although quotas exist `Disk total: 70.99 GiB (volume, tighter than the quotas)`
|
||||||
|
- **and** without any quota it reads `Disk total: 70.99 GiB` as today
|
||||||
|
- **and** the server-rendered line and the polled line are identical.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [UiViewsTest — "the disk total names the binding source: user quota, group quota, or the volume"](../../src/test/kotlin/de/hoennig/werkator/server/UiViewsTest.kt)
|
||||||
|
- `werkator.js` mirrors `UiFormats.diskTotal` (manual: the polled line must equal the rendered one after the first refresh)
|
||||||
|
|
||||||
|
#### Scenario#16.07: The highlighting follows the quota
|
||||||
|
|
||||||
|
So that the warn/critical colours fire before a build hits "Disk quota exceeded".
|
||||||
|
|
||||||
|
- **Given** a binding quota with 8 GiB soft limit and 6.6 GiB used
|
||||||
|
- **When** the system page renders
|
||||||
|
- **Then** the `Disk used` cell is highlighted `metric-warn` (82 %), and `metric-crit` from 7.2 GiB.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [UiViewsTest — "utilization highlights warn from 80% and crit from 90% of the total"](../../src/test/kotlin/de/hoennig/werkator/server/UiViewsTest.kt) (existing — the total is now the quota, nothing else changes)
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
The implementation follows the plan below with one shape difference: `DiskSpace` itself stays the plain `{totalBytes, usedBytes, freeBytes}` value it already was, and a new `DiskCandidate(space, source)` pairs it with a `DiskSource` only where a source needs naming (quota parsing, the binding choice, the collector's disk reading) — kept `DiskSpace` reusable by the unchanged volume-only tests instead of every caller now supplying a source.
|
||||||
|
|
||||||
|
**Read the quota through the CLI, like git and Docker.**
|
||||||
|
Linux exposes quotas only through the `quotactl` syscall, which Java cannot reach without JNI/JNA — a new runtime dependency and a native layer for one number.
|
||||||
|
`quota(1)` is installed wherever quotas are set (Debian's `quota` package on the Managed Webspaces, version 4.06 on `mih09`), so the collector shells out: `quota -u -g --no-wrap --raw-grace` (`-ugwp`).
|
||||||
|
`--no-wrap` keeps long device names such as `/dev/disk/by-id/wwn-…-part2` on one line, `--raw-grace` prints the grace columns as numbers instead of leaving them empty, so every filesystem line has the same nine fields and the parser needs no column heuristics.
|
||||||
|
The exit status is not read as a failure signal — `quota` also uses it to say "over quota" — only the parsed output counts; an absent binary or an empty output means "no quota", never a failed sample.
|
||||||
|
This is the same decision `werkdock doctor` took in Go; the two parsers stay separate because the tools are different binaries in different languages, but the fixture is the same real output.
|
||||||
|
|
||||||
|
**Choose the binding candidate in a pure function.**
|
||||||
|
`DiskQuota` (new, package `de.hoennig.werkator.metrics`) parses the output into lines `{kind user|group, subject, filesystem, blocksKib, softKib, hardKib}` and keeps the lines of the directory's file store (`Files.getFileStore(dir).name()` is the mount's device string, the same string `quota` prints; a resolved device path is matched by its last segment as `werkdock` does).
|
||||||
|
Each remaining line becomes a `DiskCandidate` with `total = soft`, `used = blocks`, `free = max(0, total − blocks)`; a line whose soft limit is 0 (unset) uses the hard limit as total, a line with both 0 is no candidate.
|
||||||
|
The volume's `fileStoreDiskSpace(dir)`, wrapped as `DiskCandidate` with `DiskSource.volume()`, is the last candidate, and `bindingDiskSpace(candidates)` returns the one with the smallest `free` — the tightest budget wins, and total, used and free always come from that one source.
|
||||||
|
All of it is pure over strings and numbers, so the whole matrix — user only, group only, both, none, volume tighter than the quotas, two filesystems, `*` marker, `none` line — is a Kotest table.
|
||||||
|
|
||||||
|
**The collector gets one more injectable source.**
|
||||||
|
`SystemMetricsCollector` gains `quotaOutput: () -> String?` and `fileStoreName: (Path) -> String` next to `diskSpace` (the process call with a 5 s timeout in production, a fixed string in tests); `readDisk()` collects the quota candidates plus the file store and takes the binding one — a failing `quota` is read under its own `readSource("quota")`, logged once and separately from a failing file-store read, and simply leaves the volume as the only candidate.
|
||||||
|
The binding candidate's `DiskSource` (`kind` `volume|user|group`, `subject`, `filesystem`, and for a quota `softLimitGib`/`hardLimitGib`) is carried into `SystemMetrics.diskSource` — an additive JSON field, the three existing disk fields keep their names; `quotasPresent: Boolean` says whether a quota lost against the volume, for the info line.
|
||||||
|
The persisted state gains `diskSource` (`"volume"` or `"quota:<kind>:<subject>:<filesystem>"`); a mismatch drops the two disk series before the sample is recorded (Scenario#16.05).
|
||||||
|
That reset also fires when the binding candidate switches at runtime, e.g. from the group quota to a newly introduced user quota — the series then describe one budget at a time.
|
||||||
|
The quota is read every sample: it is one syscall behind a small process, cheaper than the repo-size walk, and a raised quota should show within a minute.
|
||||||
|
|
||||||
|
**The page names the budget.**
|
||||||
|
`UiFormats.diskTotal(metrics)` formats `8.00 GiB (group quota mih09, hard limit 12.00 GiB)`, `… (user quota …)`, `70.99 GiB (volume, tighter than the quotas)` or the plain total when no quota exists; `werkator.js` gets the identical function for the poll — the UI invariant that server-rendered and polled output match.
|
||||||
|
Rows, labels and the highlighting stay as they are: `utilizationClass(used, total)` simply receives the quota as the total.
|
||||||
|
|
||||||
|
**Where it is verified live — done, 2026-09-03.**
|
||||||
|
Deployed to `mih09` (v1.1.1); `GET /api/system` and the `/system` page read `Disk total: 8.00 GiB (group quota mih09, hard limit 12.00 GiB)`, matching `diskSource: {kind: "group", subject: "mih09", ..., hardLimitGib: 12.0}`.
|
||||||
|
`Disk used`/`Disk free` came back with `min == max == current` (3.65/4.35 GiB) on the very first sample after the update — the series reset, not the volume's pre-update history — and kept aggregating normally afterward; usage is higher than the PR's original 1.04 GiB measurement because time and builds moved on, not because the quota changed (still 8.00/12.00 GiB).
|
||||||
|
The `Repo size` row and the journal were unaffected; no error or warning was logged (the quota binary is present and working on `mih09`, so the one-time "cannot read quota" log line only shows on hosts without it — confirmed separately by a local smoke test with `quota` absent).
|
||||||
|
|
||||||
|
**Order of work:**
|
||||||
|
|
||||||
|
1. ✅ `DiskQuota` parser and selection with the table test and the `mih09` fixture.
|
||||||
|
2. ✅ `SystemMetricsCollector`: the quota source, the fallback, `diskSource` in the state, the series reset.
|
||||||
|
3. ✅ `SystemMetrics.diskSource`, `UiFormats.diskTotal`, `SystemMetricsView`, `werkator.js`, `UiViewsTest`.
|
||||||
|
4. ✅ Docs: the metrics paragraph of the architecture skill, one sentence in `docs/deployment.md` (Hostsharing section) and in `docs/plan/09-system-metrics.md` (implementation note), and this PR-doc's "Verified by" links turned from planned into real.
|
||||||
|
5. ✅ Deployed to `mih09` via `tools/remote --env-file .env.mih09 werkator instance-update` (v1.1.1) and verified live as above.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **Soft or hard limit as the total?** Planned: the soft limit, with the hard limit in the info line.
|
||||||
|
Beyond the soft limit the grace period starts and writes fail once it expires, so for a service that runs for weeks the soft limit is the effective one; and a page that turns critical *before* the hard stop is the point of the highlighting.
|
||||||
|
The reviewer may prefer the hard limit as the total and the soft limit as the warn threshold instead — that would need a third highlighting rule, hence not planned.
|
||||||
|
- **The volume as a candidate — decided.** The owner asked for the smallest of user quota, group quota and free disk space, so the volume competes on equal terms instead of being only the fallback; that is what Scenario#16.02 and `bindingDiskSpace` describe.
|
||||||
|
- **A configuration switch?** Planned: none.
|
||||||
|
The quota is detected and the volume is always a candidate; a key `metrics.disk: quota|filestore` would be a fourth place to keep in sync (`WerkatorConfig`, `init` templates, `docs/configuration.md`) for a choice nobody is expected to make.
|
||||||
|
- **Which directory's filesystem?** Planned: the first served repository's, as today.
|
||||||
|
The artifact root and the worktrees live under it by default; an artifact root on another volume would need its own line — noted as a follow-up.
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- None; the change is confined to the metrics package and the system page.
|
||||||
|
It applies equally on top of PR #12 (the registry), which only changed the directory the disk metric is read for from `workingDir` to `repoDirs().first()`.
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- The implementation of this plan.
|
||||||
|
- Inode quotas, if a host ever runs into the file limit before the block limit.
|
||||||
|
- A per-volume disk metric when the artifact root is configured onto another file store than the repository.
|
||||||
|
|
||||||
|
## Attachments
|
||||||
|
|
||||||
|
### Measured on mih09 (2026-09-03)
|
||||||
|
|
||||||
|
Read as `mih09-werkator` (uid 120974, gid 102180 `mih09`); this output is the parser fixture.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ quota -u -g -w -p
|
||||||
|
Disk quotas for user mih09-werkator (uid 120974): none
|
||||||
|
Disk quotas for group mih09 (gid 102180):
|
||||||
|
Filesystem blocks quota limit grace files quota limit grace
|
||||||
|
/dev/disk/by-id/wwn-0x0000000000000001-part2 1088116 8388608 12582912 0 14226 16777216 25165824 0
|
||||||
|
/dev/sdb1 1456376 10485760 15728640 0 52983 20971520 31457280 0
|
||||||
|
|
||||||
|
$ df -Pk "$HOME"
|
||||||
|
Filesystem 1024-blocks Used Available Capacity Mounted on
|
||||||
|
/dev/disk/by-id/wwn-0x0000000000000001-part2 74436100 38933504 35502596 53% /
|
||||||
|
|
||||||
|
$ du -sk "$HOME"
|
||||||
|
954544 /home/pacs/mih09/users/werkator
|
||||||
|
```
|
||||||
|
|
||||||
|
In GiB: the group's soft limit is 8.00, the hard limit 12.00, the usage 1.04 (of which Werkator's home is 0.91); the volume is 70.99 with 33.86 free — the numbers the page shows today.
|
||||||
|
Without `--raw-grace` the empty grace columns vanish and the lines have seven fields instead of nine; without `--no-wrap` the long device name is printed on a line of its own.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
> **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
|
||||||
|
|
||||||
|
`instance-update` restarts the systemd unit: for a few seconds nothing listens on Werkator's port at all.
|
||||||
|
On the Hostsharing Managed Webspace deployment (see [deployment.md](../deployment.md#hostsharing-managed-webspace)) the platform's Apache sits in front and proxies via `.htaccess` (`SystemdServiceFiles.htaccessContent`); with the backend refusing connections, Apache answers with its own bare 502 error page, or the request just hangs until it times out.
|
||||||
|
Either way the instance looks dead rather than "back in a moment", which is confusing during a deploy that is otherwise routine and fast (the restart itself is under a second; the rest of `instance-update`'s time is upload).
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- A live progress indicator ("2 of 10 seconds left") — the maintenance page is a static file with no way to know how far the restart has gotten.
|
||||||
|
- Zero-downtime / blue-green deployment — the restart gap itself is not eliminated, only made to look intentional instead of broken.
|
||||||
|
- The Docker-host and bwrap-webspace-without-a-domain deployment variants, which do not go through Apache/`.htaccess` at all.
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: a refused connection shows a maintenance page instead of a bare error
|
||||||
|
|
||||||
|
#### Background
|
||||||
|
|
||||||
|
- `init --systemd` writes the `.htaccess` only when `server.publicBaseUrl` is set — unchanged; the maintenance page is generated under the same condition, next to it.
|
||||||
|
- The generated `.htaccess` already proxies every request to `http://127.0.0.1:<port>` via `mod_rewrite [P]`; a refused connection surfaces as Apache's own 502 (and, depending on Apache's timeout handling, 503/504).
|
||||||
|
|
||||||
|
#### Scenario#17.01: A refused backend connection serves the maintenance page
|
||||||
|
|
||||||
|
So that a deployment's restart window reads "please retry" instead of a bare error or a hang.
|
||||||
|
|
||||||
|
- **Given** the generated `.htaccess` and `werkator-maintenance.html` are in a domain's docroot
|
||||||
|
- **When** the proxied backend refuses the connection (Apache's 502/503/504)
|
||||||
|
- **Then** Apache serves `werkator-maintenance.html` instead of its default error page
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [SystemdServiceFilesTest — "the htaccess maps a refused connection to the static maintenance page"](../../src/test/kotlin/de/hoennig/werkator/commands/SystemdServiceFilesTest.kt)
|
||||||
|
- Manual on `mih09` (2026-09-03): with the service stopped, `https://werkator.javagil.de/` answered **HTTP 503 with the maintenance page in 0.14 s** — Apache reaches the error path immediately, it does not wait for a timeout. With the service running, the same URL is 200 as before, and `/werkator-maintenance.html` is directly reachable (the `RewriteCond` keeps it out of the proxy).
|
||||||
|
|
||||||
|
#### Scenario#17.02: The maintenance page itself is never proxied
|
||||||
|
|
||||||
|
So that `ErrorDocument`'s internal sub-request for the page does not loop back into the same rewrite rule (which would proxy it to the — still down — backend and fail again).
|
||||||
|
|
||||||
|
- **Given** the generated `.htaccess`
|
||||||
|
- **When** Apache serves `werkator-maintenance.html` as an `ErrorDocument`
|
||||||
|
- **Then** the `RewriteCond` excludes exactly that path from the proxy `RewriteRule`
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [SystemdServiceFilesTest — "the htaccess maps a refused connection to the static maintenance page"](../../src/test/kotlin/de/hoennig/werkator/commands/SystemdServiceFilesTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#17.03: A host without a configured public base URL is unaffected
|
||||||
|
|
||||||
|
So that a developer machine or a Docker-host deployment, which never went through `.htaccess`, sees no new file and no behavior change.
|
||||||
|
|
||||||
|
- **Given** `server.publicBaseUrl` is blank
|
||||||
|
- **When** `init --systemd` runs
|
||||||
|
- **Then** neither `.htaccess` nor `werkator-maintenance.html` is written — unchanged from before this PR
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- Existing `InitCommand` behavior (`server.publicBaseUrl.isNotBlank()` gate); not separately tested, as before this PR.
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
`SystemdServiceFiles.htaccessContent` gains three `ErrorDocument` lines (502/503/504) pointing at a new static `werkator-maintenance.html`, plus one `RewriteCond` excluding that file from the catch-all proxy rule.
|
||||||
|
`SystemdServiceFiles.maintenancePageContent()` is a small, self-contained HTML page ("Werkator is restarting — please retry in a few minutes") — no external CSS/fonts/images, since nothing would be there to serve them while Werkator itself is the thing that is down.
|
||||||
|
`InitCommand.createSystemdFiles` writes it under the same `server.publicBaseUrl.isNotBlank()` gate as the `.htaccess`, next to it in `.git/werkator/`.
|
||||||
|
`tools/remote instance-start` copies both files into the domain docroot in the same step (it already copied the `.htaccess` there; the maintenance page rides along).
|
||||||
|
|
||||||
|
No config key was added: like the `.htaccess` itself, the maintenance page is generated whenever a `publicBaseUrl` is configured, not behind a separate toggle nobody is expected to turn off.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **Does Apache actually reach the error path, or does the client just time out first?** Answered by the live test above: on `mih09` a refused connection surfaces as **503 within 0.14 s**, not a hang — a connection *refused* is immediate, unlike a connection that is accepted and then stalls. The `ErrorDocument` covers 502/503/504 so the exact code Apache picks does not matter.
|
||||||
|
|
||||||
|
## Additional Changes
|
||||||
|
|
||||||
|
- None beyond the feature itself.
|
||||||
|
|
||||||
|
## Follow-up work discovered while deploying this
|
||||||
|
|
||||||
|
- `tools/remote instance-start` places the `.htaccess` (and now the maintenance page) into `doms/<domain>/subs/www/`, but the live `mih09` instance serves from `doms/<domain>/htdocs-ssl/` — a pre-existing path mismatch, unrelated to this PR. Both files were therefore copied into `htdocs-ssl/` by hand for this deployment; `instance-start` still needs fixing separately.
|
||||||
|
- `InitCommand.loadedServerConfig()` swallows every config-loading exception and falls back to a blank `ServerConfig`, so any unrelated config error makes `init --systemd` silently skip both files as if no `publicBaseUrl` were set. Noticed while smoke-testing this change; worth a warning on that path.
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- None; builds on the existing `.htaccess` generation (`SystemdServiceFiles`, `InitCommand`), unchanged in shape.
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- An explicit Apache `ProxyTimeout` if the client-side hang (see Open Questions) turns out to matter in practice.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
> **WARNING:** This document describes only the change applied in this PR.
|
||||||
|
> It may already be outdated once the next PR is merged.
|
||||||
|
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
`tools/remote` grew with the Hostsharing Managed Webspace rollout (steps 21 and 23) and encoded that rollout's layout as if it were the only one.
|
||||||
|
The watched repository had to be `$WERKATOR_PATH/werkator`, the runtime `$WERKATOR_PATH/.werkator/werkator`, and the systemd unit was spelled out as `werkator-werkator.service`; a werkdock binary and a bwrap rootfs archive were built and uploaded unconditionally.
|
||||||
|
`vm4006`, the Docker host that has been running Werkator since long before the script existed, matches none of that: its watched repository is `~/hs.hsadmin.ng` (so its unit is `werkator-hs.hsadmin.ng.service`), its runtime lives in `~/opt/werkator`, and it has neither werkdock nor a rootfs because its builds run in Docker.
|
||||||
|
So that host could only be deployed by hand, and had drifted nine releases behind — the question that started this PR was whether the Werkdock and multi-repo work had broken it, which it had not: what was broken was the deployment tooling.
|
||||||
|
|
||||||
|
The second problem surfaced while deploying: `instance-update` stopped the systemd unit and *then* started the upload.
|
||||||
|
The 66 MB transfer to `vm4006` died with `scp: Connection closed`, leaving the host with no running Werkator and nothing new to start.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- `instance-start` for a Docker host: it places a Hostsharing `.htaccess` into a `doms/<domain>/` docroot, which only exists on a Managed Webspace. `vm4006` uses the managed nginx container instead, and keeps its existing units.
|
||||||
|
- `repo-init` for `vm4006` — the repository has been cloned and configured there for months; only `instance-update` was needed.
|
||||||
|
- Making the machine configuration of `vm4006` current: it still carries the pre-rename `gitTally:` meta key, which is read by nothing today. Harmless while `ConfigVersions.FORMAT_BROKE_IN` is empty, a trap on the day it is not.
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: one deployment command for every host layout
|
||||||
|
|
||||||
|
#### Background
|
||||||
|
|
||||||
|
- The env file carries transport values only; the three new keys describe *where* things are on the host, not what Werkator does.
|
||||||
|
- The defaults are exactly the layout `instance-install` creates, so an env file naming none of them resolves as before.
|
||||||
|
|
||||||
|
#### Scenario#18.01: A host that predates the script can be deployed with it
|
||||||
|
|
||||||
|
So that an installation is not condemned to hand-typed `scp` sequences because it was set up before the tooling existed.
|
||||||
|
|
||||||
|
- **Given** an env file with `WERKATOR_REPO_DIR=hs.hsadmin.ng`, `WERKATOR_INSTALL_DIR=/home/tallyman/opt` and `WERKATOR_SANDBOX=docker`
|
||||||
|
- **When** `tools/remote --env-file .env.vm4006 werkator instance-update` runs
|
||||||
|
- **Then** it addresses `werkator-hs.hsadmin.ng.service`, unpacks into `~/opt`, and uploads neither a werkdock binary nor a rootfs archive
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- Live on `vm4006` (2026-09-03): `check-prerequisites` reported the Docker daemon instead of running `werkdock doctor`; the update swapped `~/opt/werkator` from v1.0.1 to **v1.1.2**, the unit came up `active`, `/api/watcher` polls without errors, and `/api/system` still reports real disk figures (`diskSource.kind: volume`) rather than the quota shape from PR#16.
|
||||||
|
|
||||||
|
#### Scenario#18.02: The webspace hosts are unaffected
|
||||||
|
|
||||||
|
So that making the script layout-aware does not break the deployment path that is actually in production.
|
||||||
|
|
||||||
|
- **Given** `.env.mih09` and `.env.mih34`, neither naming any of the new keys
|
||||||
|
- **When** the layout is resolved
|
||||||
|
- **Then** repository directory, install directory, unit name and sandbox are identical to the hardcoded values they replace
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- Resolution measured for both env files (2026-09-03): `REPO_DIR=$WERKATOR_PATH/werkator`, `INSTALL_DIR=$WERKATOR_PATH/.werkator`, `UNIT=werkator-werkator.service`, `SANDBOX=bwrap`.
|
||||||
|
- Live on `mih09` (2026-09-03): a full `instance-update` ran through the bwrap path — werkdock uploaded, `werkdock 0.1.0-dev` reported after the swap, service `active`, `https://werkator.javagil.de/` answering 200.
|
||||||
|
|
||||||
|
#### Scenario#18.03: A failed transfer does not take the service down
|
||||||
|
|
||||||
|
So that a dropped connection costs the upload and nothing else.
|
||||||
|
|
||||||
|
- **Given** an instance whose service is running
|
||||||
|
- **When** the runtime bundle cannot be transferred
|
||||||
|
- **Then** the service is still running, because the upload happens before the stop, and a partially transferred file is never moved into place
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- The failure itself on `vm4006` (2026-09-03), which is what this scenario is written from: with the old order, `scp: Connection closed` left the unit stopped and the host without Werkator.
|
||||||
|
- Live on `mih09` (2026-09-03): the bundle already on the host was recognised by its sha256 and skipped, so the upload step cost nothing and the stop followed only after it.
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
Three optional env keys replace three hardcoded assumptions.
|
||||||
|
`WERKATOR_REPO_DIR` and `WERKATOR_INSTALL_DIR` are resolved absolute-or-relative-to-`WERKATOR_PATH`, and the unit name is now *derived* from the repository directory the way `SystemdServiceFiles.unitName` derives it (basename, every character outside `[A-Za-z0-9_.-]` replaced by a dash) instead of being spelled out — one rule, in two places, with the script naming the Kotlin function it mirrors.
|
||||||
|
`WERKATOR_SANDBOX=docker` skips everything bwrap-shaped: no werkdock build, no werkdock upload, no rootfs archive, and `check-prerequisites` asks the Docker daemon instead of running `werkdock doctor`.
|
||||||
|
`repo-add` clones beside the watched repository rather than into `WERKATOR_PATH`, which is the same directory whenever the default layout is used.
|
||||||
|
|
||||||
|
`deploy_instance` is split into `upload_instance_artifacts` and `swap_instance_runtime`, and `instance_update` calls the first *before* stopping the unit.
|
||||||
|
Every artifact is uploaded to `<name>.part`, compared by sha256 with the local file, and only then moved into place; three attempts, and an unchanged artifact is skipped entirely.
|
||||||
|
The checksum is not belt-and-braces: a truncated tarball would unpack into a runtime that starts and misbehaves, which is far worse than the failed transfer it came from.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **Should `instance-start` learn the Docker-host shape too?** Not answered here. `vm4006` keeps its existing units and its managed nginx; the day it needs regenerating, `init --systemd` on the host is the documented path.
|
||||||
|
|
||||||
|
## Additional Changes
|
||||||
|
|
||||||
|
- None beyond the feature itself.
|
||||||
|
|
||||||
|
## Follow-up work discovered while deploying this
|
||||||
|
|
||||||
|
- `https://vm4006.hostsharing.net:8443/` is not reachable from outside the host, while `https://127.0.0.1:8443/` answers 200 and the `werkator-nginx-hs.hsadmin.ng` container publishes both ports. Pre-existing and unrelated to this PR — the host's own firewall, not Werkator.
|
||||||
|
- The machine configuration on `vm4006` still declares `gitTally: version: since: "0.9.20"`; the current code reads only `werkator:`, so the file's version claim is silently ignored (`werkator.version.since` prints empty). It costs nothing while `ConfigVersions.FORMAT_BROKE_IN` is `""`, and stops protecting that host the moment plan step 18 sets it.
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- None; it changes only `tools/remote` and the documentation.
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- None planned.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
> **WARNING:** This document describes only the change applied in this PR.
|
||||||
|
> It may already be outdated once the next PR is merged.
|
||||||
|
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
The build sandbox for hosts without Docker was configured as `bwrap`, named after the mechanism rather than after the thing Werkator runs.
|
||||||
|
Since step 21 session C (v1.0.0) Werkator does not invoke `bwrap` at all: it shells out to the [werkdock](https://git.javagil.de/mi/werkdock) CLI, which assembles the bubblewrap invocation and owns the image store.
|
||||||
|
The name outlived its truth, and the clearest symptom was the key `bwrap.werkdock` — a section naming its own executor.
|
||||||
|
|
||||||
|
It also leaked outward.
|
||||||
|
`tools/remote` gained a `WERKATOR_SANDBOX` key in PR#18 whose value had to be `bwrap` while the very thing it switches on is *uploading the werkdock binary*, and the question that prompted this PR — "is there also `WERKATOR_SANDBOX=werkdock`?" — is one nobody would ask about a name that matched.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Refusing the old name. That belongs to the release which sets `ConfigVersions.FORMAT_BROKE_IN` (plan step 18): only there can a file that declares no version be caught by name at all, and only there is one migration asked of the operator instead of two.
|
||||||
|
- Renaming `tools/build-bwrap-rootfs.sh` or `docs/plan/17-bwrap-build-runtime.md`. The script really does build a bubblewrap rootfs, and plan documents are historic records.
|
||||||
|
- Touching ADR 0008, which decided the *runtime* and is a snapshot of that decision.
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: the sandbox is named after the tool that runs it
|
||||||
|
|
||||||
|
#### Background
|
||||||
|
|
||||||
|
- `builds.<name>.werkdock` replaces `builds.<name>.bwrap`, and `werkdock.binary` replaces `bwrap.werkdock`.
|
||||||
|
- The pinned set is unchanged in meaning: `enabled`, `rootfs` and the binary stay host-pinned, under their new names.
|
||||||
|
|
||||||
|
#### Scenario#19.01: A configuration written for the old name keeps working
|
||||||
|
|
||||||
|
So that no installation has to be edited before it can be updated — the section lives in machine configurations that no repository tracks.
|
||||||
|
|
||||||
|
- **Given** a configuration writing `builds.default.bwrap` with `enabled`, `rootfs`, `werkdock` and `env`
|
||||||
|
- **When** the configuration is loaded
|
||||||
|
- **Then** the settings appear as `werkdock.enabled`, `werkdock.rootfs`, `werkdock.binary` and `werkdock.env`, and the file is named once in a warning
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [ConfigLoaderTest — "the legacy bwrap section is read as werkdock, its werkdock key as binary"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#19.02: The old name is not a way around the pinning
|
||||||
|
|
||||||
|
So that a branch cannot escape its sandbox by writing the section a branch is not allowed to write under its previous name.
|
||||||
|
|
||||||
|
- **Given** a host configuration with `werkdock.enabled: true` and a rootfs
|
||||||
|
- **When** a branch's committed config sets `bwrap.enabled: false` with a foreign rootfs
|
||||||
|
- **Then** the sandbox stays enabled and the host's rootfs is used
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [ConfigLoaderTest — "a legacy bwrap section on a branch is pinned exactly like the new name"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#19.03: The new name behaves exactly as the old one did
|
||||||
|
|
||||||
|
So that the rename is a rename, not a change of behavior.
|
||||||
|
|
||||||
|
- **Given** configurations using `werkdock` throughout
|
||||||
|
- **When** builds are dispatched, pinned keys stripped, and both sandboxes enabled at once
|
||||||
|
- **Then** the werkdock runner is selected, the branch cannot override the pinned keys, and enabling docker and werkdock together is rejected naming both
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [DispatchingBuildRunnerTest — "runs in the werkdock sandbox when the branch enables it (and not Docker)"](../../src/test/kotlin/de/hoennig/werkator/build/DispatchingBuildRunnerTest.kt)
|
||||||
|
- [ConfigLoaderTest — "a branch cannot disable its werkdock sandbox …"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt) and "enabling both docker and werkdock on a build is rejected, not picked silently"
|
||||||
|
- [WerkdockBuildRunnerTest](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerTest.kt), unchanged in substance and renamed with the runner
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
`BwrapConfig` → `WerkdockConfig` (field `werkdock` → `binary`), `BwrapOverrides` → `WerkdockOverrides`, `BranchConfig.bwrap` → `.werkdock`, `BwrapBuildRunner` → `WerkdockBuildRunner`, and `PINNED_BWRAP_KEYS` → `PINNED_WERKDOCK_KEYS` with `binary` in place of `werkdock`.
|
||||||
|
|
||||||
|
The compatibility lives in exactly one function, `ConfigLoader.renameLegacySandbox`, applied in `loadFile` and `parseYaml` — the two places a raw layer enters — so it runs before merging, before pinning and before binding, and every consumer downstream knows one name.
|
||||||
|
That placement is what makes Scenario#19.02 hold without a second thought: the branch layer is normalised *before* `stripPinned` reads it, so the old name cannot smuggle a pinned key past a check that looks for the new one.
|
||||||
|
Where a file writes both sections, the explicit `werkdock` one wins, because it is the name that is meant.
|
||||||
|
The warning is emitted once per file (`warnedSections`), like the other section-level warnings, since the config is re-read on every poll cycle.
|
||||||
|
|
||||||
|
The version is bumped to 1.2.0 with a release note: the minor, because this changes the configuration schema, and a deployment must be identifiable as the one that introduced it.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **Should `WERKATOR_SANDBOX` keep accepting `bwrap`?** It does today, normalised on read and documented as the former name. The env files are local and gitignored, so this alias costs one line and can go whenever the config alias does.
|
||||||
|
|
||||||
|
## Additional Changes
|
||||||
|
|
||||||
|
- None beyond the rename and its documentation.
|
||||||
|
|
||||||
|
## Deployment note
|
||||||
|
|
||||||
|
The order matters, and only in one direction: deploy v1.2.0 to a host *before* rewriting its init fragment (`.env.<instance>.yml`) to the new key names.
|
||||||
|
A fragment carrying `werkdock:` applied by an older Werkator fails the fragment's strict schema validation — which is the safe outcome, but a failed `repo-init` nonetheless.
|
||||||
|
The reverse never breaks: v1.2.0 reads every existing `bwrap:` fragment and machine config as before.
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- [PR#18](2026-09-03-PR%2318-remote-host-layout.md) introduced `WERKATOR_SANDBOX`, whose value this PR renames.
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- Plan step 18 (removing the legacy `branches` section) sets `ConfigVersions.FORMAT_BROKE_IN`; the `bwrap` alias should be dropped in the same release, refusing the key by name.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
> **WARNING:** This document describes only the change applied in this PR.
|
||||||
|
> It may already be outdated once the next PR is merged.
|
||||||
|
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
The build for commit `7028ca8` on `main` failed on the mih09 production instance with
|
||||||
|
`BuildExecutorTest > with maxConcurrent 1 a second branch stays PENDING until the first finished`,
|
||||||
|
while a retry of the very same commit passed, and the test passes locally.
|
||||||
|
|
||||||
|
The test was timing-dependent.
|
||||||
|
It started a build for `branch-a` whose build command was `sleep 1`, immediately started a second build for `branch-b`,
|
||||||
|
and then asserted — without any synchronization at all — that `branch-b` was still `PENDING`.
|
||||||
|
|
||||||
|
That assertion only held as long as the test thread reached it within the one second `branch-a` slept.
|
||||||
|
On a shared host under CPU contention the executor can get through `branch-a` entirely (queued, running, slept, succeeded) first,
|
||||||
|
and `branch-b` is then already `RUNNING` or `SUCCESS` when the assertion runs.
|
||||||
|
The failure therefore says nothing about the executor; it is pure scheduling noise that costs a build and a retry every time it hits.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No change to production code — `BuildExecutor` is not touched, its queueing behaviour is unchanged.
|
||||||
|
- No sweep of the other timing-sensitive tests in the suite; only the one that actually flaked is fixed.
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
`branch-a` no longer sleeps for a fixed time, it blocks until the test says so:
|
||||||
|
its build command is `until [ -f gate ]; do sleep 0.05; done`, and the build workspace is the test's working directory.
|
||||||
|
|
||||||
|
The test now
|
||||||
|
1. waits (via `eventually`) until `branch-a` is `RUNNING`, so the single executor slot is provably occupied,
|
||||||
|
2. asserts that `branch-b` is `PENDING` — which cannot race anything, because `branch-a` cannot finish before the gate file exists,
|
||||||
|
3. creates the gate file, and only then awaits both builds' `SUCCESS`.
|
||||||
|
|
||||||
|
The assertion on the event transitions (`branch-b` goes `RUNNING` only after `branch-a` reached `SUCCESS`) is unchanged.
|
||||||
|
A blocking gate was chosen over a mocked `BuildRunner` because it keeps the test on the real `ProcessBuildRunner`,
|
||||||
|
so it still covers the actual process handling rather than only the executor's bookkeeping.
|
||||||
|
|
||||||
|
Verified by running `BuildExecutorTest` five times on an idle machine and three more times with twice `nproc` busy-loops saturating the CPU,
|
||||||
|
which is the condition that produced the original failure.
|
||||||
|
|
||||||
|
- [BuildExecutorTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
> **WARNING:** This document describes only the change applied in this PR.
|
||||||
|
> It may already be outdated once the next PR is merged.
|
||||||
|
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
The repository's home is `https://git.javagil.de/mi/werkator.git` since the move to the own Gitea instance,
|
||||||
|
but `tools/remote` still defaulted `WERKATOR_REPO_URL` to the GitHub mirror.
|
||||||
|
|
||||||
|
The mih09 production instance was cloned from that mirror and consequently watched a `main` that nobody pushes to any more:
|
||||||
|
it kept reporting the last GitHub state as green while three merged pull requests sat unbuilt on the real `main`.
|
||||||
|
The failure that started this — a flaky test already fixed on Gitea's `main` — could not be re-verified live,
|
||||||
|
because the instance had no way to see the fix.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- The existing clone on mih09 is not touched by this change; its remote was repointed by hand (`git remote set-url`), and `repo-init` skips an existing clone.
|
||||||
|
- The generic placeholder URLs in `docs/deployment.md` stay as they are — they describe cloning *any* watched repository, not werkator's own.
|
||||||
|
- No decision about mirroring to GitHub; the mirror simply stops being the source an instance builds from.
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
`REPO_URL` in [tools/remote](../../tools/remote) defaults to the Gitea URL, and the usage comment says so.
|
||||||
|
|
||||||
|
The value stays overridable via `WERKATOR_REPO_URL` in the instance's env file,
|
||||||
|
so an installation that deliberately watches a different remote is unaffected.
|
||||||
|
Anonymous HTTPS works against Gitea exactly as it did against GitHub, so no deploy key or token is involved.
|
||||||
|
|
||||||
|
Note that pull requests opened via AGit-Flow create no branch in Gitea,
|
||||||
|
so an instance watching this remote sees `main` only — branch builds require pushing real branches.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
> **WARNING:** This document describes only the change applied in this PR.
|
||||||
|
> It may already be outdated once the next PR is merged.
|
||||||
|
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
`init --systemd` generates the host integration — the systemd unit's resource limits, the Apache `.htaccess` and the maintenance page — from the effective configuration.
|
||||||
|
It read that configuration through a helper with two independent defects, both of which fail silently.
|
||||||
|
|
||||||
|
**It swallowed every error.**
|
||||||
|
The load sat in `try { … } catch (_: Exception) { ServerConfig() }`.
|
||||||
|
Any configuration error at all — a missing required field, a malformed layer, a version floor violation — produced a default `ServerConfig` with a blank `publicBaseUrl`.
|
||||||
|
A repository with a broken `.werkator.yml` then looked exactly like one that simply has no public base URL configured:
|
||||||
|
the `.htaccess` and the maintenance page were skipped without a word.
|
||||||
|
This surfaced while verifying [PR#17](2026-09-03-PR%2317-maintenance-page.md) on mih09, where the missing files looked like an unconfigured `publicBaseUrl` and were in fact an unrelated validation error.
|
||||||
|
|
||||||
|
**It read from the wrong directory.**
|
||||||
|
The helper called `configLoader.load(Paths.get("."))` — the process's current directory — while everything else in the command works off the git top level resolved by `GitService.getTopLevel`.
|
||||||
|
`ConfigLoader.loadRaw` resolves the layers directly under the directory it is given and does not walk up to the repository root, so the two agree only when `init` happens to be invoked from the root itself.
|
||||||
|
From a subdirectory the command read another repository's configuration, or none.
|
||||||
|
That also broke `--apply`: the fragment is installed into the repository root deliberately before the systemd files are written, so that its port and limits reach the generated unit, and a current-directory read does not see it.
|
||||||
|
|
||||||
|
`init --systemd` runs during initial deployment setup, which is exactly when a silent wrong answer is most expensive.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- The fallback itself is kept: a configuration that cannot be loaded is not fatal for `init`, the units are still generated with the defaults.
|
||||||
|
During the very first bootstrap there is legitimately nothing to load yet.
|
||||||
|
- No change to `ConfigLoader`, to the configuration schema, or to any other command.
|
||||||
|
- No sweep for catch-all exception handlers elsewhere in the code base;
|
||||||
|
the two other `catch` blocks in `InitCommand` already print an `Error:` and abort, so they were only checked, not changed.
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: init reports what it read and where it read it from
|
||||||
|
|
||||||
|
#### Background
|
||||||
|
|
||||||
|
- The *repository root* is the git top level as resolved by `GitService.getTopLevel`, the directory holding `.werkator.yml`, `.git/werkator/.werkator.yml` and an applied fragment.
|
||||||
|
- The *current directory* is the process working directory, which is the repository root only when `init` is invoked there.
|
||||||
|
|
||||||
|
#### Scenario#22.01: A broken configuration is named, not defaulted over
|
||||||
|
|
||||||
|
So that a validation error during deployment setup is not mistaken for an unconfigured installation.
|
||||||
|
|
||||||
|
- **Given** a repository whose effective configuration cannot be loaded
|
||||||
|
- **When** `init --systemd` runs
|
||||||
|
- **Then** the exception message is printed as a warning
|
||||||
|
- **and** the unit files are still generated with the default settings
|
||||||
|
- **and** the warning appears exactly once, although three settings are read from the configuration
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [InitCommandTest: `--systemd warns once when the effective configuration cannot be loaded`](../../src/test/kotlin/de/hoennig/werkator/commands/InitCommandTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#22.02: The configuration is read from the repository root
|
||||||
|
|
||||||
|
So that the generated host integration reflects the repository being initialized, whatever directory `init` was invoked from.
|
||||||
|
|
||||||
|
- **Given** a repository whose root configuration sets `server.publicBaseUrl` and `server.port`
|
||||||
|
- **and** a current directory that is not that repository root
|
||||||
|
- **When** `init --systemd` runs
|
||||||
|
- **Then** the `.htaccess` and the maintenance page are generated
|
||||||
|
- **and** the `.htaccess` proxies to the port from the root configuration
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [InitCommandTest: `--systemd reads the configuration from the repository root, not the current directory`](../../src/test/kotlin/de/hoennig/werkator/commands/InitCommandTest.kt)
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
The catch-all now prints the exception message before falling back:
|
||||||
|
|
||||||
|
```
|
||||||
|
Warning: the effective configuration could not be loaded (<message>)
|
||||||
|
continuing with default server settings — check the generated unit and host files
|
||||||
|
```
|
||||||
|
|
||||||
|
The configuration is read three times while the systemd files are written (`memoryMax`, `tasksMax`, `publicBaseUrl`), which would repeat the warning three times.
|
||||||
|
It is therefore loaded once per run and cached in the command, and the cache is reset at the top of `run()` so a reused instance — the command is a Spring singleton — re-reads.
|
||||||
|
|
||||||
|
The repository root is passed down into the two accessors instead of `Paths.get(".")`.
|
||||||
|
This matches every other caller of `ConfigLoader.load` in the code base, all of which pass an explicit working directory;
|
||||||
|
`InitCommand` was the only one relying on the process's current directory.
|
||||||
|
|
||||||
|
Both fixes are the same failure in two forms — the command answered from a configuration it never actually read — which is why they are in one PR.
|
||||||
|
|
||||||
|
## Additional Changes
|
||||||
|
|
||||||
|
- None.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
> **WARNING:** This document describes only the change applied in this PR.
|
||||||
|
> It may already be outdated once the next PR is merged.
|
||||||
|
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
|
||||||
|
|
||||||
|
## Related Links
|
||||||
|
|
||||||
|
- ADR 0009 — multi-repo instance: the `defaults` block carries shared repository-level keys, read by `ConfigLoader`, never directly by consumers.
|
||||||
|
- `docs/deployment.md` — registry setup: `repo-add` clones, initialises, and prints the registry entry.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
`tools/remote werkator repo-add <private-https-url>` fails on the host with `could not read Username`.
|
||||||
|
The clone runs anonymously, but the credentials exist only in the instance file's `defaults.git` block — which the script never consults.
|
||||||
|
Registering a private repository therefore needs a manual SSH session today.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
The script still does not write `~/.werkator.yml`: registering stays the operator's decision.
|
||||||
|
No SSH-URL support: the forge is reached over `https` from the host.
|
||||||
|
No new config keys: `defaults.git.account`/`defaults.git.token` already exist.
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: authenticated clone for private origins
|
||||||
|
|
||||||
|
#### Background
|
||||||
|
|
||||||
|
- The shared credentials live in `~/.werkator.yml` under `defaults.git` (ADR 0009).
|
||||||
|
- Public origins and instances without shared credentials must keep cloning anonymously.
|
||||||
|
|
||||||
|
#### Scenario#000.01: Private https origin clones with shared credentials
|
||||||
|
|
||||||
|
- **Given** `defaults.git.account`/`defaults.git.token` in `~/.werkator.yml` on the host
|
||||||
|
- **When** `tools/remote werkator repo-add <private-https-url>` runs
|
||||||
|
- **Then** the clone authenticates with those credentials and succeeds.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- Manual stub-`git` test: URL carries `account@`, askpass answers the token, `GIT_TERMINAL_PROMPT=0`.
|
||||||
|
|
||||||
|
#### Scenario#000.02: Public origin clones anonymously
|
||||||
|
|
||||||
|
- **Given** no shared credentials (or a public repository)
|
||||||
|
- **When** `repo-add` or `repo-init` runs
|
||||||
|
- **Then** the clone runs exactly as before, without authentication.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- Local helper test against a `file://` origin with and without an instance file.
|
||||||
|
|
||||||
|
#### Scenario#000.03: Token never leaks locally
|
||||||
|
|
||||||
|
- **Given** an authenticated clone
|
||||||
|
- **When** the command runs from the workstation
|
||||||
|
- **Then** the token appears in neither the local process list nor a repository config.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- Code inspection: the token is read on the host and passed via a one-shot `GIT_ASKPASS` script.
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
`tools/remote` gained a `clone_repo` helper used by both `repo-init` and `repo-add`.
|
||||||
|
For `https://` URLs it ships a small Python helper (base64-encoded, so no `$` is expanded locally) to the host.
|
||||||
|
The helper reads `defaults.git.account`/`defaults.git.token` from `~/.werkator.yml`, puts the account into the URL, and hands the token to git via a one-shot `0700` `GIT_ASKPASS` script deleted in `finally`.
|
||||||
|
Without credentials it falls back to the plain anonymous clone; non-`https` URLs clone unchanged.
|
||||||
|
`docs/deployment.md` documents that the shared credentials must exist before cloning a private repository.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Attachments
|
||||||
|
|
||||||
|
### Adding a Gitea repository — example
|
||||||
|
|
||||||
|
From the workstation, clone and initialise, then register:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/remote --env-file .env.<instance> werkator repo-add https://gitea.example.org/<owner>/<repo>.git [<name>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the printed entry to `~/.werkator.yml` under `repositories:`, then restart the service.
|
||||||
|
A public repository needs nothing else: the clone runs anonymously.
|
||||||
|
A private repository needs shared credentials once on the host, in `~/.werkator.yml` of the service user, before cloning (token: Gitea → Settings → Applications → Generate Token, scope `read:repository`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
defaults:
|
||||||
|
git:
|
||||||
|
account: <gitea-user>
|
||||||
|
token: <token>
|
||||||
|
```
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
> **WARNING:** This document describes only the change applied in this PR.
|
||||||
|
> It may already be outdated once the next PR is merged.
|
||||||
|
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
|
||||||
|
|
||||||
|
## Related Links
|
||||||
|
|
||||||
|
- Werkdock `CHANGELOG.md` (repository `mi/werkdock`, Unreleased section) — the release notes this PR adapts to: `import` replaces `load -i --name` for rootfs archives, `load` is reserved for docker/OCI image archives, image names gained docker-style tags.
|
||||||
|
- ADR 0008 — the bwrap build runtime; `docs/configuration.md`, notes on `builds.<name>.werkdock`.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
Werkdock moves its CLI closer to `docker run --rm`.
|
||||||
|
For the rootfs archives Werkator uses, the verb is now `werkdock import ARCHIVE IMAGE` (docker import semantics).
|
||||||
|
`werkdock load -i ARCHIVE --name IMAGE` still works but is a compatibility path with a note on stderr, and is announced to go away once Werkator has switched.
|
||||||
|
Werkator ships the werkdock binary with its deployment, but nothing forces the two to be updated together: an installation may run a new Werkator against an older werkdock for a while, or the other way round.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No switch of the existence check from `werkdock images` to `werkdock inspect`: `images` prints the bare name for untagged images on every werkdock version, the exact-line match keeps working, and `inspect` would tie Werkator to the new werkdock.
|
||||||
|
- No tagged image name (`werkator-buildenv:<hash>`): it would re-import every build environment on the webspace and orphan the old image, for no functional gain today.
|
||||||
|
- None of the new `run` flags (`--mount`, `--entrypoint`, `--network host`): Werkator's invocation needs none of them.
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: rootfs archives are imported with werkdock's docker-shaped verb
|
||||||
|
|
||||||
|
#### Background
|
||||||
|
|
||||||
|
- The build environment is a rootfs archive named by `builds.<name>.werkdock.rootfs`.
|
||||||
|
- Werkator creates the werkdock image `werkator-buildenv-<hash>` from it once per source.
|
||||||
|
- Werkdock answers an unknown verb with `werkdock: unknown command "import"` and exit code 125, its code for its own errors.
|
||||||
|
|
||||||
|
#### Scenario#000.01: A missing image is imported with `werkdock import`
|
||||||
|
|
||||||
|
So that Werkator uses the verb werkdock names for rootfs archives, and the deprecated path can be removed on werkdock's side.
|
||||||
|
|
||||||
|
- **Given** `werkdock images` does not list the image
|
||||||
|
- **When** a build starts
|
||||||
|
- **Then** Werkator runs `werkdock import ARCHIVE werkator-buildenv-<hash>`
|
||||||
|
- **and** runs no `werkdock load`.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [WerkdockBuildRunnerTest "imports the image once when werkdock does not know it yet"](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerTest.kt)
|
||||||
|
- [WerkdockBuildRunnerTest "downloads a URL rootfs once before importing it"](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#000.02: An older werkdock without the verb still works
|
||||||
|
|
||||||
|
So that Werkator and werkdock can be updated in either order.
|
||||||
|
|
||||||
|
- **Given** the installed werkdock answers `import` with `unknown command` and exit 125
|
||||||
|
- **When** a build starts with a missing image
|
||||||
|
- **Then** Werkator falls back to `werkdock load -i ARCHIVE --name werkator-buildenv-<hash>`
|
||||||
|
- **and** logs the fallback.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [WerkdockBuildRunnerTest "falls back to load -i --name on a werkdock without the import verb"](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#000.03: A real import failure is not masked by the fallback
|
||||||
|
|
||||||
|
So that a broken archive fails the build with werkdock's message, as before.
|
||||||
|
|
||||||
|
- **Given** `werkdock import` fails for any other reason (any other exit code, or exit 125 without `unknown command`)
|
||||||
|
- **When** a build starts with a missing image
|
||||||
|
- **Then** the build fails with that command's output
|
||||||
|
- **and** no `werkdock load` runs.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [WerkdockBuildRunnerTest "propagates an import failure that is not a missing verb"](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#000.04: An existing image is neither imported nor loaded
|
||||||
|
|
||||||
|
- **Given** `werkdock images` lists the image
|
||||||
|
- **When** a build starts
|
||||||
|
- **Then** neither `import` nor `load` runs.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [WerkdockBuildRunnerTest "does not import an image werkdock already has"](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerTest.kt)
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
`WerkdockBuildRunner.ensureImage` calls the new `importImage`: `werkdock import ARCHIVE IMAGE` through the non-throwing `run`, then either returns, falls back to `load -i --name` on exactly the unknown-verb signature (exit 125 plus `unknown command` on stderr), or rethrows the import's result as a `GitCommandException` — the same exception and message the old code produced.
|
||||||
|
The fallback is keyed on werkdock's own error signature rather than on a version string, because werkdock's version output does not yet distinguish the two builds.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- When to drop the fallback: once every installation runs a werkdock with `import`, the `load -i --name` branch and its test go, and werkdock can remove the compatibility path.
|
||||||
|
|
||||||
|
## Additional Changes
|
||||||
|
|
||||||
|
- `docs/configuration.md` and `docs/deployment.md` say "imported" where they said "loaded", and name the fallback and the reason the image name stays untagged.
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- The werkdock change that introduces `import` (repository `mi/werkdock`, CHANGELOG Unreleased); against an older werkdock the fallback path runs.
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- Remove the fallback (see Open Questions).
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# RFC 0001: Web UI Redesign — the Instrument Panel
|
||||||
|
|
||||||
|
**Status:**
|
||||||
|
- proposed: 2026-09-03
|
||||||
|
- accepted: -
|
||||||
|
- rejected: -
|
||||||
|
|
||||||
|
**Proposal:** The Werkator web UI adopts the **Instrument Panel** direction: a teal palette in a light and a dark mode, IBM Plex typography, a repository strip that previews the state of every served repository, a title hierarchy that names the view first and explains it second, and a tab bar at the foot that becomes the mobile navigation.
|
||||||
|
The architecture does not change: server-rendered Thymeleaf, one `werkator.css`, one hand-written `werkator.js`, JSON polling, no framework, no frontend build pipeline.
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
The current UI is a functional port of the legacy generated pages: a table per view, pill badges, system font, blue links.
|
||||||
|
It is correct and calm, but it looks like every other CI page and gives no hint of the other repositories an instance serves (ADR 0009).
|
||||||
|
The brief for this RFC was "fancy, but serious and trustworthy", with two references from the same author for visual kinship:
|
||||||
|
|
||||||
|
- [werkbaum.javagil.de](https://werkbaum.javagil.de/) — light paper with a fine grid, IBM Plex, a petrol accent, panel labels in small caps.
|
||||||
|
- [javagil.de/vibe-engineering](https://javagil.de/vibe-engineering) — a dark instrument panel: ink and petrol, clay for warnings, monospaced spaced labels, a tab bar at the foot.
|
||||||
|
|
||||||
|
A hard constraint of this RFC is honesty towards the data.
|
||||||
|
The mockups show only what the API delivers today; nothing is invented to make a screen look richer.
|
||||||
|
|
||||||
|
### What the UI Has to Work With
|
||||||
|
|
||||||
|
Per build row (`BuildRowView`, `BuildResultDto`): status, branch name, commit (12-character abbreviation, full id for copying), started at (`yyyy-MM-dd HH:mm`), duration (`m:ss`; a pending build shows its wait time in italics), artifact key with the artifact, permalink and live-log links, and the actions restart and delete (history has no restart).
|
||||||
|
Statuses: `pending`, `running`, `success`, `failed`, `interrupted`, `cancelled`, plus `unknown` for a never-built branch and the client-side `finished` on a card whose build has left the current list.
|
||||||
|
Views: Latest (one build per name), Branches (every origin branch and its latest build), History (all stored builds), Current (running builds with their live log), System (seven metric rows with current/min/max/avg, warn from 80 %, critical from 90 %), the artifact page, and the release notes.
|
||||||
|
Live state: the indicator is `static`, `live` or `error`; the watcher banner reports `watcher stopped`, `origin unreachable` or `poll cycle failed`.
|
||||||
|
Multi-repo: the repository switcher is a server-rendered `<select>` of names; `WatcherState.repositories` already carries a per-repository watcher state that the UI does not show.
|
||||||
|
|
||||||
|
What does **not** exist, and therefore appears in no mockup: a commit subject line, a typical or expected duration, an ETA, a per-branch build history, test counts on a row, and any cross-repository status summary in the API.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
Six directions were sketched on a shared design canvas, two rounds of three, all with the same sample rows.
|
||||||
|
|
||||||
|
| Option | Idea | Why | Tradeoff |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A · Quiet Console | Today's design refined: top bar, dot-plus-word statuses, hover actions | Smallest step, everything stays valid | Least distinctive |
|
||||||
|
| B · Mission Board | Health tiles, one card per branch with a history strip, running build with progress | Answers "is everything fine?" at a glance | Needs data the API does not have (history, typical duration) |
|
||||||
|
| C · Ledger | Warm paper, serif masthead, hairline rules, typographic status marks | The most "serious"; reads like a signed record | Leaves the system font, needs its own dark theme |
|
||||||
|
| D · Paper Rail | Werkbaum's paper and grid, a repository rail on the left with per-branch dots | Family resemblance to Werkbaum; other repositories visible | 250 px of table width lost; empty with one repository |
|
||||||
|
| **E · Instrument Panel** | Vibe-Engineering's dark panel, repositories as tiles, tab bar at the foot | Reads like a control room; failures in clay stay serious without alarm | Dark-only as drawn; needs a light palette |
|
||||||
|
| F · Fleet Overview | A new landing page with one panel per repository, ledger typography on paper | One page answers the question for the whole instance | Becomes a list beyond five repositories |
|
||||||
|
|
||||||
|
Round one (A–C) still contained invented data; it is kept on the canvas for the visual ideas only.
|
||||||
|
**E was chosen**, and round three worked out what it lacked: the light mode, the ten-repository case, the title hierarchy, and the phone layout.
|
||||||
|
|
||||||
|
## The Design
|
||||||
|
|
||||||
|
### Palette
|
||||||
|
|
||||||
|
Both modes are CSS custom properties on `:root`, switched by `prefers-color-scheme` as today (`color-scheme: light dark`).
|
||||||
|
Failures use clay, not red, so they stay serious without shouting; the accent is teal in both modes.
|
||||||
|
|
||||||
|
| Token | Dark | Light | Used for |
|
||||||
|
|---|---|---|---|
|
||||||
|
| bg | `#061C1F` | `#EAF4F2` | page ground |
|
||||||
|
| panel | `#0A2A2E` | `#FFFFFF` | tables, cards, chips |
|
||||||
|
| panel-2 | `#0F3A3D` | `#D6ECE8` | the current repository, the active tab |
|
||||||
|
| line | `#17474B` | `#C9DFDB` | borders and rules |
|
||||||
|
| text | `#E4EEEC` | `#0B2B2E` | body text |
|
||||||
|
| text-2 | `#B4CBC8` | `#35595B` | timestamps |
|
||||||
|
| muted | `#7DA19E` | `#5E8583` | labels, footers |
|
||||||
|
| accent | `#5FD3C7` | `#0E8079` | links, success, running, the live indicator |
|
||||||
|
| accent-2 | `#1E9A93` | `#149A90` | underlines, the current repository's border |
|
||||||
|
| clay | `#E09070` | `#B0563B` | failed, error, delete, watcher warnings |
|
||||||
|
| clay-2 | `#C4664A` | `#C4664A` | the border of a failing repository chip |
|
||||||
|
| ghost | `#4A7370` | `#BFD4D1` | cancelled, interrupted, unknown |
|
||||||
|
|
||||||
|
Tinted rows: a running row gets 16 % (dark) or 10 % (light) of accent-2 as background, a failed row 12 % or 10 % of clay-2.
|
||||||
|
The reference's background grid was tried and dropped: it competes with the table, especially in light mode.
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
IBM Plex Sans for text, IBM Plex Mono for commits, timestamps, durations and every label.
|
||||||
|
Labels are 10 px Mono, uppercase, letter-spaced 0.12 em, in `muted`; statuses are 11 px Mono uppercase in their status color, each preceded by an 8 px dot (outlined for pending, pulsing for running).
|
||||||
|
Fallback stacks: `"IBM Plex Sans", "Segoe UI", system-ui, sans-serif` and `"IBM Plex Mono", ui-monospace, Consolas, monospace`.
|
||||||
|
Whether Plex is bundled under `static/` or the fallback stack is accepted is an open question below.
|
||||||
|
|
||||||
|
### Anatomy of a Page (desktop)
|
||||||
|
|
||||||
|
1. **Header**, 52 px: logo, `Werkator` with the Gitea repository name in accent, a small label `updated HH:mm:ss`; right: the live indicator as an outlined chip with a pulsing dot, the reload button.
|
||||||
|
2. **Repository strip**: see below.
|
||||||
|
3. **Panel** with the view's title: the view name at 22 px semibold with a 2 px accent-2 underline, followed by a one-line label that explains it (`Latest` — `one build per branch, newest first`; `Branches` — `every origin branch and its latest build`; `History` — `all stored builds, newest first`; `System` — `instance metrics since first start`); on the right a Mono line with the row count, the last poll and the watcher state.
|
||||||
|
4. **Table**, columns as today (Status, Branch, Commit, Started, Duration, Artifacts, Actions), rows 9 px padding on a 1 px `line` rule; copy buttons as outlined 13 px icons; artifact links and actions as stroke icons (no emoji).
|
||||||
|
5. **Footer**: version and copyright left, the navigation as a Mono tab bar in the middle (Latest, Branches, History, System with icons; the active tab in panel-2 with an accent underline), Impressum and Privacy right.
|
||||||
|
|
||||||
|
### The Repository Strip
|
||||||
|
|
||||||
|
The `<select>` switcher is replaced by a strip below the header that shows every served repository with its state, so a failure elsewhere is visible without leaving the page.
|
||||||
|
|
||||||
|
- Up to about three repositories: **tiles** (220 px), each with `current` or `repo` label, the name, one dot per branch in the branch's latest status, a summary line (`6 builds · 1 failed · 1 running`), and the watcher warning in clay when that repository's watcher reports an error.
|
||||||
|
- More repositories: **chips** (30 px), each with one dot for the worst status in the repository, the name, an optional short finding (`1 failed`, `main`, `never built`), and a warning triangle when the watcher reports an error; the current repository has an accent-2 border on panel-2, a failing one a clay-2 border on the clay tint.
|
||||||
|
- Order is *failing first*: the current repository, then failing, running, then green; a summary line above (`10 served · 2 failing · 1 unreachable · 2 running`) and a sort control on the right.
|
||||||
|
- The strip **scrolls**: horizontally on the phone, and on the desktop it wraps to a second row up to about ten repositories and becomes a horizontally scrollable band beyond that, with the failing chips pinned at the front so they never scroll out of view.
|
||||||
|
- Beyond roughly twenty repositories the strip shows only the conspicuous chips (failing, running, unreachable) plus a search field for the rest.
|
||||||
|
- With a single served repository the strip is omitted, as the switcher is today.
|
||||||
|
|
||||||
|
### Phone (below 680 px)
|
||||||
|
|
||||||
|
The existing breakpoint behaviour is kept and restyled: rows become cards with the `data-label` captions, the live indicator collapses to a dot.
|
||||||
|
The header stacks `Werkator` over the repository name; the repository strip scrolls horizontally under its summary line; the panel title keeps its hierarchy; each card carries the status line with the branch, then commit, started and duration, then the artifact icons and the actions as 44 px targets.
|
||||||
|
The footer tab bar becomes a fixed bottom tab bar with icons — the same four entries as on the desktop.
|
||||||
|
No painted status bar or keyboard; the device provides those.
|
||||||
|
|
||||||
|
### What Is Deliberately Not in the Proposal
|
||||||
|
|
||||||
|
- The `DE` language button in the mockups is a leftover of the reference; the UI stays English-only.
|
||||||
|
- No commit subjects, typical durations, ETAs or history strips: they need data the server does not have, and each would be its own RFC with its own storage.
|
||||||
|
- No manual theme toggle; `prefers-color-scheme` decides, as today.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- One new endpoint, `GET /api/repos`: for every served repository its name, its UI root (`/repos/<name>`), whether it is the current one, the latest status per build name (the Latest view's `latestPerName` reduced to counts, plus the worst status), and its `RepoWatcherState` (`lastFetchError`, `lastPollError`, `lastPollAt`).
|
||||||
|
With one served repository the endpoint returns a list of one and the strip stays hidden.
|
||||||
|
- `werkator.js` polls it on the table interval (10 s) and renders the strip; every fetch keeps the timeout and the explicit error badge.
|
||||||
|
- `UiFormats` and `werkator.js` keep producing identical formats; the palette and the title labels are template and CSS only.
|
||||||
|
|
||||||
|
### Rollout, One Concern per Pull Request
|
||||||
|
|
||||||
|
1. Palette, typography and the title hierarchy in `werkator.css` and the fragments — no data change, both modes.
|
||||||
|
2. Header and footer tab bar, including the phone tab bar.
|
||||||
|
3. `GET /api/repos` and the repository strip, replacing the `<select>`.
|
||||||
|
4. Card refinements on the phone and the System and artifact pages in the new vocabulary.
|
||||||
|
|
||||||
|
Each step leaves the UI usable, and the tests in `server` that assert on markup are adjusted with the step that changes it.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **Fonts:** bundle IBM Plex Sans and Mono under `static/fonts/` (about 100–150 KB in WOFF2 for the four faces), or accept the fallback stack on hosts without the font; the reference sites load Plex from a CDN, which the deployment behind a strict reverse proxy may not want.
|
||||||
|
- **Current view:** it is reachable today only from a running row's live icon; the tab bar has room for it as a fifth entry with a count badge, or it stays a link from the row.
|
||||||
|
- **Instance pages:** `/system` and `/releases` are instance-level; in the tab bar they sit next to the per-repository views, which the strip makes visible enough, or they move to the footer's right side.
|
||||||
|
|
||||||
|
## Design Sources
|
||||||
|
|
||||||
|
The design canvas with all eleven artboards (rounds one to three, desktop and phone) is a private Claude artifact of the author; its renderings live next to this RFC under `0001-web-ui-instrument-panel/`.
|
||||||
|
The sample rows are real field shapes with invented values; the repositories other than `werkator` are invented.
|
||||||
|
|
||||||
|
The proposal:
|
||||||
|
|
||||||
|
- [E · dark, desktop](0001-web-ui-instrument-panel/e-dark-desktop.png) · [E · light, desktop](0001-web-ui-instrument-panel/e-light-desktop.png)
|
||||||
|
- [E · dark, ten repositories](0001-web-ui-instrument-panel/e-dark-10-repos.png) · [E · light, ten repositories](0001-web-ui-instrument-panel/e-light-10-repos.png)
|
||||||
|
- [E · dark, phone](0001-web-ui-instrument-panel/e-dark-phone.png) · [E · light, phone](0001-web-ui-instrument-panel/e-light-phone.png)
|
||||||
|
|
||||||
|
The alternatives, for the record:
|
||||||
|
|
||||||
|
- [A · Quiet Console](0001-web-ui-instrument-panel/a-quiet-console.png), [B · Mission Board](0001-web-ui-instrument-panel/b-mission-board.png), [C · Ledger](0001-web-ui-instrument-panel/c-ledger.png) — round one, still with invented data.
|
||||||
|
- [D · Paper Rail](0001-web-ui-instrument-panel/d-paper-rail.png), [F · Fleet Overview](0001-web-ui-instrument-panel/f-fleet-overview.png) — round two.
|
||||||
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 171 KiB |
@@ -10,7 +10,6 @@ import org.springframework.context.annotation.Profile
|
|||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import picocli.CommandLine
|
import picocli.CommandLine
|
||||||
import picocli.CommandLine.IFactory
|
import picocli.CommandLine.IFactory
|
||||||
import java.nio.file.Paths
|
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@@ -27,8 +26,6 @@ class CliRunner(
|
|||||||
private var exitCode = 0
|
private var exitCode = 0
|
||||||
|
|
||||||
override fun run(vararg args: String) {
|
override fun run(vararg args: String) {
|
||||||
// before any command resolves a path under it, and once per process
|
|
||||||
StateDirMigration.migrateIfNeeded(Paths.get("."))
|
|
||||||
exitCode =
|
exitCode =
|
||||||
CommandLine(rootCommand, factory)
|
CommandLine(rootCommand, factory)
|
||||||
.setExecutionExceptionHandler { exception, commandLine, _ ->
|
.setExecutionExceptionHandler { exception, commandLine, _ ->
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
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
|
||||||
@@ -36,6 +37,7 @@ import kotlin.concurrent.thread
|
|||||||
@Service
|
@Service
|
||||||
class BuildExecutor(
|
class BuildExecutor(
|
||||||
private val configLoader: ConfigLoader,
|
private val configLoader: ConfigLoader,
|
||||||
|
private val repoLinks: RepoLinks,
|
||||||
private val giteaClient: GiteaClient,
|
private val giteaClient: GiteaClient,
|
||||||
private val buildRunner: BuildRunner,
|
private val buildRunner: BuildRunner,
|
||||||
private val workspaces: BranchWorkspaces,
|
private val workspaces: BranchWorkspaces,
|
||||||
@@ -97,6 +99,7 @@ class BuildExecutor(
|
|||||||
val stagingDir = Files.createTempDirectory("werkator-build-")
|
val stagingDir = Files.createTempDirectory("werkator-build-")
|
||||||
val runningBuild =
|
val runningBuild =
|
||||||
RunningBuild(
|
RunningBuild(
|
||||||
|
repo = repo,
|
||||||
branch = branch,
|
branch = branch,
|
||||||
build = build,
|
build = build,
|
||||||
commit = commit,
|
commit = commit,
|
||||||
@@ -396,7 +399,7 @@ class BuildExecutor(
|
|||||||
sha = build.runningBuild.commit,
|
sha = build.runningBuild.commit,
|
||||||
status = status,
|
status = status,
|
||||||
description = description(status, duration),
|
description = description(status, duration),
|
||||||
targetUrl = null,
|
targetUrl = targetUrlOf(build),
|
||||||
workingDir = build.repo.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
|
||||||
@@ -407,6 +410,25 @@ class BuildExecutor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The artifact page of this build, so a commit status in the forge leads to the
|
||||||
|
* logs it is about — that is what `server.publicBaseUrl` is documented for, and
|
||||||
|
* until now nothing posted a link at all. Repository-scoped (ADR 0009): with
|
||||||
|
* several served repositories the unscoped path would resolve against whichever
|
||||||
|
* one the instance serves by default, which is the wrong build's page.
|
||||||
|
*/
|
||||||
|
private fun targetUrlOf(build: ActiveBuild): String? =
|
||||||
|
try {
|
||||||
|
repoLinks.buildUrl(
|
||||||
|
repo = build.repo,
|
||||||
|
publicBaseUrl = configLoader.load(build.repo.workingDir).server.publicBaseUrl,
|
||||||
|
artifactKey = build.runningBuild.artifactKey,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warn("could not build the status target URL of {}: {}", build.runningBuild.branch, e.message)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
/** The build's own Gitea status context, empty when it uses the repository-wide one. */
|
/** The build's own Gitea status context, empty when it uses the repository-wide one. */
|
||||||
private fun statusContextOf(build: ActiveBuild): String =
|
private fun statusContextOf(build: ActiveBuild): String =
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -43,17 +43,17 @@ class ProcessBuildRunner : BuildRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects the runtime per branch: Docker when `branches.<name>.docker.enabled`,
|
* Selects the runtime per branch: Docker when `builds.<name>.docker.enabled`, the
|
||||||
* bubblewrap when `branches.<name>.bwrap.enabled`, native shell execution otherwise
|
* werkdock sandbox when `builds.<name>.werkdock.enabled`, native shell execution
|
||||||
* (the unchanged default). Docker and bwrap are mutually exclusive per branch and are
|
* otherwise (the unchanged default). Docker and werkdock are mutually exclusive per
|
||||||
* rejected together at config load, so the branch order here never has to "pick".
|
* branch and are rejected together at config load, so the order here never has to "pick".
|
||||||
*/
|
*/
|
||||||
@Primary
|
@Primary
|
||||||
@Component
|
@Component
|
||||||
class DispatchingBuildRunner(
|
class DispatchingBuildRunner(
|
||||||
private val processBuildRunner: ProcessBuildRunner,
|
private val processBuildRunner: ProcessBuildRunner,
|
||||||
private val dockerBuildRunner: DockerBuildRunner,
|
private val dockerBuildRunner: DockerBuildRunner,
|
||||||
private val bwrapBuildRunner: BwrapBuildRunner,
|
private val werkdockBuildRunner: WerkdockBuildRunner,
|
||||||
) : BuildRunner {
|
) : BuildRunner {
|
||||||
override fun start(
|
override fun start(
|
||||||
command: String,
|
command: String,
|
||||||
@@ -66,7 +66,7 @@ class DispatchingBuildRunner(
|
|||||||
val runner =
|
val runner =
|
||||||
when {
|
when {
|
||||||
branchConfig.docker.enabled -> dockerBuildRunner
|
branchConfig.docker.enabled -> dockerBuildRunner
|
||||||
branchConfig.bwrap.enabled -> bwrapBuildRunner
|
branchConfig.werkdock.enabled -> werkdockBuildRunner
|
||||||
else -> processBuildRunner
|
else -> processBuildRunner
|
||||||
}
|
}
|
||||||
return runner.start(command, workingDir, environment, repoDir, branchConfig, onAuxProcess)
|
return runner.start(command, workingDir, environment, repoDir, branchConfig, onAuxProcess)
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package de.hoennig.werkator.build
|
package de.hoennig.werkator.build
|
||||||
|
|
||||||
import de.hoennig.werkator.config.BuildDefinition
|
import de.hoennig.werkator.config.BuildDefinition
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
/** Handle to a build accepted by the [BuildExecutor]; log paths become valid once the build runs. */
|
/** Handle to a build accepted by the [BuildExecutor]; log paths become valid once the build runs. */
|
||||||
data class RunningBuild(
|
data class RunningBuild(
|
||||||
|
/**
|
||||||
|
* The repository this build belongs to; the context object is the identity
|
||||||
|
* (ADR 0009), so it compares by reference. Without it neither the current-builds
|
||||||
|
* view nor the watcher's worktree pruning could tell two repositories apart —
|
||||||
|
* both would see every repository's running builds as their own.
|
||||||
|
*/
|
||||||
|
val repo: RepoContext,
|
||||||
/** The git branch being built. */
|
/** The git branch being built. */
|
||||||
val branch: String,
|
val branch: String,
|
||||||
/** The build definition (job) this build runs; its settings are resolved from config at run time. */
|
/** The build definition (job) this build runs; its settings are resolved from config at run time. */
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package de.hoennig.werkator.build
|
package de.hoennig.werkator.build
|
||||||
|
|
||||||
import de.hoennig.werkator.config.BranchConfig
|
import de.hoennig.werkator.config.BranchConfig
|
||||||
import de.hoennig.werkator.config.BwrapConfig
|
import de.hoennig.werkator.config.WerkdockConfig
|
||||||
|
import de.hoennig.werkator.git.GitCommandException
|
||||||
import de.hoennig.werkator.git.GitCommandRunner
|
import de.hoennig.werkator.git.GitCommandRunner
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
@@ -13,12 +14,12 @@ import java.security.MessageDigest
|
|||||||
* Runs build commands inside a bubblewrap user-namespace sandbox (Step 17 / ADR 0008),
|
* Runs build commands inside a bubblewrap user-namespace sandbox (Step 17 / ADR 0008),
|
||||||
* for hosts without root and without a Docker daemon (e.g. Hostsharing managed
|
* for hosts without root and without a Docker daemon (e.g. Hostsharing managed
|
||||||
* webspaces). Since step 21 session C it no longer assembles the raw `bwrap` argv:
|
* webspaces). Since step 21 session C it no longer assembles the raw `bwrap` argv:
|
||||||
* it shells out to the `werkdock` CLI (`bwrap.werkdock`, default via PATH) — the same
|
* it shells out to the `werkdock` CLI (`werkdock.binary`, default via PATH) — the same
|
||||||
* pattern as git and docker, CLI, no library.
|
* pattern as git and docker, CLI, no library.
|
||||||
*
|
*
|
||||||
* The rootfs archive becomes a werkdock *image*, loaded once per source
|
* The rootfs archive becomes a werkdock *image*, imported once per source
|
||||||
* (`werkator-buildenv-<hash>`, the hash over the source string, so a changed source
|
* (`werkator-buildenv-<hash>`, the hash over the source string, so a changed source
|
||||||
* loads a fresh image) into werkdock's own store (`$WERKDOCK_HOME`, default
|
* imports a fresh image) into werkdock's own store (`$WERKDOCK_HOME`, default
|
||||||
* `~/.werkdock`) — shared by every repository of this OS user, unlike the old
|
* `~/.werkdock`) — shared by every repository of this OS user, unlike the old
|
||||||
* per-repo unpack. Only the download cache for URL sources and the persistent
|
* per-repo unpack. Only the download cache for URL sources and the persistent
|
||||||
* toolchain home (bound to `/root` for Gradle/Go caches) stay under
|
* toolchain home (bound to `/root` for Gradle/Go caches) stay under
|
||||||
@@ -36,10 +37,10 @@ import java.security.MessageDigest
|
|||||||
* native builds.
|
* native builds.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
class BwrapBuildRunner(
|
class WerkdockBuildRunner(
|
||||||
private val commandRunner: GitCommandRunner,
|
private val commandRunner: GitCommandRunner,
|
||||||
) : BuildRunner {
|
) : BuildRunner {
|
||||||
private val log = LoggerFactory.getLogger(BwrapBuildRunner::class.java)
|
private val log = LoggerFactory.getLogger(WerkdockBuildRunner::class.java)
|
||||||
|
|
||||||
/** Replaceable process launcher so unit tests can capture the assembled `werkdock` argv. */
|
/** Replaceable process launcher so unit tests can capture the assembled `werkdock` argv. */
|
||||||
internal var processStarter: (List<String>, Path) -> Process = { command, dir ->
|
internal var processStarter: (List<String>, Path) -> Process = { command, dir ->
|
||||||
@@ -54,26 +55,28 @@ class BwrapBuildRunner(
|
|||||||
branchConfig: BranchConfig,
|
branchConfig: BranchConfig,
|
||||||
onAuxProcess: (Process) -> Unit,
|
onAuxProcess: (Process) -> Unit,
|
||||||
): Process {
|
): Process {
|
||||||
val bwrap = branchConfig.bwrap
|
val sandbox = branchConfig.werkdock
|
||||||
require(bwrap.rootfs.isNotBlank()) { "branches.<name>.bwrap.rootfs must be set when bwrap.enabled is true" }
|
require(sandbox.rootfs.isNotBlank()) { "builds.<name>.werkdock.rootfs must be set when werkdock.enabled is true" }
|
||||||
val werkdock = bwrap.werkdock.ifBlank { "werkdock" }
|
val werkdock = sandbox.binary.ifBlank { "werkdock" }
|
||||||
val image = imageName(bwrap.rootfs)
|
val image = imageName(sandbox.rootfs)
|
||||||
ensureImage(werkdock, image, bwrap, repoDir, onAuxProcess)
|
ensureImage(werkdock, image, sandbox, repoDir, onAuxProcess)
|
||||||
val homeDir = repoDir.resolve(BUILDENV_DIR).resolve(HOME_DIR)
|
val homeDir = repoDir.resolve(BUILDENV_DIR).resolve(HOME_DIR)
|
||||||
Files.createDirectories(homeDir)
|
Files.createDirectories(homeDir)
|
||||||
val args = invocation(command, workingDir, environment, repoDir, bwrap, werkdock, image, homeDir)
|
val args = invocation(command, workingDir, environment, repoDir, sandbox, werkdock, image, homeDir)
|
||||||
return processStarter(args, repoDir)
|
return processStarter(args, repoDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the rootfs archive into the werkdock image store once per source.
|
* Imports the rootfs archive into the werkdock image store once per source.
|
||||||
* `werkdock images` answers existence through the CLI, like `docker image
|
* `werkdock images` answers existence through the CLI, like `docker image
|
||||||
* inspect` does for the Docker runner.
|
* inspect` does for the Docker runner; it prints the bare name for an untagged
|
||||||
|
* image on every werkdock version, so the exact-line match holds across the
|
||||||
|
* werkdock upgrade that introduced tags.
|
||||||
*/
|
*/
|
||||||
private fun ensureImage(
|
private fun ensureImage(
|
||||||
werkdock: String,
|
werkdock: String,
|
||||||
image: String,
|
image: String,
|
||||||
bwrap: BwrapConfig,
|
sandbox: WerkdockConfig,
|
||||||
repoDir: Path,
|
repoDir: Path,
|
||||||
onAuxProcess: (Process) -> Unit,
|
onAuxProcess: (Process) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -81,10 +84,36 @@ class BwrapBuildRunner(
|
|||||||
if (image in loaded) {
|
if (image in loaded) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val envDir = repoDir.resolve(BUILDENV_DIR).resolve(sourceKey(bwrap.rootfs))
|
val envDir = repoDir.resolve(BUILDENV_DIR).resolve(sourceKey(sandbox.rootfs))
|
||||||
Files.createDirectories(envDir)
|
Files.createDirectories(envDir)
|
||||||
val archive = localArchive(bwrap.rootfs, envDir, repoDir, onAuxProcess)
|
val archive = localArchive(sandbox.rootfs, envDir, repoDir, onAuxProcess)
|
||||||
log.info("loading build environment {} as werkdock image {}", bwrap.rootfs, image)
|
log.info("importing build environment {} as werkdock image {}", sandbox.rootfs, image)
|
||||||
|
importImage(werkdock, archive, image, repoDir, onAuxProcess)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `werkdock import ARCHIVE IMAGE` (docker import semantics) creates the image from a
|
||||||
|
* rootfs archive. A werkdock that predates the verb answers `unknown command` with
|
||||||
|
* exit 125; then the older `load -i ARCHIVE --name IMAGE` does the same job, so an
|
||||||
|
* installation can update Werkator and werkdock in either order. Any other failure
|
||||||
|
* is the import's own and propagates as it did before.
|
||||||
|
*/
|
||||||
|
private fun importImage(
|
||||||
|
werkdock: String,
|
||||||
|
archive: String,
|
||||||
|
image: String,
|
||||||
|
repoDir: Path,
|
||||||
|
onAuxProcess: (Process) -> Unit,
|
||||||
|
) {
|
||||||
|
val importCommand = listOf(werkdock, "import", archive, image)
|
||||||
|
val imported = commandRunner.run(importCommand, repoDir, onProcess = onAuxProcess)
|
||||||
|
if (imported.isSuccess) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (imported.exitCode != WERKDOCK_CLI_ERROR || "unknown command" !in imported.stderr) {
|
||||||
|
throw GitCommandException(importCommand, imported)
|
||||||
|
}
|
||||||
|
log.info("this werkdock has no import verb yet, falling back to load -i --name")
|
||||||
commandRunner.runOrThrow(
|
commandRunner.runOrThrow(
|
||||||
listOf(werkdock, "load", "-i", archive, "--name", image),
|
listOf(werkdock, "load", "-i", archive, "--name", image),
|
||||||
repoDir,
|
repoDir,
|
||||||
@@ -93,7 +122,7 @@ class BwrapBuildRunner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves [BwrapConfig.rootfs] to a local archive path: a bare or `file:` path is
|
* Resolves [WerkdockConfig.rootfs] to a local archive path: a bare or `file:` path is
|
||||||
* used as-is; an `http(s)` URL is downloaded once into the buildenv cache.
|
* used as-is; an `http(s)` URL is downloaded once into the buildenv cache.
|
||||||
*/
|
*/
|
||||||
private fun localArchive(
|
private fun localArchive(
|
||||||
@@ -123,7 +152,7 @@ class BwrapBuildRunner(
|
|||||||
workspace: Path,
|
workspace: Path,
|
||||||
environment: Map<String, String>,
|
environment: Map<String, String>,
|
||||||
repoDir: Path,
|
repoDir: Path,
|
||||||
bwrap: BwrapConfig,
|
sandbox: WerkdockConfig,
|
||||||
werkdock: String,
|
werkdock: String,
|
||||||
image: String,
|
image: String,
|
||||||
homeDir: Path,
|
homeDir: Path,
|
||||||
@@ -148,7 +177,7 @@ class BwrapBuildRunner(
|
|||||||
for ((key, value) in environment) {
|
for ((key, value) in environment) {
|
||||||
args += listOf("-e", "$key=$value")
|
args += listOf("-e", "$key=$value")
|
||||||
}
|
}
|
||||||
for ((key, value) in bwrap.env) {
|
for ((key, value) in sandbox.env) {
|
||||||
args += listOf("-e", "$key=$value")
|
args += listOf("-e", "$key=$value")
|
||||||
}
|
}
|
||||||
args += listOf("-w", "$workspaceAbs")
|
args += listOf("-w", "$workspaceAbs")
|
||||||
@@ -209,5 +238,8 @@ class BwrapBuildRunner(
|
|||||||
companion object {
|
companion object {
|
||||||
const val BUILDENV_DIR = ".git/werkator/buildenv"
|
const val BUILDENV_DIR = ".git/werkator/buildenv"
|
||||||
const val HOME_DIR = "home"
|
const val HOME_DIR = "home"
|
||||||
|
|
||||||
|
/** werkdock's exit code for its own errors (docker's 125), as opposed to the sandboxed command's. */
|
||||||
|
const val WERKDOCK_CLI_ERROR = 125
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,11 @@ 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 de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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.Mixin
|
||||||
import picocli.CommandLine.Parameters
|
import picocli.CommandLine.Parameters
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.util.concurrent.Callable
|
import java.util.concurrent.Callable
|
||||||
@@ -24,9 +26,11 @@ 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). */
|
private val registry: RepoRegistry,
|
||||||
var repo: RepoContext,
|
|
||||||
) : Callable<Int> {
|
) : Callable<Int> {
|
||||||
|
@Mixin
|
||||||
|
var repoOption = RepoOption()
|
||||||
|
|
||||||
@Parameters(
|
@Parameters(
|
||||||
index = "0",
|
index = "0",
|
||||||
arity = "0..1",
|
arity = "0..1",
|
||||||
@@ -35,6 +39,8 @@ class BuildCommand(
|
|||||||
)
|
)
|
||||||
var branchFragment: String? = null
|
var branchFragment: String? = null
|
||||||
|
|
||||||
|
private lateinit var repo: RepoContext
|
||||||
|
|
||||||
private val workingDir: Path
|
private val workingDir: Path
|
||||||
get() = repo.workingDir
|
get() = repo.workingDir
|
||||||
|
|
||||||
@@ -42,6 +48,7 @@ class BuildCommand(
|
|||||||
val branch: String
|
val branch: String
|
||||||
val commit: String
|
val commit: String
|
||||||
try {
|
try {
|
||||||
|
repo = repoOption.select(registry)
|
||||||
fetchBestEffort()
|
fetchBestEffort()
|
||||||
branch = resolveBranch() ?: return ExitCode.USAGE
|
branch = resolveBranch() ?: return ExitCode.USAGE
|
||||||
commit = commitToBuild(branch) ?: return ExitCode.USAGE
|
commit = commitToBuild(branch) ?: return ExitCode.USAGE
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class InitCommand(
|
|||||||
internal var javaExecutableResolver: () -> Path = { Paths.get(System.getProperty("java.home"), "bin", "java") }
|
internal var javaExecutableResolver: () -> Path = { Paths.get(System.getProperty("java.home"), "bin", "java") }
|
||||||
|
|
||||||
override fun run() {
|
override fun run() {
|
||||||
|
cachedServerConfig = null
|
||||||
val normalizedWorkingDir = workingDir.toAbsolutePath().normalize()
|
val normalizedWorkingDir = workingDir.toAbsolutePath().normalize()
|
||||||
val root =
|
val root =
|
||||||
try {
|
try {
|
||||||
@@ -88,7 +89,7 @@ class InitCommand(
|
|||||||
if (url == null) return DetectedValues()
|
if (url == null) return DetectedValues()
|
||||||
|
|
||||||
if (url.startsWith("http")) {
|
if (url.startsWith("http")) {
|
||||||
val regex = Regex("""https?://(?:([^@]+)@)?([^/]+)/([^/]+)/([^/.]+)(?:\.git)?""")
|
val regex = Regex("""https?://(?:([^@]+)@)?([^/]+)/([^/]+)/(.+?)(?:\.git)?$""")
|
||||||
val match = regex.find(url)
|
val match = regex.find(url)
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
val (user, host, owner, repo) = match.destructured
|
val (user, host, owner, repo) = match.destructured
|
||||||
@@ -101,7 +102,7 @@ class InitCommand(
|
|||||||
}
|
}
|
||||||
} else if (url.contains("@") && url.contains(":")) {
|
} else if (url.contains("@") && url.contains(":")) {
|
||||||
// Assume SSH: git@host:owner/repo.git
|
// Assume SSH: git@host:owner/repo.git
|
||||||
val regex = Regex("""([^@]+)@([^:]+):([^/]+)/([^/.]+)(?:\.git)?""")
|
val regex = Regex("""([^@]+)@([^:]+):([^/]+)/(.+?)(?:\.git)?$""")
|
||||||
val match = regex.find(url)
|
val match = regex.find(url)
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
val (_, host, owner, repo) = match.destructured
|
val (_, host, owner, repo) = match.destructured
|
||||||
@@ -242,12 +243,13 @@ class InitCommand(
|
|||||||
context: "." # Docker build context used with dockerfile
|
context: "." # Docker build context used with dockerfile
|
||||||
network: "" # Docker network mode for the build container; empty = Docker default (pinned)
|
network: "" # Docker network mode for the build container; empty = Docker default (pinned)
|
||||||
env: {} # additional environment variables set inside the build container
|
env: {} # additional environment variables set inside the build container
|
||||||
# bubblewrap user-namespace sandbox — for hosts without root and without a
|
# werkdock sandbox (bubblewrap user namespace) — for hosts without root and
|
||||||
# Docker daemon (e.g. Hostsharing managed webspaces). Mutually exclusive with docker.
|
# without a Docker daemon (e.g. Hostsharing managed webspaces). Mutually
|
||||||
bwrap:
|
# exclusive with docker. Called bwrap before v1.2.0, still read under that name.
|
||||||
enabled: false # run clean/build in a bwrap sandbox instead of natively (pinned)
|
werkdock:
|
||||||
|
enabled: false # run clean/build in the sandbox instead of natively (pinned)
|
||||||
rootfs: "" # prepared rootfs archive (path or URL); required when enabled (pinned)
|
rootfs: "" # prepared rootfs archive (path or URL); required when enabled (pinned)
|
||||||
werkdock: werkdock # the werkdock CLI executing the sandbox; default resolves via PATH (pinned)
|
binary: werkdock # the werkdock CLI executing the sandbox; default resolves via PATH (pinned)
|
||||||
env: {} # additional environment variables set inside the sandbox
|
env: {} # additional environment variables set inside the sandbox
|
||||||
# Gitea check this build reports as; empty uses gitea.statusContext.
|
# Gitea check this build reports as; empty uses gitea.statusContext.
|
||||||
# Two builds of one commit under the same context overwrite each other.
|
# Two builds of one commit under the same context overwrite each other.
|
||||||
@@ -295,14 +297,31 @@ 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 = loadedServerConfig().systemd
|
private fun loadedSystemdConfig(root: Path): de.hoennig.werkator.config.SystemdConfig = loadedServerConfig(root).systemd
|
||||||
|
|
||||||
private fun loadedServerConfig(): de.hoennig.werkator.config.ServerConfig =
|
/** Loaded once per run, so a broken configuration is reported once and not per caller. */
|
||||||
try {
|
private var cachedServerConfig: de.hoennig.werkator.config.ServerConfig? = null
|
||||||
configLoader.load(Paths.get(".")).server
|
|
||||||
} catch (_: Exception) {
|
/**
|
||||||
de.hoennig.werkator.config
|
* Read from the repository root like every other file this command touches — the
|
||||||
.ServerConfig()
|
* layers sit there, not in whatever directory the process happens to run in, and
|
||||||
|
* an applied fragment must reach the generated unit even when `init` is invoked
|
||||||
|
* from a subdirectory.
|
||||||
|
*
|
||||||
|
* A configuration error here is not fatal — the units are still generated with defaults —
|
||||||
|
* but it must not pass for "nothing configured": without the warning a broken `.werkator.yml`
|
||||||
|
* looks exactly like an unset `publicBaseUrl` and the host integration is skipped silently.
|
||||||
|
*/
|
||||||
|
private fun loadedServerConfig(root: Path): de.hoennig.werkator.config.ServerConfig =
|
||||||
|
cachedServerConfig ?: run {
|
||||||
|
try {
|
||||||
|
configLoader.load(root).server
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Warning: the effective configuration could not be loaded (${e.message})")
|
||||||
|
println(" continuing with default server settings — check the generated unit and host files")
|
||||||
|
de.hoennig.werkator.config
|
||||||
|
.ServerConfig()
|
||||||
|
}.also { cachedServerConfig = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createSystemdFiles(
|
private fun createSystemdFiles(
|
||||||
@@ -326,8 +345,8 @@ class InitCommand(
|
|||||||
javaExecutable = javaExecutableResolver(),
|
javaExecutable = javaExecutableResolver(),
|
||||||
jarPath = jarPath,
|
jarPath = jarPath,
|
||||||
envFile = envFile,
|
envFile = envFile,
|
||||||
memoryMax = loadedSystemdConfig().memoryMax,
|
memoryMax = loadedSystemdConfig(root).memoryMax,
|
||||||
tasksMax = loadedSystemdConfig().tasksMax,
|
tasksMax = loadedSystemdConfig(root).tasksMax,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
println("created ${unitFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
|
println("created ${unitFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
|
||||||
@@ -350,7 +369,7 @@ class InitCommand(
|
|||||||
|
|
||||||
// generated host integration like the units: only meaningful behind a web
|
// generated host integration like the units: only meaningful behind a web
|
||||||
// frontend, so it needs a public base URL; unused elsewhere and harmless
|
// frontend, so it needs a public base URL; unused elsewhere and harmless
|
||||||
val server = loadedServerConfig()
|
val server = loadedServerConfig(root)
|
||||||
if (server.publicBaseUrl.isNotBlank()) {
|
if (server.publicBaseUrl.isNotBlank()) {
|
||||||
val htaccessFile = werkatorDir.resolve(SystemdServiceFiles.HTACCESS_NAME)
|
val htaccessFile = werkatorDir.resolve(SystemdServiceFiles.HTACCESS_NAME)
|
||||||
htaccessFile.toFile().writeText(SystemdServiceFiles.htaccessContent(server.port))
|
htaccessFile.toFile().writeText(SystemdServiceFiles.htaccessContent(server.port))
|
||||||
@@ -359,6 +378,13 @@ class InitCommand(
|
|||||||
normalizedWorkingDir.toFile(),
|
normalizedWorkingDir.toFile(),
|
||||||
)} (Apache reverse proxy; copy it into the domain docroot on a managed webspace)",
|
)} (Apache reverse proxy; copy it into the domain docroot on a managed webspace)",
|
||||||
)
|
)
|
||||||
|
val maintenancePageFile = werkatorDir.resolve(SystemdServiceFiles.MAINTENANCE_PAGE_NAME)
|
||||||
|
maintenancePageFile.toFile().writeText(SystemdServiceFiles.maintenancePageContent())
|
||||||
|
println(
|
||||||
|
"created ${maintenancePageFile.toFile().relativeTo(
|
||||||
|
normalizedWorkingDir.toFile(),
|
||||||
|
)} (shown by Apache while the service restarts; copy it next to the .htaccess)",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
println("install and start the service and the nightly Docker cleanup with:")
|
println("install and start the service and the nightly Docker cleanup with:")
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package de.hoennig.werkator.commands
|
||||||
|
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
|
import picocli.CommandLine.Option
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `--repo` selector of the repository-scoped commands (ADR 0009): names an entry
|
||||||
|
* of the instance registry. Without it a command means the current working directory
|
||||||
|
* when that is served, otherwise the first registered repository — so inside a
|
||||||
|
* repository every command behaves exactly as it did with one.
|
||||||
|
*/
|
||||||
|
class RepoOption {
|
||||||
|
@Option(
|
||||||
|
names = ["--repo"],
|
||||||
|
paramLabel = "<name>",
|
||||||
|
description = ["registered repository to act on (default: the current directory)"],
|
||||||
|
)
|
||||||
|
var name: String? = null
|
||||||
|
|
||||||
|
fun select(registry: RepoRegistry): RepoContext {
|
||||||
|
val wanted = name?.trim()?.takeIf { it.isNotEmpty() } ?: return registry.current()
|
||||||
|
return registry.byName(wanted)
|
||||||
|
?: throw IllegalArgumentException(
|
||||||
|
"no repository named '$wanted' is registered (registered: ${registry.all().joinToString(", ") { it.name }})",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ import de.hoennig.werkator.build.BuildResult
|
|||||||
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 de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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.Mixin
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.util.concurrent.Callable
|
import java.util.concurrent.Callable
|
||||||
|
|
||||||
@@ -25,15 +27,20 @@ import java.util.concurrent.Callable
|
|||||||
class RetryCommand(
|
class RetryCommand(
|
||||||
private val gitService: GitService,
|
private val gitService: GitService,
|
||||||
private val consoleBuildRunner: ConsoleBuildRunner,
|
private val consoleBuildRunner: ConsoleBuildRunner,
|
||||||
/** The repository to retry in: the current working directory (a repo selector comes with the registry). */
|
private val registry: RepoRegistry,
|
||||||
var repo: RepoContext,
|
|
||||||
) : Callable<Int> {
|
) : Callable<Int> {
|
||||||
|
@Mixin
|
||||||
|
var repoOption = RepoOption()
|
||||||
|
|
||||||
|
private lateinit var repo: RepoContext
|
||||||
|
|
||||||
private val workingDir: Path
|
private val workingDir: Path
|
||||||
get() = repo.workingDir
|
get() = repo.workingDir
|
||||||
|
|
||||||
override fun call(): Int {
|
override fun call(): Int {
|
||||||
val failed: List<BuildResult>
|
val failed: List<BuildResult>
|
||||||
try {
|
try {
|
||||||
|
repo = repoOption.select(registry)
|
||||||
fetchBestEffort()
|
fetchBestEffort()
|
||||||
failed = repo.results.latestPerName().filter { it.status == BuildStatus.FAILED }
|
failed = repo.results.latestPerName().filter { it.status == BuildStatus.FAILED }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
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.repo.RepoRegistry
|
||||||
import de.hoennig.werkator.server.UiFormats
|
import de.hoennig.werkator.server.UiFormats
|
||||||
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.Mixin
|
||||||
import picocli.CommandLine.Option
|
import picocli.CommandLine.Option
|
||||||
import java.util.concurrent.Callable
|
import java.util.concurrent.Callable
|
||||||
|
|
||||||
@@ -20,12 +21,22 @@ import java.util.concurrent.Callable
|
|||||||
mixinStandardHelpOptions = true,
|
mixinStandardHelpOptions = true,
|
||||||
)
|
)
|
||||||
class StatusCommand(
|
class StatusCommand(
|
||||||
private val repository: BuildResultRepository,
|
private val registry: RepoRegistry,
|
||||||
) : Callable<Int> {
|
) : Callable<Int> {
|
||||||
@Option(names = ["--history"], description = ["Print all recorded builds, not only the latest per branch"])
|
@Option(names = ["--history"], description = ["Print all recorded builds, not only the latest per branch"])
|
||||||
var history: Boolean = false
|
var history: Boolean = false
|
||||||
|
|
||||||
|
@Mixin
|
||||||
|
var repoOption = RepoOption()
|
||||||
|
|
||||||
override fun call(): Int {
|
override fun call(): Int {
|
||||||
|
val repository =
|
||||||
|
try {
|
||||||
|
repoOption.select(registry).results
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
System.err.println("error: ${e.message}")
|
||||||
|
return ExitCode.USAGE
|
||||||
|
}
|
||||||
val results = if (history) repository.history() else repository.latestPerName()
|
val results = if (history) repository.history() else repository.latestPerName()
|
||||||
if (results.isEmpty()) {
|
if (results.isEmpty()) {
|
||||||
println("(no builds recorded)")
|
println("(no builds recorded)")
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ object SystemdServiceFiles {
|
|||||||
/** 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 HTACCESS_NAME = "werkator.htaccess"
|
||||||
|
|
||||||
|
/** Static page [maintenancePageContent] serves at, referenced by [htaccessContent]'s `ErrorDocument`s. */
|
||||||
|
const val MAINTENANCE_PAGE_NAME = "werkator-maintenance.html"
|
||||||
|
|
||||||
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"
|
||||||
|
|
||||||
@@ -96,15 +99,53 @@ object SystemdServiceFiles {
|
|||||||
* Apache terminates TLS for the domain and forwards everything to the
|
* Apache terminates TLS for the domain and forwards everything to the
|
||||||
* localhost port of the "eigener Serverdienst". Generated host integration
|
* localhost port of the "eigener Serverdienst". Generated host integration
|
||||||
* like the units — the wrapper copies it into the domain's docroot.
|
* like the units — the wrapper copies it into the domain's docroot.
|
||||||
|
* `ErrorDocument` maps a refused connection (Apache's 502/503/504 while the
|
||||||
|
* service restarts) to the static [maintenancePageContent] instead of Apache's
|
||||||
|
* default error page; the `RewriteCond` keeps that one file from being proxied
|
||||||
|
* itself, since `ErrorDocument` serves it as a sub-request through the same rules.
|
||||||
*/
|
*/
|
||||||
fun htaccessContent(port: Int): String =
|
fun htaccessContent(port: Int): String =
|
||||||
"""
|
"""
|
||||||
DirectoryIndex disabled
|
DirectoryIndex disabled
|
||||||
|
ErrorDocument 502 /$MAINTENANCE_PAGE_NAME
|
||||||
|
ErrorDocument 503 /$MAINTENANCE_PAGE_NAME
|
||||||
|
ErrorDocument 504 /$MAINTENANCE_PAGE_NAME
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
RewriteBase /
|
RewriteBase /
|
||||||
|
RewriteCond %{REQUEST_URI} !^/${MAINTENANCE_PAGE_NAME}${'$'}
|
||||||
RewriteRule .* http://127.0.0.1:$port%{REQUEST_URI} [proxy]
|
RewriteRule .* http://127.0.0.1:$port%{REQUEST_URI} [proxy]
|
||||||
""".trimIndent() + "\n"
|
""".trimIndent() + "\n"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static fallback for [htaccessContent]'s `ErrorDocument`s: shown by Apache directly,
|
||||||
|
* without involving Werkator, during the brief window where the service restarts and
|
||||||
|
* nothing listens on its port yet (`instance-update`). Self-contained — no external
|
||||||
|
* assets, since nothing would be there to serve them while Werkator itself is down.
|
||||||
|
*/
|
||||||
|
fun maintenancePageContent(): String =
|
||||||
|
"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Werkator — Maintenance</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; background: #1e1e1e; color: #eee; display: flex;
|
||||||
|
align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
||||||
|
div { text-align: center; }
|
||||||
|
h1 { font-size: 1.4rem; margin-bottom: .5rem; }
|
||||||
|
p { color: #aaa; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
<h1>Werkator is restarting</h1>
|
||||||
|
<p>A deployment is in progress. Please retry in a few minutes.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".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`). */
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ data class BuildDefinition(
|
|||||||
val statusContext: String? = null,
|
val statusContext: String? = null,
|
||||||
/** Overrides of the docker settings; null inherits them. */
|
/** Overrides of the docker settings; null inherits them. */
|
||||||
val docker: DockerOverrides? = null,
|
val docker: DockerOverrides? = null,
|
||||||
/** Overrides of the bwrap settings; null inherits them. */
|
/** Overrides of the werkdock settings; null inherits them. */
|
||||||
val bwrap: BwrapOverrides? = null,
|
val werkdock: WerkdockOverrides? = null,
|
||||||
) {
|
) {
|
||||||
/** The settings this build runs with: [branchConfig] with this definition applied; unset values fall through. */
|
/** The settings this build runs with: [branchConfig] with this definition applied; unset values fall through. */
|
||||||
fun applyTo(branchConfig: BranchConfig): BranchConfig =
|
fun applyTo(branchConfig: BranchConfig): BranchConfig =
|
||||||
@@ -64,12 +64,12 @@ data class BuildDefinition(
|
|||||||
network = docker?.network ?: branchConfig.docker.network,
|
network = docker?.network ?: branchConfig.docker.network,
|
||||||
env = docker?.env ?: branchConfig.docker.env,
|
env = docker?.env ?: branchConfig.docker.env,
|
||||||
),
|
),
|
||||||
bwrap =
|
werkdock =
|
||||||
branchConfig.bwrap.copy(
|
branchConfig.werkdock.copy(
|
||||||
enabled = bwrap?.enabled ?: branchConfig.bwrap.enabled,
|
enabled = werkdock?.enabled ?: branchConfig.werkdock.enabled,
|
||||||
rootfs = bwrap?.rootfs ?: branchConfig.bwrap.rootfs,
|
rootfs = werkdock?.rootfs ?: branchConfig.werkdock.rootfs,
|
||||||
werkdock = bwrap?.werkdock ?: branchConfig.bwrap.werkdock,
|
binary = werkdock?.binary ?: branchConfig.werkdock.binary,
|
||||||
env = bwrap?.env ?: branchConfig.bwrap.env,
|
env = werkdock?.env ?: branchConfig.werkdock.env,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -169,13 +169,13 @@ data class DockerOverrides(
|
|||||||
val env: Map<String, String>? = null,
|
val env: Map<String, String>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Nullable bubblewrap overrides of a [BuildDefinition]; null values inherit the branch's setting. */
|
/** Nullable werkdock overrides of a [BuildDefinition]; null values inherit the branch's setting. */
|
||||||
data class BwrapOverrides(
|
data class WerkdockOverrides(
|
||||||
/** Run the build in the bwrap sandbox instead of natively. Pinned — a branch must not escape its sandbox. */
|
/** Run the build in the sandbox instead of natively. Pinned — a branch must not escape its sandbox. */
|
||||||
val enabled: Boolean? = null,
|
val enabled: Boolean? = null,
|
||||||
/** Rootfs archive source. Pinned — a branch must not substitute a foreign rootfs. */
|
/** Rootfs archive source. Pinned — a branch must not substitute a foreign rootfs. */
|
||||||
val rootfs: String? = null,
|
val rootfs: String? = null,
|
||||||
/** The werkdock CLI executing the sandbox. Pinned — a branch must not substitute the executing binary. */
|
/** The werkdock CLI executing the sandbox. Pinned — a branch must not substitute the executing binary. */
|
||||||
val werkdock: String? = null,
|
val binary: String? = null,
|
||||||
val env: Map<String, String>? = null,
|
val env: Map<String, String>? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ 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 instance configuration (ADR 0009), relative to the home directory of the user
|
||||||
|
* running Werkator. Deliberately the same file name: only the location carries the
|
||||||
|
* meaning — home is the instance, the repository root is the project, `.git` is the
|
||||||
|
* machine.
|
||||||
|
*/
|
||||||
|
const val INSTANCE = COMMITTED
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The applied instance fragment (`init --apply`, step 23): a config-schema YAML
|
* The applied instance fragment (`init --apply`, step 23): a config-schema YAML
|
||||||
* fragment installed verbatim as its own layer — above the committed project
|
* fragment installed verbatim as its own layer — above the committed project
|
||||||
|
|||||||
@@ -50,6 +50,35 @@ class ConfigLoader(
|
|||||||
/** Section-level warnings already reported, keyed by a fixed slug; the config is loaded on every poll cycle. */
|
/** Section-level warnings already reported, keyed by a fixed slug; the config is loaded on every poll cycle. */
|
||||||
private val warnedSections = ConcurrentHashMap.newKeySet<String>()
|
private val warnedSections = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the instance configuration lives (ADR 0009): the home directory of the user
|
||||||
|
* running Werkator, `WERKATOR_HOME` overriding it for tests and unusual layouts.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var homeDir: Path = Paths.get(System.getenv("WERKATOR_HOME")?.takeIf { it.isNotBlank() } ?: System.getProperty("user.home"))
|
||||||
|
|
||||||
|
/** The instance configuration file, whether or not it exists. */
|
||||||
|
fun instanceFile(): Path = homeDir.resolve(ConfigFiles.INSTANCE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instance configuration, or null without a home file — the single-repository
|
||||||
|
* case, in which the current directory is served exactly as before ADR 0009.
|
||||||
|
*/
|
||||||
|
fun loadInstance(): InstanceConfig? {
|
||||||
|
val raw = loadInstanceRaw()
|
||||||
|
if (raw.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return yaml.convertValue(raw, InstanceConfig::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadInstanceRaw(): Map<String, Any?> {
|
||||||
|
val file = instanceFile()
|
||||||
|
val raw = loadFile(file.toFile())
|
||||||
|
checkVersion(raw, file.toString(), ROLLBACK_HINT)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
fun load(workingDir: Path = Paths.get(".")): WerkatorConfig = toConfig(loadRaw(workingDir))
|
fun load(workingDir: Path = Paths.get(".")): WerkatorConfig = toConfig(loadRaw(workingDir))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,10 +201,10 @@ class ConfigLoader(
|
|||||||
val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } }
|
val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } }
|
||||||
if (strippedDocker.isEmpty()) result.remove("docker") else result["docker"] = strippedDocker
|
if (strippedDocker.isEmpty()) result.remove("docker") else result["docker"] = strippedDocker
|
||||||
}
|
}
|
||||||
val bwrap = entry["bwrap"] as? Map<String, Any?>
|
val werkdock = entry["werkdock"] as? Map<String, Any?>
|
||||||
if (bwrap != null) {
|
if (werkdock != null) {
|
||||||
val strippedBwrap = bwrap.toMutableMap().apply { PINNED_BWRAP_KEYS.forEach { remove(it) } }
|
val strippedWerkdock = werkdock.toMutableMap().apply { PINNED_WERKDOCK_KEYS.forEach { remove(it) } }
|
||||||
if (strippedBwrap.isEmpty()) result.remove("bwrap") else result["bwrap"] = strippedBwrap
|
if (strippedWerkdock.isEmpty()) result.remove("werkdock") else result["werkdock"] = strippedWerkdock
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -307,11 +336,79 @@ class ConfigLoader(
|
|||||||
checkTriggerBlocks(project, projectName, ROLLBACK_HINT)
|
checkTriggerBlocks(project, projectName, ROLLBACK_HINT)
|
||||||
checkTriggerBlocks(applied, ConfigFiles.APPLIED, ROLLBACK_HINT)
|
checkTriggerBlocks(applied, ConfigFiles.APPLIED, ROLLBACK_HINT)
|
||||||
checkTriggerBlocks(repoInstall, repoInstallName, ROLLBACK_HINT)
|
checkTriggerBlocks(repoInstall, repoInstallName, ROLLBACK_HINT)
|
||||||
// the applied instance fragment sits above the committed project config and
|
val instance = loadInstanceRaw()
|
||||||
// below the hand-edited machine config, which always has the last word
|
if (instance.isEmpty()) {
|
||||||
return deepMerge(deepMerge(project, applied), 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)
|
||||||
|
}
|
||||||
|
// with a home config (ADR 0009): its `defaults` sit below every repository layer,
|
||||||
|
// and the instance-level keys come from it alone — a repository file still
|
||||||
|
// carrying them is told so, never merged silently
|
||||||
|
val repoLayers =
|
||||||
|
deepMerge(
|
||||||
|
deepMerge(
|
||||||
|
withoutInstanceKeys(project, workingDir.resolve(projectName)),
|
||||||
|
withoutInstanceKeys(applied, workingDir.resolve(ConfigFiles.APPLIED)),
|
||||||
|
),
|
||||||
|
withoutInstanceKeys(repoInstall, workingDir.resolve(repoInstallName)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val defaults = instance["defaults"] as? Map<String, Any?> ?: emptyMap()
|
||||||
|
return deepMerge(deepMerge(defaults, repoLayers), instanceKeysOf(instance))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the instance-level keys from one repository layer, saying so once per file
|
||||||
|
* with both file names: the setting the operator wrote there is not in effect, and
|
||||||
|
* the message must name where it is read from instead.
|
||||||
|
*/
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun withoutInstanceKeys(
|
||||||
|
layer: Map<String, Any?>,
|
||||||
|
file: Path,
|
||||||
|
): Map<String, Any?> {
|
||||||
|
val instanceKeys = instanceKeysOf(layer)
|
||||||
|
if (instanceKeys.isEmpty()) {
|
||||||
|
return layer
|
||||||
|
}
|
||||||
|
if (warnedSections.add("instance-keys:$file")) {
|
||||||
|
log.warn(
|
||||||
|
"ignoring {} in {}: these are instance settings and come from {} now",
|
||||||
|
describeKeys(instanceKeys),
|
||||||
|
file,
|
||||||
|
instanceFile(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val result = layer.toMutableMap()
|
||||||
|
for (key in INSTANCE_SECTIONS) {
|
||||||
|
result.remove(key)
|
||||||
|
}
|
||||||
|
val watcher = (layer["watcher"] as? Map<String, Any?>)?.minus(INSTANCE_WATCHER_KEYS)
|
||||||
|
if (watcher == null || watcher.isEmpty()) result.remove("watcher") else result["watcher"] = watcher
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The instance-level part of a raw configuration map: the sections and keys owned by the instance. */
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun instanceKeysOf(raw: Map<String, Any?>): Map<String, Any?> {
|
||||||
|
val result = mutableMapOf<String, Any?>()
|
||||||
|
for (key in INSTANCE_SECTIONS) {
|
||||||
|
raw[key]?.let { result[key] = it }
|
||||||
|
}
|
||||||
|
val watcher = (raw["watcher"] as? Map<String, Any?>)?.filterKeys { it in INSTANCE_WATCHER_KEYS }
|
||||||
|
if (!watcher.isNullOrEmpty()) {
|
||||||
|
result["watcher"] = watcher
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun describeKeys(instanceKeys: Map<String, Any?>): String =
|
||||||
|
instanceKeys.keys.joinToString(", ") { key ->
|
||||||
|
if (key == "watcher") INSTANCE_WATCHER_KEYS.joinToString(", ") { "watcher.$it" } else key
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates and installs an instance fragment (`init --apply`, step 23): the file
|
* 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*
|
* must be non-empty, pass the version and trigger checks, and bind *strictly*
|
||||||
@@ -384,14 +481,62 @@ class ConfigLoader(
|
|||||||
private fun loadFile(file: File): Map<String, Any?> {
|
private fun loadFile(file: File): Map<String, Any?> {
|
||||||
if (!file.exists()) return emptyMap()
|
if (!file.exists()) return emptyMap()
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
return yaml.readValue(file, Map::class.java) as Map<String, Any?>
|
return renameLegacySandbox(yaml.readValue(file, Map::class.java) as Map<String, Any?>, file.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parses a `.werkator.yml` read from git (not from disk); blank or null yields no layer. */
|
/** Parses a `.werkator.yml` read from git (not from disk); blank or null yields no layer. */
|
||||||
private fun parseYaml(text: String?): Map<String, Any?> {
|
private fun parseYaml(text: String?): Map<String, Any?> {
|
||||||
if (text.isNullOrBlank()) return emptyMap()
|
if (text.isNullOrBlank()) return emptyMap()
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
return yaml.readValue(text, Map::class.java) as? Map<String, Any?> ?: emptyMap()
|
val raw = yaml.readValue(text, Map::class.java) as? Map<String, Any?> ?: emptyMap()
|
||||||
|
return renameLegacySandbox(raw, "the branch configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the pre-PR#19 `bwrap` section under its new name `werkdock`, including its
|
||||||
|
* `werkdock` key which is `binary` now. Done on the raw map of every layer, before
|
||||||
|
* merging, so nothing downstream — merging, pinning, binding — knows two names.
|
||||||
|
*
|
||||||
|
* Renaming rather than rejecting: the section is written in the machine configuration
|
||||||
|
* of every webspace instance, which no repository tracks. The warning is what makes
|
||||||
|
* the old name go away; the hard refusal belongs to the release that sets
|
||||||
|
* [ConfigVersions.FORMAT_BROKE_IN], where a file declaring no version can be caught
|
||||||
|
* by name at all.
|
||||||
|
*/
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun renameLegacySandbox(
|
||||||
|
raw: Map<String, Any?>,
|
||||||
|
source: String,
|
||||||
|
): Map<String, Any?> {
|
||||||
|
var renamed = false
|
||||||
|
val result =
|
||||||
|
raw.mapValues { (section, value) ->
|
||||||
|
if (section != "builds" && section != "branches") {
|
||||||
|
return@mapValues value
|
||||||
|
}
|
||||||
|
val entries = value as? Map<String, Any?> ?: return@mapValues value
|
||||||
|
entries.mapValues inner@{ (_, entry) ->
|
||||||
|
val settings = entry as? Map<String, Any?> ?: return@inner entry
|
||||||
|
val legacy = settings["bwrap"] as? Map<String, Any?> ?: return@inner entry
|
||||||
|
renamed = true
|
||||||
|
val moved =
|
||||||
|
legacy.mapKeys { (key, _) -> if (key == "werkdock") "binary" else key }
|
||||||
|
val existing = settings["werkdock"] as? Map<String, Any?> ?: emptyMap()
|
||||||
|
settings.toMutableMap().apply {
|
||||||
|
remove("bwrap")
|
||||||
|
// an explicit werkdock section wins: the new name is the one meant
|
||||||
|
put("werkdock", moved + existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (renamed && warnedSections.add("bwrap-renamed:$source")) {
|
||||||
|
log.warn(
|
||||||
|
"reading the 'bwrap' section of {} as 'werkdock' (and 'bwrap.werkdock' as 'werkdock.binary'); " +
|
||||||
|
"rename it — the old name goes away with the next breaking configuration change",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
@@ -453,8 +598,8 @@ class ConfigLoader(
|
|||||||
/** `docker` keys a branch must never override: the sandbox policy. */
|
/** `docker` keys a branch must never override: the sandbox policy. */
|
||||||
private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
|
private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
|
||||||
|
|
||||||
/** `bwrap` keys a branch must never override: the sandbox policy (Step 17) and its executing binary. */
|
/** `werkdock` keys a branch must never override: the sandbox policy (Step 17) and its executing binary. */
|
||||||
private val PINNED_BWRAP_KEYS = setOf("enabled", "rootfs", "werkdock")
|
private val PINNED_WERKDOCK_KEYS = setOf("enabled", "rootfs", "binary")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one key of a build definition that says *when* and *for which branches* it
|
* The one key of a build definition that says *when* and *for which branches* it
|
||||||
@@ -467,6 +612,16 @@ class ConfigLoader(
|
|||||||
/** The keys that moved into [TRIGGER_KEYS]; still writing them flat is refused, not ignored. */
|
/** The keys that moved into [TRIGGER_KEYS]; still writing them flat is refused, not ignored. */
|
||||||
private val FLAT_TRIGGER_KEYS = setOf("onPush", "atTimes", "branches", "activeWithin")
|
private val FLAT_TRIGGER_KEYS = setOf("onPush", "atTimes", "branches", "activeWithin")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level sections owned by the instance once a home config exists (ADR 0009):
|
||||||
|
* the one server and the one global concurrency cap. Read from the home file,
|
||||||
|
* ignored with a warning in a repository's files.
|
||||||
|
*/
|
||||||
|
private val INSTANCE_SECTIONS = setOf("server", "executor")
|
||||||
|
|
||||||
|
/** The `watcher` keys owned by the instance: one loop, one delay; the gates stay per repository. */
|
||||||
|
private val INSTANCE_WATCHER_KEYS = setOf("pollInterval")
|
||||||
|
|
||||||
private const val LEGACY_BRANCHES_WARNING = "legacy-branches-ignored"
|
private const val LEGACY_BRANCHES_WARNING = "legacy-branches-ignored"
|
||||||
|
|
||||||
private const val NO_TRIGGER_WARNING = "no-build-triggered"
|
private const val NO_TRIGGER_WARNING = "no-build-triggered"
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package de.hoennig.werkator.config
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instance configuration (ADR 0009): `~/.werkator.yml` in the home directory of the
|
||||||
|
* user running Werkator — one instance per OS user. It owns what is shared by every
|
||||||
|
* repository the instance serves: the repository registry, the `server` section, the
|
||||||
|
* global `executor.maxConcurrent`, and the watcher poll interval. Everything else in
|
||||||
|
* the file is either `defaults` — a fragment in the repository config schema merged
|
||||||
|
* *below* every repository's own layers — or ignored.
|
||||||
|
*
|
||||||
|
* Without this file, Werkator serves the current working directory exactly as before.
|
||||||
|
* With it, the registry wins over the current directory (`werkator server` serves the
|
||||||
|
* registered repositories wherever it is started), and the instance-level keys of a
|
||||||
|
* repository's own files are ignored with a warning naming both files, never merged.
|
||||||
|
*/
|
||||||
|
data class InstanceConfig(
|
||||||
|
/** What this file declares about the Werkator that reads it; see [VersionRequirement]. */
|
||||||
|
val werkator: WerkatorMeta = WerkatorMeta(),
|
||||||
|
val server: ServerConfig = ServerConfig(),
|
||||||
|
val executor: ExecutorConfig = ExecutorConfig(),
|
||||||
|
val watcher: InstanceWatcherConfig = InstanceWatcherConfig(),
|
||||||
|
/** The registry: the repositories this instance serves; empty means the current directory. */
|
||||||
|
val repositories: List<RepositoryEntry> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Repository-level keys in the repository config schema (e.g. one `git.account`/`git.token`
|
||||||
|
* for every repository of the same forge), merged below each repository's own layers.
|
||||||
|
* Raw on purpose: it is a fragment, not a configuration, and binds through the same
|
||||||
|
* path as every other layer.
|
||||||
|
*/
|
||||||
|
val defaults: Map<String, Any?> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The watcher settings that are the instance's, not a repository's: one loop, one delay. */
|
||||||
|
data class InstanceWatcherConfig(
|
||||||
|
/** Delay between poll cycles over all repositories, e.g. `10s` or `1m`. */
|
||||||
|
val pollInterval: String = "10s",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One registry entry: a repository directory and the name it is known by. */
|
||||||
|
data class RepositoryEntry(
|
||||||
|
/** The repository's primary checkout, absolute or relative to the home directory; `~` expands. */
|
||||||
|
val path: String = "",
|
||||||
|
/** Short unique name for display and routes; empty means the directory basename. */
|
||||||
|
val name: String = "",
|
||||||
|
)
|
||||||
@@ -38,9 +38,9 @@ data class WerkatorConfig(
|
|||||||
): BranchConfig {
|
): BranchConfig {
|
||||||
val branchConfig = branches[branch] ?: branches["default"] ?: BranchConfig()
|
val branchConfig = branches[branch] ?: branches["default"] ?: BranchConfig()
|
||||||
val settings = effectiveBuildDefinitions()[build]?.applyTo(branchConfig) ?: branchConfig
|
val settings = effectiveBuildDefinitions()[build]?.applyTo(branchConfig) ?: branchConfig
|
||||||
if (settings.docker.enabled && settings.bwrap.enabled) {
|
if (settings.docker.enabled && settings.werkdock.enabled) {
|
||||||
throw IllegalArgumentException(
|
throw IllegalArgumentException(
|
||||||
"builds.$build on '$branch' enables both docker and bwrap; a build runs in exactly one sandbox. " +
|
"builds.$build on '$branch' enables both docker and werkdock; a build runs in exactly one sandbox. " +
|
||||||
"Disable one of them.",
|
"Disable one of them.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -179,17 +179,22 @@ data class BranchConfig(
|
|||||||
val statusContext: String = "",
|
val statusContext: String = "",
|
||||||
val autoBuild: AutoBuildConfig = AutoBuildConfig(),
|
val autoBuild: AutoBuildConfig = AutoBuildConfig(),
|
||||||
val docker: DockerConfig = DockerConfig(),
|
val docker: DockerConfig = DockerConfig(),
|
||||||
/** bubblewrap user-namespace sandbox; mutually exclusive with [docker]. */
|
/** werkdock sandbox (bubblewrap user namespace); mutually exclusive with [docker]. */
|
||||||
val bwrap: BwrapConfig = BwrapConfig(),
|
val werkdock: WerkdockConfig = WerkdockConfig(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* bubblewrap build sandbox (Step 17): runs the build in an unprivileged user namespace
|
* The werkdock build sandbox (Step 17, executed by the werkdock CLI since step 21):
|
||||||
* with a prepared Debian root filesystem. For hosts without root and without a Docker
|
* runs the build in an unprivileged bubblewrap user namespace over a prepared Debian
|
||||||
* daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md`.
|
* root filesystem. For hosts without root and without a Docker daemon (e.g. Hostsharing
|
||||||
|
* managed webspaces); see `docs/plan/17-bwrap-build-runtime.md`.
|
||||||
|
*
|
||||||
|
* The section was called `bwrap` until PR#19 and is still read under that name, with a
|
||||||
|
* warning: `bwrap` named the mechanism one layer below the tool that actually runs it,
|
||||||
|
* which made `bwrap.werkdock` the key naming its own executor.
|
||||||
*/
|
*/
|
||||||
data class BwrapConfig(
|
data class WerkdockConfig(
|
||||||
/** Run the clean and build commands in a bwrap sandbox instead of natively. */
|
/** Run the clean and build commands in the sandbox instead of natively. */
|
||||||
val enabled: Boolean = false,
|
val enabled: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* Path or URL of the prepared rootfs archive (e.g. `werkator-buildenv-trixie-java21.tar.zst`),
|
* Path or URL of the prepared rootfs archive (e.g. `werkator-buildenv-trixie-java21.tar.zst`),
|
||||||
@@ -200,8 +205,9 @@ data class BwrapConfig(
|
|||||||
/**
|
/**
|
||||||
* The werkdock CLI executing the sandbox (step 21 session C); empty or the default
|
* The werkdock CLI executing the sandbox (step 21 session C); empty or the default
|
||||||
* resolves via PATH. Pinned — a branch must not substitute the executing binary.
|
* resolves via PATH. Pinned — a branch must not substitute the executing binary.
|
||||||
|
* Was `bwrap.werkdock` until PR#19.
|
||||||
*/
|
*/
|
||||||
val werkdock: String = "werkdock",
|
val binary: String = "werkdock",
|
||||||
/** Additional environment variables set inside the sandbox. */
|
/** Additional environment variables set inside the sandbox. */
|
||||||
val env: Map<String, String> = emptyMap(),
|
val env: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package de.hoennig.werkator.metrics
|
||||||
|
|
||||||
|
/** One `blocks`/`quota`/`limit` line of `quota -u -g --no-wrap --raw-grace`, KiB throughout. */
|
||||||
|
data class QuotaLine(
|
||||||
|
val kind: String,
|
||||||
|
val subject: String,
|
||||||
|
val filesystem: String,
|
||||||
|
val blocksKib: Long,
|
||||||
|
val softKib: Long,
|
||||||
|
val hardKib: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the binding [SystemMetricsCollector.DiskSpace] came from: the volume itself, or a
|
||||||
|
* user/group quota. `kind` is `"volume"`, `"user"` or `"group"`; `subject`/`filesystem`/the
|
||||||
|
* limits are set only for a quota. Serialized as-is into `GET /api/system`.
|
||||||
|
*/
|
||||||
|
data class DiskSource(
|
||||||
|
val kind: String,
|
||||||
|
val subject: String? = null,
|
||||||
|
val filesystem: String? = null,
|
||||||
|
val softLimitGib: Double? = null,
|
||||||
|
val hardLimitGib: Double? = null,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun volume() = DiskSource(kind = "volume")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One candidate budget for a directory: a quota line or the volume, each with its source. */
|
||||||
|
data class DiskCandidate(
|
||||||
|
val space: SystemMetricsCollector.DiskSpace,
|
||||||
|
val source: DiskSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses `quota -u -g --no-wrap --raw-grace` and picks the tightest of user quota, group
|
||||||
|
* quota, and the volume — pure functions over strings and numbers, see PR#16.
|
||||||
|
*/
|
||||||
|
object DiskQuota {
|
||||||
|
private val subjectHeader = Regex("""^Disk quotas for (user|group) (\S+) \([ug]id \d+\):\s*(none)?\s*$""")
|
||||||
|
|
||||||
|
/** `null`/blank output, and a subject reported `none`, both yield no line for that subject. */
|
||||||
|
fun parse(output: String): List<QuotaLine> {
|
||||||
|
val lines = mutableListOf<QuotaLine>()
|
||||||
|
var kind: String? = null
|
||||||
|
var subject: String? = null
|
||||||
|
for (rawLine in output.lines()) {
|
||||||
|
val header = subjectHeader.find(rawLine.trimEnd())
|
||||||
|
if (header != null) {
|
||||||
|
kind = header.groupValues[1]
|
||||||
|
subject = header.groupValues[2]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val currentKind = kind ?: continue
|
||||||
|
val currentSubject = subject ?: continue
|
||||||
|
val trimmed = rawLine.trim()
|
||||||
|
if (trimmed.isEmpty() || trimmed.startsWith("Filesystem")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val fields = trimmed.split(Regex("\\s+"))
|
||||||
|
if (fields.size < 4) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val blocksKib = fields[1].trimEnd('*').toLongOrNull() ?: continue
|
||||||
|
val softKib = fields[2].toLongOrNull() ?: continue
|
||||||
|
val hardKib = fields[3].toLongOrNull() ?: continue
|
||||||
|
lines +=
|
||||||
|
QuotaLine(
|
||||||
|
kind = currentKind,
|
||||||
|
subject = currentSubject,
|
||||||
|
filesystem = fields[0],
|
||||||
|
blocksKib = blocksKib,
|
||||||
|
softKib = softKib,
|
||||||
|
hardKib = hardKib,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lines whose filesystem matches [directoryFilesystem] — exactly, or by the last path
|
||||||
|
* segment when one side is a resolved device path (`/dev/sdb1` vs `/dev/disk/by-id/…`) — each
|
||||||
|
* turned into a candidate. A line with no soft and no hard limit (both zero) is no candidate;
|
||||||
|
* a line with only a hard limit uses it as the total.
|
||||||
|
*/
|
||||||
|
fun candidatesFor(
|
||||||
|
lines: List<QuotaLine>,
|
||||||
|
directoryFilesystem: String,
|
||||||
|
): List<DiskCandidate> =
|
||||||
|
lines
|
||||||
|
.filter { matchesFilesystem(it.filesystem, directoryFilesystem) }
|
||||||
|
.mapNotNull { line ->
|
||||||
|
val softBytes = line.softKib * BYTES_PER_KIB
|
||||||
|
val hardBytes = line.hardKib * BYTES_PER_KIB
|
||||||
|
val totalBytes = if (softBytes > 0) softBytes else hardBytes
|
||||||
|
if (totalBytes <= 0) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
val usedBytes = line.blocksKib * BYTES_PER_KIB
|
||||||
|
val freeBytes = maxOf(0L, totalBytes - usedBytes)
|
||||||
|
DiskCandidate(
|
||||||
|
space =
|
||||||
|
SystemMetricsCollector.DiskSpace(
|
||||||
|
totalBytes = totalBytes,
|
||||||
|
usedBytes = usedBytes,
|
||||||
|
freeBytes = freeBytes,
|
||||||
|
),
|
||||||
|
source =
|
||||||
|
DiskSource(
|
||||||
|
kind = line.kind,
|
||||||
|
subject = line.subject,
|
||||||
|
filesystem = line.filesystem,
|
||||||
|
softLimitGib = softBytes / BYTES_PER_GIB,
|
||||||
|
hardLimitGib = hardBytes / BYTES_PER_GIB,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesFilesystem(
|
||||||
|
quotaFilesystem: String,
|
||||||
|
directoryFilesystem: String,
|
||||||
|
): Boolean =
|
||||||
|
quotaFilesystem == directoryFilesystem ||
|
||||||
|
quotaFilesystem.substringAfterLast('/') == directoryFilesystem.substringAfterLast('/')
|
||||||
|
|
||||||
|
/** The candidate with the smallest headroom; the tightest budget always wins. */
|
||||||
|
fun bindingDiskSpace(candidates: List<DiskCandidate>): DiskCandidate = candidates.minBy { it.space.freeBytes }
|
||||||
|
|
||||||
|
private const val BYTES_PER_KIB = 1024L
|
||||||
|
|
||||||
|
private const val BYTES_PER_GIB = 1_073_741_824.0
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.hoennig.werkator.metrics
|
package de.hoennig.werkator.metrics
|
||||||
|
|
||||||
import de.hoennig.werkator.build.ArtifactStore
|
import de.hoennig.werkator.build.ArtifactStore
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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.time.Clock
|
import java.time.Clock
|
||||||
@@ -16,10 +17,12 @@ class MetricsConfiguration {
|
|||||||
@Bean
|
@Bean
|
||||||
fun systemMetricsCollector(
|
fun systemMetricsCollector(
|
||||||
artifactStore: ArtifactStore,
|
artifactStore: ArtifactStore,
|
||||||
|
registry: RepoRegistry,
|
||||||
clock: Clock,
|
clock: Clock,
|
||||||
): SystemMetricsCollector =
|
): SystemMetricsCollector =
|
||||||
SystemMetricsCollector(
|
SystemMetricsCollector(
|
||||||
stateFile = { artifactStore.rootDir().resolve(SystemMetricsCollector.STATE_FILE_NAME) },
|
stateFile = { artifactStore.rootDir().resolve(SystemMetricsCollector.STATE_FILE_NAME) },
|
||||||
|
repoDirs = { registry.all().map { it.workingDir } },
|
||||||
clock = clock,
|
clock = clock,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ data class SystemMetrics(
|
|||||||
val cpuCount: Int,
|
val cpuCount: Int,
|
||||||
val ramTotalGib: Double?,
|
val ramTotalGib: Double?,
|
||||||
val diskTotalGib: Double?,
|
val diskTotalGib: Double?,
|
||||||
|
/** What `diskTotalGib`/`diskUsedGib`/`diskFreeGib` describe: the volume, or a binding quota. */
|
||||||
|
val diskSource: DiskSource? = null,
|
||||||
|
/** True when a user or group quota existed but the volume was still the tighter budget. */
|
||||||
|
val quotasPresent: Boolean = false,
|
||||||
val cpuUsed: MetricAggregate?,
|
val cpuUsed: MetricAggregate?,
|
||||||
val cpuIdle: MetricAggregate?,
|
val cpuIdle: MetricAggregate?,
|
||||||
val ramUsedGib: MetricAggregate?,
|
val ramUsedGib: MetricAggregate?,
|
||||||
|
|||||||
@@ -19,10 +19,15 @@ import java.util.concurrent.Executors
|
|||||||
import java.util.concurrent.ScheduledExecutorService
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/** The aggregation state persisted in the artifact root (the legacy `system_state.dat`, but as JSON). */
|
/**
|
||||||
|
* The aggregation state persisted in the artifact root (the legacy `system_state.dat`, but as JSON).
|
||||||
|
* [diskSourceKey] defaults to `"volume"` — the only source before quota-awareness existed — so an
|
||||||
|
* older state file upgrades cleanly: it only triggers the disk-series reset when a quota now binds.
|
||||||
|
*/
|
||||||
data class PersistedMetricsState(
|
data class PersistedMetricsState(
|
||||||
val sampleCount: Long = 0,
|
val sampleCount: Long = 0,
|
||||||
val series: Map<String, MetricSeries> = emptyMap(),
|
val series: Map<String, MetricSeries> = emptyMap(),
|
||||||
|
val diskSourceKey: String = "volume",
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,12 +40,15 @@ data class PersistedMetricsState(
|
|||||||
*/
|
*/
|
||||||
class SystemMetricsCollector(
|
class SystemMetricsCollector(
|
||||||
private val stateFile: () -> Path,
|
private val stateFile: () -> Path,
|
||||||
private val workingDir: Path = Paths.get("."),
|
/** The served repositories: the disk metric is the first one's file store, the repository size their sum. */
|
||||||
|
private val repoDirs: () -> List<Path> = { listOf(Paths.get(".")) },
|
||||||
private val clock: Clock = Clock.systemUTC(),
|
private val clock: Clock = Clock.systemUTC(),
|
||||||
private val procStat: Path = Paths.get("/proc/stat"),
|
private val procStat: Path = Paths.get("/proc/stat"),
|
||||||
private val procMeminfo: Path = Paths.get("/proc/meminfo"),
|
private val procMeminfo: Path = Paths.get("/proc/meminfo"),
|
||||||
private val cpuCount: Int = Runtime.getRuntime().availableProcessors(),
|
private val cpuCount: Int = Runtime.getRuntime().availableProcessors(),
|
||||||
private val diskSpace: (Path) -> DiskSpace = { dir -> fileStoreDiskSpace(dir) },
|
private val diskSpace: (Path) -> DiskSpace = { dir -> fileStoreDiskSpace(dir) },
|
||||||
|
private val fileStoreName: (Path) -> String = { dir -> Files.getFileStore(dir).name() },
|
||||||
|
private val quotaOutput: () -> String? = { readQuotaOutput() },
|
||||||
private val repoSizeBytes: (Path) -> Long = { dir -> directorySizeBytes(dir) },
|
private val repoSizeBytes: (Path) -> Long = { dir -> directorySizeBytes(dir) },
|
||||||
) {
|
) {
|
||||||
/** Disk usage in bytes; used/free follow `df` semantics (free is what a user can still allocate). */
|
/** Disk usage in bytes; used/free follow `df` semantics (free is what a user can still allocate). */
|
||||||
@@ -121,6 +129,13 @@ class SystemMetricsCollector(
|
|||||||
val cpu = readCpuLoad()
|
val cpu = readCpuLoad()
|
||||||
val ram = readRam()
|
val ram = readRam()
|
||||||
val disk = readDisk()
|
val disk = readDisk()
|
||||||
|
val diskSourceKey = disk?.let { diskSourceKey(it.source) } ?: previousState.diskSourceKey
|
||||||
|
if (disk != null && diskSourceKey != previousState.diskSourceKey) {
|
||||||
|
// the previous binary/binding source measured a different budget (Scenario#16.05):
|
||||||
|
// continuing its min/max/avg would poison the new series with a stale ceiling
|
||||||
|
series.remove("diskUsedGib")
|
||||||
|
series.remove("diskFreeGib")
|
||||||
|
}
|
||||||
val repoSizeGib = readRepoSizeThrottled()
|
val repoSizeGib = readRepoSizeThrottled()
|
||||||
snapshot =
|
snapshot =
|
||||||
SystemMetrics(
|
SystemMetrics(
|
||||||
@@ -128,19 +143,25 @@ class SystemMetricsCollector(
|
|||||||
sampleCount = sampleCount,
|
sampleCount = sampleCount,
|
||||||
cpuCount = cpuCount,
|
cpuCount = cpuCount,
|
||||||
ramTotalGib = ram?.totalGib,
|
ramTotalGib = ram?.totalGib,
|
||||||
diskTotalGib = disk?.totalBytes?.let { it / BYTES_PER_GIB },
|
diskTotalGib = disk?.space?.totalBytes?.let { it / BYTES_PER_GIB },
|
||||||
|
diskSource = disk?.source,
|
||||||
|
quotasPresent = disk?.quotasPresent ?: false,
|
||||||
cpuUsed = record("cpuUsed", cpu?.usedCores),
|
cpuUsed = record("cpuUsed", cpu?.usedCores),
|
||||||
cpuIdle = record("cpuIdle", cpu?.idleCores),
|
cpuIdle = record("cpuIdle", cpu?.idleCores),
|
||||||
ramUsedGib = record("ramUsedGib", ram?.usedGib),
|
ramUsedGib = record("ramUsedGib", ram?.usedGib),
|
||||||
ramFreeGib = record("ramFreeGib", ram?.freeGib),
|
ramFreeGib = record("ramFreeGib", ram?.freeGib),
|
||||||
diskUsedGib = record("diskUsedGib", disk?.usedBytes?.let { it / BYTES_PER_GIB }),
|
diskUsedGib = record("diskUsedGib", disk?.space?.usedBytes?.let { it / BYTES_PER_GIB }),
|
||||||
diskFreeGib = record("diskFreeGib", disk?.freeBytes?.let { it / BYTES_PER_GIB }),
|
diskFreeGib = record("diskFreeGib", disk?.space?.freeBytes?.let { it / BYTES_PER_GIB }),
|
||||||
repoSizeGib = record("repoSizeGib", repoSizeGib),
|
repoSizeGib = record("repoSizeGib", repoSizeGib),
|
||||||
)
|
)
|
||||||
state = PersistedMetricsState(sampleCount = sampleCount, series = series)
|
state = PersistedMetricsState(sampleCount = sampleCount, series = series, diskSourceKey = diskSourceKey)
|
||||||
saveState(state!!)
|
saveState(state!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `"volume"`, or `"quota:<kind>:<subject>:<filesystem>"` — the reset trigger of Scenario#16.05. */
|
||||||
|
private fun diskSourceKey(source: DiskSource): String =
|
||||||
|
if (source.kind == "volume") "volume" else "quota:${source.kind}:${source.subject}:${source.filesystem}"
|
||||||
|
|
||||||
private fun sampleSafely() {
|
private fun sampleSafely() {
|
||||||
try {
|
try {
|
||||||
sample()
|
sample()
|
||||||
@@ -156,6 +177,8 @@ class SystemMetricsCollector(
|
|||||||
cpuCount = cpuCount,
|
cpuCount = cpuCount,
|
||||||
ramTotalGib = null,
|
ramTotalGib = null,
|
||||||
diskTotalGib = null,
|
diskTotalGib = null,
|
||||||
|
diskSource = null,
|
||||||
|
quotasPresent = false,
|
||||||
cpuUsed = null,
|
cpuUsed = null,
|
||||||
cpuIdle = null,
|
cpuIdle = null,
|
||||||
ramUsedGib = null,
|
ramUsedGib = null,
|
||||||
@@ -239,7 +262,27 @@ class SystemMetricsCollector(
|
|||||||
.removeSuffix(" kB")
|
.removeSuffix(" kB")
|
||||||
.toLong()
|
.toLong()
|
||||||
|
|
||||||
private fun readDisk(): DiskSpace? = readSource("disk") { diskSpace(workingDir) }
|
/** Disk reading plus whether a quota lost against the volume, for the info line (Scenario#16.06). */
|
||||||
|
private data class DiskReading(
|
||||||
|
val space: DiskSpace,
|
||||||
|
val source: DiskSource,
|
||||||
|
val quotasPresent: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tightest of the user quota, the group quota, and the volume itself (Scenario#16.02):
|
||||||
|
* a failing or absent `quota` binary just leaves the volume as the only candidate, logged
|
||||||
|
* once under its own "quota" source, separately from a failing file-store read.
|
||||||
|
*/
|
||||||
|
private fun readDisk(): DiskReading? =
|
||||||
|
readSource("disk") {
|
||||||
|
val dir = repoDirs().first()
|
||||||
|
val volume = DiskCandidate(diskSpace(dir), DiskSource.volume())
|
||||||
|
val quotaLines = readSource("quota") { quotaOutput()?.let(DiskQuota::parse) ?: emptyList() } ?: emptyList()
|
||||||
|
val quotaCandidates = DiskQuota.candidatesFor(quotaLines, fileStoreName(dir))
|
||||||
|
val binding = DiskQuota.bindingDiskSpace(quotaCandidates + volume)
|
||||||
|
DiskReading(space = binding.space, source = binding.source, quotasPresent = quotaCandidates.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The repository size is expensive to determine (a full file walk), so unlike
|
* The repository size is expensive to determine (a full file walk), so unlike
|
||||||
@@ -251,7 +294,7 @@ class SystemMetricsCollector(
|
|||||||
samplesSinceRepoSizeProbe++
|
samplesSinceRepoSizeProbe++
|
||||||
return lastRepoSizeGib
|
return lastRepoSizeGib
|
||||||
}
|
}
|
||||||
lastRepoSizeGib = readSource("repo size") { repoSizeBytes(workingDir) / BYTES_PER_GIB }
|
lastRepoSizeGib = readSource("repo size") { repoDirs().sumOf(repoSizeBytes) / BYTES_PER_GIB }
|
||||||
samplesSinceRepoSizeProbe = 1
|
samplesSinceRepoSizeProbe = 1
|
||||||
return lastRepoSizeGib
|
return lastRepoSizeGib
|
||||||
}
|
}
|
||||||
@@ -324,6 +367,26 @@ class SystemMetricsCollector(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `quota -u -g --no-wrap --raw-grace`: `--no-wrap` keeps long device names on one line,
|
||||||
|
* `--raw-grace` prints the grace columns as numbers, so every filesystem line has the
|
||||||
|
* same nine fields ([DiskQuota] needs no column heuristics). The exit status is not read —
|
||||||
|
* `quota` also uses it to say "over quota" — only the parsed output counts; an absent
|
||||||
|
* binary throws (caught by the caller's `readSource`, logged once), a hung process is
|
||||||
|
* killed after 5s and treated the same way.
|
||||||
|
*/
|
||||||
|
fun readQuotaOutput(): String {
|
||||||
|
val process =
|
||||||
|
ProcessBuilder("quota", "-u", "-g", "--no-wrap", "--raw-grace")
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
if (!process.waitFor(5, TimeUnit.SECONDS)) {
|
||||||
|
process.destroyForcibly()
|
||||||
|
error("quota command timed out")
|
||||||
|
}
|
||||||
|
return process.inputStream.bufferedReader().readText()
|
||||||
|
}
|
||||||
|
|
||||||
/** File-walk replacement for the legacy `du -sk`; unreadable subtrees are skipped, links are not followed. */
|
/** File-walk replacement for the legacy `du -sk`; unreadable subtrees are skipped, links are not followed. */
|
||||||
fun directorySizeBytes(dir: Path): Long {
|
fun directorySizeBytes(dir: Path): Long {
|
||||||
var size = 0L
|
var size = 0L
|
||||||
|
|||||||
@@ -2,15 +2,14 @@ package de.hoennig.werkator.repo
|
|||||||
|
|
||||||
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 RepoConfiguration {
|
class RepoConfiguration {
|
||||||
/**
|
/**
|
||||||
* The single-repository case: the current working directory, which is how every
|
* The repository the unscoped code paths mean — the current working directory
|
||||||
* CLI command and the server resolve their files. Only paths are computed here, so
|
* when it is served, see [RepoRegistry.current]. Without a registry only paths are
|
||||||
* the bean is safe outside a git repository.
|
* computed here, so the bean is safe outside a git repository.
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
fun currentRepo(repoContexts: RepoContexts): RepoContext = repoContexts.open(Paths.get("."))
|
fun currentRepo(registry: RepoRegistry): RepoContext = registry.current()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package de.hoennig.werkator.repo
|
package de.hoennig.werkator.repo
|
||||||
|
|
||||||
|
import de.hoennig.werkator.StateDirMigration
|
||||||
import de.hoennig.werkator.artifacts.FileArtifactStore
|
import de.hoennig.werkator.artifacts.FileArtifactStore
|
||||||
import de.hoennig.werkator.build.FileBuildResultRepository
|
import de.hoennig.werkator.build.FileBuildResultRepository
|
||||||
import de.hoennig.werkator.config.ConfigLoader
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
/** Opens a [RepoContext] over a repository directory; nothing is touched until the first build. */
|
/**
|
||||||
|
* Opens a [RepoContext] over a repository directory. Nothing is created until the first
|
||||||
|
* build; only a pre-rename state directory is moved to its current name on the way, per
|
||||||
|
* repository, before any path under it is resolved.
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
class RepoContexts(
|
class RepoContexts(
|
||||||
private val configLoader: ConfigLoader,
|
private val configLoader: ConfigLoader,
|
||||||
@@ -14,13 +19,15 @@ class RepoContexts(
|
|||||||
fun open(
|
fun open(
|
||||||
workingDir: Path,
|
workingDir: Path,
|
||||||
name: String = defaultName(workingDir),
|
name: String = defaultName(workingDir),
|
||||||
): RepoContext =
|
): RepoContext {
|
||||||
RepoContext(
|
StateDirMigration.migrateIfNeeded(workingDir)
|
||||||
|
return RepoContext(
|
||||||
name = name,
|
name = name,
|
||||||
workingDir = workingDir,
|
workingDir = workingDir,
|
||||||
results = FileBuildResultRepository(workingDir.resolve(RESULTS_FILE)),
|
results = FileBuildResultRepository(workingDir.resolve(RESULTS_FILE)),
|
||||||
artifactStore = FileArtifactStore(configLoader, workingDir),
|
artifactStore = FileArtifactStore(configLoader, workingDir),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Results file relative to the repository, next to the machine config in `.git/werkator/`. */
|
/** Results file relative to the repository, next to the machine config in `.git/werkator/`. */
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.hoennig.werkator.repo
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path prefix a link to a repository carries (ADR 0009). One place knows the
|
||||||
|
* rule, because three do the same thing with it: the pages, the API answers, and
|
||||||
|
* the target URLs posted to Gitea — and a rule spelled out three times is a rule
|
||||||
|
* that drifts.
|
||||||
|
*
|
||||||
|
* It follows the NUMBER of served repositories, not the route a request arrived
|
||||||
|
* through: with one repository an installation keeps the URLs it always had, with
|
||||||
|
* several every link names its repository.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
class RepoLinks(
|
||||||
|
private val registry: RepoRegistry,
|
||||||
|
) {
|
||||||
|
fun base(repo: RepoContext): String = if (registry.all().size > 1) "/repos/${repo.name}" else ""
|
||||||
|
|
||||||
|
fun apiBase(repo: RepoContext): String = if (registry.all().size > 1) "/api/repos/${repo.name}" else "/api"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The absolute artifact-page URL of a build, for links Werkator posts elsewhere
|
||||||
|
* (`server.publicBaseUrl`). Null without a public base URL: a relative link in a
|
||||||
|
* Gitea status is worse than none — it would resolve against the forge.
|
||||||
|
*/
|
||||||
|
fun buildUrl(
|
||||||
|
repo: RepoContext,
|
||||||
|
publicBaseUrl: String,
|
||||||
|
artifactKey: String,
|
||||||
|
): String? {
|
||||||
|
val root = publicBaseUrl.trim().trimEnd('/')
|
||||||
|
if (root.isEmpty()) return null
|
||||||
|
return "$root${base(repo)}/builds/$artifactKey"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package de.hoennig.werkator.repo
|
||||||
|
|
||||||
|
import de.hoennig.werkator.config.ConfigException
|
||||||
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
|
import de.hoennig.werkator.config.RepositoryEntry
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The repositories this instance serves (ADR 0009): one [RepoContext] per entry of the
|
||||||
|
* home configuration's `repositories`, or — without a home config or with an empty
|
||||||
|
* registry — the current working directory, exactly as before.
|
||||||
|
*
|
||||||
|
* Opened once, on first use, and loudly: an entry that is no git repository or a name
|
||||||
|
* used twice aborts the start with a message naming the home file, because an instance
|
||||||
|
* serving the wrong set is worse than one that does not come up. A repository whose
|
||||||
|
* configuration Werkator must not read (version or format violation) is the exception:
|
||||||
|
* it is skipped with an error, like a branch config violation fails only that branch —
|
||||||
|
* the other repositories keep building.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
class RepoRegistry(
|
||||||
|
private val configLoader: ConfigLoader,
|
||||||
|
private val repoContexts: RepoContexts,
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(RepoRegistry::class.java)
|
||||||
|
|
||||||
|
private val contexts: List<RepoContext> by lazy { open() }
|
||||||
|
|
||||||
|
/** Every served repository, in registry order. */
|
||||||
|
fun all(): List<RepoContext> = contexts
|
||||||
|
|
||||||
|
/** The repository registered under [name], or null. */
|
||||||
|
fun byName(name: String): RepoContext? = contexts.firstOrNull { it.name == name }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The repository a command without a selector means: the current working directory
|
||||||
|
* when it is served (so `werkator status` inside a repository behaves as today),
|
||||||
|
* otherwise the first registered one.
|
||||||
|
*/
|
||||||
|
fun current(): RepoContext {
|
||||||
|
val cwd = Paths.get(".").toAbsolutePath().normalize()
|
||||||
|
return contexts.firstOrNull { it.workingDir.toAbsolutePath().normalize() == cwd } ?: contexts.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun open(): List<RepoContext> {
|
||||||
|
val entries = configLoader.loadInstance()?.repositories.orEmpty()
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
return listOf(repoContexts.open(Paths.get(".")))
|
||||||
|
}
|
||||||
|
val home = configLoader.instanceFile()
|
||||||
|
val opened = entries.mapNotNull { openEntry(it, home) }
|
||||||
|
val duplicates = opened.groupBy { it.name }.filterValues { it.size > 1 }
|
||||||
|
if (duplicates.isNotEmpty()) {
|
||||||
|
val listed =
|
||||||
|
duplicates.entries.joinToString(
|
||||||
|
"; ",
|
||||||
|
) { (name, repos) -> "$name: ${repos.joinToString(", ") { it.workingDir.toString() }}" }
|
||||||
|
throw IllegalStateException("$home registers the same repository name more than once ($listed); set a distinct name per entry")
|
||||||
|
}
|
||||||
|
check(opened.isNotEmpty()) { "$home registers no readable repository" }
|
||||||
|
return opened
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openEntry(
|
||||||
|
entry: RepositoryEntry,
|
||||||
|
home: Path,
|
||||||
|
): RepoContext? {
|
||||||
|
val dir = resolve(entry.path)
|
||||||
|
if (!Files.isDirectory(dir) || !Files.exists(dir.resolve(".git"))) {
|
||||||
|
throw IllegalStateException(
|
||||||
|
"$home registers ${entry.path.ifBlank { "an entry without a path" }}, which is not a git repository ($dir)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val name = entry.name.trim().ifEmpty { RepoContexts.defaultName(dir) }
|
||||||
|
try {
|
||||||
|
// the configuration is read here only to find out whether Werkator may read it at all
|
||||||
|
configLoader.load(dir)
|
||||||
|
} catch (e: ConfigException) {
|
||||||
|
log.error("not serving repository {} ({}): {}", name, dir, e.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return repoContexts.open(dir, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `~` expands to the home directory; a relative path is relative to the home directory, not the cwd. */
|
||||||
|
private fun resolve(path: String): Path {
|
||||||
|
val home = configLoader.homeDir
|
||||||
|
val expanded =
|
||||||
|
when {
|
||||||
|
path == "~" -> home
|
||||||
|
path.startsWith("~/") -> home.resolve(path.removePrefix("~/"))
|
||||||
|
else -> home.resolve(path)
|
||||||
|
}
|
||||||
|
return expanded.toAbsolutePath().normalize()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ data class BuildResultDto(
|
|||||||
fun from(
|
fun from(
|
||||||
result: BuildResult,
|
result: BuildResult,
|
||||||
isLatestGreen: Boolean = false,
|
isLatestGreen: Boolean = false,
|
||||||
|
base: String = "",
|
||||||
) = BuildResultDto(
|
) = BuildResultDto(
|
||||||
branch = result.branch,
|
branch = result.branch,
|
||||||
name = result.name,
|
name = result.name,
|
||||||
@@ -36,7 +37,7 @@ data class BuildResultDto(
|
|||||||
runningSince = result.runningSince,
|
runningSince = result.runningSince,
|
||||||
durationSeconds = result.duration?.seconds,
|
durationSeconds = result.duration?.seconds,
|
||||||
artifactKey = result.artifactKey,
|
artifactKey = result.artifactKey,
|
||||||
latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.name) else null,
|
latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.name, base) else null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.hoennig.werkator.server
|
package de.hoennig.werkator.server
|
||||||
|
|
||||||
import de.hoennig.werkator.build.ArtifactStore
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import org.springframework.core.io.FileSystemResource
|
import org.springframework.core.io.FileSystemResource
|
||||||
import org.springframework.core.io.Resource
|
import org.springframework.core.io.Resource
|
||||||
@@ -25,17 +26,22 @@ import kotlin.streams.asSequence
|
|||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
class ArtifactFileController(
|
class ArtifactFileController(
|
||||||
private val artifactStore: ArtifactStore,
|
|
||||||
private val branchPermalinks: BranchPermalinks,
|
private val branchPermalinks: BranchPermalinks,
|
||||||
|
private val registry: RepoRegistry,
|
||||||
) {
|
) {
|
||||||
@GetMapping("/artifacts/{artifactKey}/{*path}")
|
/** Scoped and unscoped, like every other route (ADR 0009); unscoped means the served repository. */
|
||||||
|
private fun repoOf(name: String?): RepoContext =
|
||||||
|
if (name == null) registry.current() else registry.byName(name) ?: throw UnknownRepositoryException(name)
|
||||||
|
|
||||||
|
@GetMapping("/artifacts/{artifactKey}/{*path}", "/repos/{repo}/artifacts/{artifactKey}/{*path}")
|
||||||
fun serve(
|
fun serve(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable artifactKey: String,
|
@PathVariable artifactKey: String,
|
||||||
@PathVariable path: String,
|
@PathVariable path: String,
|
||||||
request: HttpServletRequest,
|
request: HttpServletRequest,
|
||||||
): ResponseEntity<Resource> {
|
): ResponseEntity<Resource> {
|
||||||
val artifactDir =
|
val artifactDir =
|
||||||
artifactStore.artifactDir(artifactKey)
|
repoOf(repoName).artifactStore.artifactDir(artifactKey)
|
||||||
?: return ResponseEntity.notFound().build()
|
?: return ResponseEntity.notFound().build()
|
||||||
val relativePath = path.removePrefix("/").removeSuffix("/")
|
val relativePath = path.removePrefix("/").removeSuffix("/")
|
||||||
directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it }
|
directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it }
|
||||||
@@ -52,15 +58,17 @@ class ArtifactFileController(
|
|||||||
* slash, so relative links inside reports resolve correctly), and everything is
|
* slash, so relative links inside reports resolve correctly), and everything is
|
||||||
* `no-store` because the content behind a URL changes with every new green build.
|
* `no-store` because the content behind a URL changes with every new green build.
|
||||||
*/
|
*/
|
||||||
@GetMapping("/branches/{branchKey}/{*path}")
|
@GetMapping("/branches/{branchKey}/{*path}", "/repos/{repo}/branches/{branchKey}/{*path}")
|
||||||
fun serveLatestGreen(
|
fun serveLatestGreen(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable branchKey: String,
|
@PathVariable branchKey: String,
|
||||||
@PathVariable path: String,
|
@PathVariable path: String,
|
||||||
request: HttpServletRequest,
|
request: HttpServletRequest,
|
||||||
): ResponseEntity<Resource> {
|
): ResponseEntity<Resource> {
|
||||||
val build = branchPermalinks.latestGreenBuild(branchKey)
|
val repo = repoOf(repoName)
|
||||||
|
val build = branchPermalinks.latestGreenBuild(repo, branchKey)
|
||||||
val artifactDir =
|
val artifactDir =
|
||||||
artifactStore.artifactDir(build.artifactKey)
|
repo.artifactStore.artifactDir(build.artifactKey)
|
||||||
?: throw ResponseStatusException(
|
?: throw ResponseStatusException(
|
||||||
HttpStatus.NOT_FOUND,
|
HttpStatus.NOT_FOUND,
|
||||||
"the artifacts of build '${build.artifactKey}' are not stored anymore",
|
"the artifacts of build '${build.artifactKey}' are not stored anymore",
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import org.springframework.stereotype.Component
|
|||||||
class BranchListing(
|
class BranchListing(
|
||||||
private val gitService: GitService,
|
private val gitService: GitService,
|
||||||
) {
|
) {
|
||||||
fun branches(repo: RepoContext): List<BranchDto> {
|
fun branches(
|
||||||
|
repo: RepoContext,
|
||||||
|
base: String = "",
|
||||||
|
): List<BranchDto> {
|
||||||
val repository = repo.results
|
val repository = repo.results
|
||||||
val heads = gitService.originBranchHeads(repo.workingDir)
|
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 }
|
||||||
@@ -45,7 +48,7 @@ class BranchListing(
|
|||||||
// the permanent link belongs to the build it resolves to, not to every build of the name
|
// the permanent link belongs to the build it resolves to, not to every build of the name
|
||||||
val isLatestGreen =
|
val isLatestGreen =
|
||||||
row.artifactKey.isNotEmpty() && row.artifactKey == repository.latestGreenFor(row.name)?.artifactKey
|
row.artifactKey.isNotEmpty() && row.artifactKey == repository.latestGreenFor(row.name)?.artifactKey
|
||||||
if (isLatestGreen) row.copy(latestGreenUrl = BranchPermalinks.permanentUrl(row.name)) else row
|
if (isLatestGreen) row.copy(latestGreenUrl = BranchPermalinks.permanentUrl(row.name, base)) else row
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package de.hoennig.werkator.server
|
|||||||
|
|
||||||
import de.hoennig.werkator.build.ArtifactKeys
|
import de.hoennig.werkator.build.ArtifactKeys
|
||||||
import de.hoennig.werkator.build.BuildResult
|
import de.hoennig.werkator.build.BuildResult
|
||||||
import de.hoennig.werkator.build.BuildResultRepository
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
@@ -18,10 +18,12 @@ import org.springframework.web.server.ResponseStatusException
|
|||||||
* artifacts.
|
* artifacts.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
class BranchPermalinks(
|
class BranchPermalinks {
|
||||||
private val repository: BuildResultRepository,
|
fun latestGreenBuild(
|
||||||
) {
|
repo: RepoContext,
|
||||||
fun latestGreenBuild(branchKey: String): BuildResult {
|
branchKey: String,
|
||||||
|
): BuildResult {
|
||||||
|
val repository = repo.results
|
||||||
val names =
|
val names =
|
||||||
repository
|
repository
|
||||||
.latestPerName()
|
.latestPerName()
|
||||||
@@ -41,7 +43,16 @@ class BranchPermalinks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The permanent artifact-index URL of the build name (branch or named slot), shown in the branches view. */
|
/**
|
||||||
fun permanentUrl(name: String): String = "/branches/${ArtifactKeys.permanentBranchKey(name)}"
|
* The permanent artifact-index URL of the build name (branch or named slot), shown
|
||||||
|
* in the branches view. [base] is the repository prefix (`/repos/<name>`, empty with
|
||||||
|
* one served repository): the key is a hash of the name alone, so two repositories
|
||||||
|
* both having `main` would otherwise share one permanent URL — and it would resolve
|
||||||
|
* against whichever repository the instance happens to serve unscoped.
|
||||||
|
*/
|
||||||
|
fun permanentUrl(
|
||||||
|
name: String,
|
||||||
|
base: String = "",
|
||||||
|
): String = "$base/branches/${ArtifactKeys.permanentBranchKey(name)}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
package de.hoennig.werkator.server
|
package de.hoennig.werkator.server
|
||||||
|
|
||||||
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.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 de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
@@ -32,34 +33,72 @@ import java.nio.file.StandardOpenOption
|
|||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
class BuildsApiController(
|
class BuildsApiController(
|
||||||
private val repository: BuildResultRepository,
|
|
||||||
private val buildExecutor: BuildExecutor,
|
private val buildExecutor: BuildExecutor,
|
||||||
private val artifactStore: ArtifactStore,
|
|
||||||
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,
|
private val registry: RepoRegistry,
|
||||||
|
private val repoLinks: RepoLinks,
|
||||||
) {
|
) {
|
||||||
private val workingDir: Path
|
/**
|
||||||
get() = repo.workingDir
|
* Every route exists twice: repository-scoped (`/api/repos/<name>/…`) and unscoped.
|
||||||
|
* The unscoped form means the served repository ([RepoRegistry.current]) and stays
|
||||||
|
* for good — bookmarks, the legacy UI, and the links already posted to Gitea were
|
||||||
|
* written without a repository segment, and a CI that breaks its own old links is
|
||||||
|
* a CI nobody trusts.
|
||||||
|
*/
|
||||||
|
private fun repoOf(name: String?): RepoContext =
|
||||||
|
if (name == null) registry.current() else registry.byName(name) ?: throw UnknownRepositoryException(name)
|
||||||
|
|
||||||
@GetMapping("/api/builds/latest")
|
private fun RepoContext.isLatestGreen(result: BuildResult): Boolean =
|
||||||
fun latest(): List<BuildResultDto> = repository.latestPerName().map { BuildResultDto.from(it, it.isLatestGreen()) }
|
results.latestGreenFor(result.name)?.artifactKey == result.artifactKey
|
||||||
|
|
||||||
|
/** The prefix the permanent links in the answers carry; empty with one served repository. */
|
||||||
|
private fun uiBase(repo: RepoContext): String = repoLinks.base(repo)
|
||||||
|
|
||||||
|
/** An unknown repository name answers like every other miss of this API: 404 with `error`. */
|
||||||
|
@ExceptionHandler(UnknownRepositoryException::class)
|
||||||
|
fun unknownRepository(e: UnknownRepositoryException): ResponseEntity<Any> = notFound(e.message ?: "unknown repository")
|
||||||
|
|
||||||
|
@GetMapping("/api/builds/latest", "/api/repos/{repo}/builds/latest")
|
||||||
|
fun latest(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
|
): List<BuildResultDto> {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
return repo.results.latestPerName().map { BuildResultDto.from(it, repo.isLatestGreen(it), uiBase(repo)) }
|
||||||
|
}
|
||||||
|
|
||||||
/** 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", "/api/repos/{repo}/branches")
|
||||||
fun branches(): List<BranchDto> = branchListing.branches(repo)
|
fun branches(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
|
): List<BranchDto> {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
return branchListing.branches(repo, uiBase(repo))
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/api/builds/history")
|
@GetMapping("/api/builds/history", "/api/repos/{repo}/builds/history")
|
||||||
fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it, it.isLatestGreen()) }
|
fun history(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
|
): List<BuildResultDto> {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
return repo.results.history().map { BuildResultDto.from(it, repo.isLatestGreen(it), uiBase(repo)) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun BuildResult.isLatestGreen(): Boolean = repository.latestGreenFor(name)?.artifactKey == artifactKey
|
/**
|
||||||
|
* The currently executing builds of the served repository — several are possible,
|
||||||
/** The currently executing builds — several are possible, up to `executor.maxConcurrent`. */
|
* up to `executor.maxConcurrent`. The executor is instance-global and returns the
|
||||||
@GetMapping("/api/builds/current")
|
* builds of every registered repository, so this view filters: its [repository]
|
||||||
fun current(): List<CurrentBuildDto> {
|
* holds only this repository's results, and a foreign build looked up in them
|
||||||
val results = repository.history()
|
* would fall back to RUNNING and show a status nobody recorded.
|
||||||
return buildExecutor.currentBuilds().map { build ->
|
*/
|
||||||
|
@GetMapping("/api/builds/current", "/api/repos/{repo}/builds/current")
|
||||||
|
fun current(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
|
): List<CurrentBuildDto> {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
val results = repo.results.history()
|
||||||
|
return buildExecutor.currentBuilds().filter { it.repo === repo }.map { build ->
|
||||||
CurrentBuildDto(
|
CurrentBuildDto(
|
||||||
branch = build.branch,
|
branch = build.branch,
|
||||||
name = build.name,
|
name = build.name,
|
||||||
@@ -76,13 +115,15 @@ class BuildsApiController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Incremental live-log fetch of one running build; poll again with `offset = nextOffset`. */
|
/** Incremental live-log fetch of one running build; poll again with `offset = nextOffset`. */
|
||||||
@GetMapping("/api/builds/current/{artifactKey}/log")
|
@GetMapping("/api/builds/current/{artifactKey}/log", "/api/repos/{repo}/builds/current/{artifactKey}/log")
|
||||||
fun currentLog(
|
fun currentLog(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable artifactKey: String,
|
@PathVariable artifactKey: String,
|
||||||
@RequestParam(defaultValue = "0") offset: Long,
|
@RequestParam(defaultValue = "0") offset: Long,
|
||||||
): ResponseEntity<Any> {
|
): ResponseEntity<Any> {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
val build =
|
val build =
|
||||||
buildExecutor.currentBuilds().firstOrNull { it.artifactKey == artifactKey }
|
buildExecutor.currentBuilds().firstOrNull { it.repo === repo && it.artifactKey == artifactKey }
|
||||||
?: return notFound("no running build with artifact key '$artifactKey'")
|
?: return notFound("no running build with artifact key '$artifactKey'")
|
||||||
return ResponseEntity.ok(readLogTail(artifactKey, build.liveLogFile, offset))
|
return ResponseEntity.ok(readLogTail(artifactKey, build.liveLogFile, offset))
|
||||||
}
|
}
|
||||||
@@ -102,14 +143,17 @@ class BuildsApiController(
|
|||||||
* The name is a parameter, not a path variable, because branch names may contain
|
* The name is a parameter, not a path variable, because branch names may contain
|
||||||
* slashes (Tomcat rejects encoded slashes in the path by default).
|
* slashes (Tomcat rejects encoded slashes in the path by default).
|
||||||
*/
|
*/
|
||||||
@PostMapping("/api/builds/restart")
|
@PostMapping("/api/builds/restart", "/api/repos/{repo}/builds/restart")
|
||||||
fun restart(
|
fun restart(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@RequestParam branch: String,
|
@RequestParam branch: String,
|
||||||
@RequestParam(defaultValue = "false") atOriginHead: Boolean,
|
@RequestParam(defaultValue = "false") atOriginHead: Boolean,
|
||||||
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
||||||
): ResponseEntity<Any> {
|
): ResponseEntity<Any> {
|
||||||
rejectBadToken(headerToken)?.let { return it }
|
rejectBadToken(headerToken)?.let { return it }
|
||||||
val latest = repository.latestFor(branch)
|
val repo = repoOf(repoName)
|
||||||
|
val workingDir = repo.workingDir
|
||||||
|
val latest = repo.results.latestFor(branch)
|
||||||
// the name may be a pool like `main@pitest`; the branch to build is the recorded one
|
// the name may be a pool like `main@pitest`; the branch to build is the recorded one
|
||||||
val branchName = latest?.branch ?: branch
|
val branchName = latest?.branch ?: branch
|
||||||
val commit =
|
val commit =
|
||||||
@@ -143,12 +187,20 @@ class BuildsApiController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Cancels by artifact key because multiple builds can run concurrently. */
|
/** Cancels by artifact key because multiple builds can run concurrently. */
|
||||||
@PostMapping("/api/builds/{artifactKey}/cancel")
|
@PostMapping("/api/builds/{artifactKey}/cancel", "/api/repos/{repo}/builds/{artifactKey}/cancel")
|
||||||
fun cancel(
|
fun cancel(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable artifactKey: String,
|
@PathVariable artifactKey: String,
|
||||||
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
||||||
): ResponseEntity<Any> {
|
): ResponseEntity<Any> {
|
||||||
rejectBadToken(headerToken)?.let { return it }
|
rejectBadToken(headerToken)?.let { return it }
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
// the executor cancels by key across all repositories; a route that names a
|
||||||
|
// repository must not reach into another one, and a queued or running build
|
||||||
|
// always has its PENDING/RUNNING result recorded in its own repository
|
||||||
|
if (repo.results.history().none { it.artifactKey == artifactKey }) {
|
||||||
|
return notFound("no queued or running build with artifact key '$artifactKey'")
|
||||||
|
}
|
||||||
if (!buildExecutor.cancel(artifactKey)) {
|
if (!buildExecutor.cancel(artifactKey)) {
|
||||||
return notFound("no queued or running build with artifact key '$artifactKey'")
|
return notFound("no queued or running build with artifact key '$artifactKey'")
|
||||||
}
|
}
|
||||||
@@ -156,16 +208,18 @@ class BuildsApiController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Removes the stored result and its artifact directory, like the legacy `/control/delete`. */
|
/** Removes the stored result and its artifact directory, like the legacy `/control/delete`. */
|
||||||
@DeleteMapping("/api/builds/{artifactKey}")
|
@DeleteMapping("/api/builds/{artifactKey}", "/api/repos/{repo}/builds/{artifactKey}")
|
||||||
fun delete(
|
fun delete(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable artifactKey: String,
|
@PathVariable artifactKey: String,
|
||||||
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
||||||
): ResponseEntity<Any> {
|
): ResponseEntity<Any> {
|
||||||
rejectBadToken(headerToken)?.let { return it }
|
rejectBadToken(headerToken)?.let { return it }
|
||||||
if (!repository.delete(artifactKey)) {
|
val repo = repoOf(repoName)
|
||||||
|
if (!repo.results.delete(artifactKey)) {
|
||||||
return notFound("no build with artifact key '$artifactKey'")
|
return notFound("no build with artifact key '$artifactKey'")
|
||||||
}
|
}
|
||||||
artifactStore.prune(repository.history())
|
repo.artifactStore.prune(repo.results.history())
|
||||||
return ResponseEntity.ok(mapOf("deleted" to artifactKey))
|
return ResponseEntity.ok(mapOf("deleted" to artifactKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.hoennig.werkator.server
|
package de.hoennig.werkator.server
|
||||||
|
|
||||||
import de.hoennig.werkator.repo.RepoContext
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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
|
||||||
@@ -9,7 +9,7 @@ import org.springframework.context.event.EventListener
|
|||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the watcher poll loop over the served repository once the server context
|
* Starts the watcher poll loop over the served repositories once the server context
|
||||||
* is ready and stops it on shutdown. Only in the `server` profile — CLI commands
|
* is ready and stops it on shutdown. Only in the `server` profile — CLI commands
|
||||||
* and tests never start the loop (see [Watcher]).
|
* and tests never start the loop (see [Watcher]).
|
||||||
*/
|
*/
|
||||||
@@ -17,11 +17,11 @@ import org.springframework.stereotype.Component
|
|||||||
@Profile("server")
|
@Profile("server")
|
||||||
class ServerWatcherLifecycle(
|
class ServerWatcherLifecycle(
|
||||||
private val watcher: Watcher,
|
private val watcher: Watcher,
|
||||||
private val repo: RepoContext,
|
private val registry: RepoRegistry,
|
||||||
) {
|
) {
|
||||||
@EventListener(ApplicationReadyEvent::class)
|
@EventListener(ApplicationReadyEvent::class)
|
||||||
fun onApplicationReady() {
|
fun onApplicationReady() {
|
||||||
watcher.start(repo)
|
watcher.start(registry.all())
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
package de.hoennig.werkator.server
|
package de.hoennig.werkator.server
|
||||||
|
|
||||||
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.config.ConfigFiles
|
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 de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.stereotype.Controller
|
import org.springframework.stereotype.Controller
|
||||||
import org.springframework.ui.Model
|
import org.springframework.ui.Model
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
@@ -34,19 +36,32 @@ import kotlin.streams.asSequence
|
|||||||
*/
|
*/
|
||||||
@Controller
|
@Controller
|
||||||
class UiController(
|
class UiController(
|
||||||
private val repository: BuildResultRepository,
|
|
||||||
private val buildExecutor: BuildExecutor,
|
private val buildExecutor: BuildExecutor,
|
||||||
private val artifactStore: ArtifactStore,
|
|
||||||
private val configLoader: ConfigLoader,
|
private val configLoader: ConfigLoader,
|
||||||
private val gitService: GitService,
|
private val gitService: GitService,
|
||||||
private val metricsCollector: SystemMetricsCollector,
|
private val metricsCollector: SystemMetricsCollector,
|
||||||
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,
|
private val registry: RepoRegistry,
|
||||||
|
private val repoLinks: RepoLinks,
|
||||||
) {
|
) {
|
||||||
private val workingDir: Path
|
/**
|
||||||
get() = repo.workingDir
|
* Every page exists twice, like the API (ADR 0009): repository-scoped under
|
||||||
|
* `/repos/<name>/…` and unscoped, which means the served repository. Pages stay
|
||||||
|
* per repository instead of merging every repository's rows into one table with a
|
||||||
|
* repository column: a row's actions (restart, cancel, delete) need the repository
|
||||||
|
* anyway, branches come from one origin and artifacts from one store — and with
|
||||||
|
* the one repository that most installations have, such a column is pure noise.
|
||||||
|
* What makes the instance one UI is the repository switcher in the navigation.
|
||||||
|
*/
|
||||||
|
private fun repoOf(name: String?): RepoContext =
|
||||||
|
if (name == null) registry.current() else registry.byName(name) ?: throw UnknownRepositoryException(name)
|
||||||
|
|
||||||
|
/** An unknown repository name is a 404 page, not a server error. */
|
||||||
|
@ExceptionHandler(UnknownRepositoryException::class)
|
||||||
|
fun unknownRepository(e: UnknownRepositoryException): ResponseEntity<String> =
|
||||||
|
ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.message)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permanent redirects for the legacy script's static page names, so bookmarks
|
* Permanent redirects for the legacy script's static page names, so bookmarks
|
||||||
@@ -58,11 +73,15 @@ class UiController(
|
|||||||
setStatusCode(HttpStatus.MOVED_PERMANENTLY)
|
setStatusCode(HttpStatus.MOVED_PERMANENTLY)
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/")
|
@GetMapping("/", "/repos/{repo}")
|
||||||
fun latest(model: Model): String {
|
fun latest(
|
||||||
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds")
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
model.addAttribute("rows", repository.latestPerName().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
|
model: Model,
|
||||||
model.addAttribute("apiPath", "/api/builds/latest")
|
): String {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds", repo = repo)
|
||||||
|
model.addAttribute("rows", repo.results.latestPerName().map { BuildRowView.from(it, links, permanentUrlOf(repo, it)) })
|
||||||
|
model.addAttribute("apiPath", apiBase(repo) + "/builds/latest")
|
||||||
model.addAttribute("allowRestart", true)
|
model.addAttribute("allowRestart", true)
|
||||||
model.addAttribute("restartAtOriginHead", false)
|
model.addAttribute("restartAtOriginHead", false)
|
||||||
model.addAttribute("emptyMessage", "No builds recorded yet.")
|
model.addAttribute("emptyMessage", "No builds recorded yet.")
|
||||||
@@ -70,11 +89,15 @@ class UiController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The legacy branches view: every origin branch with its latest build or an `unknown` row. */
|
/** The legacy branches view: every origin branch with its latest build or an `unknown` row. */
|
||||||
@GetMapping("/branches")
|
@GetMapping("/branches", "/repos/{repo}/branches")
|
||||||
fun branches(model: Model): String {
|
fun branches(
|
||||||
val links = baseModel(model, view = "branches", pageTitle = "Branches")
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
model.addAttribute("rows", branchListing.branches(repo).map { BuildRowView.from(it, links) })
|
model: Model,
|
||||||
model.addAttribute("apiPath", "/api/branches")
|
): String {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
val links = baseModel(model, view = "branches", pageTitle = "Branches", repo = repo)
|
||||||
|
model.addAttribute("rows", branchListing.branches(repo, uiBase(repo)).map { BuildRowView.from(it, links) })
|
||||||
|
model.addAttribute("apiPath", apiBase(repo) + "/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
|
||||||
model.addAttribute("restartAtOriginHead", true)
|
model.addAttribute("restartAtOriginHead", true)
|
||||||
@@ -82,11 +105,15 @@ class UiController(
|
|||||||
return "builds"
|
return "builds"
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/history")
|
@GetMapping("/history", "/repos/{repo}/history")
|
||||||
fun history(model: Model): String {
|
fun history(
|
||||||
val links = baseModel(model, view = "history", pageTitle = "Build History")
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
model.addAttribute("rows", repository.history().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
|
model: Model,
|
||||||
model.addAttribute("apiPath", "/api/builds/history")
|
): String {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
val links = baseModel(model, view = "history", pageTitle = "Build History", repo = repo)
|
||||||
|
model.addAttribute("rows", repo.results.history().map { BuildRowView.from(it, links, permanentUrlOf(repo, it)) })
|
||||||
|
model.addAttribute("apiPath", apiBase(repo) + "/builds/history")
|
||||||
model.addAttribute("allowRestart", false)
|
model.addAttribute("allowRestart", false)
|
||||||
model.addAttribute("restartAtOriginHead", false)
|
model.addAttribute("restartAtOriginHead", false)
|
||||||
model.addAttribute("emptyMessage", "No builds archived yet.")
|
model.addAttribute("emptyMessage", "No builds archived yet.")
|
||||||
@@ -94,19 +121,26 @@ class UiController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The permanent branch URL belongs to the build it resolves to — the name's latest green build. */
|
/** The permanent branch URL belongs to the build it resolves to — the name's latest green build. */
|
||||||
private fun permanentUrlOf(result: BuildResult): String? =
|
private fun permanentUrlOf(
|
||||||
if (repository.latestGreenFor(result.name)?.artifactKey == result.artifactKey) {
|
repo: RepoContext,
|
||||||
BranchPermalinks.permanentUrl(result.name)
|
result: BuildResult,
|
||||||
|
): String? =
|
||||||
|
if (repo.results.latestGreenFor(result.name)?.artifactKey == result.artifactKey) {
|
||||||
|
BranchPermalinks.permanentUrl(result.name, uiBase(repo))
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/current")
|
@GetMapping("/current", "/repos/{repo}/current")
|
||||||
fun current(model: Model): String {
|
fun current(
|
||||||
val links = baseModel(model, view = "current", pageTitle = "Current Builds")
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
val results = repository.history()
|
model: Model,
|
||||||
|
): String {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
val links = baseModel(model, view = "current", pageTitle = "Current Builds", repo = repo)
|
||||||
|
val results = repo.results.history()
|
||||||
val currentBuilds =
|
val currentBuilds =
|
||||||
buildExecutor.currentBuilds().map { build ->
|
buildExecutor.currentBuilds().filter { it.repo === repo }.map { build ->
|
||||||
CurrentBuildView(
|
CurrentBuildView(
|
||||||
branch = build.branch,
|
branch = build.branch,
|
||||||
name = build.name,
|
name = build.name,
|
||||||
@@ -130,35 +164,39 @@ class UiController(
|
|||||||
/** Hand-maintained release notes (templates/releases.html); linked from the version in the footer. */
|
/** Hand-maintained release notes (templates/releases.html); linked from the version in the footer. */
|
||||||
@GetMapping("/releases")
|
@GetMapping("/releases")
|
||||||
fun releases(model: Model): String {
|
fun releases(model: Model): String {
|
||||||
baseModel(model, view = "releases", pageTitle = "Release Notes")
|
baseModel(model, view = "releases", pageTitle = "Release Notes", repo = registry.current())
|
||||||
return "releases"
|
return "releases"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One metrics page for the whole instance — the resources are the instance's, not a repository's. */
|
||||||
@GetMapping("/system")
|
@GetMapping("/system")
|
||||||
fun system(model: Model): String {
|
fun system(model: Model): String {
|
||||||
baseModel(model, view = "system", pageTitle = "System Metrics")
|
baseModel(model, view = "system", pageTitle = "System Metrics", repo = registry.current())
|
||||||
model.addAttribute("metrics", SystemMetricsView.from(metricsCollector.snapshot()))
|
model.addAttribute("metrics", SystemMetricsView.from(metricsCollector.snapshot()))
|
||||||
return "system"
|
return "system"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Artifact index rendered from the artifact store — legacy pre-generated this page as static HTML. */
|
/** Artifact index rendered from the artifact store — legacy pre-generated this page as static HTML. */
|
||||||
@GetMapping("/builds/{artifactKey}")
|
@GetMapping("/builds/{artifactKey}", "/repos/{repo}/builds/{artifactKey}")
|
||||||
fun artifactIndex(
|
fun artifactIndex(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable artifactKey: String,
|
@PathVariable artifactKey: String,
|
||||||
model: Model,
|
model: Model,
|
||||||
): String {
|
): String {
|
||||||
val result = repository.history().firstOrNull { it.artifactKey == artifactKey }
|
val repo = repoOf(repoName)
|
||||||
val artifactDir = artifactStore.artifactDir(artifactKey)
|
val result = repo.results.history().firstOrNull { it.artifactKey == artifactKey }
|
||||||
|
val artifactDir = repo.artifactStore.artifactDir(artifactKey)
|
||||||
if (result == null && artifactDir == null) {
|
if (result == null && artifactDir == null) {
|
||||||
throw ResponseStatusException(HttpStatus.NOT_FOUND, "no build with artifact key '$artifactKey'")
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "no build with artifact key '$artifactKey'")
|
||||||
}
|
}
|
||||||
return artifactIndexView(
|
return artifactIndexView(
|
||||||
model,
|
model,
|
||||||
pageTitle = "Build Artifacts",
|
pageTitle = "Build Artifacts",
|
||||||
|
repo = repo,
|
||||||
result = result,
|
result = result,
|
||||||
artifactKey = artifactKey,
|
artifactKey = artifactKey,
|
||||||
artifactDir = artifactDir,
|
artifactDir = artifactDir,
|
||||||
filesBase = "/artifacts/$artifactKey",
|
filesBase = uiBase(repo) + "/artifacts/$artifactKey",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,38 +205,42 @@ class UiController(
|
|||||||
* stay on the permanent `/branches/…` paths, so every link copied from this page
|
* stay on the permanent `/branches/…` paths, so every link copied from this page
|
||||||
* outlives artifact pruning.
|
* outlives artifact pruning.
|
||||||
*/
|
*/
|
||||||
@GetMapping("/branches/{branchKey}")
|
@GetMapping("/branches/{branchKey}", "/repos/{repo}/branches/{branchKey}")
|
||||||
fun latestGreenArtifactIndex(
|
fun latestGreenArtifactIndex(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable branchKey: String,
|
@PathVariable branchKey: String,
|
||||||
model: Model,
|
model: Model,
|
||||||
): String {
|
): String {
|
||||||
val build = branchPermalinks.latestGreenBuild(branchKey)
|
val repo = repoOf(repoName)
|
||||||
|
val build = branchPermalinks.latestGreenBuild(repo, branchKey)
|
||||||
model.addAttribute("permanentBranch", build.branch)
|
model.addAttribute("permanentBranch", build.branch)
|
||||||
model.addAttribute("concreteUrl", "/builds/${build.artifactKey}")
|
model.addAttribute("concreteUrl", uiBase(repo) + "/builds/${build.artifactKey}")
|
||||||
return artifactIndexView(
|
return artifactIndexView(
|
||||||
model,
|
model,
|
||||||
pageTitle = "Latest Green Build",
|
pageTitle = "Latest Green Build",
|
||||||
|
repo = repo,
|
||||||
result = build,
|
result = build,
|
||||||
artifactKey = build.artifactKey,
|
artifactKey = build.artifactKey,
|
||||||
artifactDir = artifactStore.artifactDir(build.artifactKey),
|
artifactDir = repo.artifactStore.artifactDir(build.artifactKey),
|
||||||
filesBase = "/branches/$branchKey",
|
filesBase = uiBase(repo) + "/branches/$branchKey",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun artifactIndexView(
|
private fun artifactIndexView(
|
||||||
model: Model,
|
model: Model,
|
||||||
pageTitle: String,
|
pageTitle: String,
|
||||||
|
repo: RepoContext,
|
||||||
result: BuildResult?,
|
result: BuildResult?,
|
||||||
artifactKey: String,
|
artifactKey: String,
|
||||||
artifactDir: Path?,
|
artifactDir: Path?,
|
||||||
filesBase: String,
|
filesBase: String,
|
||||||
): String {
|
): String {
|
||||||
val links = baseModel(model, view = "artifact", pageTitle = pageTitle)
|
val links = baseModel(model, view = "artifact", pageTitle = pageTitle, repo = repo)
|
||||||
model.addAttribute("artifactKey", artifactKey)
|
model.addAttribute("artifactKey", artifactKey)
|
||||||
model.addAttribute("filesBase", filesBase)
|
model.addAttribute("filesBase", filesBase)
|
||||||
model.addAttribute("result", result?.let { BuildRowView.from(it, links) })
|
model.addAttribute("result", result?.let { BuildRowView.from(it, links) })
|
||||||
model.addAttribute("hasArtifacts", artifactDir != null)
|
model.addAttribute("hasArtifacts", artifactDir != null)
|
||||||
model.addAttribute("buildCommand", result?.let { buildCommandOf(it) })
|
model.addAttribute("buildCommand", result?.let { buildCommandOf(repo, it) })
|
||||||
model.addAttribute(
|
model.addAttribute(
|
||||||
"logs",
|
"logs",
|
||||||
artifactDir?.let { logFiles(it, scanForFailure = result != null && result.status != BuildStatus.SUCCESS) }
|
artifactDir?.let { logFiles(it, scanForFailure = result != null && result.status != BuildStatus.SUCCESS) }
|
||||||
@@ -226,21 +268,41 @@ class UiController(
|
|||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix every in-page link and API path is built from. It follows the number
|
||||||
|
* of served repositories, not the route the page was reached through: with one
|
||||||
|
* repository the installation keeps its existing URLs (the session-D acceptance
|
||||||
|
* criterion), with several every link names its repository.
|
||||||
|
*/
|
||||||
|
private fun uiBase(repo: RepoContext): String = repoLinks.base(repo)
|
||||||
|
|
||||||
|
private fun apiBase(repo: RepoContext): String = repoLinks.apiBase(repo)
|
||||||
|
|
||||||
/** 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,
|
||||||
view: String,
|
view: String,
|
||||||
pageTitle: String,
|
pageTitle: String,
|
||||||
|
repo: RepoContext,
|
||||||
): GiteaWebLinks {
|
): GiteaWebLinks {
|
||||||
val config = configLoader.load(workingDir)
|
val config = configLoader.load(repo.workingDir)
|
||||||
val links = GiteaWebLinks(config.gitea)
|
val links = GiteaWebLinks(config.gitea)
|
||||||
val repoName =
|
val repoName =
|
||||||
listOf(config.gitea.owner.trim(), config.gitea.repo.trim())
|
listOf(config.gitea.owner.trim(), config.gitea.repo.trim())
|
||||||
.filter { it.isNotEmpty() }
|
.filter { it.isNotEmpty() }
|
||||||
.joinToString("/")
|
.joinToString("/")
|
||||||
|
val served = registry.all()
|
||||||
model.addAttribute("view", view)
|
model.addAttribute("view", view)
|
||||||
model.addAttribute("pageTitle", pageTitle)
|
model.addAttribute("pageTitle", pageTitle)
|
||||||
model.addAttribute("repoName", repoName)
|
model.addAttribute("repoName", repoName)
|
||||||
|
// every in-page link is built from this prefix, so a scoped page stays scoped
|
||||||
|
model.addAttribute("repoBase", uiBase(repo))
|
||||||
|
model.addAttribute("homeUrl", uiBase(repo).ifEmpty { "/" })
|
||||||
|
model.addAttribute("apiBase", apiBase(repo))
|
||||||
|
model.addAttribute("repoKey", repo.name)
|
||||||
|
// the switcher is what makes several repositories one UI; with one there is nothing to switch
|
||||||
|
model.addAttribute("multiRepo", served.size > 1)
|
||||||
|
model.addAttribute("repos", served.map { RepoLinkView(name = it.name, url = "/repos/${it.name}", current = it === repo) })
|
||||||
model.addAttribute("version", buildProperties.getIfAvailable()?.version ?: "dev")
|
model.addAttribute("version", buildProperties.getIfAvailable()?.version ?: "dev")
|
||||||
model.addAttribute("impressumUrl", config.server.impressumUrl.trim())
|
model.addAttribute("impressumUrl", config.server.impressumUrl.trim())
|
||||||
model.addAttribute("giteaRepoUrl", links.repoUrl ?: "")
|
model.addAttribute("giteaRepoUrl", links.repoUrl ?: "")
|
||||||
@@ -254,7 +316,11 @@ class UiController(
|
|||||||
* build of this pool ever ran — the branch and its job usually override it.
|
* build of this pool ever ran — the branch and its job usually override it.
|
||||||
* The command used by a past run is not persisted, so this is the current answer.
|
* The command used by a past run is not persisted, so this is the current answer.
|
||||||
*/
|
*/
|
||||||
private fun buildCommandOf(result: BuildResult): String {
|
private fun buildCommandOf(
|
||||||
|
repo: RepoContext,
|
||||||
|
result: BuildResult,
|
||||||
|
): String {
|
||||||
|
val workingDir = repo.workingDir
|
||||||
val config =
|
val config =
|
||||||
try {
|
try {
|
||||||
configLoader.loadWithBranchLayer(
|
configLoader.loadWithBranchLayer(
|
||||||
|
|||||||
@@ -88,6 +88,22 @@ object UiFormats {
|
|||||||
|
|
||||||
private const val UTILIZATION_WARN = 0.80
|
private const val UTILIZATION_WARN = 0.80
|
||||||
private const val UTILIZATION_CRIT = 0.90
|
private const val UTILIZATION_CRIT = 0.90
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `8.00 GiB (group quota mih09, hard limit 12.00 GiB)`, `70.99 GiB (volume, tighter than the
|
||||||
|
* quotas)`, or the plain total when no quota exists — `werkator.js` (`formatDiskTotal`) mirrors
|
||||||
|
* this exactly, since it renders the same field from the polled `GET /api/system`.
|
||||||
|
*/
|
||||||
|
fun diskTotal(metrics: SystemMetrics): String {
|
||||||
|
val total = metrics.diskTotalGib ?: return "n/a"
|
||||||
|
val totalText = "${metric(total)} GiB"
|
||||||
|
val source = metrics.diskSource
|
||||||
|
if (source == null || source.kind == "volume") {
|
||||||
|
return if (metrics.quotasPresent) "$totalText (volume, tighter than the quotas)" else totalText
|
||||||
|
}
|
||||||
|
val hardLimit = metric(source.hardLimitGib)
|
||||||
|
return "$totalText (${source.kind} quota ${source.subject}, hard limit $hardLimit GiB)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One row of the build tables; [latestGreenUrl] only on the build that permanent link resolves to. */
|
/** One row of the build tables; [latestGreenUrl] only on the build that permanent link resolves to. */
|
||||||
@@ -247,8 +263,15 @@ data class SystemMetricsView(
|
|||||||
),
|
),
|
||||||
cpuCount = "${metrics.cpuCount} cores",
|
cpuCount = "${metrics.cpuCount} cores",
|
||||||
ramTotal = metrics.ramTotalGib?.let { "${UiFormats.metric(it)} GiB" } ?: "n/a",
|
ramTotal = metrics.ramTotalGib?.let { "${UiFormats.metric(it)} GiB" } ?: "n/a",
|
||||||
diskTotal = metrics.diskTotalGib?.let { "${UiFormats.metric(it)} GiB" } ?: "n/a",
|
diskTotal = UiFormats.diskTotal(metrics),
|
||||||
updated = metrics.timestamp?.let { UiFormats.timeOfDay(it) } ?: "n/a",
|
updated = metrics.timestamp?.let { UiFormats.timeOfDay(it) } ?: "n/a",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One entry of the repository switcher in the navigation (ADR 0009). */
|
||||||
|
data class RepoLinkView(
|
||||||
|
val name: String,
|
||||||
|
val url: String,
|
||||||
|
val current: Boolean,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.hoennig.werkator.server
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A route named a repository this instance does not serve (ADR 0009). Thrown by the
|
||||||
|
* repository-scoped controllers and turned into their own 404 shape by their exception
|
||||||
|
* handlers — a name that is simply not registered is a miss like any other, not a
|
||||||
|
* server error.
|
||||||
|
*/
|
||||||
|
class UnknownRepositoryException(
|
||||||
|
val name: String,
|
||||||
|
) : RuntimeException("no repository named '$name'")
|
||||||
@@ -26,10 +26,12 @@ import java.util.concurrent.ScheduledExecutorService
|
|||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces the legacy blocking main loop: a non-blocking fixed-delay poll cycle that
|
* Replaces the legacy blocking main loop: a non-blocking fixed-delay poll cycle that,
|
||||||
* fetches origin, enqueues due branches via the async [BuildExecutor], and prunes
|
* for every served repository, fetches origin, enqueues due branches via the async
|
||||||
* retention — it never waits for a build and never builds in the primary checkout
|
* [BuildExecutor], and prunes retention — it never waits for a build and never builds
|
||||||
* (whose branch refs it does fast-forward, see [fastForwardLocalRefs]).
|
* in the primary checkout (whose branch refs it does fast-forward, see
|
||||||
|
* [fastForwardLocalRefs]). One repository's failure never reaches another: each is
|
||||||
|
* polled in its own guard and reports on its own in [WatcherState.repositories].
|
||||||
* The loop only runs after an explicit [start] (server/watch mode, step 07);
|
* The loop only runs after an explicit [start] (server/watch mode, step 07);
|
||||||
* nothing is scheduled during CLI commands or tests.
|
* nothing is scheduled during CLI commands or tests.
|
||||||
*/
|
*/
|
||||||
@@ -78,24 +80,35 @@ class Watcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the startup recovery and schedules the poll loop with the fixed delay
|
* Runs the startup recovery of every repository and schedules the poll loop with the
|
||||||
* `watcher.pollInterval`; the first poll runs immediately.
|
* fixed delay `watcher.pollInterval` — one loop, one delay: the instance's setting,
|
||||||
|
* which every repository's effective config carries; the first poll runs immediately.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun start(repo: RepoContext) {
|
fun start(repos: List<RepoContext>) {
|
||||||
check(scheduler == null) { "watcher is already running" }
|
check(scheduler == null) { "watcher is already running" }
|
||||||
recoverOnStartup(repo)
|
require(repos.isNotEmpty()) { "no repository to watch" }
|
||||||
val interval = DurationParser.parse(configLoader.load(repo.workingDir).watcher.pollInterval)
|
repos.forEach { recoverSafely(it) }
|
||||||
|
val interval = DurationParser.parse(configLoader.load(repos.first().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(repo) }, 0, interval.toMillis(), TimeUnit.MILLISECONDS)
|
it.scheduleWithFixedDelay({ pollAll(repos) }, 0, interval.toMillis(), TimeUnit.MILLISECONDS)
|
||||||
}
|
}
|
||||||
state = state.copy(running = true)
|
state = state.copy(running = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A repository whose recovery crashes is still polled; the others' recovery is never skipped. */
|
||||||
|
private fun recoverSafely(repo: RepoContext) {
|
||||||
|
try {
|
||||||
|
recoverOnStartup(repo)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error("[{}] startup recovery failed", repo.name, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun stop() {
|
fun stop() {
|
||||||
scheduler?.shutdownNow()
|
scheduler?.shutdownNow()
|
||||||
@@ -144,31 +157,73 @@ class Watcher(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One poll cycle over a single repository; see [pollAll]. */
|
||||||
|
fun poll(repo: RepoContext) = pollAll(listOf(repo))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One poll cycle, never blocking on a build: fetch origin (on failure: log once per
|
* One poll cycle over all served repositories, never blocking on a build. Each
|
||||||
* message, expose in [state], retry next cycle), enqueue due branches — changed local branches
|
* repository is polled in its own guard — a crash or an unreachable origin is that
|
||||||
* first, then recent new origin branches, then due auto-build slots — then
|
* repository's report, and the next one is polled regardless — and the cycle's
|
||||||
* fast-forward the local branch refs, and finally prune results, artifacts, and
|
* [state] aggregates the reports: the top-level fields read as before with one
|
||||||
* worktrees of branches gone from origin.
|
* repository, and name the repository in front of every message with several.
|
||||||
*/
|
*/
|
||||||
fun poll(repo: RepoContext) {
|
fun pollAll(repos: List<RepoContext>) {
|
||||||
val startedAt = clock.instant()
|
val startedAt = clock.instant()
|
||||||
|
val reports = repos.map { pollSafely(it, startedAt) }
|
||||||
|
val several = repos.size > 1
|
||||||
|
|
||||||
|
fun named(
|
||||||
|
report: RepoWatcherState,
|
||||||
|
message: String,
|
||||||
|
): String = if (several) "${report.name}: $message" else message
|
||||||
|
state =
|
||||||
|
state.copy(
|
||||||
|
lastPollAt = startedAt,
|
||||||
|
lastFetchError = reports.mapNotNull { report -> report.lastFetchError?.let { named(report, it) } }.joinOrNull(),
|
||||||
|
lastPollError = reports.mapNotNull { report -> report.lastPollError?.let { named(report, it) } }.joinOrNull(),
|
||||||
|
queuedBranches = reports.flatMap { it.queuedBranches },
|
||||||
|
repositories = reports,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.joinOrNull(): String? = takeIf { it.isNotEmpty() }?.joinToString("; ")
|
||||||
|
|
||||||
|
private fun pollSafely(
|
||||||
|
repo: RepoContext,
|
||||||
|
startedAt: Instant,
|
||||||
|
): RepoWatcherState =
|
||||||
|
try {
|
||||||
|
pollRepo(repo, startedAt)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error("[{}] poll cycle failed", repo.name, e)
|
||||||
|
RepoWatcherState(repo.name, lastPollAt = startedAt, lastPollError = e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One repository's poll: fetch origin (on failure: log once per message, report it,
|
||||||
|
* retry next cycle), enqueue due branches — changed local branches first, then recent
|
||||||
|
* new origin branches, then due auto-build slots — then fast-forward the local branch
|
||||||
|
* refs, and finally prune results, artifacts, and worktrees of branches gone from origin.
|
||||||
|
*/
|
||||||
|
private fun pollRepo(
|
||||||
|
repo: RepoContext,
|
||||||
|
startedAt: Instant,
|
||||||
|
): RepoWatcherState {
|
||||||
val workingDir = repo.workingDir
|
val workingDir = repo.workingDir
|
||||||
val watch = watchOf(repo)
|
val watch = watchOf(repo)
|
||||||
try {
|
try {
|
||||||
gitService.fetchOrigin(workingDir)
|
gitService.fetchOrigin(workingDir)
|
||||||
if (watch.loggedFetchError != null) {
|
if (watch.loggedFetchError != null) {
|
||||||
log.info("fetching origin succeeded again")
|
log.info("[{}] fetching origin succeeded again", repo.name)
|
||||||
watch.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 (watch.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: {}", repo.name, failure)
|
||||||
watch.loggedFetchError = failure
|
watch.loggedFetchError = failure
|
||||||
}
|
}
|
||||||
state = state.copy(lastPollAt = startedAt, lastFetchError = failure)
|
return RepoWatcherState(repo.name, lastPollAt = startedAt, lastFetchError = failure)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
val config = configLoader.load(workingDir)
|
val config = configLoader.load(workingDir)
|
||||||
val originBranches = gitService.originBranches(workingDir)
|
val originBranches = gitService.originBranches(workingDir)
|
||||||
@@ -177,26 +232,15 @@ class Watcher(
|
|||||||
fastForwardLocalRefs(workingDir)
|
fastForwardLocalRefs(workingDir)
|
||||||
}
|
}
|
||||||
prune(repo, config, originBranches)
|
prune(repo, config, originBranches)
|
||||||
state =
|
return RepoWatcherState(
|
||||||
state.copy(
|
repo.name,
|
||||||
lastPollAt = startedAt,
|
lastPollAt = startedAt,
|
||||||
lastFetchError = null,
|
queuedBranches =
|
||||||
lastPollError = null,
|
repo.results
|
||||||
queuedBranches =
|
.latestPerName()
|
||||||
repo.results
|
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
||||||
.latestPerName()
|
.map { it.name },
|
||||||
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
)
|
||||||
.map { it.name },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pollSafely(repo: RepoContext) {
|
|
||||||
try {
|
|
||||||
poll(repo)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.error("poll cycle failed", e)
|
|
||||||
state = state.copy(lastPollAt = clock.instant(), lastPollError = e.message ?: e.javaClass.simpleName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -343,7 +387,7 @@ class Watcher(
|
|||||||
log.info("not enqueueing branch {}: no pull request has head commit {}", branch, commit)
|
log.info("not enqueueing branch {}: no pull request has head commit {}", branch, commit)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
log.info("enqueueing build {} of branch {} at commit {}", build, branch, commit)
|
log.info("[{}] enqueueing build {} of branch {} at commit {}", repo.name, build, branch, commit)
|
||||||
buildExecutor.startBuild(repo, branch, commit, build)
|
buildExecutor.startBuild(repo, branch, commit, build)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -462,7 +506,10 @@ 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()
|
||||||
|
.filter { it.repo === repo }
|
||||||
|
.forEach { keep += ArtifactKeys.branchKey(it.branch) }
|
||||||
repo.results
|
repo.results
|
||||||
.latestPerName()
|
.latestPerName()
|
||||||
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
||||||
|
|||||||
@@ -2,16 +2,32 @@ package de.hoennig.werkator.watcher
|
|||||||
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
/** Observable watcher health for status endpoints (step 07) and the UI (step 08). */
|
/**
|
||||||
|
* Observable watcher health for status endpoints (step 07) and the UI (step 08).
|
||||||
|
* The top-level fields describe the whole poll cycle — with one repository they are
|
||||||
|
* that repository's, with several they aggregate [repositories], where each served
|
||||||
|
* repository reports on its own (ADR 0009).
|
||||||
|
*/
|
||||||
data class WatcherState(
|
data class WatcherState(
|
||||||
/** Whether the poll loop is scheduled. */
|
/** Whether the poll loop is scheduled. */
|
||||||
val running: Boolean = false,
|
val running: Boolean = false,
|
||||||
/** When the last poll cycle started, successful or not. */
|
/** When the last poll cycle started, successful or not. */
|
||||||
val lastPollAt: Instant? = null,
|
val lastPollAt: Instant? = null,
|
||||||
/** Why the last `fetchOrigin` failed; null after a successful fetch. */
|
/** Why the last `fetchOrigin` failed; null after a successful fetch. With several repositories, `<name>: <reason>` per failure. */
|
||||||
|
val lastFetchError: String? = null,
|
||||||
|
/** Why the last poll cycle crashed after a successful fetch; null after a clean cycle. Named per repository like [lastFetchError]. */
|
||||||
|
val lastPollError: String? = null,
|
||||||
|
/** Branches whose latest build was PENDING or RUNNING at the end of the last poll, over all repositories. */
|
||||||
|
val queuedBranches: List<String> = emptyList(),
|
||||||
|
/** The same per served repository, in registry order. */
|
||||||
|
val repositories: List<RepoWatcherState> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One repository's part of the last poll cycle. */
|
||||||
|
data class RepoWatcherState(
|
||||||
|
val name: String,
|
||||||
|
val lastPollAt: Instant? = null,
|
||||||
val lastFetchError: String? = null,
|
val lastFetchError: String? = null,
|
||||||
/** Why the last poll cycle crashed after a successful fetch; null after a clean cycle. */
|
|
||||||
val lastPollError: String? = null,
|
val lastPollError: String? = null,
|
||||||
/** Branches whose latest build was PENDING or RUNNING at the end of the last poll. */
|
|
||||||
val queuedBranches: List<String> = emptyList(),
|
val queuedBranches: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ main { width: min(1180px, calc(100% - 32px)); margin: 32px auto; }
|
|||||||
h1 { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; font-size: 28px; font-weight: 700; }
|
h1 { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; font-size: 28px; font-weight: 700; }
|
||||||
h1 img { width: 32px; height: 32px; flex: none; }
|
h1 img { width: 32px; height: 32px; flex: none; }
|
||||||
h1 .repo-name { color: var(--muted); font-size: 18px; font-weight: 400; align-self: flex-end; }
|
h1 .repo-name { color: var(--muted); font-size: 18px; font-weight: 400; align-self: flex-end; }
|
||||||
|
/* the switcher takes the repository name's place and dresses like it: no box until hovered */
|
||||||
|
h1 .repo-select { align-self: center; padding: 2px 4px; border: 1px solid transparent; border-radius: 6px;
|
||||||
|
background: transparent; color: var(--muted); font: inherit; font-size: 18px; font-weight: 400; cursor: pointer; }
|
||||||
|
h1 .repo-select:hover, h1 .repo-select:focus { border-color: var(--border); background: var(--panel); }
|
||||||
|
h1 .repo-select option { color: var(--text); background: var(--panel); }
|
||||||
h2 { margin: 20px 0 10px; font-size: 18px; }
|
h2 { margin: 20px 0 10px; font-size: 18px; }
|
||||||
.title-home { display: inline-flex; flex: none; }
|
.title-home { display: inline-flex; flex: none; }
|
||||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
|
||||||
@@ -163,6 +168,7 @@ th.num { font-family: inherit; font-size: 12px; }
|
|||||||
h1 .title-home { grid-column: 1; grid-row: 1 / span 2; align-self: center; }
|
h1 .title-home { grid-column: 1; grid-row: 1 / span 2; align-self: center; }
|
||||||
h1 .title-text { grid-column: 2; grid-row: 1; align-self: end; }
|
h1 .title-text { grid-column: 2; grid-row: 1; align-self: end; }
|
||||||
h1 .repo-name { grid-column: 2; grid-row: 2; align-self: start; font-size: 15px; overflow-wrap: anywhere; }
|
h1 .repo-name { grid-column: 2; grid-row: 2; align-self: start; font-size: 15px; overflow-wrap: anywhere; }
|
||||||
|
h1 .repo-select { grid-column: 2; grid-row: 2; align-self: start; justify-self: start; margin: 0; padding: 0 4px; font-size: 15px; }
|
||||||
.view-row { flex-wrap: wrap; }
|
.view-row { flex-wrap: wrap; }
|
||||||
.view-toggle a, .view-toggle span { min-width: 0; padding: 6px 9px; font-size: 13px; }
|
.view-toggle a, .view-toggle span { min-width: 0; padding: 6px 9px; font-size: 13px; }
|
||||||
/* the live indicator collapses to a colored state dot so it cannot squeeze the menu;
|
/* the live indicator collapses to a colored state dot so it cannot squeeze the menu;
|
||||||
|
|||||||
@@ -99,6 +99,17 @@ function metaContent(name) {
|
|||||||
|
|
||||||
const giteaRepoUrl = metaContent("werkator-gitea-repo-url");
|
const giteaRepoUrl = metaContent("werkator-gitea-repo-url");
|
||||||
|
|
||||||
|
// Empty with one served repository, `/repos/<name>` with several (ADR 0009). Every
|
||||||
|
// path this script builds itself is prefixed with it, so an action triggered on a
|
||||||
|
// repository's page acts on that repository — the paths rendered into the DOM
|
||||||
|
// (`data-api`, artifact links) already carry it.
|
||||||
|
const repoBase = metaContent("werkator-repo-base") || "";
|
||||||
|
|
||||||
|
/** The API of the repository this page belongs to; `/api` when only one is served. */
|
||||||
|
function apiBase() {
|
||||||
|
return repoBase ? "/api" + repoBase : "/api";
|
||||||
|
}
|
||||||
|
|
||||||
// The control token is deliberately NOT embedded in the pages: reading them is
|
// The control token is deliberately NOT embedded in the pages: reading them is
|
||||||
// unauthenticated, so anyone could have read it out of the HTML. The operator
|
// unauthenticated, so anyone could have read it out of the HTML. The operator
|
||||||
// pastes it once per browser from `.git/werkator/control-token` on the server;
|
// pastes it once per browser from `.git/werkator/control-token` on the server;
|
||||||
@@ -389,7 +400,7 @@ function renderBuildRow(build, allowRestart, restartAtOriginHead) {
|
|||||||
const inProgress = build.status === "running" || build.status === "pending";
|
const inProgress = build.status === "running" || build.status === "pending";
|
||||||
if (build.artifactKey) {
|
if (build.artifactKey) {
|
||||||
const artifactLink = elem("a", "artifact-link", inProgress ? "⏳" : "📄");
|
const artifactLink = elem("a", "artifact-link", inProgress ? "⏳" : "📄");
|
||||||
artifactLink.href = "/builds/" + encodeURIComponent(build.artifactKey);
|
artifactLink.href = repoBase + "/builds/" + encodeURIComponent(build.artifactKey);
|
||||||
artifactLink.title = inProgress ? "Open build log — no artifacts yet" : "Open artifacts";
|
artifactLink.title = inProgress ? "Open build log — no artifacts yet" : "Open artifacts";
|
||||||
artifactsCell.appendChild(artifactLink);
|
artifactsCell.appendChild(artifactLink);
|
||||||
}
|
}
|
||||||
@@ -542,7 +553,7 @@ function initCurrentBuilds() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const offset = logOffsets.get(build.artifactKey) || 0;
|
const offset = logOffsets.get(build.artifactKey) || 0;
|
||||||
const url = `/api/builds/current/${encodeURIComponent(build.artifactKey)}/log?offset=${offset}`;
|
const url = `${apiBase()}/builds/current/${encodeURIComponent(build.artifactKey)}/log?offset=${offset}`;
|
||||||
const tail = await fetchJson(url);
|
const tail = await fetchJson(url);
|
||||||
logOffsets.set(build.artifactKey, tail.nextOffset);
|
logOffsets.set(build.artifactKey, tail.nextOffset);
|
||||||
if (tail.content) {
|
if (tail.content) {
|
||||||
@@ -588,6 +599,20 @@ function initCurrentBuilds() {
|
|||||||
/** The total each utilization metric is compared against for the critical highlighting. */
|
/** The total each utilization metric is compared against for the critical highlighting. */
|
||||||
const UTILIZATION_TOTALS = { cpuUsed: "cpuCount", ramUsedGib: "ramTotalGib", diskUsedGib: "diskTotalGib" };
|
const UTILIZATION_TOTALS = { cpuUsed: "cpuCount", ramUsedGib: "ramTotalGib", diskUsedGib: "diskTotalGib" };
|
||||||
|
|
||||||
|
/** Mirrors UiFormats.diskTotal exactly, since both render the same field from GET /api/system. */
|
||||||
|
function formatDiskTotal(metrics) {
|
||||||
|
if (metrics.diskTotalGib == null) {
|
||||||
|
return "n/a";
|
||||||
|
}
|
||||||
|
const totalText = formatMetric(metrics.diskTotalGib) + " GiB";
|
||||||
|
const source = metrics.diskSource;
|
||||||
|
if (!source || source.kind === "volume") {
|
||||||
|
return metrics.quotasPresent ? totalText + " (volume, tighter than the quotas)" : totalText;
|
||||||
|
}
|
||||||
|
const hardLimit = formatMetric(source.hardLimitGib);
|
||||||
|
return totalText + " (" + source.kind + " quota " + source.subject + ", hard limit " + hardLimit + " GiB)";
|
||||||
|
}
|
||||||
|
|
||||||
/** Same thresholds as UiFormats.utilizationClass: warn from 80% of the total, critical from 90%. */
|
/** Same thresholds as UiFormats.utilizationClass: warn from 80% of the total, critical from 90%. */
|
||||||
function utilizationClass(used, total) {
|
function utilizationClass(used, total) {
|
||||||
if (used == null || total == null || !(total > 0)) {
|
if (used == null || total == null || !(total > 0)) {
|
||||||
@@ -636,7 +661,7 @@ function initSystemTable() {
|
|||||||
});
|
});
|
||||||
setText("info-cpu-count", metrics.cpuCount != null ? metrics.cpuCount + " cores" : "n/a");
|
setText("info-cpu-count", metrics.cpuCount != null ? metrics.cpuCount + " cores" : "n/a");
|
||||||
setText("info-ram-total", metrics.ramTotalGib != null ? formatMetric(metrics.ramTotalGib) + " GiB" : "n/a");
|
setText("info-ram-total", metrics.ramTotalGib != null ? formatMetric(metrics.ramTotalGib) + " GiB" : "n/a");
|
||||||
setText("info-disk-total", metrics.diskTotalGib != null ? formatMetric(metrics.diskTotalGib) + " GiB" : "n/a");
|
setText("info-disk-total", formatDiskTotal(metrics));
|
||||||
setText("info-updated", formatTimeOfDay(metrics.timestamp));
|
setText("info-updated", formatTimeOfDay(metrics.timestamp));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,11 +715,11 @@ document.addEventListener("click", async (event) => {
|
|||||||
try {
|
try {
|
||||||
if (action === "restart") {
|
if (action === "restart") {
|
||||||
const atOriginHead = button.dataset.atOriginHead === "true" ? "&atOriginHead=true" : "";
|
const atOriginHead = button.dataset.atOriginHead === "true" ? "&atOriginHead=true" : "";
|
||||||
await sendAction("/api/builds/restart?branch=" + encodeURIComponent(button.dataset.branch) + atOriginHead, "POST");
|
await sendAction(apiBase() + "/builds/restart?branch=" + encodeURIComponent(button.dataset.branch) + atOriginHead, "POST");
|
||||||
} else if (action === "cancel") {
|
} else if (action === "cancel") {
|
||||||
await sendAction(`/api/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST");
|
await sendAction(`${apiBase()}/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST");
|
||||||
} else if (action === "delete") {
|
} else if (action === "delete") {
|
||||||
await sendAction("/api/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE");
|
await sendAction(apiBase() + "/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE");
|
||||||
}
|
}
|
||||||
if (refreshNow) {
|
if (refreshNow) {
|
||||||
refreshNow();
|
refreshNow();
|
||||||
@@ -725,12 +750,34 @@ function initReloadButton() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- repository switcher -------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switching the repository keeps the view: an option's value is the repository root
|
||||||
|
* (`/repos/<name>`), and the view this page shows is appended to it. A page that only
|
||||||
|
* exists for one repository's build — an artifact index — falls back to that
|
||||||
|
* repository's latest builds.
|
||||||
|
*/
|
||||||
|
function initRepoSelect() {
|
||||||
|
const select = document.getElementById("repo-select");
|
||||||
|
if (!select) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const view = repoBase && path.startsWith(repoBase) ? path.slice(repoBase.length) : path;
|
||||||
|
const keptView = ["/branches", "/history", "/current"].includes(view) ? view : "";
|
||||||
|
select.addEventListener("change", () => {
|
||||||
|
window.location.href = select.value + keptView;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- page wiring ---------------------------------------------------------------
|
// ---- page wiring ---------------------------------------------------------------
|
||||||
|
|
||||||
initBuildsTable();
|
initBuildsTable();
|
||||||
initCurrentBuilds();
|
initCurrentBuilds();
|
||||||
initSystemTable();
|
initSystemTable();
|
||||||
initReloadButton();
|
initReloadButton();
|
||||||
|
initRepoSelect();
|
||||||
// pages with a poller update the banner from their own tick; the static ones ask once
|
// pages with a poller update the banner from their own tick; the static ones ask once
|
||||||
if (!refreshNow) {
|
if (!refreshNow) {
|
||||||
refreshWatcherBanner();
|
refreshWatcherBanner();
|
||||||
|
|||||||
@@ -52,13 +52,13 @@
|
|||||||
<td class="duration-cell" data-label="Duration" th:text="${row.duration}">1:23</td>
|
<td class="duration-cell" data-label="Duration" th:text="${row.duration}">1:23</td>
|
||||||
<td data-label="Artifacts">
|
<td data-label="Artifacts">
|
||||||
<a th:if="${row.artifactKey != ''}" class="artifact-link"
|
<a th:if="${row.artifactKey != ''}" class="artifact-link"
|
||||||
th:href="'/builds/' + ${row.artifactKey}"
|
th:href="${repoBase} + '/builds/' + ${row.artifactKey}"
|
||||||
th:text="${row.inProgress} ? '⏳' : '📄'"
|
th:text="${row.inProgress} ? '⏳' : '📄'"
|
||||||
th:title="${row.inProgress} ? 'Open build log — no artifacts yet' : 'Open artifacts'">📄</a>
|
th:title="${row.inProgress} ? 'Open build log — no artifacts yet' : 'Open artifacts'">📄</a>
|
||||||
<a th:if="${row.latestGreenUrl != null}" class="artifact-link"
|
<a th:if="${row.latestGreenUrl != null}" class="artifact-link"
|
||||||
th:href="${row.latestGreenUrl}"
|
th:href="${row.latestGreenUrl}"
|
||||||
title="Permanent link: artifacts of the latest green build">🔗</a>
|
title="Permanent link: artifacts of the latest green build">🔗</a>
|
||||||
<a th:if="${row.inProgress}" class="artifact-link" href="/current"
|
<a th:if="${row.inProgress}" class="artifact-link" th:href="${repoBase} + '/current'" href="/current"
|
||||||
title="Watch this build live">📡</a>
|
title="Watch this build live">📡</a>
|
||||||
<span th:if="${row.artifactKey == ''}">n/a</span>
|
<span th:if="${row.artifactKey == ''}">n/a</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<main>
|
<main>
|
||||||
<h1 th:replace="~{fragments :: header(${pageTitle})}"></h1>
|
<h1 th:replace="~{fragments :: header(${pageTitle})}"></h1>
|
||||||
<div th:replace="~{fragments :: nav(${view})}"></div>
|
<div th:replace="~{fragments :: nav(${view})}"></div>
|
||||||
<div id="current-builds" data-api="/api/builds/current">
|
<div id="current-builds" th:attr="data-api=${apiBase} + '/builds/current'" data-api="/api/builds/current">
|
||||||
<p id="no-current" class="empty-panel" th:style="${#lists.isEmpty(currentBuilds)} ? '' : 'display: none'">
|
<p id="no-current" class="empty-panel" th:style="${#lists.isEmpty(currentBuilds)} ? '' : 'display: none'">
|
||||||
No build is currently running.
|
No build is currently running.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -7,24 +7,35 @@
|
|||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/werkator.css">
|
<link rel="stylesheet" href="/werkator.css">
|
||||||
<meta name="werkator-gitea-repo-url" th:content="${giteaRepoUrl}">
|
<meta name="werkator-gitea-repo-url" th:content="${giteaRepoUrl}">
|
||||||
|
<!-- Empty with one served repository, `/repos/<name>` with several: werkator.js
|
||||||
|
builds its action and artifact URLs from it, so a page stays in its repository. -->
|
||||||
|
<meta name="werkator-repo-base" th:content="${repoBase}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<h1 th:fragment="header(title)">
|
<h1 th:fragment="header(title)">
|
||||||
<a class="title-home" href="/" aria-label="Open latest builds"><img src="/favicon.svg" alt=""></a>
|
<a class="title-home" th:href="${homeUrl}" href="/" aria-label="Open latest builds"><img src="/favicon.svg" alt=""></a>
|
||||||
<span class="title-text" th:text="${title}">Latest Builds</span>
|
<span class="title-text" th:text="${title}">Latest Builds</span>
|
||||||
<span class="repo-name" th:unless="${#strings.isEmpty(repoName)}" th:text="${repoName}">owner/repo</span>
|
<!-- The repository switcher (ADR 0009): what makes several repositories one UI. It sits
|
||||||
|
where the repository name sits, because it *is* the repository name — the page's
|
||||||
|
subject, not a navigation target. Its option values are the repository roots;
|
||||||
|
werkator.js appends the current view, so switching keeps the reader on the same page.
|
||||||
|
With a single served repository there is nothing to switch, and the name is plain text. -->
|
||||||
|
<select th:if="${multiRepo}" id="repo-select" class="repo-select" aria-label="Repository">
|
||||||
|
<option th:each="r : ${repos}" th:value="${r.url}" th:selected="${r.current}" th:text="${r.name}">werkator</option>
|
||||||
|
</select>
|
||||||
|
<span class="repo-name" th:unless="${multiRepo} or ${#strings.isEmpty(repoName)}" th:text="${repoName}">owner/repo</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<th:block th:fragment="nav(view)">
|
<th:block th:fragment="nav(view)">
|
||||||
<div class="view-row">
|
<div class="view-row">
|
||||||
<nav class="view-toggle">
|
<nav class="view-toggle">
|
||||||
<span th:if="${view == 'latest'}">Latest</span>
|
<span th:if="${view == 'latest'}">Latest</span>
|
||||||
<a th:unless="${view == 'latest'}" href="/">Latest</a>
|
<a th:unless="${view == 'latest'}" th:href="${homeUrl}" href="/">Latest</a>
|
||||||
<span th:if="${view == 'branches'}">Branches</span>
|
<span th:if="${view == 'branches'}">Branches</span>
|
||||||
<a th:unless="${view == 'branches'}" href="/branches">Branches</a>
|
<a th:unless="${view == 'branches'}" th:href="${repoBase} + '/branches'" href="/branches">Branches</a>
|
||||||
<span th:if="${view == 'history'}">History</span>
|
<span th:if="${view == 'history'}">History</span>
|
||||||
<a th:unless="${view == 'history'}" href="/history">History</a>
|
<a th:unless="${view == 'history'}" th:href="${repoBase} + '/history'" href="/history">History</a>
|
||||||
<span th:if="${view == 'current'}">Current</span>
|
<span th:if="${view == 'current'}">Current</span>
|
||||||
<span th:if="${view == 'system'}">System</span>
|
<span th:if="${view == 'system'}">System</span>
|
||||||
<a th:unless="${view == 'system'}" href="/system">System</a>
|
<a th:unless="${view == 'system'}" href="/system">System</a>
|
||||||
|
|||||||
@@ -7,6 +7,57 @@
|
|||||||
<div th:replace="~{fragments :: nav(${view})}"></div>
|
<div th:replace="~{fragments :: nav(${view})}"></div>
|
||||||
<div class="panel release-notes">
|
<div class="panel release-notes">
|
||||||
|
|
||||||
|
<h2>v1.2.0 <span class="muted">— 2026-09-03</span></h2>
|
||||||
|
<ul>
|
||||||
|
<li>The build sandbox for hosts without Docker is configured as <code>werkdock</code> now,
|
||||||
|
not <code>bwrap</code> (PR#19), and its <code>bwrap.werkdock</code> key — which named its
|
||||||
|
own executor — is <code>werkdock.binary</code>. The old section is still read, with a
|
||||||
|
warning naming the file, so no installation has to be changed before its next
|
||||||
|
configuration edit. <code>bwrap</code> named the mechanism one layer below the tool that
|
||||||
|
actually runs it: builds have been executed by the werkdock CLI since v1.0.0.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>v1.1.2 <span class="muted">— 2026-09-03</span></h2>
|
||||||
|
<ul>
|
||||||
|
<li>On a Hostsharing Managed Webspace, an <code>instance-update</code> restart no longer looks
|
||||||
|
dead while nothing is listening on the port for a moment: the generated
|
||||||
|
<code>.htaccess</code> (PR#17) now maps a refused connection to a static
|
||||||
|
"Werkator is restarting — please retry in a few minutes" page instead of Apache's default
|
||||||
|
error page. Generated only where a <code>server.publicBaseUrl</code> is configured, next to
|
||||||
|
the existing <code>.htaccess</code>; nothing changes for a Docker-host deployment.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>v1.1.1 <span class="muted">— 2026-09-03</span></h2>
|
||||||
|
<ul>
|
||||||
|
<li>The system page's disk metric is now quota-aware (PR#16): it shows the tightest of the
|
||||||
|
user quota, the group quota, and the volume itself, instead of always the volume. On a
|
||||||
|
Hostsharing Managed Webspace that is usually a group quota, far tighter than the shared
|
||||||
|
host disk — the info line names the binding source and its hard limit, and the
|
||||||
|
warn/critical highlighting now fires against the real budget. A host without a binding
|
||||||
|
quota renders exactly as before.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>v1.1.0 <span class="muted">— 2026-09-03</span></h2>
|
||||||
|
<ul>
|
||||||
|
<li>One instance can now serve several repositories (ADR 0009): a <code>~/.werkator.yml</code>
|
||||||
|
lists them, and each is worked on through its own <code>RepoContext</code> — checkout,
|
||||||
|
results, artifact store, and worktrees never share state across repositories. The watcher
|
||||||
|
polls every registered repository in its own guard, and the executor's
|
||||||
|
<code>maxConcurrent</code> still applies per <code>(repository, branch)</code>, not just
|
||||||
|
per branch.</li>
|
||||||
|
<li>Server routes carry the repository as <code>/repos/<name>/…</code> and
|
||||||
|
<code>/api/repos/<name>/…</code>; the unscoped routes keep meaning the served
|
||||||
|
repository, so a single-repository installation notices nothing. <code>build</code>,
|
||||||
|
<code>retry</code>, and <code>status</code> take a <code>--repo</code> option to pick a
|
||||||
|
registered repository instead of always acting on the current directory.</li>
|
||||||
|
<li>The system page's resource metrics are sampled and reported per repository instead of
|
||||||
|
once for the whole instance.</li>
|
||||||
|
<li>The page title carries a drop-down next to the heading in place of the plain repository
|
||||||
|
name once more than one repository is registered — picking a different one switches
|
||||||
|
straight to the same view (<em>Branches</em>, <em>History</em>, …) on it. A single-repository
|
||||||
|
installation still shows the plain name, unchanged.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h2>v1.0.1 <span class="muted">— 2026-08-31</span></h2>
|
<h2>v1.0.1 <span class="muted">— 2026-08-31</span></h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>The restart button on the <em>Branches</em> view builds the branch's current head
|
<li>The restart button on the <em>Branches</em> view builds the branch's current head
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.hoennig.werkator
|
||||||
|
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.shouldNotBe
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A version bump is easy to forget on a deployment (it happened twice on 2026-09-03) — this
|
||||||
|
* fails the build so a deployment can never carry an artifact whose footer/`--version` claims
|
||||||
|
* a release that has no notes, or notes for a release nothing was actually built for.
|
||||||
|
*/
|
||||||
|
class ReleaseVersionConsistencyTest : FunSpec() {
|
||||||
|
init {
|
||||||
|
test("the top release-notes entry names the current project version") {
|
||||||
|
val buildGradle = File("build.gradle.kts").readText()
|
||||||
|
val versionMatch = Regex("""^version = "([^"]+)"""", RegexOption.MULTILINE).find(buildGradle)
|
||||||
|
versionMatch shouldNotBe null
|
||||||
|
val projectVersion = versionMatch!!.groupValues[1]
|
||||||
|
|
||||||
|
val releaseNotes = File("src/main/resources/templates/releases.html").readText()
|
||||||
|
val topHeadingMatch = Regex("""<h2>v([0-9.]+)\s""").find(releaseNotes)
|
||||||
|
topHeadingMatch shouldNotBe null
|
||||||
|
val topReleasedVersion = topHeadingMatch!!.groupValues[1]
|
||||||
|
|
||||||
|
topReleasedVersion shouldBe projectVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,14 @@ 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 de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.kotest.matchers.string.shouldContain
|
import io.kotest.matchers.string.shouldContain
|
||||||
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
@@ -42,6 +45,7 @@ class BuildExecutorArtifactIntegrationTest : FunSpec() {
|
|||||||
val executor =
|
val executor =
|
||||||
BuildExecutor(
|
BuildExecutor(
|
||||||
configLoader = ConfigLoader(),
|
configLoader = ConfigLoader(),
|
||||||
|
repoLinks = RepoLinks(mockk<RepoRegistry>().also { every { it.all() } returns listOf(repo) }),
|
||||||
giteaClient = mockk<GiteaClient>(relaxed = true),
|
giteaClient = mockk<GiteaClient>(relaxed = true),
|
||||||
buildRunner = ProcessBuildRunner(),
|
buildRunner = ProcessBuildRunner(),
|
||||||
workspaces = BranchWorkspaces { _, _, _ -> workspace },
|
workspaces = BranchWorkspaces { _, _, _ -> workspace },
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ 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 de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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
|
||||||
@@ -53,6 +55,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
val executor =
|
val executor =
|
||||||
BuildExecutor(
|
BuildExecutor(
|
||||||
configLoader = ConfigLoader(),
|
configLoader = ConfigLoader(),
|
||||||
|
repoLinks = RepoLinks(mockk<RepoRegistry>().also { every { it.all() } returns listOf(repo) }),
|
||||||
giteaClient = giteaClient,
|
giteaClient = giteaClient,
|
||||||
buildRunner = buildRunner,
|
buildRunner = buildRunner,
|
||||||
workspaces = workspaces,
|
workspaces = workspaces,
|
||||||
@@ -138,6 +141,35 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
verify { h.artifactStore.persist(match { it.status == BuildStatus.SUCCESS }, build.stagingDir, h.workingDir) }
|
verify { h.artifactStore.persist(match { it.status == BuildStatus.SUCCESS }, build.stagingDir, h.workingDir) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("the commit status carries the artifact page when a public base URL is configured") {
|
||||||
|
val h =
|
||||||
|
Harness(
|
||||||
|
"""
|
||||||
|
server:
|
||||||
|
publicBaseUrl: https://ci.example.org/
|
||||||
|
branches:
|
||||||
|
default:
|
||||||
|
buildCommand: "true"
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val build = h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
|
|
||||||
|
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
||||||
|
awaitIdle(h)
|
||||||
|
// one served repository: the installation's existing URLs, no repository segment
|
||||||
|
verify {
|
||||||
|
h.giteaClient.publishStatus(
|
||||||
|
"abc123",
|
||||||
|
BuildStatus.SUCCESS,
|
||||||
|
any(),
|
||||||
|
"https://ci.example.org/builds/" + build.artifactKey,
|
||||||
|
h.workingDir,
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
@@ -473,6 +505,8 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("with maxConcurrent 1 a second branch stays PENDING until the first finished") {
|
test("with maxConcurrent 1 a second branch stays PENDING until the first finished") {
|
||||||
|
// branch-a blocks on a gate file the test creates, so the PENDING assertion
|
||||||
|
// below cannot race the first build finishing on a loaded machine
|
||||||
val h =
|
val h =
|
||||||
Harness(
|
Harness(
|
||||||
"""
|
"""
|
||||||
@@ -480,7 +514,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
maxConcurrent: 1
|
maxConcurrent: 1
|
||||||
branches:
|
branches:
|
||||||
branch-a:
|
branch-a:
|
||||||
buildCommand: "sleep 1"
|
buildCommand: "until [ -f gate ]; do sleep 0.05; done"
|
||||||
cleanCommand: ""
|
cleanCommand: ""
|
||||||
branch-b:
|
branch-b:
|
||||||
buildCommand: "echo ok"
|
buildCommand: "echo ok"
|
||||||
@@ -491,8 +525,13 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
h.executor.startBuild(h.repo, "branch-a", "sha-a")
|
h.executor.startBuild(h.repo, "branch-a", "sha-a")
|
||||||
h.executor.startBuild(h.repo, "branch-b", "sha-b")
|
h.executor.startBuild(h.repo, "branch-b", "sha-b")
|
||||||
|
|
||||||
|
eventually(30.seconds) {
|
||||||
|
h.repository.latestFor("branch-a")?.status shouldBe BuildStatus.RUNNING
|
||||||
|
}
|
||||||
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.PENDING
|
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.PENDING
|
||||||
|
|
||||||
|
Files.createFile(h.workingDir.resolve("gate"))
|
||||||
|
|
||||||
awaitStatus(h, "branch-b", BuildStatus.SUCCESS)
|
awaitStatus(h, "branch-b", BuildStatus.SUCCESS)
|
||||||
awaitStatus(h, "branch-a", BuildStatus.SUCCESS)
|
awaitStatus(h, "branch-a", BuildStatus.SUCCESS)
|
||||||
val transitions = h.events.map { it.result.branch to it.result.status }
|
val transitions = h.events.map { it.result.branch to it.result.status }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package de.hoennig.werkator.build
|
package de.hoennig.werkator.build
|
||||||
|
|
||||||
import de.hoennig.werkator.config.BranchConfig
|
import de.hoennig.werkator.config.BranchConfig
|
||||||
import de.hoennig.werkator.config.BwrapConfig
|
|
||||||
import de.hoennig.werkator.config.DockerConfig
|
import de.hoennig.werkator.config.DockerConfig
|
||||||
|
import de.hoennig.werkator.config.WerkdockConfig
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.mockk.Called
|
import io.mockk.Called
|
||||||
@@ -15,13 +15,13 @@ import java.nio.file.Paths
|
|||||||
class DispatchingBuildRunnerTest : FunSpec() {
|
class DispatchingBuildRunnerTest : FunSpec() {
|
||||||
private val processBuildRunner = mockk<ProcessBuildRunner>()
|
private val processBuildRunner = mockk<ProcessBuildRunner>()
|
||||||
private val dockerBuildRunner = mockk<DockerBuildRunner>()
|
private val dockerBuildRunner = mockk<DockerBuildRunner>()
|
||||||
private val bwrapBuildRunner = mockk<BwrapBuildRunner>()
|
private val werkdockBuildRunner = mockk<WerkdockBuildRunner>()
|
||||||
private val dispatcher = DispatchingBuildRunner(processBuildRunner, dockerBuildRunner, bwrapBuildRunner)
|
private val dispatcher = DispatchingBuildRunner(processBuildRunner, dockerBuildRunner, werkdockBuildRunner)
|
||||||
private val process = mockk<Process>()
|
private val process = mockk<Process>()
|
||||||
private val dir = Paths.get(".")
|
private val dir = Paths.get(".")
|
||||||
|
|
||||||
init {
|
init {
|
||||||
beforeEach { clearMocks(processBuildRunner, dockerBuildRunner, bwrapBuildRunner) }
|
beforeEach { clearMocks(processBuildRunner, dockerBuildRunner, werkdockBuildRunner) }
|
||||||
|
|
||||||
test("runs natively by default") {
|
test("runs natively by default") {
|
||||||
val branchConfig = BranchConfig()
|
val branchConfig = BranchConfig()
|
||||||
@@ -30,7 +30,7 @@ class DispatchingBuildRunnerTest : FunSpec() {
|
|||||||
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
|
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
|
||||||
|
|
||||||
verify { dockerBuildRunner wasNot Called }
|
verify { dockerBuildRunner wasNot Called }
|
||||||
verify { bwrapBuildRunner wasNot Called }
|
verify { werkdockBuildRunner wasNot Called }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("runs in Docker when the branch enables it") {
|
test("runs in Docker when the branch enables it") {
|
||||||
@@ -40,12 +40,12 @@ class DispatchingBuildRunnerTest : FunSpec() {
|
|||||||
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
|
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
|
||||||
|
|
||||||
verify { processBuildRunner wasNot Called }
|
verify { processBuildRunner wasNot Called }
|
||||||
verify { bwrapBuildRunner wasNot Called }
|
verify { werkdockBuildRunner wasNot Called }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("runs in bwrap when the branch enables it (and not Docker)") {
|
test("runs in the werkdock sandbox when the branch enables it (and not Docker)") {
|
||||||
val branchConfig = BranchConfig(bwrap = BwrapConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst"))
|
val branchConfig = BranchConfig(werkdock = WerkdockConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst"))
|
||||||
every { bwrapBuildRunner.start("cmd", dir, emptyMap(), dir, branchConfig) } returns process
|
every { werkdockBuildRunner.start("cmd", dir, emptyMap(), dir, branchConfig) } returns process
|
||||||
|
|
||||||
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
|
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package de.hoennig.werkator.build
|
package de.hoennig.werkator.build
|
||||||
|
|
||||||
import de.hoennig.werkator.config.BranchConfig
|
import de.hoennig.werkator.config.BranchConfig
|
||||||
import de.hoennig.werkator.config.BwrapConfig
|
import de.hoennig.werkator.config.WerkdockConfig
|
||||||
|
import de.hoennig.werkator.git.GitCommandException
|
||||||
import de.hoennig.werkator.git.GitCommandResult
|
import de.hoennig.werkator.git.GitCommandResult
|
||||||
import de.hoennig.werkator.git.GitCommandRunner
|
import de.hoennig.werkator.git.GitCommandRunner
|
||||||
import io.kotest.assertions.throwables.shouldThrow
|
import io.kotest.assertions.throwables.shouldThrow
|
||||||
@@ -15,20 +16,20 @@ import io.mockk.verify
|
|||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
class BwrapBuildRunnerTest : FunSpec() {
|
class WerkdockBuildRunnerTest : FunSpec() {
|
||||||
private val commandRunner = mockk<GitCommandRunner>()
|
private val commandRunner = mockk<GitCommandRunner>()
|
||||||
private lateinit var runner: BwrapBuildRunner
|
private lateinit var runner: WerkdockBuildRunner
|
||||||
private lateinit var repoDir: Path
|
private lateinit var repoDir: Path
|
||||||
private lateinit var workspace: Path
|
private lateinit var workspace: Path
|
||||||
private val captured = mutableListOf<List<String>>()
|
private val captured = mutableListOf<List<String>>()
|
||||||
|
|
||||||
private fun bwrapBranchConfig(
|
private fun werkdockBranchConfig(
|
||||||
rootfs: String = "/srv/buildenv.tar.zst",
|
rootfs: String = "/srv/buildenv.tar.zst",
|
||||||
env: Map<String, String> = emptyMap(),
|
env: Map<String, String> = emptyMap(),
|
||||||
): BranchConfig =
|
): BranchConfig =
|
||||||
BranchConfig(
|
BranchConfig(
|
||||||
bwrap =
|
werkdock =
|
||||||
BwrapConfig(
|
WerkdockConfig(
|
||||||
enabled = true,
|
enabled = true,
|
||||||
rootfs = rootfs,
|
rootfs = rootfs,
|
||||||
env = env,
|
env = env,
|
||||||
@@ -37,7 +38,11 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
|
|
||||||
private fun imageName(rootfs: String = "/srv/buildenv.tar.zst"): String = "werkator-buildenv-${rootfs.sha12()}"
|
private fun imageName(rootfs: String = "/srv/buildenv.tar.zst"): String = "werkator-buildenv-${rootfs.sha12()}"
|
||||||
|
|
||||||
/** The image is already loaded: `werkdock images` lists it, so no load runs. */
|
private fun importCommand(): List<String> = listOf("werkdock", "import", "/srv/buildenv.tar.zst", imageName())
|
||||||
|
|
||||||
|
private fun loadCommand(): List<String> = listOf("werkdock", "load", "-i", "/srv/buildenv.tar.zst", "--name", imageName())
|
||||||
|
|
||||||
|
/** The image is already there: `werkdock images` lists it, so no import runs. */
|
||||||
private fun givenImageLoaded(rootfs: String = "/srv/buildenv.tar.zst") {
|
private fun givenImageLoaded(rootfs: String = "/srv/buildenv.tar.zst") {
|
||||||
every { commandRunner.runOrThrow(listOf("werkdock", "images"), repoDir, any(), any()) } returns
|
every { commandRunner.runOrThrow(listOf("werkdock", "images"), repoDir, any(), any()) } returns
|
||||||
GitCommandResult(0, imageName(rootfs) + "\n", "")
|
GitCommandResult(0, imageName(rootfs) + "\n", "")
|
||||||
@@ -52,9 +57,9 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
beforeEach {
|
beforeEach {
|
||||||
clearMocks(commandRunner)
|
clearMocks(commandRunner)
|
||||||
captured.clear()
|
captured.clear()
|
||||||
repoDir = Files.createTempDirectory("werkator-bwrap-runner")
|
repoDir = Files.createTempDirectory("werkator-werkdock-runner")
|
||||||
workspace = repoDir.resolve("workspace")
|
workspace = repoDir.resolve("workspace")
|
||||||
runner = BwrapBuildRunner(commandRunner)
|
runner = WerkdockBuildRunner(commandRunner)
|
||||||
runner.processStarter = { command, _ ->
|
runner.processStarter = { command, _ ->
|
||||||
captured += command
|
captured += command
|
||||||
ProcessBuilder("true").start()
|
ProcessBuilder("true").start()
|
||||||
@@ -64,7 +69,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
test("assembles the exact werkdock run command for a loaded image") {
|
test("assembles the exact werkdock run command for a loaded image") {
|
||||||
givenImageLoaded()
|
givenImageLoaded()
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
|
||||||
|
|
||||||
captured.single() shouldBe
|
captured.single() shouldBe
|
||||||
listOf(
|
listOf(
|
||||||
@@ -88,34 +93,47 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("loads the image once when werkdock does not know it yet") {
|
test("imports the image once when werkdock does not know it yet") {
|
||||||
givenImageMissing()
|
givenImageMissing()
|
||||||
every {
|
every { commandRunner.run(importCommand(), repoDir, any(), any()) } returns GitCommandResult(0, "", "")
|
||||||
commandRunner.runOrThrow(
|
|
||||||
listOf("werkdock", "load", "-i", "/srv/buildenv.tar.zst", "--name", imageName()),
|
|
||||||
repoDir,
|
|
||||||
any(),
|
|
||||||
any(),
|
|
||||||
)
|
|
||||||
} returns GitCommandResult(0, "", "")
|
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
|
||||||
|
|
||||||
verify {
|
verify { commandRunner.run(importCommand(), repoDir, any(), any()) }
|
||||||
commandRunner.runOrThrow(
|
verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) }
|
||||||
listOf("werkdock", "load", "-i", "/srv/buildenv.tar.zst", "--name", imageName()),
|
|
||||||
repoDir,
|
|
||||||
any(),
|
|
||||||
any(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("does not load an image werkdock already has") {
|
test("falls back to load -i --name on a werkdock without the import verb") {
|
||||||
|
givenImageMissing()
|
||||||
|
every { commandRunner.run(importCommand(), repoDir, any(), any()) } returns
|
||||||
|
GitCommandResult(125, "", "werkdock: unknown command \"import\"\n")
|
||||||
|
every { commandRunner.runOrThrow(loadCommand(), repoDir, any(), any()) } returns GitCommandResult(0, "", "")
|
||||||
|
|
||||||
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
|
||||||
|
|
||||||
|
verify { commandRunner.runOrThrow(loadCommand(), repoDir, any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("propagates an import failure that is not a missing verb") {
|
||||||
|
givenImageMissing()
|
||||||
|
every { commandRunner.run(importCommand(), repoDir, any(), any()) } returns
|
||||||
|
GitCommandResult(125, "", "werkdock: unpacking /srv/buildenv.tar.zst failed\n")
|
||||||
|
|
||||||
|
val exception =
|
||||||
|
shouldThrow<GitCommandException> {
|
||||||
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
exception.message shouldContain "unpacking"
|
||||||
|
verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("does not import an image werkdock already has") {
|
||||||
givenImageLoaded()
|
givenImageLoaded()
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
|
||||||
|
|
||||||
|
verify(exactly = 0) { commandRunner.run(match { "import" in it }, any(), any(), any()) }
|
||||||
verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) }
|
verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +142,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
GitCommandResult(0, imageName() + "\n", "")
|
GitCommandResult(0, imageName() + "\n", "")
|
||||||
val branchConfig =
|
val branchConfig =
|
||||||
BranchConfig(
|
BranchConfig(
|
||||||
bwrap = BwrapConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst", werkdock = "/opt/bin/werkdock"),
|
werkdock = WerkdockConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst", binary = "/opt/bin/werkdock"),
|
||||||
)
|
)
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, emptyMap(), repoDir, branchConfig)
|
runner.start("./gradlew test", workspace, emptyMap(), repoDir, branchConfig)
|
||||||
@@ -136,7 +154,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
givenImageLoaded()
|
givenImageLoaded()
|
||||||
val relativeWorkspace = repoDir.relativize(workspace)
|
val relativeWorkspace = repoDir.relativize(workspace)
|
||||||
|
|
||||||
runner.start("./gradlew test", relativeWorkspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
runner.start("./gradlew test", relativeWorkspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
|
||||||
|
|
||||||
val args = captured.single()
|
val args = captured.single()
|
||||||
val absolute = workspace.toAbsolutePath().normalize().toString()
|
val absolute = workspace.toAbsolutePath().normalize().toString()
|
||||||
@@ -145,7 +163,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
args.count { it == "$absolute:$absolute" } shouldBe 1
|
args.count { it == "$absolute:$absolute" } shouldBe 1
|
||||||
}
|
}
|
||||||
|
|
||||||
test("adds bwrap env after the branch environment") {
|
test("adds the sandbox env after the branch environment") {
|
||||||
givenImageLoaded()
|
givenImageLoaded()
|
||||||
|
|
||||||
runner.start(
|
runner.start(
|
||||||
@@ -153,7 +171,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
workspace,
|
workspace,
|
||||||
mapOf("branch" to "main"),
|
mapOf("branch" to "main"),
|
||||||
repoDir,
|
repoDir,
|
||||||
bwrapBranchConfig(env = mapOf("FOO" to "bar")),
|
werkdockBranchConfig(env = mapOf("FOO" to "bar")),
|
||||||
)
|
)
|
||||||
|
|
||||||
val args = captured.single()
|
val args = captured.single()
|
||||||
@@ -171,7 +189,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n")
|
Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n")
|
||||||
givenImageLoaded()
|
givenImageLoaded()
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
|
||||||
|
|
||||||
val args = captured.single()
|
val args = captured.single()
|
||||||
args[args.indexOf("$gitDir:$gitDir:ro") - 1] shouldBe "-v"
|
args[args.indexOf("$gitDir:$gitDir:ro") - 1] shouldBe "-v"
|
||||||
@@ -190,7 +208,7 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
givenImageLoaded()
|
givenImageLoaded()
|
||||||
Files.createDirectories(workspace)
|
Files.createDirectories(workspace)
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
|
||||||
|
|
||||||
val args = captured.single()
|
val args = captured.single()
|
||||||
val gitDir = repoDir.resolve(".git")
|
val gitDir = repoDir.resolve(".git")
|
||||||
@@ -199,37 +217,37 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("fails without a configured rootfs") {
|
test("fails without a configured rootfs") {
|
||||||
val branchConfig = BranchConfig(bwrap = BwrapConfig(enabled = true))
|
val branchConfig = BranchConfig(werkdock = WerkdockConfig(enabled = true))
|
||||||
|
|
||||||
val exception =
|
val exception =
|
||||||
shouldThrow<IllegalArgumentException> {
|
shouldThrow<IllegalArgumentException> {
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, branchConfig)
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, branchConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
exception.message shouldContain "bwrap.rootfs"
|
exception.message shouldContain "werkdock.rootfs"
|
||||||
}
|
}
|
||||||
|
|
||||||
test("downloads a URL rootfs once before loading it") {
|
test("downloads a URL rootfs once before importing it") {
|
||||||
val url = "https://example.test/buildenv.tar.zst"
|
val url = "https://example.test/buildenv.tar.zst"
|
||||||
val downloadTarget =
|
val downloadTarget =
|
||||||
repoDir
|
repoDir
|
||||||
.resolve(BwrapBuildRunner.BUILDENV_DIR)
|
.resolve(WerkdockBuildRunner.BUILDENV_DIR)
|
||||||
.resolve(url.sha12())
|
.resolve(url.sha12())
|
||||||
.resolve("buildenv.tar.zst")
|
.resolve("buildenv.tar.zst")
|
||||||
givenImageMissing()
|
givenImageMissing()
|
||||||
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 { "load" in it }, any(), any(), any()) } returns
|
every { commandRunner.run(match { "import" in it }, any(), any(), any()) } returns
|
||||||
GitCommandResult(0, "", "")
|
GitCommandResult(0, "", "")
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig(rootfs = url))
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig(rootfs = url))
|
||||||
|
|
||||||
verify {
|
verify {
|
||||||
commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any())
|
commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any())
|
||||||
}
|
}
|
||||||
verify {
|
verify {
|
||||||
commandRunner.runOrThrow(
|
commandRunner.run(
|
||||||
listOf("werkdock", "load", "-i", downloadTarget.toString(), "--name", "werkator-buildenv-${url.sha12()}"),
|
listOf("werkdock", "import", downloadTarget.toString(), "werkator-buildenv-${url.sha12()}"),
|
||||||
repoDir,
|
repoDir,
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
@@ -3,6 +3,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 de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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
|
||||||
@@ -20,9 +21,10 @@ class BuildCommandTest : FunSpec() {
|
|||||||
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 val repo = RepoContext("test", dir, mockk(), mockk())
|
||||||
|
private val registry = mockk<RepoRegistry>().also { every { it.current() } returns repo }
|
||||||
|
|
||||||
private fun command(fragment: String? = null) =
|
private fun command(fragment: String? = null) =
|
||||||
BuildCommand(gitService, consoleBuildRunner, repo).apply {
|
BuildCommand(gitService, consoleBuildRunner, registry).apply {
|
||||||
branchFragment = fragment
|
branchFragment = fragment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class ConsoleBuildRunnerTest : FunSpec() {
|
|||||||
|
|
||||||
private fun runningBuild(stagingDir: Path) =
|
private fun runningBuild(stagingDir: Path) =
|
||||||
RunningBuild(
|
RunningBuild(
|
||||||
|
repo = repo,
|
||||||
branch = "main",
|
branch = "main",
|
||||||
commit = "0123456789abcdef",
|
commit = "0123456789abcdef",
|
||||||
artifactKey = "main-key",
|
artifactKey = "main-key",
|
||||||
|
|||||||
@@ -109,6 +109,21 @@ class InitCommandTest : FunSpec() {
|
|||||||
projectContent shouldContain "repo: my-repo"
|
projectContent shouldContain "repo: my-repo"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("keeps dots in repository names") {
|
||||||
|
val tempDir = Files.createTempDirectory("werkator-init-test")
|
||||||
|
initCommand.workingDir = tempDir
|
||||||
|
|
||||||
|
every { gitService.getTopLevel(tempDir) } returns tempDir
|
||||||
|
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/mi/michael.hoennig.de.git"
|
||||||
|
|
||||||
|
initCommand.run()
|
||||||
|
|
||||||
|
val projectConfig = tempDir.resolve(".werkator.yml")
|
||||||
|
val projectContent = projectConfig.toFile().readText()
|
||||||
|
projectContent shouldContain "owner: mi"
|
||||||
|
projectContent shouldContain "repo: michael.hoennig.de"
|
||||||
|
}
|
||||||
|
|
||||||
test("does not overwrite existing files") {
|
test("does not overwrite existing files") {
|
||||||
val tempDir = Files.createTempDirectory("werkator-init-test")
|
val tempDir = Files.createTempDirectory("werkator-init-test")
|
||||||
initCommand.workingDir = tempDir
|
initCommand.workingDir = tempDir
|
||||||
@@ -252,5 +267,50 @@ class InitCommandTest : FunSpec() {
|
|||||||
// This should not throw IllegalArgumentException
|
// This should not throw IllegalArgumentException
|
||||||
initCommand.run()
|
initCommand.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("--systemd reads the configuration from the repository root, not the current directory") {
|
||||||
|
val tempDir = Files.createTempDirectory("werkator-init-test")
|
||||||
|
// written before the run, so `init` keeps it instead of creating a template
|
||||||
|
tempDir.resolve(".werkator.yml").toFile().writeText(
|
||||||
|
"server:\n publicBaseUrl: \"https://werkator.example.org/\"\n port: 18099\n",
|
||||||
|
)
|
||||||
|
initCommand.workingDir = tempDir
|
||||||
|
initCommand.systemd = true
|
||||||
|
initCommand.jarPathResolver = { Paths.get("/home/ci/bin/werkator.jar") }
|
||||||
|
initCommand.javaExecutableResolver = { Paths.get("/usr/bin/java") }
|
||||||
|
|
||||||
|
every { gitService.getTopLevel(tempDir) } returns tempDir
|
||||||
|
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/my-org/my-repo.git"
|
||||||
|
|
||||||
|
initCommand.run()
|
||||||
|
|
||||||
|
// the host integration is generated only when the root's config has a public base URL
|
||||||
|
val htaccess = tempDir.resolve(".git/werkator/${SystemdServiceFiles.HTACCESS_NAME}")
|
||||||
|
htaccess.toFile().shouldExist()
|
||||||
|
htaccess.toFile().readText() shouldContain "18099"
|
||||||
|
tempDir.resolve(".git/werkator/${SystemdServiceFiles.MAINTENANCE_PAGE_NAME}").toFile().shouldExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("--systemd warns once when the effective configuration cannot be loaded") {
|
||||||
|
val tempDir = Files.createTempDirectory("werkator-init-test")
|
||||||
|
val brokenLoader = mockk<de.hoennig.werkator.config.ConfigLoader>()
|
||||||
|
every { brokenLoader.load(any()) } throws IllegalStateException("gitea.owner is required")
|
||||||
|
val command = InitCommand(gitService, brokenLoader)
|
||||||
|
command.workingDir = tempDir
|
||||||
|
command.systemd = true
|
||||||
|
command.jarPathResolver = { Paths.get("/home/ci/bin/werkator.jar") }
|
||||||
|
command.javaExecutableResolver = { Paths.get("/usr/bin/java") }
|
||||||
|
|
||||||
|
every { gitService.getTopLevel(tempDir) } returns tempDir
|
||||||
|
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/my-org/my-repo.git"
|
||||||
|
|
||||||
|
val console = captureConsole { command.run() }
|
||||||
|
|
||||||
|
console.stdout shouldContain "gitea.owner is required"
|
||||||
|
// the three readers of the configuration must not repeat the warning
|
||||||
|
console.stdout.windowed("Warning:".length).count { it == "Warning:" } shouldBe 1
|
||||||
|
// the units are still written with the defaults
|
||||||
|
tempDir.resolve(".git/werkator/${SystemdServiceFiles.unitName(tempDir)}").toFile().shouldExist()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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
|
||||||
@@ -24,8 +25,9 @@ class RetryCommandTest : FunSpec() {
|
|||||||
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 val repo = RepoContext("test", dir, repository, mockk())
|
||||||
|
private val registry = mockk<RepoRegistry>().also { every { it.current() } returns repo }
|
||||||
|
|
||||||
private fun command() = RetryCommand(gitService, consoleBuildRunner, repo)
|
private fun command() = RetryCommand(gitService, consoleBuildRunner, registry)
|
||||||
|
|
||||||
private fun result(
|
private fun result(
|
||||||
branch: String,
|
branch: String,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ 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.BuildResultRepository
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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
|
||||||
@@ -16,6 +18,30 @@ import java.time.Instant
|
|||||||
|
|
||||||
class StatusCommandTest : FunSpec() {
|
class StatusCommandTest : FunSpec() {
|
||||||
private val repository = mockk<BuildResultRepository>()
|
private val repository = mockk<BuildResultRepository>()
|
||||||
|
private val other = mockk<BuildResultRepository>()
|
||||||
|
private val registry =
|
||||||
|
mockk<RepoRegistry>().also {
|
||||||
|
val current =
|
||||||
|
RepoContext(
|
||||||
|
"current",
|
||||||
|
java.nio.file.Paths
|
||||||
|
.get("."),
|
||||||
|
repository,
|
||||||
|
mockk(),
|
||||||
|
)
|
||||||
|
val second =
|
||||||
|
RepoContext(
|
||||||
|
"second",
|
||||||
|
java.nio.file.Paths
|
||||||
|
.get("second"),
|
||||||
|
other,
|
||||||
|
mockk(),
|
||||||
|
)
|
||||||
|
every { it.current() } returns current
|
||||||
|
every { it.all() } returns listOf(current, second)
|
||||||
|
every { it.byName("second") } returns second
|
||||||
|
every { it.byName("nope") } returns null
|
||||||
|
}
|
||||||
|
|
||||||
private fun result(
|
private fun result(
|
||||||
branch: String,
|
branch: String,
|
||||||
@@ -32,7 +58,21 @@ class StatusCommandTest : FunSpec() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
beforeEach {
|
beforeEach {
|
||||||
clearMocks(repository)
|
clearMocks(repository, other)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("--repo selects a registered repository; an unknown name is a usage error naming the registered ones") {
|
||||||
|
every { other.latestPerName() } returns listOf(result("main", BuildStatus.SUCCESS))
|
||||||
|
|
||||||
|
var exitCode = -1
|
||||||
|
val console = captureConsole { exitCode = StatusCommand(registry).apply { repoOption.name = "second" }.call() }
|
||||||
|
exitCode shouldBe 0
|
||||||
|
console.stdout shouldContain "main"
|
||||||
|
verify(exactly = 0) { repository.latestPerName() }
|
||||||
|
|
||||||
|
val failed = captureConsole { exitCode = StatusCommand(registry).apply { repoOption.name = "nope" }.call() }
|
||||||
|
exitCode shouldBe 2
|
||||||
|
failed.stderr shouldContain "current, second"
|
||||||
}
|
}
|
||||||
|
|
||||||
test("prints the latest build per branch as a table with short commits and legacy duration format") {
|
test("prints the latest build per branch as a table with short commits and legacy duration format") {
|
||||||
@@ -43,7 +83,7 @@ class StatusCommandTest : FunSpec() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
var exitCode = -1
|
var exitCode = -1
|
||||||
val console = captureConsole { exitCode = StatusCommand(repository).call() }
|
val console = captureConsole { exitCode = StatusCommand(registry).call() }
|
||||||
|
|
||||||
exitCode shouldBe 0
|
exitCode shouldBe 0
|
||||||
console.stdout shouldContain "BRANCH"
|
console.stdout shouldContain "BRANCH"
|
||||||
@@ -64,7 +104,7 @@ class StatusCommandTest : FunSpec() {
|
|||||||
result("main", BuildStatus.FAILED),
|
result("main", BuildStatus.FAILED),
|
||||||
)
|
)
|
||||||
|
|
||||||
val command = StatusCommand(repository).apply { history = true }
|
val command = StatusCommand(registry).apply { history = true }
|
||||||
var exitCode = -1
|
var exitCode = -1
|
||||||
val console = captureConsole { exitCode = command.call() }
|
val console = captureConsole { exitCode = command.call() }
|
||||||
|
|
||||||
@@ -78,7 +118,7 @@ class StatusCommandTest : FunSpec() {
|
|||||||
every { repository.latestPerName() } returns emptyList()
|
every { repository.latestPerName() } returns emptyList()
|
||||||
|
|
||||||
var exitCode = -1
|
var exitCode = -1
|
||||||
val console = captureConsole { exitCode = StatusCommand(repository).call() }
|
val console = captureConsole { exitCode = StatusCommand(registry).call() }
|
||||||
|
|
||||||
exitCode shouldBe 0
|
exitCode shouldBe 0
|
||||||
console.stdout shouldContain "(no builds recorded)"
|
console.stdout shouldContain "(no builds recorded)"
|
||||||
|
|||||||
@@ -97,5 +97,26 @@ class SystemdServiceFilesTest : FunSpec() {
|
|||||||
content shouldContain "DirectoryIndex disabled"
|
content shouldContain "DirectoryIndex disabled"
|
||||||
content shouldContain "RewriteRule .* http://127.0.0.1:18088%{REQUEST_URI} [proxy]"
|
content shouldContain "RewriteRule .* http://127.0.0.1:18088%{REQUEST_URI} [proxy]"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("the htaccess maps a refused connection to the static maintenance page") {
|
||||||
|
val content = SystemdServiceFiles.htaccessContent(18088)
|
||||||
|
|
||||||
|
content shouldContain "ErrorDocument 502 /werkator-maintenance.html"
|
||||||
|
content shouldContain "ErrorDocument 503 /werkator-maintenance.html"
|
||||||
|
content shouldContain "ErrorDocument 504 /werkator-maintenance.html"
|
||||||
|
// the maintenance page itself must not be proxied, or ErrorDocument's sub-request loops
|
||||||
|
content shouldContain "RewriteCond %{REQUEST_URI} !^/werkator-maintenance.html$"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the maintenance page is a self-contained, static page naming a retry") {
|
||||||
|
val content = SystemdServiceFiles.maintenancePageContent()
|
||||||
|
|
||||||
|
content shouldContain "<html"
|
||||||
|
content shouldContain "restarting"
|
||||||
|
content shouldContain "retry"
|
||||||
|
content shouldNotContain "http://"
|
||||||
|
content shouldNotContain "https://"
|
||||||
|
content shouldNotContain "src="
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,95 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
"./gradlew fromBranch"
|
"./gradlew fromBranch"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("without a home config the instance is null and the repository layers are read as before") {
|
||||||
|
val home = Files.createTempDirectory("werkator-home")
|
||||||
|
val loader = ConfigLoader().apply { homeDir = home }
|
||||||
|
|
||||||
|
loader.loadInstance() shouldBe null
|
||||||
|
loader.instanceFile() shouldBe home.resolve(".werkator.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the home config carries the registry, and its defaults sit below every repository layer") {
|
||||||
|
val home = Files.createTempDirectory("werkator-home")
|
||||||
|
home.resolve(".werkator.yml").toFile().writeText(
|
||||||
|
"""
|
||||||
|
repositories:
|
||||||
|
- path: ~/repos/werkator
|
||||||
|
- path: /srv/werkbaum
|
||||||
|
name: baum
|
||||||
|
defaults:
|
||||||
|
git:
|
||||||
|
account: shared-bot
|
||||||
|
token: shared-secret
|
||||||
|
gitea:
|
||||||
|
baseUrl: https://git.example.org
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
val loader = ConfigLoader().apply { homeDir = home }
|
||||||
|
val dir = Files.createTempDirectory("werkator-test")
|
||||||
|
dir.resolve(".werkator.yml").toFile().writeText("gitea:\n owner: my-org\n")
|
||||||
|
Files.createDirectories(dir.resolve(".git/werkator"))
|
||||||
|
dir.resolve(".git/werkator/.werkator.yml").toFile().writeText("git:\n token: own-secret\n")
|
||||||
|
|
||||||
|
val instance = loader.loadInstance().shouldNotBeNull()
|
||||||
|
instance.repositories.map { it.path to it.name } shouldBe listOf("~/repos/werkator" to "", "/srv/werkbaum" to "baum")
|
||||||
|
|
||||||
|
val config = loader.load(dir)
|
||||||
|
// the repository's own layers win over the defaults, untouched keys fall through
|
||||||
|
config.git.account shouldBe "shared-bot"
|
||||||
|
config.git.token shouldBe "own-secret"
|
||||||
|
config.gitea.baseUrl shouldBe "https://git.example.org"
|
||||||
|
config.gitea.owner shouldBe "my-org"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("with a home config the instance keys come from it alone, and a repository's copies are ignored") {
|
||||||
|
val home = Files.createTempDirectory("werkator-home")
|
||||||
|
home.resolve(".werkator.yml").toFile().writeText(
|
||||||
|
"""
|
||||||
|
server:
|
||||||
|
port: 18088
|
||||||
|
executor:
|
||||||
|
maxConcurrent: 3
|
||||||
|
watcher:
|
||||||
|
pollInterval: 1m
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
val loader = ConfigLoader().apply { homeDir = home }
|
||||||
|
val dir = Files.createTempDirectory("werkator-test")
|
||||||
|
dir.resolve(".werkator.yml").toFile().writeText(
|
||||||
|
"""
|
||||||
|
server:
|
||||||
|
port: 1000
|
||||||
|
bindAddress: 0.0.0.0
|
||||||
|
watcher:
|
||||||
|
pollInterval: 5s
|
||||||
|
pullRequestGate: false
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
Files.createDirectories(dir.resolve(".git/werkator"))
|
||||||
|
dir.resolve(".git/werkator/.werkator.yml").toFile().writeText("executor:\n maxConcurrent: 9\n")
|
||||||
|
|
||||||
|
val config = loader.load(dir)
|
||||||
|
|
||||||
|
// the whole server section is the instance's, not merged key by key
|
||||||
|
config.server.port shouldBe 18088
|
||||||
|
config.server.bindAddress shouldBe "127.0.0.1"
|
||||||
|
config.executor.maxConcurrent shouldBe 3
|
||||||
|
config.watcher.pollInterval shouldBe "1m"
|
||||||
|
// the per-repository watcher gates stay the repository's
|
||||||
|
config.watcher.pullRequestGate.shouldBeFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the home config is version-checked like every other file") {
|
||||||
|
val home = Files.createTempDirectory("werkator-home")
|
||||||
|
home.resolve(".werkator.yml").toFile().writeText("werkator:\n version:\n since: \"9.9\"\n")
|
||||||
|
val loader = loaderRunning("1.0.0").apply { homeDir = home }
|
||||||
|
|
||||||
|
val error = shouldThrow<ConfigVersionException> { loader.loadInstance() }
|
||||||
|
|
||||||
|
error.message shouldContain home.resolve(".werkator.yml").toString()
|
||||||
|
}
|
||||||
|
|
||||||
test("the applied instance fragment layers above the project config and below the machine config") {
|
test("the applied instance fragment layers above the project config and below the machine config") {
|
||||||
val dir = Files.createTempDirectory("werkator-test")
|
val dir = Files.createTempDirectory("werkator-test")
|
||||||
dir.resolve(".werkator.yml").toFile().writeText("server:\n port: 1000\n publicBaseUrl: \"https://project/\"\n")
|
dir.resolve(".werkator.yml").toFile().writeText("server:\n port: 1000\n publicBaseUrl: \"https://project/\"\n")
|
||||||
@@ -407,7 +496,7 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
settings.docker.image shouldBe "attacker-image"
|
settings.docker.image shouldBe "attacker-image"
|
||||||
}
|
}
|
||||||
|
|
||||||
test("a branch cannot disable its bwrap sandbox or substitute a foreign rootfs through a build definition") {
|
test("the legacy bwrap section is read as werkdock, its werkdock key as binary") {
|
||||||
val dir = Files.createTempDirectory("werkator-test")
|
val dir = Files.createTempDirectory("werkator-test")
|
||||||
dir.resolve(".werkator.yml").toFile().writeText(
|
dir.resolve(".werkator.yml").toFile().writeText(
|
||||||
"""
|
"""
|
||||||
@@ -416,6 +505,58 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
bwrap:
|
bwrap:
|
||||||
enabled: true
|
enabled: true
|
||||||
rootfs: /host/rootfs.tar.zst
|
rootfs: /host/rootfs.tar.zst
|
||||||
|
werkdock: /opt/bin/werkdock
|
||||||
|
env:
|
||||||
|
FOO: bar
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val settings = loader.load(dir).buildSettings("any-branch", "default")
|
||||||
|
|
||||||
|
settings.werkdock.enabled shouldBe true
|
||||||
|
settings.werkdock.rootfs shouldBe "/host/rootfs.tar.zst"
|
||||||
|
settings.werkdock.binary shouldBe "/opt/bin/werkdock"
|
||||||
|
settings.werkdock.env shouldBe mapOf("FOO" to "bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a legacy bwrap section on a branch is pinned exactly like the new name") {
|
||||||
|
val dir = Files.createTempDirectory("werkator-test")
|
||||||
|
dir.resolve(".werkator.yml").toFile().writeText(
|
||||||
|
"""
|
||||||
|
builds:
|
||||||
|
default:
|
||||||
|
werkdock:
|
||||||
|
enabled: true
|
||||||
|
rootfs: /host/rootfs.tar.zst
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
val worktree = Files.createTempDirectory("werkator-test-worktree")
|
||||||
|
// the old name must not become a way around the pinning
|
||||||
|
worktree.resolve(".werkator.yml").toFile().writeText(
|
||||||
|
"""
|
||||||
|
builds:
|
||||||
|
default:
|
||||||
|
bwrap:
|
||||||
|
enabled: false
|
||||||
|
rootfs: /attacker/rootfs.tar.zst
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default")
|
||||||
|
|
||||||
|
settings.werkdock.enabled shouldBe true
|
||||||
|
settings.werkdock.rootfs shouldBe "/host/rootfs.tar.zst"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a branch cannot disable its werkdock sandbox or substitute a foreign rootfs through a build definition") {
|
||||||
|
val dir = Files.createTempDirectory("werkator-test")
|
||||||
|
dir.resolve(".werkator.yml").toFile().writeText(
|
||||||
|
"""
|
||||||
|
builds:
|
||||||
|
default:
|
||||||
|
werkdock:
|
||||||
|
enabled: true
|
||||||
|
rootfs: /host/rootfs.tar.zst
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
val worktree = Files.createTempDirectory("werkator-test-worktree")
|
val worktree = Files.createTempDirectory("werkator-test-worktree")
|
||||||
@@ -423,7 +564,7 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
"""
|
"""
|
||||||
builds:
|
builds:
|
||||||
default:
|
default:
|
||||||
bwrap:
|
werkdock:
|
||||||
enabled: false
|
enabled: false
|
||||||
rootfs: /attacker/rootfs.tar.zst
|
rootfs: /attacker/rootfs.tar.zst
|
||||||
env:
|
env:
|
||||||
@@ -434,10 +575,10 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default")
|
val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default")
|
||||||
|
|
||||||
// pinned: the sandbox can neither be switched off nor pointed at a foreign rootfs
|
// pinned: the sandbox can neither be switched off nor pointed at a foreign rootfs
|
||||||
settings.bwrap.enabled shouldBe true
|
settings.werkdock.enabled shouldBe true
|
||||||
settings.bwrap.rootfs shouldBe "/host/rootfs.tar.zst"
|
settings.werkdock.rootfs shouldBe "/host/rootfs.tar.zst"
|
||||||
// everything that describes the build itself stays the branch's own business
|
// everything that describes the build itself stays the branch's own business
|
||||||
settings.bwrap.env shouldBe mapOf("FOO" to "from-branch")
|
settings.werkdock.env shouldBe mapOf("FOO" to "from-branch")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("a build the branch invents inherits the host's sandbox policy") {
|
test("a build the branch invents inherits the host's sandbox policy") {
|
||||||
@@ -476,7 +617,7 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
settings.requirePullRequest shouldBe true
|
settings.requirePullRequest shouldBe true
|
||||||
}
|
}
|
||||||
|
|
||||||
test("enabling both docker and bwrap on a build is rejected, not picked silently") {
|
test("enabling both docker and werkdock on a build is rejected, not picked silently") {
|
||||||
val dir = Files.createTempDirectory("werkator-test")
|
val dir = Files.createTempDirectory("werkator-test")
|
||||||
dir.resolve(".werkator.yml").toFile().writeText(
|
dir.resolve(".werkator.yml").toFile().writeText(
|
||||||
"""
|
"""
|
||||||
@@ -485,7 +626,7 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
docker:
|
docker:
|
||||||
enabled: true
|
enabled: true
|
||||||
image: build-env
|
image: build-env
|
||||||
bwrap:
|
werkdock:
|
||||||
enabled: true
|
enabled: true
|
||||||
rootfs: /srv/rootfs.tar.zst
|
rootfs: /srv/rootfs.tar.zst
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
@@ -497,7 +638,7 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
config.buildSettings("any-branch", "default")
|
config.buildSettings("any-branch", "default")
|
||||||
}
|
}
|
||||||
|
|
||||||
exception.message shouldContain "both docker and bwrap"
|
exception.message shouldContain "both docker and werkdock"
|
||||||
exception.message shouldContain "builds.default"
|
exception.message shouldContain "builds.default"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package de.hoennig.werkator.metrics
|
||||||
|
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.collections.shouldBeEmpty
|
||||||
|
import io.kotest.matchers.collections.shouldHaveSize
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
|
||||||
|
/** `quota -u -g --no-wrap --raw-grace` on mih09, 2026-09-03 (the PR#16 attachment); the parser fixture. */
|
||||||
|
private val MIH09_QUOTA_OUTPUT =
|
||||||
|
"""
|
||||||
|
Disk quotas for user mih09-werkator (uid 120974): none
|
||||||
|
Disk quotas for group mih09 (gid 102180):
|
||||||
|
Filesystem blocks quota limit grace files quota limit grace
|
||||||
|
/dev/disk/by-id/wwn-0x0000000000000001-part2 1088116 8388608 12582912 0 14226 16777216 25165824 0
|
||||||
|
/dev/sdb1 1456376 10485760 15728640 0 52983 20971520 31457280 0
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
class DiskQuotaTest : FunSpec() {
|
||||||
|
init {
|
||||||
|
test("the mih09 output parses into one group line per filesystem and no user line") {
|
||||||
|
val lines = DiskQuota.parse(MIH09_QUOTA_OUTPUT)
|
||||||
|
|
||||||
|
lines shouldHaveSize 2
|
||||||
|
lines.none { it.kind == "user" } shouldBe true
|
||||||
|
lines[0] shouldBe
|
||||||
|
QuotaLine(
|
||||||
|
kind = "group",
|
||||||
|
subject = "mih09",
|
||||||
|
filesystem = "/dev/disk/by-id/wwn-0x0000000000000001-part2",
|
||||||
|
blocksKib = 1_088_116,
|
||||||
|
softKib = 8_388_608,
|
||||||
|
hardKib = 12_582_912,
|
||||||
|
)
|
||||||
|
lines[1].filesystem shouldBe "/dev/sdb1"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("no quota tooling, an empty output, and a 'none'-only output all parse to no lines") {
|
||||||
|
DiskQuota.parse("").shouldBeEmpty()
|
||||||
|
DiskQuota.parse("Disk quotas for user mih09-werkator (uid 120974): none").shouldBeEmpty()
|
||||||
|
DiskQuota
|
||||||
|
.parse(
|
||||||
|
"""
|
||||||
|
Disk quotas for user mih09-werkator (uid 120974): none
|
||||||
|
Disk quotas for group mih09 (gid 102180): none
|
||||||
|
""".trimIndent(),
|
||||||
|
).shouldBeEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("only the lines of the directory's file store are considered, matched exactly or by device name") {
|
||||||
|
val lines = DiskQuota.parse(MIH09_QUOTA_OUTPUT)
|
||||||
|
|
||||||
|
val exact = DiskQuota.candidatesFor(lines, "/dev/disk/by-id/wwn-0x0000000000000001-part2")
|
||||||
|
exact shouldHaveSize 1
|
||||||
|
exact[0].source.filesystem shouldBe "/dev/disk/by-id/wwn-0x0000000000000001-part2"
|
||||||
|
|
||||||
|
val byDeviceName = DiskQuota.candidatesFor(lines, "/dev/sdb1")
|
||||||
|
byDeviceName shouldHaveSize 1
|
||||||
|
byDeviceName[0].source.filesystem shouldBe "/dev/sdb1"
|
||||||
|
|
||||||
|
DiskQuota.candidatesFor(lines, "/dev/mapper/unrelated").shouldBeEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a group quota candidate reports soft limit as total, blocks as used, and the hard limit alongside") {
|
||||||
|
val lines = DiskQuota.parse(MIH09_QUOTA_OUTPUT)
|
||||||
|
|
||||||
|
val candidates = DiskQuota.candidatesFor(lines, "/dev/disk/by-id/wwn-0x0000000000000001-part2")
|
||||||
|
|
||||||
|
val candidate = candidates.single()
|
||||||
|
candidate.space.totalBytes shouldBe 8_388_608L * 1024
|
||||||
|
candidate.space.usedBytes shouldBe 1_088_116L * 1024
|
||||||
|
candidate.space.freeBytes shouldBe (8_388_608L - 1_088_116L) * 1024
|
||||||
|
candidate.source.kind shouldBe "group"
|
||||||
|
candidate.source.subject shouldBe "mih09"
|
||||||
|
candidate.source.softLimitGib!! shouldBe (8_388_608.0 * 1024 / GIB)
|
||||||
|
candidate.source.hardLimitGib!! shouldBe (12_582_912.0 * 1024 / GIB)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an over-quota '*' marker on the blocks field does not break parsing") {
|
||||||
|
val output =
|
||||||
|
"""
|
||||||
|
Disk quotas for group mih09 (gid 102180):
|
||||||
|
Filesystem blocks quota limit grace files quota limit grace
|
||||||
|
/dev/sda1 9000000* 8388608 12582912 604800 14226 16777216 25165824 0
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val lines = DiskQuota.parse(output)
|
||||||
|
|
||||||
|
lines.single().blocksKib shouldBe 9_000_000
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a line with only a hard limit uses it as the total; a line with neither is no candidate") {
|
||||||
|
val output =
|
||||||
|
"""
|
||||||
|
Disk quotas for group mih09 (gid 102180):
|
||||||
|
Filesystem blocks quota limit grace files quota limit grace
|
||||||
|
/dev/sda1 1000000 0 2000000 0 1 0 0 0
|
||||||
|
/dev/sdc1 1000000 0 0 0 1 0 0 0
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val lines = DiskQuota.parse(output)
|
||||||
|
|
||||||
|
val hardOnly = DiskQuota.candidatesFor(lines, "/dev/sda1")
|
||||||
|
hardOnly.single().space.totalBytes shouldBe 2_000_000L * 1024
|
||||||
|
|
||||||
|
DiskQuota.candidatesFor(lines, "/dev/sdc1").shouldBeEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("among user quota, group quota and volume the smallest headroom binds") {
|
||||||
|
val userQuota =
|
||||||
|
DiskCandidate(
|
||||||
|
space =
|
||||||
|
SystemMetricsCollector.DiskSpace(
|
||||||
|
totalBytes = 10L * GIB.toLong(),
|
||||||
|
usedBytes = 8L * GIB.toLong(),
|
||||||
|
freeBytes =
|
||||||
|
2L * GIB.toLong(),
|
||||||
|
),
|
||||||
|
source = DiskSource(kind = "user", subject = "mih09-werkator", filesystem = "/dev/x"),
|
||||||
|
)
|
||||||
|
val groupQuota =
|
||||||
|
DiskCandidate(
|
||||||
|
space =
|
||||||
|
SystemMetricsCollector.DiskSpace(
|
||||||
|
totalBytes = 15L * GIB.toLong(),
|
||||||
|
usedBytes = 8L * GIB.toLong(),
|
||||||
|
freeBytes =
|
||||||
|
7L * GIB.toLong(),
|
||||||
|
),
|
||||||
|
source = DiskSource(kind = "group", subject = "mih09", filesystem = "/dev/x"),
|
||||||
|
)
|
||||||
|
val roomyVolume =
|
||||||
|
DiskCandidate(
|
||||||
|
space =
|
||||||
|
SystemMetricsCollector.DiskSpace(
|
||||||
|
totalBytes = 71L * GIB.toLong(),
|
||||||
|
usedBytes = 37L * GIB.toLong(),
|
||||||
|
freeBytes =
|
||||||
|
34L * GIB.toLong(),
|
||||||
|
),
|
||||||
|
source = DiskSource.volume(),
|
||||||
|
)
|
||||||
|
val tightVolume = roomyVolume.copy(space = roomyVolume.space.copy(freeBytes = 1L * GIB.toLong()))
|
||||||
|
|
||||||
|
DiskQuota.bindingDiskSpace(listOf(userQuota, groupQuota, roomyVolume)).source.kind shouldBe "user"
|
||||||
|
DiskQuota.bindingDiskSpace(listOf(groupQuota, roomyVolume)).source.kind shouldBe "group"
|
||||||
|
DiskQuota.bindingDiskSpace(listOf(groupQuota, tightVolume)).source.kind shouldBe "volume"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val GIB = 1_073_741_824.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,15 +17,19 @@ class SystemMetricsCollectorTest : FunSpec() {
|
|||||||
private fun collector(
|
private fun collector(
|
||||||
cpuCount: Int = 4,
|
cpuCount: Int = 4,
|
||||||
diskSpace: (Path) -> SystemMetricsCollector.DiskSpace = { GIB_100_DISK },
|
diskSpace: (Path) -> SystemMetricsCollector.DiskSpace = { GIB_100_DISK },
|
||||||
|
fileStoreName: (Path) -> String = { "/dev/volume" },
|
||||||
|
quotaOutput: () -> String? = { null },
|
||||||
repoSizeBytes: (Path) -> Long = { HALF_GIB_BYTES },
|
repoSizeBytes: (Path) -> Long = { HALF_GIB_BYTES },
|
||||||
) = SystemMetricsCollector(
|
) = SystemMetricsCollector(
|
||||||
stateFile = { tempDir.resolve("system-metrics-state.json") },
|
stateFile = { tempDir.resolve("system-metrics-state.json") },
|
||||||
workingDir = tempDir,
|
repoDirs = { listOf(tempDir) },
|
||||||
clock = Clock.fixed(now, ZoneOffset.UTC),
|
clock = Clock.fixed(now, ZoneOffset.UTC),
|
||||||
procStat = tempDir.resolve("stat"),
|
procStat = tempDir.resolve("stat"),
|
||||||
procMeminfo = tempDir.resolve("meminfo"),
|
procMeminfo = tempDir.resolve("meminfo"),
|
||||||
cpuCount = cpuCount,
|
cpuCount = cpuCount,
|
||||||
diskSpace = diskSpace,
|
diskSpace = diskSpace,
|
||||||
|
fileStoreName = fileStoreName,
|
||||||
|
quotaOutput = quotaOutput,
|
||||||
repoSizeBytes = repoSizeBytes,
|
repoSizeBytes = repoSizeBytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -200,6 +204,94 @@ class SystemMetricsCollectorTest : FunSpec() {
|
|||||||
snapshot.cpuCount shouldBe 4
|
snapshot.cpuCount shouldBe 4
|
||||||
snapshot.cpuUsed shouldBe null
|
snapshot.cpuUsed shouldBe null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("a group quota on the repository's filesystem replaces the file-store disk numbers") {
|
||||||
|
writeStat(total = 1000, idle = 700)
|
||||||
|
writeMeminfo(totalKib = 33_554_432, availableKib = 25_165_824)
|
||||||
|
val collector =
|
||||||
|
collector(
|
||||||
|
diskSpace = { SystemMetricsCollector.DiskSpace(totalBytes = 71L * GIB, usedBytes = 37L * GIB, freeBytes = 34L * GIB) },
|
||||||
|
fileStoreName = { MIH09_FILESYSTEM },
|
||||||
|
quotaOutput = { MIH09_QUOTA_OUTPUT },
|
||||||
|
)
|
||||||
|
|
||||||
|
collector.sample()
|
||||||
|
|
||||||
|
val snapshot = collector.snapshot()
|
||||||
|
snapshot.diskTotalGib shouldBe 8_388_608.0 * 1024 / GIB
|
||||||
|
snapshot.diskUsedGib.shouldNotBeNull().current shouldBe 1_088_116.0 * 1024 / GIB
|
||||||
|
snapshot.diskFreeGib.shouldNotBeNull().current shouldBe (8_388_608.0 - 1_088_116.0) * 1024 / GIB
|
||||||
|
snapshot.diskSource.shouldNotBeNull().let {
|
||||||
|
it.kind shouldBe "group"
|
||||||
|
it.subject shouldBe "mih09"
|
||||||
|
it.hardLimitGib shouldBe 12_582_912.0 * 1024 / GIB
|
||||||
|
}
|
||||||
|
snapshot.quotasPresent shouldBe true
|
||||||
|
}
|
||||||
|
|
||||||
|
test("without a quota the volume stays the disk source") {
|
||||||
|
writeStat(total = 1000, idle = 700)
|
||||||
|
writeMeminfo(totalKib = 33_554_432, availableKib = 25_165_824)
|
||||||
|
val collector = collector(quotaOutput = { null })
|
||||||
|
|
||||||
|
collector.sample()
|
||||||
|
|
||||||
|
val snapshot = collector.snapshot()
|
||||||
|
snapshot.diskTotalGib shouldBe 100.0
|
||||||
|
snapshot.diskSource.shouldNotBeNull().kind shouldBe "volume"
|
||||||
|
snapshot.quotasPresent shouldBe false
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a failing quota command degrades to the volume, logged once, the sample still succeeds") {
|
||||||
|
writeStat(total = 1000, idle = 700)
|
||||||
|
writeMeminfo(totalKib = 33_554_432, availableKib = 25_165_824)
|
||||||
|
val collector = collector(quotaOutput = { error("quota: command not found") })
|
||||||
|
|
||||||
|
collector.sample()
|
||||||
|
|
||||||
|
val snapshot = collector.snapshot()
|
||||||
|
snapshot.diskTotalGib shouldBe 100.0
|
||||||
|
snapshot.diskSource.shouldNotBeNull().kind shouldBe "volume"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a changed disk source restarts the disk series and keeps the others") {
|
||||||
|
writeStat(total = 1000, idle = 700)
|
||||||
|
writeMeminfo(totalKib = 33_554_432, availableKib = 25_165_824)
|
||||||
|
// sample 1, on the legacy volume-only binary: disk used 37 GiB, poisoning any later max
|
||||||
|
collector(
|
||||||
|
diskSpace = { SystemMetricsCollector.DiskSpace(totalBytes = 71L * GIB, usedBytes = 37L * GIB, freeBytes = 34L * GIB) },
|
||||||
|
).sample()
|
||||||
|
|
||||||
|
// sample 2, after the update: the first sample to find a binding group quota (~1 GiB used)
|
||||||
|
val quotaCollector = { output: String ->
|
||||||
|
collector(
|
||||||
|
diskSpace = { SystemMetricsCollector.DiskSpace(totalBytes = 71L * GIB, usedBytes = 37L * GIB, freeBytes = 34L * GIB) },
|
||||||
|
fileStoreName = { MIH09_FILESYSTEM },
|
||||||
|
quotaOutput = { output },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val sample2 = quotaCollector(MIH09_QUOTA_OUTPUT)
|
||||||
|
sample2.sample()
|
||||||
|
|
||||||
|
val afterUpdate = sample2.snapshot()
|
||||||
|
afterUpdate.sampleCount shouldBe 2
|
||||||
|
val diskUsedAfterUpdate = afterUpdate.diskUsedGib.shouldNotBeNull()
|
||||||
|
// a fresh series of one sample: min/max are the quota's ~1 GiB, not the 37 GiB volume history
|
||||||
|
diskUsedAfterUpdate.max shouldBe diskUsedAfterUpdate.current
|
||||||
|
diskUsedAfterUpdate.min shouldBe diskUsedAfterUpdate.current
|
||||||
|
// ram, unaffected by the disk-source change, keeps aggregating across both samples
|
||||||
|
afterUpdate.ramUsedGib.shouldNotBeNull().min shouldBe 8.0
|
||||||
|
|
||||||
|
// sample 3, same (unchanged) quota source but higher usage: the series must now continue
|
||||||
|
val sample3 = quotaCollector(MIH09_QUOTA_OUTPUT_HIGHER_USAGE)
|
||||||
|
sample3.sample()
|
||||||
|
|
||||||
|
val afterRestart = sample3.snapshot()
|
||||||
|
afterRestart.sampleCount shouldBe 3
|
||||||
|
val diskUsedAfterRestart = afterRestart.diskUsedGib.shouldNotBeNull()
|
||||||
|
diskUsedAfterRestart.min shouldBe diskUsedAfterUpdate.current
|
||||||
|
diskUsedAfterRestart.max shouldBe (2_000_000.0 * 1024 / GIB)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -213,5 +305,19 @@ class SystemMetricsCollectorTest : FunSpec() {
|
|||||||
usedBytes = 40L * GIB,
|
usedBytes = 40L * GIB,
|
||||||
freeBytes = 55L * GIB,
|
freeBytes = 55L * GIB,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private const val MIH09_FILESYSTEM = "/dev/disk/by-id/wwn-0x0000000000000001-part2"
|
||||||
|
|
||||||
|
/** `quota -u -g --no-wrap --raw-grace` on mih09, 2026-09-03 (the PR#16 attachment). */
|
||||||
|
private val MIH09_QUOTA_OUTPUT =
|
||||||
|
"""
|
||||||
|
Disk quotas for user mih09-werkator (uid 120974): none
|
||||||
|
Disk quotas for group mih09 (gid 102180):
|
||||||
|
Filesystem blocks quota limit grace files quota limit grace
|
||||||
|
$MIH09_FILESYSTEM 1088116 8388608 12582912 0 14226 16777216 25165824 0
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
/** Same group quota, but usage risen from 1088116 to 2000000 KiB — for the series-continuation test. */
|
||||||
|
private val MIH09_QUOTA_OUTPUT_HIGHER_USAGE = MIH09_QUOTA_OUTPUT.replace("1088116", "2000000")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package de.hoennig.werkator.repo
|
||||||
|
|
||||||
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
|
import io.kotest.assertions.throwables.shouldThrow
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.string.shouldContain
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.springframework.beans.factory.ObjectProvider
|
||||||
|
import org.springframework.boot.info.BuildProperties
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
class RepoRegistryTest : FunSpec() {
|
||||||
|
private fun loaderWithHome(
|
||||||
|
home: Path,
|
||||||
|
version: String = "1.0.0",
|
||||||
|
): ConfigLoader {
|
||||||
|
val provider = mockk<ObjectProvider<BuildProperties>>()
|
||||||
|
every { provider.getIfAvailable() } returns BuildProperties(Properties().apply { setProperty("version", version) })
|
||||||
|
return ConfigLoader(provider).apply { homeDir = home }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun gitRepo(
|
||||||
|
parent: Path,
|
||||||
|
name: String,
|
||||||
|
): Path = Files.createDirectories(parent.resolve(name)).also { Files.createDirectories(it.resolve(".git")) }
|
||||||
|
|
||||||
|
private fun registry(loader: ConfigLoader) = RepoRegistry(loader, RepoContexts(loader))
|
||||||
|
|
||||||
|
init {
|
||||||
|
test("without a home config the registry is the current directory alone") {
|
||||||
|
val home = Files.createTempDirectory("werkator-home")
|
||||||
|
val registry = registry(loaderWithHome(home))
|
||||||
|
|
||||||
|
registry.all().map { it.workingDir } shouldBe listOf(Paths.get("."))
|
||||||
|
registry.current().workingDir shouldBe Paths.get(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("every registry entry becomes a context, named after its directory unless the entry says otherwise") {
|
||||||
|
val home = Files.createTempDirectory("werkator-home")
|
||||||
|
val one = gitRepo(home, "repos/werkator")
|
||||||
|
val two = gitRepo(home, "repos/werkbaum")
|
||||||
|
home.resolve(".werkator.yml").toFile().writeText(
|
||||||
|
"""
|
||||||
|
repositories:
|
||||||
|
- path: ~/repos/werkator
|
||||||
|
- path: $two
|
||||||
|
name: baum
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val registry = registry(loaderWithHome(home))
|
||||||
|
|
||||||
|
registry.all().map { it.name to it.workingDir } shouldBe listOf("werkator" to one, "baum" to two)
|
||||||
|
registry.byName("baum")?.workingDir shouldBe two
|
||||||
|
registry.byName("nope") shouldBe null
|
||||||
|
// the cwd is not registered, so the first entry is the default
|
||||||
|
registry.current().name shouldBe "werkator"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an entry that is not a git repository aborts the start, naming the home file") {
|
||||||
|
val home = Files.createTempDirectory("werkator-home")
|
||||||
|
Files.createDirectories(home.resolve("not-a-repo"))
|
||||||
|
home.resolve(".werkator.yml").toFile().writeText("repositories:\n - path: ~/not-a-repo\n")
|
||||||
|
|
||||||
|
val error = shouldThrow<IllegalStateException> { registry(loaderWithHome(home)).all() }
|
||||||
|
|
||||||
|
error.message shouldContain home.resolve(".werkator.yml").toString()
|
||||||
|
error.message shouldContain "not a git repository"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("two entries resolving to the same name abort the start") {
|
||||||
|
val home = Files.createTempDirectory("werkator-home")
|
||||||
|
gitRepo(home, "a/werkator")
|
||||||
|
gitRepo(home, "b/werkator")
|
||||||
|
home.resolve(".werkator.yml").toFile().writeText("repositories:\n - path: ~/a/werkator\n - path: ~/b/werkator\n")
|
||||||
|
|
||||||
|
val error = shouldThrow<IllegalStateException> { registry(loaderWithHome(home)).all() }
|
||||||
|
|
||||||
|
error.message shouldContain "werkator"
|
||||||
|
error.message shouldContain "distinct name"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a repository whose configuration must not be read is skipped, the others are served") {
|
||||||
|
val home = Files.createTempDirectory("werkator-home")
|
||||||
|
val fine = gitRepo(home, "fine")
|
||||||
|
val broken = gitRepo(home, "broken")
|
||||||
|
broken.resolve(".werkator.yml").toFile().writeText("werkator:\n version:\n since: \"9.9\"\n")
|
||||||
|
home.resolve(".werkator.yml").toFile().writeText("repositories:\n - path: ~/broken\n - path: ~/fine\n")
|
||||||
|
|
||||||
|
val registry = registry(loaderWithHome(home, version = "1.0.0"))
|
||||||
|
|
||||||
|
registry.all().map { it.workingDir } shouldBe listOf(fine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import com.ninjasquad.springmockk.MockkBean
|
|||||||
import de.hoennig.werkator.build.ArtifactStore
|
import de.hoennig.werkator.build.ArtifactStore
|
||||||
import de.hoennig.werkator.build.BuildResult
|
import de.hoennig.werkator.build.BuildResult
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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
|
||||||
@@ -32,6 +34,12 @@ class ArtifactFileControllerTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var branchPermalinks: BranchPermalinks
|
lateinit var branchPermalinks: BranchPermalinks
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var repo: RepoContext
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var registry: RepoRegistry
|
||||||
|
|
||||||
private val artifactDir: Path = Files.createTempDirectory("werkator-artifact-serve-test")
|
private val artifactDir: Path = Files.createTempDirectory("werkator-artifact-serve-test")
|
||||||
|
|
||||||
private val greenBuild =
|
private val greenBuild =
|
||||||
@@ -46,12 +54,18 @@ class ArtifactFileControllerTest : FunSpec() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
beforeEach {
|
beforeEach {
|
||||||
clearMocks(artifactStore, branchPermalinks)
|
clearMocks(artifactStore, branchPermalinks, repo, registry)
|
||||||
|
every { repo.name } returns "test"
|
||||||
|
every { repo.artifactStore } returns artifactStore
|
||||||
|
every { registry.current() } returns repo
|
||||||
|
every { registry.all() } returns listOf(repo)
|
||||||
|
every { registry.byName(any()) } returns null
|
||||||
|
every { registry.byName("test") } returns repo
|
||||||
every { artifactStore.artifactDir(any()) } returns null
|
every { artifactStore.artifactDir(any()) } returns null
|
||||||
every { artifactStore.artifactDir("known-key") } returns artifactDir
|
every { artifactStore.artifactDir("known-key") } returns artifactDir
|
||||||
every { branchPermalinks.latestGreenBuild(any()) } throws
|
every { branchPermalinks.latestGreenBuild(any(), any()) } throws
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds")
|
ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds")
|
||||||
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild
|
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild
|
||||||
}
|
}
|
||||||
|
|
||||||
test("serves an html artifact with no-cache headers") {
|
test("serves an html artifact with no-cache headers") {
|
||||||
@@ -188,7 +202,7 @@ class ArtifactFileControllerTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("permanent URL answers 404 when the green build's artifacts are gone") {
|
test("permanent URL answers 404 when the green build's artifacts are gone") {
|
||||||
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild.copy(artifactKey = "pruned-key")
|
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild.copy(artifactKey = "pruned-key")
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
.perform(get("/branches/main/build.log"))
|
.perform(get("/branches/main/build.log"))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import de.hoennig.werkator.build.ArtifactKeys
|
|||||||
import de.hoennig.werkator.build.BuildResult
|
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.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.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
@@ -17,7 +18,8 @@ import java.time.Instant
|
|||||||
|
|
||||||
class BranchPermalinksTest : FunSpec() {
|
class BranchPermalinksTest : FunSpec() {
|
||||||
private val repository = mockk<BuildResultRepository>()
|
private val repository = mockk<BuildResultRepository>()
|
||||||
private val permalinks = BranchPermalinks(repository)
|
private val repo = mockk<RepoContext>().also { every { it.results } returns repository }
|
||||||
|
private val permalinks = BranchPermalinks()
|
||||||
|
|
||||||
private fun result(
|
private fun result(
|
||||||
branch: String,
|
branch: String,
|
||||||
@@ -36,20 +38,20 @@ class BranchPermalinksTest : FunSpec() {
|
|||||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("main"))
|
every { repository.latestPerName() } returns listOf(result("feature/x"), result("main"))
|
||||||
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
||||||
|
|
||||||
permalinks.latestGreenBuild("feature_x") shouldBe result("feature/x")
|
permalinks.latestGreenBuild(repo, "feature_x") shouldBe result("feature/x")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("resolves the full branch key with hash suffix") {
|
test("resolves the full branch key with hash suffix") {
|
||||||
every { repository.latestPerName() } returns listOf(result("feature/x"))
|
every { repository.latestPerName() } returns listOf(result("feature/x"))
|
||||||
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
||||||
|
|
||||||
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
permalinks.latestGreenBuild(repo, ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("an unknown branch key answers 404") {
|
test("an unknown branch key answers 404") {
|
||||||
every { repository.latestPerName() } returns listOf(result("main"))
|
every { repository.latestPerName() } returns listOf(result("main"))
|
||||||
|
|
||||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("gone") }
|
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "gone") }
|
||||||
|
|
||||||
exception.statusCode shouldBe HttpStatus.NOT_FOUND
|
exception.statusCode shouldBe HttpStatus.NOT_FOUND
|
||||||
}
|
}
|
||||||
@@ -58,7 +60,7 @@ class BranchPermalinksTest : FunSpec() {
|
|||||||
every { repository.latestPerName() } returns listOf(result("main", status = BuildStatus.FAILED))
|
every { repository.latestPerName() } returns listOf(result("main", status = BuildStatus.FAILED))
|
||||||
every { repository.latestGreenFor("main") } returns null
|
every { repository.latestGreenFor("main") } returns null
|
||||||
|
|
||||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("main") }
|
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "main") }
|
||||||
|
|
||||||
exception.statusCode shouldBe HttpStatus.NOT_FOUND
|
exception.statusCode shouldBe HttpStatus.NOT_FOUND
|
||||||
}
|
}
|
||||||
@@ -66,7 +68,7 @@ class BranchPermalinksTest : FunSpec() {
|
|||||||
test("a permanent key matching several branches answers 409 and names the candidates") {
|
test("a permanent key matching several branches answers 409 and names the candidates") {
|
||||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
||||||
|
|
||||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("feature_x") }
|
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "feature_x") }
|
||||||
|
|
||||||
exception.statusCode shouldBe HttpStatus.CONFLICT
|
exception.statusCode shouldBe HttpStatus.CONFLICT
|
||||||
exception.reason.orEmpty() shouldContain "feature/x"
|
exception.reason.orEmpty() shouldContain "feature/x"
|
||||||
@@ -76,7 +78,7 @@ class BranchPermalinksTest : FunSpec() {
|
|||||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
||||||
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
||||||
|
|
||||||
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
permalinks.latestGreenBuild(repo, ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("permanentUrl uses the hash-free branch key") {
|
test("permanentUrl uses the hash-free branch key") {
|
||||||
@@ -89,7 +91,7 @@ class BranchPermalinksTest : FunSpec() {
|
|||||||
every { repository.latestGreenFor("main@nightly") } returns nightly
|
every { repository.latestGreenFor("main@nightly") } returns nightly
|
||||||
|
|
||||||
// sanitized like any branch key: the '@' becomes '_' in the URL
|
// sanitized like any branch key: the '@' becomes '_' in the URL
|
||||||
permalinks.latestGreenBuild("main_nightly") shouldBe nightly
|
permalinks.latestGreenBuild(repo, "main_nightly") shouldBe nightly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ 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 de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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
|
||||||
|
import io.mockk.mockk
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
@@ -27,6 +31,7 @@ import java.time.Duration
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@WebMvcTest(BuildsApiController::class, properties = ["spring.main.web-application-type=servlet"])
|
@WebMvcTest(BuildsApiController::class, properties = ["spring.main.web-application-type=servlet"])
|
||||||
|
@Import(RepoLinks::class)
|
||||||
class BuildsApiControllerTest : FunSpec() {
|
class BuildsApiControllerTest : FunSpec() {
|
||||||
private val tempDir: Path = Files.createTempDirectory("werkator-server-test")
|
private val tempDir: Path = Files.createTempDirectory("werkator-server-test")
|
||||||
|
|
||||||
@@ -54,6 +59,9 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var repo: RepoContext
|
lateinit var repo: RepoContext
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var registry: RepoRegistry
|
||||||
|
|
||||||
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 =
|
||||||
@@ -68,6 +76,7 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
|
|
||||||
private fun runningBuild(liveLogFile: Path) =
|
private fun runningBuild(liveLogFile: Path) =
|
||||||
RunningBuild(
|
RunningBuild(
|
||||||
|
repo = repo,
|
||||||
branch = "main",
|
branch = "main",
|
||||||
commit = successResult.commit,
|
commit = successResult.commit,
|
||||||
artifactKey = "main-abc123-running",
|
artifactKey = "main-abc123-running",
|
||||||
@@ -78,8 +87,16 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
beforeEach {
|
beforeEach {
|
||||||
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing, repo)
|
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing, repo, registry)
|
||||||
|
every { repo.name } returns "test"
|
||||||
every { repo.workingDir } returns tempDir
|
every { repo.workingDir } returns tempDir
|
||||||
|
every { repo.results } returns repository
|
||||||
|
every { repo.artifactStore } returns artifactStore
|
||||||
|
// the unscoped routes mean the served repository; `/api/repos/test/…` names it
|
||||||
|
every { registry.all() } returns listOf(repo)
|
||||||
|
every { registry.current() } returns repo
|
||||||
|
every { registry.byName(any()) } returns null
|
||||||
|
every { registry.byName("test") } returns repo
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -133,6 +150,35 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
.andExpect(jsonPath("$[0].logSize").value(5))
|
.andExpect(jsonPath("$[0].logSize").value(5))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("current answers only the served repository's builds") {
|
||||||
|
val liveLogFile = Files.writeString(tempDir.resolve("mine.log"), "12345")
|
||||||
|
val mine = runningBuild(liveLogFile)
|
||||||
|
val foreign =
|
||||||
|
runningBuild(liveLogFile).copy(
|
||||||
|
repo = mockk<RepoContext>(),
|
||||||
|
artifactKey = "other-repo-running",
|
||||||
|
)
|
||||||
|
every { buildExecutor.currentBuilds() } returns listOf(mine, foreign)
|
||||||
|
every { repository.history() } returns
|
||||||
|
listOf(successResult.copy(status = BuildStatus.RUNNING, artifactKey = mine.artifactKey))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/builds/current"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$[0].artifactKey").value(mine.artifactKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("current log of a build in another repository answers 404") {
|
||||||
|
val liveLogFile = Files.writeString(tempDir.resolve("foreign.log"), "hello world")
|
||||||
|
val foreign = runningBuild(liveLogFile).copy(repo = mockk<RepoContext>())
|
||||||
|
every { buildExecutor.currentBuilds() } returns listOf(foreign)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/builds/current/${foreign.artifactKey}/log"))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
test("current log answers the tail from the requested offset") {
|
test("current log answers the tail from the requested offset") {
|
||||||
val liveLogFile = Files.writeString(tempDir.resolve("tail.log"), "hello world")
|
val liveLogFile = Files.writeString(tempDir.resolve("tail.log"), "hello world")
|
||||||
val build = runningBuild(liveLogFile)
|
val build = runningBuild(liveLogFile)
|
||||||
@@ -299,6 +345,8 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("cancel answers 202 for a cancellable build and 404 otherwise") {
|
test("cancel answers 202 for a cancellable build and 404 otherwise") {
|
||||||
|
every { repository.history() } returns
|
||||||
|
listOf(successResult.copy(artifactKey = "known-key"), successResult.copy(artifactKey = "unknown-key"))
|
||||||
every { buildExecutor.cancel("known-key") } returns true
|
every { buildExecutor.cancel("known-key") } returns true
|
||||||
every { buildExecutor.cancel("unknown-key") } returns false
|
every { buildExecutor.cancel("unknown-key") } returns false
|
||||||
|
|
||||||
@@ -311,6 +359,32 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
.andExpect(status().isNotFound)
|
.andExpect(status().isNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("cancel does not reach a build of another repository") {
|
||||||
|
// the key exists in the executor, but not in this repository's results
|
||||||
|
every { repository.history() } returns listOf(successResult)
|
||||||
|
every { buildExecutor.cancel(any()) } returns true
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
post("/api/repos/test/builds/other-repo-key/cancel")
|
||||||
|
.header(BuildsApiController.TOKEN_HEADER, "secret"),
|
||||||
|
).andExpect(status().isNotFound)
|
||||||
|
verify(exactly = 0) { buildExecutor.cancel(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the repository-scoped routes answer for the named repository and 404 for an unknown name") {
|
||||||
|
every { repository.latestPerName() } returns listOf(successResult)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/repos/test/builds/latest"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$[0].artifactKey").value("main-abc123-key"))
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/repos/no-such-repo/builds/latest"))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
.andExpect(jsonPath("$.error").value("no repository named 'no-such-repo'"))
|
||||||
|
}
|
||||||
|
|
||||||
test("a token in the query string is not accepted — the header is the only way") {
|
test("a token in the query string is not accepted — the header is the only way") {
|
||||||
mockMvc
|
mockMvc
|
||||||
.perform(post("/api/builds/restart").param("branch", "main").param("token", "secret"))
|
.perform(post("/api/builds/restart").param("branch", "main").param("token", "secret"))
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ 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 de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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
|
||||||
import org.hamcrest.Matchers.containsString
|
import org.hamcrest.Matchers.containsString
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
||||||
@@ -36,6 +39,7 @@ import java.time.Instant
|
|||||||
controllers = [UiController::class, ArtifactFileController::class],
|
controllers = [UiController::class, ArtifactFileController::class],
|
||||||
properties = ["spring.main.web-application-type=servlet"],
|
properties = ["spring.main.web-application-type=servlet"],
|
||||||
)
|
)
|
||||||
|
@Import(RepoLinks::class)
|
||||||
class PermanentBranchRoutesTest : FunSpec() {
|
class PermanentBranchRoutesTest : FunSpec() {
|
||||||
@Autowired
|
@Autowired
|
||||||
lateinit var mockMvc: MockMvc
|
lateinit var mockMvc: MockMvc
|
||||||
@@ -70,6 +74,9 @@ class PermanentBranchRoutesTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var repo: RepoContext
|
lateinit var repo: RepoContext
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var registry: RepoRegistry
|
||||||
|
|
||||||
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 =
|
||||||
@@ -95,14 +102,22 @@ class PermanentBranchRoutesTest : FunSpec() {
|
|||||||
branchListing,
|
branchListing,
|
||||||
branchPermalinks,
|
branchPermalinks,
|
||||||
repo,
|
repo,
|
||||||
|
registry,
|
||||||
)
|
)
|
||||||
|
every { repo.name } returns "test"
|
||||||
every { repo.workingDir } returns Paths.get(".")
|
every { repo.workingDir } returns Paths.get(".")
|
||||||
|
every { repo.results } returns repository
|
||||||
|
every { repo.artifactStore } returns artifactStore
|
||||||
|
every { registry.all() } returns listOf(repo)
|
||||||
|
every { registry.current() } returns repo
|
||||||
|
every { registry.byName(any()) } returns null
|
||||||
|
every { registry.byName("test") } returns repo
|
||||||
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
|
||||||
every { controlTokens.token() } returns "test-token"
|
every { controlTokens.token() } returns "test-token"
|
||||||
every { branchListing.branches(any()) } returns emptyList()
|
every { branchListing.branches(any()) } returns emptyList()
|
||||||
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild
|
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild
|
||||||
every { artifactStore.artifactDir("main-key") } returns artifactDir
|
every { artifactStore.artifactDir("main-key") } returns artifactDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,16 +18,20 @@ 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 de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
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
|
||||||
import io.kotest.matchers.string.shouldNotContain
|
import io.kotest.matchers.string.shouldNotContain
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
import org.hamcrest.Matchers.containsString
|
import org.hamcrest.Matchers.containsString
|
||||||
import org.hamcrest.Matchers.not
|
import org.hamcrest.Matchers.not
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
@@ -42,6 +46,7 @@ import java.time.Duration
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@WebMvcTest(UiController::class, properties = ["spring.main.web-application-type=servlet"])
|
@WebMvcTest(UiController::class, properties = ["spring.main.web-application-type=servlet"])
|
||||||
|
@Import(RepoLinks::class)
|
||||||
class UiControllerTest : FunSpec() {
|
class UiControllerTest : FunSpec() {
|
||||||
private val tempDir: Path = Files.createTempDirectory("werkator-ui-test")
|
private val tempDir: Path = Files.createTempDirectory("werkator-ui-test")
|
||||||
|
|
||||||
@@ -78,6 +83,9 @@ class UiControllerTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var repo: RepoContext
|
lateinit var repo: RepoContext
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var registry: RepoRegistry
|
||||||
|
|
||||||
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 =
|
||||||
@@ -119,8 +127,16 @@ class UiControllerTest : FunSpec() {
|
|||||||
branchListing,
|
branchListing,
|
||||||
branchPermalinks,
|
branchPermalinks,
|
||||||
repo,
|
repo,
|
||||||
|
registry,
|
||||||
)
|
)
|
||||||
|
every { repo.name } returns "test"
|
||||||
every { repo.workingDir } returns Paths.get(".")
|
every { repo.workingDir } returns Paths.get(".")
|
||||||
|
every { repo.results } returns repository
|
||||||
|
every { repo.artifactStore } returns artifactStore
|
||||||
|
every { registry.all() } returns listOf(repo)
|
||||||
|
every { registry.current() } returns repo
|
||||||
|
every { registry.byName(any()) } returns null
|
||||||
|
every { registry.byName("test") } returns repo
|
||||||
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"),
|
||||||
@@ -144,6 +160,43 @@ class UiControllerTest : FunSpec() {
|
|||||||
.andExpect(content().string(not(containsString("""href="/current""""))))
|
.andExpect(content().string(not(containsString("""href="/current""""))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("with one served repository the pages keep their existing URLs and show no switcher") {
|
||||||
|
every { repository.latestPerName() } returns listOf(successResult)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(content().string(containsString("""href="/branches"""")))
|
||||||
|
// Thymeleaf drops an attribute whose value is empty, and werkator.js falls back to ""
|
||||||
|
.andExpect(content().string(containsString("""<meta name="werkator-repo-base">""")))
|
||||||
|
.andExpect(content().string(not(containsString("""class="repo-select""""))))
|
||||||
|
.andExpect(content().string(containsString("""class="repo-name"""")))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("with several served repositories every link names its repository and the switcher appears") {
|
||||||
|
val other = mockk<RepoContext>()
|
||||||
|
every { other.name } returns "other"
|
||||||
|
every { registry.all() } returns listOf(repo, other)
|
||||||
|
every { repository.latestPerName() } returns listOf(successResult)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/repos/test"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(content().string(containsString("""href="/repos/test/branches"""")))
|
||||||
|
.andExpect(content().string(containsString("""data-api="/api/repos/test/builds/latest"""")))
|
||||||
|
.andExpect(content().string(containsString("""<meta name="werkator-repo-base" content="/repos/test">""")))
|
||||||
|
.andExpect(content().string(containsString("""class="repo-select"""")))
|
||||||
|
.andExpect(content().string(containsString("""<option value="/repos/test" selected="selected">test</option>""")))
|
||||||
|
.andExpect(content().string(containsString("""<option value="/repos/other">other</option>""")))
|
||||||
|
.andExpect(content().string(not(containsString("""class="repo-name""""))))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a page of a repository this instance does not serve answers 404") {
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/repos/no-such-repo"))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
|
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
|
||||||
every { repository.latestPerName() } returns listOf(successResult)
|
every { repository.latestPerName() } returns listOf(successResult)
|
||||||
|
|
||||||
@@ -246,6 +299,7 @@ class UiControllerTest : FunSpec() {
|
|||||||
test("current view renders a card per running build with cancel button and started-at attribute") {
|
test("current view renders a card per running build with cancel button and started-at attribute") {
|
||||||
val build =
|
val build =
|
||||||
RunningBuild(
|
RunningBuild(
|
||||||
|
repo = repo,
|
||||||
branch = "main",
|
branch = "main",
|
||||||
commit = successResult.commit,
|
commit = successResult.commit,
|
||||||
artifactKey = "main-abc123-running",
|
artifactKey = "main-abc123-running",
|
||||||
@@ -502,7 +556,7 @@ class UiControllerTest : FunSpec() {
|
|||||||
Files.writeString(artifactDir.resolve("build.stdout.log"), "out")
|
Files.writeString(artifactDir.resolve("build.stdout.log"), "out")
|
||||||
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
|
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
|
||||||
Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "<html></html>")
|
Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "<html></html>")
|
||||||
every { branchPermalinks.latestGreenBuild("main") } returns successResult
|
every { branchPermalinks.latestGreenBuild(any(), "main") } returns successResult
|
||||||
every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir
|
every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
@@ -516,7 +570,7 @@ class UiControllerTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("permanent artifact index of a branch without a green build answers 404") {
|
test("permanent artifact index of a branch without a green build answers 404") {
|
||||||
every { branchPermalinks.latestGreenBuild("main") } throws
|
every { branchPermalinks.latestGreenBuild(any(), "main") } throws
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "branch 'main' has no successful build")
|
ResponseStatusException(HttpStatus.NOT_FOUND, "branch 'main' has no successful build")
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.hoennig.werkator.server
|
package de.hoennig.werkator.server
|
||||||
|
|
||||||
import de.hoennig.werkator.config.GiteaConfig
|
import de.hoennig.werkator.config.GiteaConfig
|
||||||
|
import de.hoennig.werkator.metrics.DiskSource
|
||||||
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 io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
@@ -8,6 +9,27 @@ import io.kotest.matchers.shouldBe
|
|||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
|
private fun metricsWithDisk(
|
||||||
|
diskTotalGib: Double,
|
||||||
|
diskSource: DiskSource? = null,
|
||||||
|
quotasPresent: Boolean = false,
|
||||||
|
) = SystemMetrics(
|
||||||
|
timestamp = Instant.parse("2026-09-03T12:00:00Z"),
|
||||||
|
sampleCount = 1,
|
||||||
|
cpuCount = 4,
|
||||||
|
ramTotalGib = 8.0,
|
||||||
|
diskTotalGib = diskTotalGib,
|
||||||
|
diskSource = diskSource,
|
||||||
|
quotasPresent = quotasPresent,
|
||||||
|
cpuUsed = null,
|
||||||
|
cpuIdle = null,
|
||||||
|
ramUsedGib = null,
|
||||||
|
ramFreeGib = null,
|
||||||
|
diskUsedGib = null,
|
||||||
|
diskFreeGib = null,
|
||||||
|
repoSizeGib = null,
|
||||||
|
)
|
||||||
|
|
||||||
class UiViewsTest : FunSpec() {
|
class UiViewsTest : FunSpec() {
|
||||||
init {
|
init {
|
||||||
test("durations format as m:ss and h:mm:ss like the legacy duration column") {
|
test("durations format as m:ss and h:mm:ss like the legacy duration column") {
|
||||||
@@ -71,6 +93,36 @@ class UiViewsTest : FunSpec() {
|
|||||||
classesByKey["repoSizeGib"] shouldBe ""
|
classesByKey["repoSizeGib"] shouldBe ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("the disk total names the binding source: user quota, group quota, or the volume") {
|
||||||
|
UiFormats.diskTotal(metricsWithDisk(70.99, diskSource = null)) shouldBe "70.99 GiB"
|
||||||
|
|
||||||
|
UiFormats.diskTotal(
|
||||||
|
metricsWithDisk(
|
||||||
|
8.0,
|
||||||
|
diskSource = DiskSource(kind = "group", subject = "mih09", hardLimitGib = 12.0),
|
||||||
|
),
|
||||||
|
) shouldBe "8.00 GiB (group quota mih09, hard limit 12.00 GiB)"
|
||||||
|
|
||||||
|
UiFormats.diskTotal(
|
||||||
|
metricsWithDisk(
|
||||||
|
4.0,
|
||||||
|
diskSource = DiskSource(kind = "user", subject = "mih09-werkator", hardLimitGib = 6.0),
|
||||||
|
),
|
||||||
|
) shouldBe "4.00 GiB (user quota mih09-werkator, hard limit 6.00 GiB)"
|
||||||
|
|
||||||
|
UiFormats.diskTotal(
|
||||||
|
metricsWithDisk(70.99, diskSource = DiskSource.volume(), quotasPresent = true),
|
||||||
|
) shouldBe "70.99 GiB (volume, tighter than the quotas)"
|
||||||
|
|
||||||
|
UiFormats.diskTotal(
|
||||||
|
metricsWithDisk(70.99, diskSource = DiskSource.volume(), quotasPresent = false),
|
||||||
|
) shouldBe "70.99 GiB"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the disk total is n/a when the disk metric itself is unavailable") {
|
||||||
|
UiFormats.diskTotal(metricsWithDisk(0.0, diskSource = null).copy(diskTotalGib = null)) shouldBe "n/a"
|
||||||
|
}
|
||||||
|
|
||||||
test("Gitea web links escape branch segments but keep slashes") {
|
test("Gitea web links escape branch segments but keep slashes") {
|
||||||
val links =
|
val links =
|
||||||
GiteaWebLinks(GiteaConfig(baseUrl = "https://git.example.org/", owner = "acme", repo = "widget"))
|
GiteaWebLinks(GiteaConfig(baseUrl = "https://git.example.org/", owner = "acme", repo = "widget"))
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ class WatcherTest : FunSpec() {
|
|||||||
val branch = secondArg<String>()
|
val branch = secondArg<String>()
|
||||||
val commit = thirdArg<String>()
|
val commit = thirdArg<String>()
|
||||||
startedBuilds += branch to commit
|
startedBuilds += branch to commit
|
||||||
runningBuild(branch, commit)
|
runningBuild(repo, branch, commit)
|
||||||
}
|
}
|
||||||
every { artifactStore.prune(any()) } returns emptyList()
|
every { artifactStore.prune(any()) } returns emptyList()
|
||||||
}
|
}
|
||||||
@@ -131,11 +131,13 @@ class WatcherTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun runningBuild(
|
private fun runningBuild(
|
||||||
|
repo: RepoContext,
|
||||||
branch: String,
|
branch: String,
|
||||||
commit: String,
|
commit: String,
|
||||||
): RunningBuild {
|
): RunningBuild {
|
||||||
val stagingDir = Files.createTempDirectory("werkator-watcher-staging")
|
val stagingDir = Files.createTempDirectory("werkator-watcher-staging")
|
||||||
return RunningBuild(
|
return RunningBuild(
|
||||||
|
repo = repo,
|
||||||
branch = branch,
|
branch = branch,
|
||||||
commit = commit,
|
commit = commit,
|
||||||
artifactKey = ArtifactKeys.buildKey(branch, Instant.now()),
|
artifactKey = ArtifactKeys.buildKey(branch, Instant.now()),
|
||||||
@@ -272,7 +274,7 @@ class WatcherTest : FunSpec() {
|
|||||||
test("a poll cycle completes while a build is running and still enqueues other branches") {
|
test("a poll cycle completes while a build is running and still enqueues other branches") {
|
||||||
val harness = Harness()
|
val harness = Harness()
|
||||||
harness.seed("main", BuildStatus.RUNNING, commit = "commit-1")
|
harness.seed("main", BuildStatus.RUNNING, commit = "commit-1")
|
||||||
every { harness.buildExecutor.currentBuilds() } returns listOf(runningBuild("main", "commit-1"))
|
every { harness.buildExecutor.currentBuilds() } returns listOf(runningBuild(harness.repo, "main", "commit-1"))
|
||||||
every { harness.gitService.originBranches(any()) } returns listOf("main", "feature/other")
|
every { harness.gitService.originBranches(any()) } returns listOf("main", "feature/other")
|
||||||
every { harness.gitService.localBranches(any()) } returns listOf("main")
|
every { harness.gitService.localBranches(any()) } returns listOf("main")
|
||||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||||
@@ -767,19 +769,84 @@ class WatcherTest : FunSpec() {
|
|||||||
Files.exists(busyWorktree).shouldBeTrue()
|
Files.exists(busyWorktree).shouldBeTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("a running build of another repository does not keep this repository's worktree") {
|
||||||
|
val harness = Harness()
|
||||||
|
harness.seed("gone", BuildStatus.SUCCESS, commit = "commit-1")
|
||||||
|
val goneWorktree = harness.worktreeDir("gone")
|
||||||
|
val otherDir = Files.createTempDirectory("werkator-watcher-other-running")
|
||||||
|
val other =
|
||||||
|
RepoContext(
|
||||||
|
"other",
|
||||||
|
otherDir,
|
||||||
|
FileBuildResultRepository(otherDir.resolve(".git/werkator/build-results.json")),
|
||||||
|
harness.artifactStore,
|
||||||
|
)
|
||||||
|
// the other repository builds a branch of the same name — its build must not
|
||||||
|
// protect this repository's worktree, whose branch is gone from origin
|
||||||
|
every { harness.buildExecutor.currentBuilds() } returns listOf(runningBuild(other, "gone", "commit-other"))
|
||||||
|
every { harness.gitService.originBranches(any()) } returns emptyList()
|
||||||
|
|
||||||
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
|
Files.exists(goneWorktree).shouldBeFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("one repository's unreachable origin neither stops nor silences the other") {
|
||||||
|
val harness = Harness()
|
||||||
|
val otherDir = Files.createTempDirectory("werkator-watcher-other")
|
||||||
|
val other =
|
||||||
|
RepoContext(
|
||||||
|
"other",
|
||||||
|
otherDir,
|
||||||
|
FileBuildResultRepository(otherDir.resolve(".git/werkator/build-results.json")),
|
||||||
|
harness.artifactStore,
|
||||||
|
)
|
||||||
|
every { harness.gitService.fetchOrigin(harness.workingDir) } throws RuntimeException("origin unreachable")
|
||||||
|
every { harness.gitService.originBranches(otherDir) } returns listOf("main")
|
||||||
|
every { harness.gitService.originBranchHeads(otherDir) } returns mapOf("main" to "commit-other")
|
||||||
|
every { harness.gitService.localBranches(otherDir) } returns listOf("main")
|
||||||
|
every { harness.gitService.hasNewCommits("main", otherDir) } returns true
|
||||||
|
every { harness.gitService.originHeadCommit("main", otherDir) } returns "commit-other"
|
||||||
|
|
||||||
|
harness.watcher.pollAll(listOf(harness.repo, other))
|
||||||
|
|
||||||
|
verify { harness.buildExecutor.startBuild(other, "main", "commit-other", BuildDefinition.DEFAULT) }
|
||||||
|
verify(exactly = 0) { harness.buildExecutor.startBuild(harness.repo, any(), any(), any()) }
|
||||||
|
val state = harness.watcher.state()
|
||||||
|
state.lastFetchError shouldBe "test: origin unreachable"
|
||||||
|
state.lastPollError shouldBe null
|
||||||
|
state.repositories.map { it.name } shouldBe listOf("test", "other")
|
||||||
|
state.repositories[0].lastFetchError shouldBe "origin unreachable"
|
||||||
|
state.repositories[1].lastFetchError shouldBe null
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a repository whose poll crashes reports it by name and the cycle goes on") {
|
||||||
|
val harness = Harness()
|
||||||
|
val otherDir = Files.createTempDirectory("werkator-watcher-other")
|
||||||
|
val other = RepoContext("other", otherDir, harness.repository, harness.artifactStore)
|
||||||
|
every { harness.gitService.originBranches(otherDir) } throws IllegalStateException("corrupt refs")
|
||||||
|
|
||||||
|
harness.watcher.pollAll(listOf(harness.repo, other))
|
||||||
|
|
||||||
|
val state = harness.watcher.state()
|
||||||
|
state.lastPollError shouldBe "other: corrupt refs"
|
||||||
|
state.repositories[1].lastPollError shouldBe "corrupt refs"
|
||||||
|
state.repositories[0].lastPollError shouldBe null
|
||||||
|
}
|
||||||
|
|
||||||
test("start runs recovery plus an immediate first poll; stop halts the loop") {
|
test("start runs recovery plus an immediate first poll; stop halts the loop") {
|
||||||
val harness = Harness()
|
val harness = Harness()
|
||||||
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.repo)
|
harness.watcher.start(listOf(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.repo) }
|
shouldThrow<IllegalStateException> { harness.watcher.start(listOf(harness.repo)) }
|
||||||
|
|
||||||
harness.watcher.stop()
|
harness.watcher.stop()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Push the current main branch to the GitHub mirror.
|
||||||
|
#
|
||||||
|
# Werkator's canonical repository lives on git.javagil.de (Gitea, remote
|
||||||
|
# "origin"); GitHub stays around as a public read-only mirror (remote
|
||||||
|
# "github"). This script keeps that mirror's main branch in sync — nothing
|
||||||
|
# else: no other branches, no force-push, no tags. Run it after pushing main
|
||||||
|
# to origin, or set it up as a post-push hook / cron job if that becomes
|
||||||
|
# annoying to remember.
|
||||||
|
#
|
||||||
|
# Usage: tools/push-github-mirror.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
git fetch origin main
|
||||||
|
git push github refs/remotes/origin/main:refs/heads/main
|
||||||