Author SHA1 Message Date
mhoennigandClaude Sonnet 5 ed89911871 docs(prs): the maintenance-page PR-doc
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:49:21 +02:00
mhoennigandClaude Sonnet 5 ca9c23d55b Show a static maintenance page while the service restarts
A deployment's ErrorDocument-covered downtime window looked dead: Apache's
default 502/503/504 error page, or a hung request, while instance-update
briefly restarts the systemd unit. init --systemd now also writes a static
werkator-maintenance.html next to the generated .htaccess, and the .htaccess
maps a refused connection straight to it — served by Apache alone, no
change needed to Werkator itself since it is exactly the process that is
down.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:48:06 +02:00
mhoennigandClaude Sonnet 5 06967c551e docs(prs): PR#16 verified live on mih09
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:40:07 +02:00
mhoennigandClaude Sonnet 5 108fabeae6 Bump version to 1.1.1 for the quota-aware disk metrics deployment
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:35:25 +02:00
mhoennigandClaude Sonnet 5 61c235313f Implement quota-aware disk metrics (PR#16)
The system page showed the disk of the host volume, not the budget the
instance can actually fill — on a Hostsharing Managed Webspace that is a
group quota, tighter than the volume by an order of magnitude, so the
warn/critical highlighting could never fire before a build failed with
"Disk quota exceeded".

DiskQuota parses `quota -u -g --no-wrap --raw-grace` and picks the
tightest of the user quota, the group quota, and the volume itself;
SystemMetricsCollector reports whichever binds, resets the disk
min/max/avg when the binding source changes, and the system page names
the source in its info line. A host without a binding quota (Docker
hosts, developer machines) renders exactly as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:23:39 +02:00
mhoennigandClaude Sonnet 5 be965617cd docs(prs): das PR-Dokument der Quota-Anzeige trägt seine echte Nummer — PR#16
Die alte GitHub-PR#14 kollidiert mit der Gitea-Nummerierung, die nach dem
Umzug bei 1 neu beginnt; PR#16 ist die tatsächliche Nummer auf git.javagil.de.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:04:27 +02:00
mhoennigandClaude Fable 5.1 7eac1c342d docs(prs): das PR-Dokument der Quota-Anzeige trägt seine echte Nummer — PR#14
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:04:27 +02:00
mhoennigandClaude Fable 5.1 623266e060 docs(prs): PR#13 — die Anzeige nimmt den kleinsten Spielraum aus User-Quota, Gruppen-Quota und freiem Plattenplatz
Die Volume-Zahlen sind nicht mehr nur Rückfall, sondern dritter Kandidat: Gebunden
ist, was am wenigsten Platz lässt; eine später eingeführte User-Quota geht ohne
Änderung in den Vergleich ein.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:04:27 +02:00
mhoennigandClaude Fable 5.1 22a1b28824 docs(prs): Plan für die quota-bewusste Plattenplatz-Anzeige der Systemübersicht — PR#13
Auf mih09 zeigt /system die 71-GiB-Platte des Hosts, gemessen bindet aber die
Gruppen-Quota des Pakets (8 GiB soft, 12 GiB hard, 1,04 GiB belegt). Das PR-Doc
plant: quota(1) per CLI lesen, die bindende Zeile (User/Gruppe, Dateisystem des
Repos) als Disk-Budget nehmen, ohne Quota unverändert der File Store, Serien-Reset
bei Quellenwechsel, Budget in der Info-Zeile benannt. Nur Plan, kein Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:04:27 +02:00
mhoennigandClaude Sonnet 5 4d76a93927 Add a script to mirror main to GitHub after the move to Gitea
Werkator's canonical repository now lives on git.javagil.de; GitHub stays
around as a public mirror. This keeps its main branch in sync without
touching any other branch or the repo's other history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 16:34:44 +02:00
mhoennigandClaude Sonnet 5 4f2ad3b244 Fail the build when the project version and the release notes disagree
A version bump is manual and easy to forget on a deployment — it happened
twice on 2026-09-03, leaving a running instance whose footer/--version
claimed a version stale by two deployments. ReleaseVersionConsistencyTest
now compares build.gradle.kts against the top entry of releases.html so a
forgotten bump, or notes written for a version nothing was built as, fails
before an artifact can even be produced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 16:02:11 +02:00
mhoennigandClaude Sonnet 5 7028ca8ca3 Bump version to 1.1.0 for the multi-repo registry deployment
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 15:50:00 +02:00
mhoennigandClaude Fable 5.1 0012c17cb7 UI: die Repository-Auswahl sitzt mittig zur Überschrift
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 15:34:13 +02:00
mhoennigandClaude Fable 5.1 fe11a339ad UI: das Repository wird in der Seitenüberschrift per Drop-Down gewählt
Die Link-Leiste neben dem Menü entfällt. An ihrer Stelle steht dort, wo
bisher der Repository-Name stand, eine Auswahlliste der bedienten
Repositories; werkator.js hängt die aktuelle Ansicht (branches, history,
current) an, damit der Wechsel auf derselben Seite bleibt. Mit nur einem
Repository bleibt der Name wie bisher reiner Text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 15:26:29 +02:00
mhoennigandClaude Opus 5 e61adfbbaa docs: nachgemessen, wann die entfernte Build-Definition wirklich verschwindet
Die offene Frage im PR-Dokument sagte „löst sich beim Merge". Das Log sagt
es genauer: Am Merge-Commit wurde `werkdock` noch eingereiht, erst am
nächsten nicht mehr. Die Projektschicht ist die Datei im Arbeitsbaum des
beobachteten Klons — die Definition überlebt, bis dieser Baum an der
Entfernung vorbei ist, der Merge selbst bekommt also einen letzten
Geister-Build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:22:10 +02:00
mhoennigandClaude Opus 5 c0fd1de954 docs: das PR-Dokument der Werkdock-Herauslösung trägt seine echte Nummer
Geschrieben wurde es als #14 — vergeben wurde beim Öffnen #15. Die
Namenskonvention (docs/prs/README.md) will die Nummer aus dem Tracker,
sonst zeigt später nichts mehr aufeinander.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:18:14 +02:00
Michael HönnigandClaude Opus 5 ac53b5f211 step 21e werkdock moved to own repo (#15)
* refactor: werkdock/ verlässt dieses Repository

Die neun Commits unterhalb von werkdock/ sind per `git subtree split`
historieerhaltend nach https://git.javagil.de/mi/werkdock gehoben; hier
bleibt nichts davon zurück.

Mit dem Verzeichnis geht die Build-Definition `werkdock`: Sie beschrieb ein
Go-Modul, das der Kotlin-Build um sie herum nicht berühren kann — jeder
Werkator-Branch baute es dennoch als `<branch>@werkdock` mit. Drüben ist
sie die einzige und heißt `default`.

Die Kopplung bleibt, wie Sitzung C sie gemacht hat: Werkator ruft das
`werkdock`-Binary über PATH (`bwrap.werkdock`, weiterhin gepinnt), so wie
es `git` ruft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(remote): das werkdock-Binary kommt aus einem eigenen Checkout

Bisher baute das Skript es aus dem Unterverzeichnis. Jetzt nennt
WERKDOCK_REPO den Checkout (Vorgabe: ein Geschwister dieses Repositories —
die übliche Ablage, wenn man an beidem arbeitet), WERKDOCK_BINARY das
gebaute Binary darin; beides ist wie jeder Transportwert aus der Umgebung
übersteuerbar.

Fehlt der Checkout, nennt die Meldung die Klon-URL, statt bloß „file not
found" zu sagen: Das Skript kann die Lage nicht mehr selbst beheben, also
sagt es, was sie beheben würde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: Sitzung E — Werkdock hat ein eigenes Repository

Plan-Schritt 21 bekommt die Sitzung, die die Herauslösung beschreibt, samt
Abnahmekriterium; Plan-Index und Architektur-Skill sagen nicht mehr, sie
stehe noch bevor, und der Deployment-Leitfaden verlinkt das Repository
statt eines Verzeichnisses, das es hier nicht mehr gibt.

Dazu das PR-Dokument nach Hausregel (docs/prs/README.md).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(remote): repo-add wendet das Instanz-Fragment an

`repo-add` rief `werkator init` ohne `--apply`, `repo-init` dagegen mit.
Folge: Ein so aufgenommenes Repository bekam die Sandbox-Politik nicht —
`bwrap.rootfs` und `bwrap.werkdock` fehlten, und der erste Build lief auf
dem nackten Host statt in der Sandbox. Gefunden beim Aufnehmen von
Werkdock: `gofmt: command not found`, weil die Go-Toolchain nur im
Sandbox-Image liegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(remote): repo-add erkennt einen ~/-Registry-Eintrag, und der Kommentar sagt die Wahrheit

Die Registry-Prüfung suchte nur den absoluten Pfad und meldete deshalb
„not registered yet“, obwohl der Eintrag als `path: ~/werkdock` dasteht —
eine Einladung, ihn ein zweites Mal einzutragen. Gegriffen wird jetzt
lokal, damit beide Schreibweisen ohne zweite Schicht Remote-Quoting
treffbar sind.

Dazu der Kommentar über dem Laden der Env-Datei: Er behauptete, die
ausdrückliche Umgebung gewinne. `set -a; source` weist unbedingt zu — die
Datei gewinnt. Beim Nachziehen des Werkdock-Fragments hat mich genau das
eine Runde gekostet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:15:15 +02:00
mhoennigandClaude Opus 5 06f4052848 Merge PR #13: Routen, UI und Artefakt-Links tragen das Repository
/repos/<name>/… und /api/repos/<name>/… neben den bisherigen Pfaden; die
Kopfzeile bekommt einen Repo-Umschalter, sobald mehr als ein Repository
registriert ist, und die Gitea-Statusmeldung trägt eine dauerhafte URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:26:56 +02:00
mhoennigandClaude Opus 5 f11b53bdb4 Merge PR #12: Repo-Registry — mehrere Repositories je Instanz
Die Instanz liest ihre Repositories aus ~/.werkator.yml (ADR 0009); Watcher,
Build-Ausführung und Artefakte arbeiten je Repository. Bisher lief sie auf
einem Vorab-Build dieses Branches — mit dem Merge entspricht main wieder dem,
was produktiv läuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:26:56 +02:00
mhoennigandClaude Opus 5 c1629cace3 feat(remote): repo-add bereitet ein weiteres Repository für die Registry vor
`tools/remote` konnte genau ein beobachtetes Repository einrichten
(`repo-init`, fest auf $WERKATOR_PATH/werkator). Für eine Instanz, die eine
Registry bedient (ADR 0009), fehlte der Weg, ein zweites hinzuzunehmen —
und das braucht der Rollout unabhängig davon, welche Repositories es am Ende
sind.

`repo-add <url> [name]` klont daneben, führt `init` darin aus und PRÜFT, ob
die Registry den Pfad schon nennt — geschrieben wird sie nicht. `~/.werkator.yml`
ist die Datei der Instanz: Port, globale Nebenläufigkeit, womöglich geteilte
Zugangsdaten. Ein Skript, das sie in place umschreibt, verändert die
Konfiguration des Betreibers hinter dessen Rücken. Klonen und Initialisieren
ist mechanisch, Registrieren ist eine Entscheidung — also druckt es den
Eintrag und sagt, dass die Registry beim Start gelesen wird.

Der Name muss ein Pfadsegment sein: Er wird das Routen-Segment /repos/<name>.
docs/deployment.md verweist im Registry-Abschnitt darauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:59:24 +02:00
mhoennigandClaude Opus 5 80a14a7467 feat(gitea): der Commit-Status verlinkt endlich die Artefaktseite — repo-bezogen
Der letzte offene Punkt der Sitzung D („Gitea status links use the repo-scoped
URLs"). Beim Nachsehen war es kein Scoping-Problem, sondern ein fehlendes
Feature: `server.publicBaseUrl` ist als „used for all links posted to Gitea"
dokumentiert, der Executor postete aber `targetUrl = null` — es gab überhaupt
keinen Link. Jetzt zeigt der Status auf die Artefaktseite des Builds, also auf
die Logs, um die es geht. Ohne `publicBaseUrl` bleibt er null: ein relativer
Link in einem Gitea-Status wäre schlimmer als keiner, er löste gegen die Forge
auf.

Die Präfix-Regel wohnt jetzt an einer Stelle (`RepoLinks`), weil drei
dasselbe damit tun: die Seiten, die API-Antworten und diese Ziel-URLs — und
eine dreimal ausgeschriebene Regel driftet. Die Slice-Tests binden die echte
Komponente per @Import ein, statt die Regel im Test nachzubauen.

Ein neuer Test (mit publicBaseUrl trägt der Status die Artefaktseite; die
vorhandenen Tests halten fest, dass ohne sie weiterhin null gepostet wird).
499 Tests grün, ktlint sauber. Plan und Architektur-Skill nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:53:51 +02:00
mhoennigandClaude Opus 5 91e1dd47a3 docs(deployment): wie eine Instanz mehrere Repositories bedient
Der aus Sitzung C nach E übertragene Doku-Punkt: docs/deployment.md
beschreibt die Registry jetzt betrieblich — klonen, vorbereiten, in
~/.werkator.yml eintragen, neu starten. Dazu die Regeln, die beim Eintragen
weh tun, wenn man sie nicht kennt: Der Name ist Routen-Segment und
Umschalter-Eintrag und muss eindeutig sein (ein Doppel bricht den Start ab,
ebenso ein Eintrag, der kein git-Repository ist), eine unlesbare Konfiguration
überspringt nur ihr Repository, geteilte Zugangsdaten können einmal im
defaults-Block liegen, und die Unit behält ihren Namen — sie gehört der
Instanz, nicht einem Repository.

Dazu, was die Adressen tun: mit einem Repository bleibt jede URL, wie sie
war; mit mehreren tragen Seiten und API das Repository, und die unscoped
Pfade meinen weiterhin das bediente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:41:26 +02:00
mhoennigandClaude Opus 5 c82f2a965c feat(server): Routen, Seiten und Artefakte tragen das Repository — /repos/<name>/… (Sitzung D)
Zweite Hälfte von Sitzung D (docs/plan/22-multi-repo.md, PR #13): Der Server
bediente bisher genau ein Repository der Registry. Jede Route — API, Seiten,
Artefakt-Dateien — arbeitete auf `registry.current()`; ein zweites
registriertes Repository wurde gebaut und gepollt, war aber unsichtbar und
unerreichbar.

Jeder Controller löst sein Repository jetzt je Anfrage auf, statt das
bediente als Bohne zu halten; jede Route ist zweimal gemappt. Die unscoped
Form ist kein Übergangs-Alias, sondern dauerhaft die Art zu sagen „das
bediente Repository" — Lesezeichen und die nach Gitea geposteten Links
kennen kein Segment.

Entschieden gegen die Repo-Spalte: Die Seiten bleiben je Repository, die
Navigation bekommt einen Umschalter. Die Aktionen einer Zeile brauchen das
Repository ohnehin, Branches kommen von einem origin und Artefakte aus einem
Store — und bei dem einen Repository, das die meisten Installationen haben,
wäre eine Spalte nur Rauschen.

Das Link-Präfix folgt der ZAHL der bedienten Repositories, nicht dem Weg, über
den eine Seite erreicht wurde: mit einem behält die Installation ihre
bisherigen URLs (Abnahmekriterium der Sitzung), mit mehreren benennt jeder
Link sein Repository. werkator.js liest das Präfix einmal aus einem
`werkator-repo-base`-Meta. `BranchPermalinks.permanentUrl` bekommt es
ebenfalls — der permanente Schlüssel ist ein Hash des Build-Namens allein,
zwei Repositories mit `main` teilten sich sonst eine permanente URL.

Fünf neue Tests, Gegenprobe per Mutation gezogen (Präfix fest auf leer →
genau der Mehr-Repo-Test fällt). 498 Tests grün, ktlint sauber. PR-Dokument
docs/prs/2026-09-03-PR#13-…, Plan, Architektur-Skill und AGENTS.md
nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:39:09 +02:00
mhoennigandClaude Opus 5 4304dd7c4b feat(server): die Builds-API trägt das Repository im Pfad — /api/repos/<name>/builds/…
Sitzung D, erste Hälfte (docs/plan/22-multi-repo.md): Jede Route der
Builds-API gibt es jetzt zweimal — repo-benannt und unscoped. Die unscoped
Form bleibt dauerhaft und meint das bediente Repository
(`RepoRegistry.current`): Lesezeichen, die alte Oberfläche und die bereits
nach Gitea geposteten Links kennen kein Repo-Segment, und eine CI, die ihre
eigenen alten Links bricht, ist eine CI, der niemand traut.

Der Controller hält kein `BuildResultRepository`, keinen `ArtifactStore` und
keinen `RepoContext` mehr als Bohne, sondern löst je Anfrage über die
Registry auf. Ein unbekannter Name beantwortet sich in der Form dieser API
(404 mit `error`) über einen eigenen ExceptionHandler statt über Springs
Problem-Detail.

Dabei gefunden und mitbehoben: `cancel` griff über Repository-Grenzen. Der
Executor bricht per Schlüssel instanzweit ab — eine Route, die ein
Repository benennt, darf damit kein fremdes erreichen. Sie prüft jetzt
zuerst, ob der Schlüssel in den Ergebnissen DIESES Repositories steht; ein
wartender oder laufender Build hat dort immer sein PENDING/RUNNING.

Drei neue Tests: die repo-benannte Route antwortet, ein unbekannter Name
gibt 404 mit Meldung, und `cancel` erreicht keinen fremden Build. 496 Tests
grün, ktlint sauber.

Noch nicht scoped: die Oberfläche und die Artefakt-Routen — die kommen mit
der zweiten Hälfte von Sitzung D.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:04:52 +02:00
mhoennigandClaude Opus 5 6ca67a6a37 feat(build): every running build knows its repository — die laufenden Builds und das Worktree-Aufräumen unterscheiden Repositories
Der letzte Übertrag aus Sitzung C: `RunningBuild` trug kein Repository, der
Executor ist aber instanzweit. Zwei Folgen, beide gemessen und jetzt behoben:

- Das Worktree-Aufräumen schützte die Worktrees ALLER Repositories. Ein
  laufender Build eines anderen Repositories auf einem gleichnamigen Branch
  hielt hier einen Worktree fest, dessen Branch längst von origin weg war.
- Die Current-Builds-Ansicht und `/api/builds/current` zeigten die Builds
  aller Repositories, schlugen ihren Status aber in den Ergebnissen NUR
  dieses Repositories nach — ein fremder Build fiel auf RUNNING zurück und
  zeigte einen Zustand, den niemand aufgezeichnet hat. Dasselbe beim
  Live-Log: der Schlüssel eines fremden Builds wurde beantwortet.

`RunningBuild.repo` ist der `RepoContext` selbst, verglichen wird per
Referenz — der Kontext ist die Identität (ADR 0009), und der Executor hat ihn
in `startBuild` ohnehin zur Hand. Watcher, API und UI filtern damit auf ihr
eigenes Repository.

Drei neue Tests, Gegenprobe per Mutation gezogen: Nimmt man die drei Filter
wieder heraus, fallen genau diese drei und sonst keiner. 493 Tests grün,
ktlint sauber.

Architektur-Skill und Plan (docs/plan/22-multi-repo.md) nachgezogen — die
Aussage „RunningBuild carries no repository" stimmte nicht mehr. Was von
Sitzung D bleibt: die repo-bezogenen Routen und die Oberfläche, die weiterhin
nur `registry.current()` bedienen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:36:07 +02:00
mhoennigandClaude Opus 5 6fba43c627 docs(prs): das PR-Dokument der Repository-Registry trägt seine echte Nummer — PR#12
Die Datei lag als `PR#000`-Platzhalter, wie es die Konvention in
docs/prs/README.md vorsieht: Die Nummer gibt es erst, wenn der Pull Request
offen ist. Er ist es — #12. Umbenannt samt der fünf Szenario-IDs
(`Scenario#000.01` … `.05` -> `Scenario#12.01` … `.05`), wie es Schritt 21
für PR #4 vorgemacht hat; sonst zeigen die IDs auf eine Nummer, die es nicht
gibt. Kein `#000` bleibt in der Datei, keine andere Datei verweist auf den
alten Namen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:35:53 +02:00
mhoennigandClaude Fable 5.1 58799ed0ed Step 22 C: metrics over the registry, documentation, PR-doc
The metrics page sums the registered repositories' sizes; the instance
configuration is documented in docs/configuration.md, the architecture skill,
AGENTS.md and the step 22 plan (session C ticked, fairness decided FIFO).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 09:06:13 +02:00
mhoennigandClaude Fable 5.1 b66d26a03a Step 22 C: --repo selects a registered repository in build, retry, and status
The RepoOption mixin resolves the name through the registry; without it a
command means the current directory when served, else the first entry. An
unknown name is a usage error naming the registered repositories.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 09:03:07 +02:00
mhoennigandClaude Fable 5.1 a5027acfaf Step 22 C: the watcher multiplexes the registry
start takes the served repositories; one poll cycle polls each in its own guard
and aggregates the reports into WatcherState, which gains a per-repository list.
With one repository the top-level fields read exactly as before; with several,
every message is prefixed by the repository name. Log lines carry the name too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 09:00:52 +02:00
mhoennigandClaude Fable 5.1 b16c9c2e27 Step 22 C: the repository registry
RepoRegistry opens one RepoContext per entry of the home configuration, or the
current directory without one. An entry that is no git repository or a name
used twice aborts the start naming the home file; a repository whose config
must not be read is skipped with an error and the others are served. The
current-repo bean now comes from the registry (the cwd when served, else the
first entry), and the pre-rename state-dir migration runs per opened repository.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 08:58:04 +02:00
mhoennigandClaude Fable 5.1 ab2a4133e8 Step 22 C: the instance configuration in ~/.werkator.yml
ConfigLoader reads the home file (WERKATOR_HOME overrides the location): its
defaults block merges below every repository's layers, and once it exists the
server section, executor and watcher.pollInterval come from it alone — a
repository file still carrying them is told so once, naming both files.
InstanceConfig binds the registry entries; nothing reads them yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 08:55:59 +02:00
95 changed files with 2916 additions and 2330 deletions
+6 -4
View File
@@ -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 `BwrapBuildRunner` when `bwrap.enabled` — docker and bwrap are mutually exclusive per build and rejected in `buildSettings`, never picked silently. The Docker runner shells out to the `docker` CLI (no SDK): it (re)builds the configured image when the Dockerfile inputs changed (tracked via the `org.werkator.build-inputs-sha256` image label), maintains a per-repo Gradle cache volume, mounts the worktree and the Docker socket into a labelled (`org.hoennig.werkator`) `--rm --init` container, and repairs workspace ownership in-container after each command (under a rootless daemon the container runs as root, which is the host user, and the repair degenerates to `0:0`). Git works inside the container: the primary `.git` is mounted read-only with `.git/werkator/` masked by an empty tmpfs (credential isolation) and the worktree's admin dir mounted read-write (`gitMetadataMounts`). The returned `Process` is the attached `docker run` client, so log streaming and termination work exactly like native builds.
`BwrapBuildRunner` (ADR 0008) is the third runtime, for hosts without root and without Docker — Hostsharing Managed Webspaces. It shells out to the `bwrap` CLI (no library): a prepared rootfs archive (`bwrap.rootfs`, built by `tools/build-bwrap-rootfs.sh`) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`, with uid 0 inside mapped to the calling user; isolation is filesystem-only — network, uid, `/proc`, `/dev` are the host's by contract. It reuses the Docker runner's `gitMetadataMounts`; mount order matters (repo dir read-write before the metadata mounts and the workspace), and bind mountpoints missing from the rootfs are pre-created there, since the rootfs is a plain host directory while bwrap cannot mkdir against the read-only sandbox root. `bwrap.enabled`/`bwrap.rootfs` are pinned like the docker sandbox policy. The returned `Process` is the attached `bwrap` process, so streaming and cancellation are unchanged. Plan step 21 will extract the generic sandbox machinery into the standalone tool Werkdock (grown in `werkdock/`); the runner then delegates to the `werkdock` CLI. `BwrapBuildRunner` (ADR 0008) is the third runtime, for hosts without root and without Docker — Hostsharing Managed Webspaces. It shells out to the `bwrap` CLI (no library): a prepared rootfs archive (`bwrap.rootfs`, built by `tools/build-bwrap-rootfs.sh`) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`, with uid 0 inside mapped to the calling user; isolation is filesystem-only — network, uid, `/proc`, `/dev` are the host's by contract. It reuses the Docker runner's `gitMetadataMounts`; mount order matters (repo dir read-write before the metadata mounts and the workspace), and bind mountpoints missing from the rootfs are pre-created there, since the rootfs is a plain host directory while bwrap cannot mkdir against the read-only sandbox root. `bwrap.enabled`/`bwrap.rootfs` are pinned like the docker sandbox policy. The returned `Process` is the attached `bwrap` process, so streaming and cancellation are unchanged. The generic sandbox machinery is the standalone tool [Werkdock](https://git.javagil.de/mi/werkdock) (plan step 21: grown in `werkdock/`, consumed via the CLI since session C, its own repository since session E); the runner delegates to the `werkdock` CLI and this repository no longer carries its source.
## 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.
-11
View File
@@ -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
+1 -1
View File
@@ -37,7 +37,7 @@ 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 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.
+3 -2
View File
@@ -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.1.1"
java { java {
toolchain { toolchain {
+1 -1
View File
@@ -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
+48
View File
@@ -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 |
@@ -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
+58 -1
View File
@@ -81,6 +81,60 @@ 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.
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
@@ -265,7 +319,7 @@ 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.
@@ -291,3 +345,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.
+2
View File
@@ -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 BD 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.
+13 -9
View File
@@ -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.
+2 -2
View File
@@ -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.
+132
View File
@@ -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,86 @@
> **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, pending: an `instance-update` deploy on `mih09` watched live during the restart window, to confirm Apache actually reaches 502 there rather than timing out first (see Open Questions).
#### 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 502, or does the client just time out first?** Depends on Apache's `ProxyTimeout`/connect-timeout configuration on the managed webspace, which this PR does not change or configure — where Apache's own request timeout fires before the backend's refused-connection error would, the client still sees a hang, not the maintenance page, for that fraction of the restart window. Not addressed here; a follow-up could set an explicit short `ProxyTimeout` if this turns out to matter in practice.
## Additional Changes
- None beyond the feature itself.
## 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.
@@ -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 {
@@ -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. */
@@ -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
@@ -359,6 +359,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`). */
@@ -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))
/** /**
@@ -307,10 +336,78 @@ 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)
val instance = loadInstanceRaw()
if (instance.isEmpty()) {
// the applied instance fragment sits above the committed project config and // the applied instance fragment sits above the committed project config and
// below the hand-edited machine config, which always has the last word // below the hand-edited machine config, which always has the last word
return deepMerge(deepMerge(project, applied), repoInstall) 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
@@ -467,6 +564,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 = "",
)
@@ -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,11 +232,9 @@ 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,
lastPollError = null,
queuedBranches = queuedBranches =
repo.results repo.results
.latestPerName() .latestPerName()
@@ -190,15 +243,6 @@ class Watcher(
) )
} }
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)
}
}
/** /**
* Brings the primary checkout's local branch refs up to origin, because a build worktree * Brings the primary checkout's local branch refs up to origin, because a build worktree
* shares that `.git`: a build tool comparing a local branch with its origin counterpart * shares that `.git`: a build tool comparing a local branch with its origin counterpart
@@ -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(),
) )
+6
View File
@@ -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;
+53 -6
View File
@@ -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();
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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>
+16 -5
View File
@@ -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,37 @@
<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.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/&lt;name&gt;/…</code> and
<code>/api/repos/&lt;name&gt;/…</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")
@@ -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",
@@ -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")
@@ -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()
+19
View File
@@ -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
+80 -6
View File
@@ -19,6 +19,8 @@
# tools/remote [--env-file FILE] werkator instance-update redeploy bundle + werkdock, restart the service # tools/remote [--env-file FILE] werkator instance-update redeploy bundle + werkdock, restart the service
# tools/remote [--env-file FILE] werkator instance-start apply fragment, Apache proxy, systemd unit # tools/remote [--env-file FILE] werkator instance-start apply fragment, Apache proxy, systemd unit
# tools/remote [--env-file FILE] werkator repo-init clone the watched repo, init --apply, rootfs # tools/remote [--env-file FILE] werkator repo-init clone the watched repo, init --apply, rootfs
# tools/remote [--env-file FILE] werkator repo-add URL [NAME] clone and init ANOTHER repository for the
# registry, then print the entry to add to ~/.werkator.yml
# tools/remote [--env-file FILE] werkator control-token # tools/remote [--env-file FILE] werkator control-token
# tools/remote [--env-file FILE] port-forward start background tunnel to the Werkator UI # tools/remote [--env-file FILE] port-forward start background tunnel to the Werkator UI
# tools/remote [--env-file FILE] port-forward stop # tools/remote [--env-file FILE] port-forward stop
@@ -43,6 +45,9 @@
# WERKATOR_INIT_CONFIG the init fragment to apply (repo-init, instance-start) # WERKATOR_INIT_CONFIG the init fragment to apply (repo-init, instance-start)
# WERKATOR_REPO_URL https clone URL of the watched repository # WERKATOR_REPO_URL https clone URL of the watched repository
# (default: https://github.com/mhoennig/werkator.git) # (default: https://github.com/mhoennig/werkator.git)
# WERKDOCK_REPO checkout of the werkdock repository, whose binary the
# instance runs (default: <repo>/../werkdock)
# WERKDOCK_BINARY the built werkdock binary (default: $WERKDOCK_REPO/dist/werkdock)
# WERKATOR_ROOTFS rootfs archive path for repo-init # WERKATOR_ROOTFS rootfs archive path for repo-init
# (default: <repo>/build/werkator-buildenv-trixie-java-go-node.tar.zst) # (default: <repo>/build/werkator-buildenv-trixie-java-go-node.tar.zst)
# #
@@ -68,7 +73,11 @@ COMMAND="${2:-}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUNTIME_BUNDLE="$REPO_ROOT/build/distributions/werkator-runtime-linux-x64.tar.gz" RUNTIME_BUNDLE="$REPO_ROOT/build/distributions/werkator-runtime-linux-x64.tar.gz"
WERKDOCK_BINARY="$REPO_ROOT/werkdock/dist/werkdock" # Werkdock lives in its own repository since step 21 session E. Its binary is
# built there, not here: WERKDOCK_REPO names the checkout (default: a sibling
# of this repository), WERKDOCK_BINARY the built binary within it.
WERKDOCK_REPO="${WERKDOCK_REPO:-$REPO_ROOT/../werkdock}"
WERKDOCK_BINARY="${WERKDOCK_BINARY:-$WERKDOCK_REPO/dist/werkdock}"
PID_FILE="/tmp/werkator-port-forward-$(id -u).pid" PID_FILE="/tmp/werkator-port-forward-$(id -u).pid"
LOG_FILE="/tmp/werkator-port-forward-$(id -u).log" LOG_FILE="/tmp/werkator-port-forward-$(id -u).log"
@@ -90,8 +99,10 @@ require_env() {
[ -n "$REPO" ] && [ -n "$COMMAND" ] || usage [ -n "$REPO" ] && [ -n "$COMMAND" ] || usage
# Load the transport values; explicit environment wins, the selected env file # Load the transport values. The selected env file (default: the .env in the
# (default: the .env in the repository root) fills the rest. # repository root) wins over the environment — `set -a; source` assigns
# unconditionally; pick the target with --env-file rather than by exporting
# single values.
ENV_FILE="${ENV_FILE:-$REPO_ROOT/.env}" ENV_FILE="${ENV_FILE:-$REPO_ROOT/.env}"
set -a set -a
[ -f "$ENV_FILE" ] && source "$ENV_FILE" [ -f "$ENV_FILE" ] && source "$ENV_FILE"
@@ -135,8 +146,11 @@ check_prerequisites() {
ensure_werkdock_binary() { ensure_werkdock_binary() {
if [ ! -f "$WERKDOCK_BINARY" ]; then if [ ! -f "$WERKDOCK_BINARY" ]; then
echo "==> werkdock binary not found; building it locally (go build)" [ -f "$WERKDOCK_REPO/go.mod" ] || die \
(cd "$REPO_ROOT/werkdock" && CGO_ENABLED=0 go build -o dist/werkdock .) "werkdock binary missing: $WERKDOCK_BINARY — clone https://git.javagil.de/mi/werkdock.git \
next to this repository, or point WERKDOCK_REPO/WERKDOCK_BINARY at your checkout"
echo "==> werkdock binary not found; building it in $WERKDOCK_REPO (go build)"
(cd "$WERKDOCK_REPO" && CGO_ENABLED=0 go build -o dist/werkdock .)
fi fi
[ -f "$WERKDOCK_BINARY" ] || die "werkdock binary missing: $WERKDOCK_BINARY" [ -f "$WERKDOCK_BINARY" ] || die "werkdock binary missing: $WERKDOCK_BINARY"
} }
@@ -273,6 +287,61 @@ repo_init() {
echo " then tools/remote werkator instance-start" echo " then tools/remote werkator instance-start"
} }
# Prepare a SECOND (third, …) repository for the registry of an installed
# instance (ADR 0009): clone it next to the others and run `init` in it, so it
# has its own machine config. The registry entry itself is only PRINTED, never
# written: `~/.werkator.yml` is the instance's own file — it carries the port,
# the global concurrency and possibly shared credentials, and a script that
# edits it in place would rewrite the operator's own configuration behind their
# back. Cloning and initialising is mechanical, registering is a decision.
repo_add() {
local url="${1:-}"
[ -n "$url" ] || die "usage: tools/remote [--env-file FILE] werkator repo-add <clone-url> [name]"
local name="${2:-$(basename "$url" .git)}"
case "$name" in
*/*|"") die "the repository name is one path segment (it becomes the route segment /repos/<name>)" ;;
esac
ensure_ssh
ssh "$HOST" "test -x '$WERKATOR_BIN'" || die "no instance on $HOST — run instance-install first"
echo "==> Cloning $url as '$name'"
if ssh "$HOST" "test -d '$TARGET_DIR/$name/.git'"; then
echo " (already cloned, skipping)"
else
ssh "$HOST" "git clone '$url' '$TARGET_DIR/$name'"
fi
# The instance fragment carries the sandbox policy (bwrap rootfs and werkdock
# binary). Without it a watched repository builds on the bare host, where the
# toolchains are not installed — the same --apply repo-init does.
echo "==> Running werkator init in $name${WERKATOR_INIT_CONFIG:+ --apply $(basename "${WERKATOR_INIT_CONFIG}")}"
local fragment_remote
fragment_remote="$(upload_fragment)"
ssh "$HOST" "cd '$TARGET_DIR/$name' && '$WERKATOR_BIN' init ${fragment_remote:+--apply '$fragment_remote'}"
echo "==> Checking the registry"
# Grepped locally: the entry may name the path absolute or as ~/<name>, and
# matching both is easier without a second layer of remote shell quoting.
if ssh "$HOST" "cat ~/.werkator.yml 2>/dev/null" |
grep -qE "path: *(~|$TARGET_DIR)/$name[[:space:]]*$"; then
echo " (~/.werkator.yml already names this path)"
else
echo " not registered yet — add this entry to ~/.werkator.yml on $HOST:"
echo
echo " repositories:"
echo " - path: $TARGET_DIR/$name"
echo " name: $name"
echo
fi
echo "==> Repository prepared."
echo " Repo: $TARGET_DIR/$name"
echo " Next: fill git.account/git.token in $TARGET_DIR/$name/.git/werkator/.werkator.yml if the origin is private"
echo " (or once for all repositories in the 'defaults' block of ~/.werkator.yml),"
echo " then restart the service — the registry is read at start."
}
# Start the server as a systemd user unit behind the managed Apache. All # Start the server as a systemd user unit behind the managed Apache. All
# configuration comes from the instance fragment (server.port, publicBaseUrl, # configuration comes from the instance fragment (server.port, publicBaseUrl,
# systemd limits); init generates the units AND the .htaccess — this script # systemd limits); init generates the units AND the .htaccess — this script
@@ -288,9 +357,11 @@ instance_start() {
local htaccess_src="$TARGET_DIR/werkator/.git/werkator/werkator.htaccess" local htaccess_src="$TARGET_DIR/werkator/.git/werkator/werkator.htaccess"
local htaccess="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/.htaccess" local htaccess="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/.htaccess"
local maintenance_src="$TARGET_DIR/werkator/.git/werkator/werkator-maintenance.html"
local maintenance="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/werkator-maintenance.html"
if ssh "$HOST" "test -f '$htaccess_src'"; then if ssh "$HOST" "test -f '$htaccess_src'"; then
echo "==> Placing the generated Apache reverse proxy at $htaccess" echo "==> Placing the generated Apache reverse proxy at $htaccess"
ssh "$HOST" "mkdir -p '$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www' && cp '$htaccess_src' '$htaccess'" ssh "$HOST" "mkdir -p '$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www' && cp '$htaccess_src' '$htaccess' && cp '$maintenance_src' '$maintenance'"
else else
echo "==> No werkator.htaccess generated (no server.publicBaseUrl configured) — skipping the Apache proxy" echo "==> No werkator.htaccess generated (no server.publicBaseUrl configured) — skipping the Apache proxy"
fi fi
@@ -384,6 +455,9 @@ case "$REPO" in
repo-init) repo-init)
repo_init repo_init
;; ;;
repo-add)
repo_add "${3:-}" "${4:-}"
;;
control-token) control-token)
control_token control_token
;; ;;
-2
View File
@@ -1,2 +0,0 @@
/werkdock
/dist/
-46
View File
@@ -1,46 +0,0 @@
# Werkdock
A docker-like sandbox CLI over `bwrap` — filesystem isolation only.
A dock is the enclosed basin in which ships are built: the dock gate controls what passes, the water outside is shared with the whole harbor.
Accordingly, network, uid, `/proc`, `/dev`, and `/tmp` come from the host by contract; that is what makes Werkdock work without root on a Hostsharing Managed Webspace.
Semantics — docker-compatible as far as the filesystem-only contract allows (see [RFC 0002](docs/rfcs/0002-docker-compatible-surface.md)):
- An *image* is a rootfs archive; an *instance* is an unpacked, writable directory tree and corresponds to a docker container.
- `werkdock run [flags] IMAGE [CMD...]` creates an instance and executes in the sandbox with uid 0 mapped to the calling user; verbs and flags follow docker, unsupported docker flags fail loudly.
- `werkdock doctor` checks the host: user-namespace capability, disk and quota headroom.
- A daemon speaking the Docker Engine API subset (for Testcontainers) is designed for but deferred.
## Disk Footprint
Werkdock's storage model is coarser than Docker's on the image side and cheaper on the instance side:
- An image is a flat, complete directory tree — there are no layers, and nothing is shared between images.
A JDK+Go+Node build image is roughly 2 GiB unpacked, plus its compressed archive (~0.5 GiB) as long as that is kept around.
- An instance costs (almost) nothing: the rootfs is bound read-only into every sandbox, writable are only tmpfs (`/tmp`, `/root`) and the caller's binds.
Ten parallel runs in one image add zero filesystem copies; what grows per project are its own caches in bound volumes.
- Consequence: prefer ONE fat image shared by all projects over per-project images.
- Watch out for orphans: consumers that key an unpacked environment by the archive's source path (Werkator's bwrap runtime does) leave the old tree behind on every path change; pruning is manual until `rmi`/`prune` verbs exist.
- Future options that would remove the flat-tree cost, in their own RFCs when they come due: composable toolchain mounts — a slim base plus per-toolchain prefix binds, no overlayfs needed ([RFC 0003](docs/rfcs/0003-composable-toolchain-mounts.md), candidate) — overlayfs layers (the kernel allows it unprivileged in a user namespace since 5.11; the webspaces' bwrap 0.8.0 cannot yet), or hardlink deduplication between image versions in the store (the ostree principle, no root needed).
## Build and Test
```bash
go test ./... # all tests; sandbox integration tests skip without bwrap/userns
go vet ./... && gofmt -l . # quality gates (gofmt must print nothing)
CGO_ENABLED=0 go build . # one static linux binary, ~3 MB
```
First steps on a host:
```bash
werkdock doctor # can this host run sandboxes?
werkdock load -i rootfs.tar.zst # import a rootfs archive as an image
werkdock run --rm -v /repo:/repo -w /repo IMAGE sh -c './gradlew build'
```
Status: bootstrap.
The implementation language is Go, decided in [RFC 0001](docs/rfcs/0001-implementation-language.md).
Werkdock grows in this subdirectory of the Werkator repository and moves to its own repository once it stands on its own.
It must stay self-contained: no imports from Werkator code, no Gradle coupling to the Werkator build.
The roadmap is session B of [docs/plan/21-werkdock-extraction-and-webspace-install.md](../docs/plan/21-werkdock-extraction-and-webspace-install.md).
Vendored Executable
BIN
View File
Binary file not shown.
@@ -1,109 +0,0 @@
# RFC 0001: Implementation Language for Werkdock
**Status:**
- proposed: 2026-09-01
- accepted: 2026-09-01
- rejected: -
**Proposal:** Werkdock is implemented in **Go** — as a single static binary, stdlib-only, with the sandbox engine behind an interface so bwrap can later be replaced by native namespaces.
## Context and Problem Statement
Werkdock is a docker-like sandbox CLI over `bwrap`, filesystem isolation only (see [README](../../README.md) and Werkator plan step 21).
Three hard requirements drive the language choice:
1. **Distribution to a Managed Webspace without root** — the tool must arrive and run with no package installation and no runtime dependency on the host.
2. **The work is process and filesystem orchestration** — spawning `bwrap`/`tar`/`zstd` with streamed logs and forwarded signals, assembling mount arguments, `doctor` checks.
3. **Self-contained and testable** — no code sharing and no build coupling with Werkator; the integration is `werkdock run` as a CLI call, like git and docker.
Two further criteria matter in this project:
- **AI-generated code quality** — the tool is developed AI-assisted; languages where generated code is reliably correct and idiomatic reduce review load.
- **Security** — Werkdock assembles mount arguments and uid mappings from user input; language safety and a small supply chain count.
## Considered Options
bash, Python 3, Kotlin Native, Rust, Go.
Scoring: 2 (unsuitable) to +2 (ideal), unweighted sum.
| Criterion | bash | Python 3 | Kotlin Native | Rust | Go |
|---|---:|---:|---:|---:|---:|
| Distribution to webspace (no root) | +2 | +1 | 1 | +2 | +2 |
| Fit for process/FS orchestration | +1 | +2 | 0 | +2 | +2 |
| Testability | 2 | +2 | +1 | +2 | +2 |
| Robustness/maintainability as it grows | 2 | +1 | +1 | +2 | +2 |
| Closeness to the maintainer's stack (Kotlin dev) | 0 | +1 | +2 | 1 | +1 |
| Genre references to learn from | 1 | 0 | 1 | +1 | +2 |
| Future: own namespaces instead of bwrap | 2 | 1 | 0 | +2 | +1 |
| Toolchain/build effort | +2 | +2 | 2 | 0 | +2 |
| AI-generated code quality | 1 | +2 | 0 | +1 | +2 |
| Security | 2 | +1 | +1 | +2 | +2 |
| **Sum** | **5** | **+11** | **+1** | **+13** | **+18** |
The ranking is robust against re-weighting: Go scores below +1 in no criterion — it wins by absence of weaknesses, not by one outlier.
### bash
Out on principle: the Werkator repository exists because a grown bash CI script became unmaintainable.
A tool with subcommands, image/instance state, and doctor checks starts beyond the bash comfort zone.
AI generates bash fluently but with the classic silent defects (quoting, word splitting, unchecked exit codes), and the missing test story means nobody notices.
Security 2 is earned: injection via word splitting in exactly the kind of code Werkdock writes — user-supplied paths assembled into mount arguments.
The existing scripts serve as specification, not as foundation.
### Python 3
The best "no new compiler" candidate: present on every Debian webspace, the stdlib suffices (unpacking `tar.zst` shells out to `zstd` anyway), excellent testability, excellent AI generation.
Weaknesses: version drift across hosts (3.11/3.13), no static type check at runtime, and the tool runs as a tamperable source file on the host interpreter instead of as a binary.
### Kotlin Native
Loses despite maximum stack closeness, and not narrowly — the weakness sits exactly where Werkdock lives:
- **The stdlib gap hits the tool's core.** Kotlin never had its own system libraries; on the JVM it delegates file, process, and IO work to the JDK. On Native that platform library is gone and only `platform.posix` remains. Werkdock's central operation — spawning processes with log streaming, signal forwarding, and exit codes — means hand-written `fork`/`execvp`/`waitpid` over cinterop.
- **Kotlin Native was built for iOS, not for CLI tools.** The driver was Kotlin Multiplatform (no JVM allowed on iPhone); the kotlinx ecosystem grew what mobile apps need. Mobile apps never spawn child processes, so no official process API exists.
- **AI drifts to the JVM.** The Kotlin training corpus is overwhelmingly JVM/Android; models reliably propose `ProcessBuilder` and `java.nio`, which do not exist on Native.
- **Distribution is build-machine-bound.** Unlike the jlink bundle (which copies Temurin's prebuilt binaries, glibc floor 2.15, measured in Werkator ADR 0006), Kotlin Native compiles locally, so the binary's glibc floor is the build machine's.
- **The expected payoff never materializes.** There is no shared code and no shared build graph with Werkator by design; "same language" buys only developer familiarity — and JVM-library-free Native Kotlin feels more foreign than Go does after a week.
The honest variant of language consistency — Kotlin/JVM plus a jlink bundle like Werkator itself — was not on the ballot and would be disproportionate: a ~66 MB bundle for a sandbox helper copied to foreign webspaces, against one static Go binary.
### Rust
Technically the strongest language for the genre and the best if Werkdock one day opens namespaces itself (direct syscalls, `youki` as a memory-safe sandbox reference).
Price: the steepest learning curve for a Kotlin developer and the slowest progress; AI-generated Rust needs iterations at the borrow checker, which the compiler at least enforces loudly.
### Go
The sweet spot:
- The container world Werkdock imitates is written in Go — docker CLI, podman, runc — so every subproblem has a proven, readable reference.
- One static binary (`CGO_ENABLED=0`) is the perfect webspace distribution; cross-compilation is a `GOOS`/`GOARCH` pair; builds take seconds.
- Testing is built in; `gofmt` knows exactly one style, which makes AI-generated Go above-average correct on the first attempt.
- The stdlib covers everything the tool does (`os/exec`, `os`, `io`, `archive/tar`), keeping the dependency list near zero — the smallest supply chain in the field.
- Coming from Kotlin, Go is productive within days: garbage collector, familiar concepts, deliberately small language.
## The Namespace Future, Concretely
Own namespaces instead of shelling out to `bwrap` are a real option, and Go keeps it open:
- The webspace kernel provably allows unprivileged user namespaces — Debian's `bwrap` has not been setuid since bookworm and uses nothing else.
- Go needs no cgo for it: namespaces are created when spawning the child via `SysProcAttr` (`Cloneflags`, `UidMappings`/`GidMappings`), with the usual re-exec pattern (`werkdock run` starts itself as a hidden init subcommand inside the fresh namespaces, sets up mounts, then execs the payload).
- The concrete payoff: since kernel 5.11, overlayfs mounts are allowed inside a user namespace unprivileged — the webspace runs 6.1, but its `bubblewrap 0.8.0` has no `--overlay` (added in 0.9.0). Own namespace code could provide the throwaway writable layer per build today.
- The counterweight: `bwrap` is hardened, Flatpak-tested code, and if the platform ever adopts an AppArmor userns restriction (as Ubuntu 24.04 did), the distribution's `bwrap` would likely stay permitted while a brought-along binary gets its `clone()` refused.
Consequence for the design, independent of the engine question's outcome: the sandbox engine sits behind an interface from the start — engine 1 is `bwrap` (present, proven, invocation logic exists), engine 2 can later be native namespaces.
## Concrete Proposal
1. **Language**: Go, current stable toolchain, pinned in `go.mod` (`toolchain` directive).
2. **Module**: `werkdock` as its own Go module in this subdirectory — no Gradle involvement, `go build` / `go test` / `go vet` are the whole toolchain.
3. **Dependency policy**: stdlib-only; any third-party dependency needs an RFC.
4. **Distribution**: one static linux/amd64 binary, built with `CGO_ENABLED=0`; other architectures are a build-matrix entry away if ever needed.
5. **Style and quality gates**: `gofmt` (enforced), `go vet`, table-driven tests with the built-in `testing` package.
6. **Architecture**: CLI semantics (`run`, images, instances, `doctor`) decoupled from a sandbox engine interface; `bwrap` is the first engine, native namespaces a possible second.
7. **External processes**: `bwrap`, `tar`, `zstd` are called as CLIs via `os/exec` — the same pattern Werkator uses for git and docker.
## Decision Outcome
Accepted on 2026-09-01: Werkdock is implemented in Go, under the terms of the concrete proposal above.
@@ -1,90 +0,0 @@
# RFC 0002: Docker-Compatible Surface
**Status:**
- proposed: 2026-09-01
- accepted: 2026-09-01 (level 1 as the shape of the CLI; levels 2 and 3 deferred indefinitely)
- rejected: -
**Proposal:** Werkdock's user-facing surface follows Docker wherever the filesystem-only contract allows: level 1 is a docker-compatible CLI (verbs, flags, exit codes), level 2 is pulling OCI images from registries, level 3 is a daemon offering the Docker Engine REST API subset that Testcontainers needs.
Level 1 is built in session B; levels 2 and 3 are designed for but deferred.
## Context and Problem Statement
The requirement (2026-09-01): the CLI — and a daemon API, if one is needed — shall be docker-compatible as far as possible, also to enable integrating Testcontainers later.
Docker compatibility is not one thing; it comes in three separable levels, and Testcontainers forces a position on each:
1. **CLI compatibility**`werkdock run` takes the flags a docker user already knows. Cheap, pure design discipline, and it makes every docker tutorial partially applicable.
2. **Image compatibility** — a werkdock image today is a self-built rootfs archive; docker images are OCI images from registries. Pulling and flattening OCI images makes the world's images usable.
3. **API compatibility** — Testcontainers never invokes the CLI; it speaks the Docker Engine REST API over a unix socket (`DOCKER_HOST`). Podman achieves Testcontainers support exactly this way (`podman system service`). Without this level there is no Testcontainers, regardless of the CLI.
## What Testcontainers Actually Needs
From observing docker-java/testcontainers-java against real daemons:
- `/version` and `/info` handshakes; then image pull (level 2 is a prerequisite), container create/start/inspect/logs/wait/remove.
- Port mapping: create requests an exposed container port with an empty host port, inspect must answer with the mapped ephemeral host port (`NetworkSettings.Ports`).
- The Ryuk reaper container (disableable via `TESTCONTAINERS_RYUK_DISABLED=true`).
The port mapping is the crux for Werkdock: with filesystem-only isolation there is no network namespace, the payload binds host ports directly.
Two consequences:
- "Mapping" degenerates to identity — inspect reports the port the service actually bound. Workable for sequential CI use.
- Two containers wanting the same fixed port collide, exactly as with docker's `--network=host`.
The honest way out, if Testcontainers support ever becomes serious: unprivileged network namespaces are available inside a user namespace (rootless podman does networking this way, via a userspace stack — pasta/slirp4netns).
That would be a deliberate, opt-in extension of the filesystem-only contract, decided in its own RFC — not implied by this one.
## Considered Options
* Docker-compatible from the start on all three levels — rejected: level 3 without a consumer is speculation, and the Ryuk/port semantics need real Testcontainers runs to validate against.
* Own CLI idioms (`werkdock run <instance> -- <cmd>` as sketched in plan step 21), compatibility later — rejected: retrofitting docker semantics onto a shipped CLI breaks users; the compatibility must shape the surface from day one.
* Docker-compatible CLI now, API-ready architecture, levels 2 and 3 deferred — chosen.
## Concrete Proposal
### Level 1 — CLI (session B)
Verbs and flags follow docker; unsupported docker flags fail loudly with a reason, never silently no-op:
| Werkdock | Docker equivalent | Notes |
|---|---|---|
| `werkdock run [flags] IMAGE [CMD...]` | `docker run` | creates an instance from the image, runs CMD |
| `werkdock create` / `start` / `stop` / `rm` | same | instance lifecycle |
| `werkdock ps [-a]` | same | running/all instances |
| `werkdock images` / `rmi` | same | local image store |
| `werkdock load -i FILE` | `docker load` | imports a rootfs archive as an image |
| `werkdock exec INSTANCE CMD...` | `docker exec` | additional process in a running sandbox |
| `werkdock logs [-f] INSTANCE` | `docker logs` | |
| `werkdock inspect NAME` | `docker inspect` | JSON, docker-shaped where fields apply |
| `werkdock doctor` | *(none)* | host capability and quota check; `info` aliases the summary |
Supported `run` flags from the start: `-v/--volume` (bind mounts), `-e/--env`, `-w/--workdir`, `--rm`, `--name`, `-d/--detach`, `--entrypoint`.
Refused with explanation: everything that promises isolation Werkdock does not provide (`-p/--publish`, `--network`, `--memory`, `--cpus`, `--user` beyond the fixed uid-0 mapping).
Semantic shift against the step-21 sketch: `run` takes an **image** (docker semantics), not a pre-unpacked instance; instances are created per run and correspond to docker containers.
`--rm` deletes the instance tree afterwards; without it, `ps -a`/`start` see it again.
### Level 2 — OCI images (deferred, designed for)
`werkdock pull IMAGE[:TAG]` fetches from an OCI registry (Docker Hub et al.) and flattens the layers into a rootfs.
This is HTTP + JSON + tar with whiteout handling — implementable within the stdlib-only policy (RFC 0001), but a substantial work package (registry auth token dance included).
Until then, `werkdock load` and the self-built rootfs archives carry the image store.
### Level 3 — daemon API (deferred, designed for)
`werkdock daemon` serves the Docker Engine API subset from "What Testcontainers Actually Needs" on a unix socket; consumers set `DOCKER_HOST=unix://$XDG_RUNTIME_DIR/werkdock.sock`.
Architecture consequence now: the CLI must not own the lifecycle logic — verbs are thin frontends over the same internal service the daemon would expose, and instance state lives on disk in a format both can read.
Ryuk stays disabled in documentation until proven.
## Consequences
- Plan step 21 session B and the README change their CLI sketch to the docker-shaped surface above.
- The engine interface from RFC 0001 is unaffected — compatibility shapes the surface, engines stay swappable behind it.
- Testcontainers remains a stated goal, not a claim: it is validated the day level 3 exists, and the port-collision limitation is documented until a network-namespace RFC changes it.
## Decision Outcome
Decided 2026-09-01: level 1 shapes the CLI — verbs and flags follow docker, unsupported flags fail loudly.
Levels 2 and 3 (OCI pull, daemon API, Testcontainers) are deferred indefinitely; nothing in the code may make them harder, nothing is built for them now.
The immediate goal is narrower than level 1's full verb list: `doctor`, `load`, and `run` — enough for the sandbox builds of Werkator, Werkbaum, and Werkdock itself; the remaining verbs follow with need.
@@ -1,54 +0,0 @@
# RFC 0003: Composable Toolchain Mounts
**Status:**
- proposed: 2026-09-01 (as a candidate — comes due when more than one toolchain combination is needed)
- accepted: -
- rejected: -
**Proposal:** Instead of baking every toolchain combination into its own flat image, werkdock composes a sandbox at run time: a slim base image plus per-toolchain artifacts from the store, mounted read-only under their own prefixes.
## Context and Problem Statement
Werkdock images are flat trees without layers (see the Disk Footprint section of the README): every toolchain combination is a full archive, built, uploaded, and unpacked as a whole.
The pain is concrete: adding Go and Node to the JDK build environment meant rebuilding and re-uploading a ~600 MB archive whose JDK half did not change.
Docker solves this with content-addressed layers over overlayfs — which needs either root or an overlay-capable bwrap (0.9+), neither available on the target webspaces today.
## The Key Insight
Overlayfs is only needed when trees must merge *at the same paths*.
Toolchains that live under their own prefix need no merging at all — and the official tarball distributions do exactly that:
- Go unpacks to `/usr/local/go`
- Node unpacks to `/usr/local/node-<version>`
- Temurin JDKs unpack to `/usr/local/jdk-<version>`
So composition is plain bind mounts, available in every bwrap version, no root, no overlayfs:
```
werkdock run --rm --with jdk-21 --with go-1.24 --with node-20 base sh -c '...'
```
## Sketch
- The base image shrinks to what apt must provide (debootstrap minbase, git, ca-certificates, locales — roughly 300 MB unpacked).
- A *toolchain* is a store artifact beside images: an unpacked tarball plus a small manifest naming its mount prefix and the environment it needs (`PATH` entries, `JAVA_HOME`, `GOROOT`, ...).
- `--with NAME` adds a read-only bind of the toolchain at its prefix and applies its manifest environment; order follows the flags, like `-v`.
- Deduplication falls out for free: each toolchain is stored once, every combination costs zero additional disk.
## Limits
- Only tarball-distributed toolchains fit; apt-installed ones spread across `/usr` and cannot be prefix-mounted.
For JDK, Go, and Node the official tarballs exist; toolchains without one stay in the base image.
- `--with` is a werkdock extension beyond the docker-compatible surface (RFC 0002) — docker has no counterpart.
It is additive: level-1 compatibility of the remaining CLI is untouched.
## Considered Alternatives
- On-target image building (apt/mmdebstrap on the webspace, unprivileged): technically possible via user namespaces, but slow, network-bound per build, and a relapse into the self-build drift step 21 corrects — build locally, install artifacts.
- Letting package managers fill a persistent home cache (Gradle toolchains, Go modules): works today as a side effect, but unhermetic and network-dependent on cold caches.
- Overlayfs layers or hardlink dedup between image versions: the general solutions, still worthwhile later, but blocked on bwrap 0.9+ (overlay) or more store machinery (dedup) — composition needs neither.
## Decision Outcome
Pending — to be decided when a second toolchain combination is actually needed (for example Werkbaum pinning its own Node version).
Until then the one fat image (RFC 0002 outcome, plan step 21) stays the deliberate choice.
-3
View File
@@ -1,3 +0,0 @@
module werkdock
go 1.22
-72
View File
@@ -1,72 +0,0 @@
// Package cli parses werkdock's docker-shaped command line (RFC 0002)
// and dispatches to the internal packages. Exit codes follow docker:
// 125 for werkdock's own errors, otherwise the sandboxed command's code
// is passed through.
package cli
import (
"fmt"
"io"
"os"
)
// Version is replaced at release time; the dev default marks unreleased
// builds.
var Version = "0.1.0-dev"
const exitCLIError = 125
// Main runs the CLI and returns the process exit code.
func Main(args []string) int {
if len(args) == 0 {
usage(os.Stderr)
return exitCLIError
}
switch args[0] {
case "run":
return runCmd(args[1:])
case "load":
return loadCmd(args[1:])
case "images":
return imagesCmd(args[1:])
case "doctor":
return doctorCmd(args[1:])
case "version", "--version":
fmt.Printf("werkdock %s\n", Version)
return 0
case "help", "--help", "-h":
usage(os.Stdout)
return 0
default:
fmt.Fprintf(os.Stderr, "werkdock: unknown command %q\n\n", args[0])
usage(os.Stderr)
return exitCLIError
}
}
func usage(w io.Writer) {
fmt.Fprint(w, `werkdock a docker-like sandbox CLI over bwrap, filesystem isolation only.
Network, uid, /proc, /dev, and /tmp come from the host by contract.
Usage:
werkdock run [flags] IMAGE COMMAND [ARG...] run a command in a sandbox
werkdock load -i ARCHIVE [--name NAME] import a rootfs archive as an image
werkdock images list loaded images, one name per line
werkdock doctor [TARGET_DIR] check whether this host can run sandboxes
werkdock version print the version
Run flags:
-v, --volume SRC:DEST[:ro] bind mount (repeatable; -v and --tmpfs apply in flag order)
--tmpfs DEST empty tmpfs at DEST (repeatable)
-e, --env KEY=VALUE set an environment variable (KEY alone copies it from the host)
-w, --workdir DIR working directory inside the sandbox (default /)
--rm remove the instance afterwards (currently required)
The store lives in $WERKDOCK_HOME (default ~/.werkdock).
`)
}
func fail(err error) int {
fmt.Fprintf(os.Stderr, "werkdock: %v\n", err)
return exitCLIError
}
-57
View File
@@ -1,57 +0,0 @@
package cli
import (
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"werkdock/internal/doctor"
"werkdock/internal/store"
)
func doctorCmd(args []string) int {
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return fail(err)
}
targetDir := ""
switch len(fs.Args()) {
case 0:
st, err := store.Default()
if err != nil {
return fail(err)
}
targetDir = st.Root
// The store may not exist yet; measure its closest existing
// ancestor, which sits on the same filesystem.
for {
if _, err := os.Stat(targetDir); err == nil {
break
}
parent := filepath.Dir(targetDir)
if parent == targetDir {
break
}
targetDir = parent
}
case 1:
targetDir = fs.Args()[0]
default:
return fail(fmt.Errorf("unexpected argument %q", fs.Args()[1]))
}
report := doctor.Run(targetDir, os.Getuid(), runCombined)
report.Render(os.Stdout)
if report.OK() {
return 0
}
return 1
}
func runCombined(name string, args ...string) (string, error) {
out, err := exec.Command(name, args...).CombinedOutput()
return string(out), err
}
-35
View File
@@ -1,35 +0,0 @@
package cli
import (
"flag"
"fmt"
"io"
"werkdock/internal/store"
)
// imagesCmd prints the loaded image names, one per line — machine-usable
// (Werkator checks image existence through it) and close enough to
// `docker images --format '{{.Repository}}'`.
func imagesCmd(args []string) int {
fs := flag.NewFlagSet("images", flag.ContinueOnError)
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return fail(err)
}
if len(fs.Args()) != 0 {
return fail(fmt.Errorf("unexpected argument %q", fs.Args()[0]))
}
st, err := store.Default()
if err != nil {
return fail(err)
}
names, err := st.List()
if err != nil {
return fail(err)
}
for _, name := range names {
fmt.Println(name)
}
return 0
}
-40
View File
@@ -1,40 +0,0 @@
package cli
import (
"errors"
"flag"
"fmt"
"io"
"werkdock/internal/store"
)
func loadCmd(args []string) int {
fs := flag.NewFlagSet("load", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var input, name string
fs.StringVar(&input, "i", "", "rootfs archive to import")
fs.StringVar(&input, "input", "", "rootfs archive to import")
fs.StringVar(&name, "name", "", "image name (default: derived from the archive file name)")
if err := fs.Parse(args); err != nil {
return fail(err)
}
if input == "" {
return fail(errors.New("load needs -i ARCHIVE"))
}
if len(fs.Args()) != 0 {
return fail(fmt.Errorf("unexpected argument %q", fs.Args()[0]))
}
if name == "" {
name = store.ImageNameFromArchive(input)
}
st, err := store.Default()
if err != nil {
return fail(err)
}
if err := st.Load(input, name); err != nil {
return fail(err)
}
fmt.Printf("Loaded image: %s\n", name)
return 0
}
-203
View File
@@ -1,203 +0,0 @@
package cli
import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"werkdock/internal/engine"
"werkdock/internal/store"
)
// runOptions is the parsed form of `werkdock run` flags, separated from
// execution so the parsing is testable and a later daemon can reuse it.
type runOptions struct {
Mounts []engine.Mount
Env []engine.EnvVar
Workdir string
Remove bool
Image string
Command []string
}
func runCmd(args []string) int {
opts, err := parseRun(args, os.Getenv)
if err != nil {
return fail(err)
}
st, err := store.Default()
if err != nil {
return fail(err)
}
rootfs, err := st.RootFS(opts.Image)
if err != nil {
return fail(err)
}
spec := engine.RunSpec{
RootFS: rootfs,
Mounts: hostMounts(opts.Mounts),
Env: opts.Env,
Workdir: opts.Workdir,
Command: opts.Command,
}
eng := &engine.Bwrap{}
code, err := eng.Run(spec)
if err != nil {
return fail(err)
}
return code
}
// hostMounts prepends the host mounts the contract prescribes: DNS comes
// from the host, so /etc/resolv.conf is bound read-only when it exists —
// before the user mounts, so an explicit mount over /etc wins.
func hostMounts(mounts []engine.Mount) []engine.Mount {
var all []engine.Mount
if fi, err := os.Stat("/etc/resolv.conf"); err == nil && fi.Mode().IsRegular() {
all = append(all, engine.Mount{Mode: engine.MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"})
}
return append(all, mounts...)
}
// parseRun parses the docker-shaped run flags. Docker flags whose
// promise werkdock cannot keep are registered and refused with a
// reason — never silently ignored (RFC 0002).
func parseRun(args []string, getenv func(string) string) (*runOptions, error) {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var envs stringList
opts := &runOptions{}
// -v and --tmpfs collect into ONE ordered list: bwrap layers mounts in
// order, so a tmpfs between two binds (the git-metadata mask) must stay
// between them.
volumes := &mountFlag{mounts: &opts.Mounts}
tmpfs := &mountFlag{mounts: &opts.Mounts, tmpfs: true}
fs.Var(volumes, "v", "bind mount SRC:DEST[:ro]")
fs.Var(volumes, "volume", "bind mount SRC:DEST[:ro]")
fs.Var(tmpfs, "tmpfs", "empty tmpfs at DEST")
fs.Var(&envs, "e", "environment variable KEY=VALUE")
fs.Var(&envs, "env", "environment variable KEY=VALUE")
fs.StringVar(&opts.Workdir, "w", "", "working directory inside the sandbox")
fs.StringVar(&opts.Workdir, "workdir", "", "working directory inside the sandbox")
fs.BoolVar(&opts.Remove, "rm", false, "remove the instance afterwards")
refuse(fs, "p", "werkdock has no network isolation; the sandbox binds host ports directly")
refuse(fs, "publish", "werkdock has no network isolation; the sandbox binds host ports directly")
refuse(fs, "network", "the network is the host's by contract; there is nothing to configure")
refuse(fs, "memory", "werkdock does not manage resources; use the host's limits (e.g. systemd)")
refuse(fs, "cpus", "werkdock does not manage resources; use the host's limits (e.g. systemd)")
refuse(fs, "user", "the sandbox always runs uid 0 mapped to the calling user")
refuse(fs, "d", "detached instances are not implemented yet")
refuse(fs, "detach", "detached instances are not implemented yet")
if err := fs.Parse(args); err != nil {
return nil, err
}
if !opts.Remove {
return nil, errors.New("persistent instances are not implemented yet; run with --rm")
}
rest := fs.Args()
if len(rest) == 0 {
return nil, errors.New("no image specified")
}
if len(rest) == 1 {
return nil, errors.New("no command specified (werkdock images carry no default command yet)")
}
opts.Image = rest[0]
opts.Command = rest[1:]
for _, e := range envs {
opts.Env = append(opts.Env, parseEnv(e, getenv))
}
if opts.Workdir != "" && !filepath.IsAbs(opts.Workdir) {
return nil, fmt.Errorf("workdir must be an absolute path: %s", opts.Workdir)
}
return opts, nil
}
func parseVolume(v string) (engine.Mount, error) {
parts := strings.Split(v, ":")
if len(parts) < 2 || len(parts) > 3 {
return engine.Mount{}, fmt.Errorf("invalid volume %q, expected SRC:DEST[:ro]", v)
}
mount := engine.Mount{Mode: engine.MountBind, Source: parts[0], Dest: parts[1]}
if len(parts) == 3 {
switch parts[2] {
case "ro":
mount.Mode = engine.MountRoBind
case "rw":
// docker accepts :rw as the explicit default; so do we
default:
return engine.Mount{}, fmt.Errorf("invalid volume option %q in %q, only 'ro' and 'rw' are supported", parts[2], v)
}
}
if !filepath.IsAbs(mount.Source) {
return engine.Mount{}, fmt.Errorf("volume source must be an absolute path: %s", mount.Source)
}
if !filepath.IsAbs(mount.Dest) {
return engine.Mount{}, fmt.Errorf("volume destination must be an absolute path: %s", mount.Dest)
}
return mount, nil
}
// mountFlag appends -v/--volume and --tmpfs values to one shared,
// ordered mount list.
type mountFlag struct {
mounts *[]engine.Mount
tmpfs bool
}
func (f *mountFlag) String() string { return "" }
func (f *mountFlag) Set(v string) error {
if f.tmpfs {
if !filepath.IsAbs(v) {
return fmt.Errorf("tmpfs destination must be an absolute path: %s", v)
}
*f.mounts = append(*f.mounts, engine.Mount{Mode: engine.MountTmpfs, Dest: v})
return nil
}
mount, err := parseVolume(v)
if err != nil {
return err
}
*f.mounts = append(*f.mounts, mount)
return nil
}
func parseEnv(e string, getenv func(string) string) engine.EnvVar {
if key, value, found := strings.Cut(e, "="); found {
return engine.EnvVar{Key: key, Value: value}
}
return engine.EnvVar{Key: e, Value: getenv(e)}
}
// stringList collects a repeatable flag's values in order.
type stringList []string
func (s *stringList) String() string { return strings.Join(*s, ",") }
func (s *stringList) Set(v string) error {
*s = append(*s, v)
return nil
}
// refusedFlag rejects a known docker flag with the reason werkdock
// cannot honor it.
type refusedFlag struct {
name string
reason string
}
func (f *refusedFlag) String() string { return "" }
func (f *refusedFlag) Set(string) error {
return fmt.Errorf("flag -%s is not supported: %s", f.name, f.reason)
}
func (f *refusedFlag) IsBoolFlag() bool { return true }
func refuse(fs *flag.FlagSet, name, reason string) {
fs.Var(&refusedFlag{name: name, reason: reason}, name, reason)
}
-158
View File
@@ -1,158 +0,0 @@
package cli
import (
"reflect"
"strings"
"testing"
"werkdock/internal/engine"
)
func noEnv(string) string { return "" }
func TestParseRunSupportedFlags(t *testing.T) {
opts, err := parseRun([]string{
"--rm",
"-v", "/repo:/repo",
"--volume", "/cache:/root/.gradle:ro",
"-e", "CI=true",
"-w", "/repo",
"buildenv", "sh", "-c", "./gradlew build",
}, noEnv)
if err != nil {
t.Fatal(err)
}
if opts.Image != "buildenv" {
t.Errorf("image: got %q", opts.Image)
}
if !reflect.DeepEqual(opts.Command, []string{"sh", "-c", "./gradlew build"}) {
t.Errorf("command: got %q", opts.Command)
}
wantMounts := []engine.Mount{
{Mode: engine.MountBind, Source: "/repo", Dest: "/repo"},
{Mode: engine.MountRoBind, Source: "/cache", Dest: "/root/.gradle"},
}
if !reflect.DeepEqual(opts.Mounts, wantMounts) {
t.Errorf("mounts: got %+v", opts.Mounts)
}
if !reflect.DeepEqual(opts.Env, []engine.EnvVar{{Key: "CI", Value: "true"}}) {
t.Errorf("env: got %+v", opts.Env)
}
if opts.Workdir != "/repo" {
t.Errorf("workdir: got %q", opts.Workdir)
}
}
func TestParseRunCopiesBareEnvKeysFromTheHost(t *testing.T) {
getenv := func(key string) string {
if key == "LANG" {
return "C.UTF-8"
}
return ""
}
opts, err := parseRun([]string{"--rm", "-e", "LANG", "img", "true"}, getenv)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(opts.Env, []engine.EnvVar{{Key: "LANG", Value: "C.UTF-8"}}) {
t.Errorf("env: got %+v", opts.Env)
}
}
func TestParseRunRefusesDockerFlagsLoudly(t *testing.T) {
tests := []struct {
args []string
wantReason string
}{
{[]string{"--rm", "-p", "8080:80", "img", "true"}, "no network isolation"},
{[]string{"--rm", "--network", "host", "img", "true"}, "network is the host's"},
{[]string{"--rm", "--memory", "1g", "img", "true"}, "does not manage resources"},
{[]string{"--rm", "--user", "1000", "img", "true"}, "uid 0 mapped to the calling user"},
{[]string{"--rm", "-d", "img", "true"}, "not implemented yet"},
}
for _, tt := range tests {
t.Run(strings.Join(tt.args, " "), func(t *testing.T) {
_, err := parseRun(tt.args, noEnv)
if err == nil || !strings.Contains(err.Error(), tt.wantReason) {
t.Errorf("got %v, want refusal containing %q", err, tt.wantReason)
}
})
}
}
func TestParseRunRequiresRmForNow(t *testing.T) {
_, err := parseRun([]string{"img", "true"}, noEnv)
if err == nil || !strings.Contains(err.Error(), "--rm") {
t.Errorf("got %v, want the --rm requirement", err)
}
}
func TestParseRunValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{"no image", []string{"--rm"}, "no image specified"},
{"no command", []string{"--rm", "img"}, "no command specified"},
{"volume without dest", []string{"--rm", "-v", "/only-src", "img", "true"}, "expected SRC:DEST"},
{"volume with bad option", []string{"--rm", "-v", "/a:/b:cached", "img", "true"}, "only 'ro' and 'rw' are supported"},
{"relative tmpfs dest", []string{"--rm", "--tmpfs", "rel", "img", "true"}, "absolute"},
{"relative volume source", []string{"--rm", "-v", "rel:/b", "img", "true"}, "absolute"},
{"relative volume dest", []string{"--rm", "-v", "/a:rel", "img", "true"}, "absolute"},
{"relative workdir", []string{"--rm", "-w", "rel", "img", "true"}, "absolute"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := parseRun(tt.args, noEnv)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("got %v, want it to contain %q", err, tt.wantErr)
}
})
}
}
func TestParseRunKeepsMountFlagOrderAcrossVolumeAndTmpfs(t *testing.T) {
// The git-metadata mask depends on it: ro-bind .git, tmpfs over
// .git/werkator, then the workspace bind — in exactly this order.
opts, err := parseRun([]string{
"--rm",
"-v", "/r/.git:/r/.git:ro",
"--tmpfs", "/r/.git/werkator",
"-v", "/r/ws:/r/ws",
"img", "true",
}, noEnv)
if err != nil {
t.Fatal(err)
}
want := []engine.Mount{
{Mode: engine.MountRoBind, Source: "/r/.git", Dest: "/r/.git"},
{Mode: engine.MountTmpfs, Dest: "/r/.git/werkator"},
{Mode: engine.MountBind, Source: "/r/ws", Dest: "/r/ws"},
}
if !reflect.DeepEqual(opts.Mounts, want) {
t.Errorf("mounts: got %+v", opts.Mounts)
}
}
func TestParseRunAcceptsTheExplicitRwVolumeOption(t *testing.T) {
opts, err := parseRun([]string{"--rm", "-v", "/a:/b:rw", "img", "true"}, noEnv)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(opts.Mounts, []engine.Mount{{Mode: engine.MountBind, Source: "/a", Dest: "/b"}}) {
t.Errorf("mounts: got %+v", opts.Mounts)
}
}
func TestParseRunStopsFlagParsingAtTheImage(t *testing.T) {
// Docker semantics: everything after the image belongs to the
// command, even if it looks like a flag.
opts, err := parseRun([]string{"--rm", "img", "ls", "-la", "/tmp"}, noEnv)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(opts.Command, []string{"ls", "-la", "/tmp"}) {
t.Errorf("command: got %q", opts.Command)
}
}
-292
View File
@@ -1,292 +0,0 @@
// Package doctor checks whether this host can run werkdock sandboxes:
// unprivileged user namespaces with a uid-0 mapping and enforced
// read-only root binds, the required CLI tools, and disk/quota headroom
// for the build footprint. It is a port of Werkator's
// werkator-build-prerequisites.sh, with the same PASS/FAIL output.
package doctor
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// MinFreeKiB is the disk footprint a sandbox build needs headroom for:
// unpacked rootfs (zstd expands roughly 3-4x), toolchain caches, build
// output. ~5 GiB, in KiB.
const MinFreeKiB = 5 * 1024 * 1024
// Runner executes a command and returns its combined output; injected
// so the evaluation logic is testable against captured fixtures.
type Runner func(name string, args ...string) (string, error)
// Report is the outcome of all checks.
type Report struct {
Checks []Check
Warnings []string
}
// Check is one PASS/FAIL line.
type Check struct {
OK bool
Msg string
}
func (r *Report) pass(format string, a ...any) {
r.Checks = append(r.Checks, Check{OK: true, Msg: fmt.Sprintf(format, a...)})
}
func (r *Report) fail(format string, a ...any) {
r.Checks = append(r.Checks, Check{OK: false, Msg: fmt.Sprintf(format, a...)})
}
func (r *Report) warn(format string, a ...any) {
r.Warnings = append(r.Warnings, fmt.Sprintf(format, a...))
}
// OK reports whether no check failed.
func (r *Report) OK() bool {
for _, c := range r.Checks {
if !c.OK {
return false
}
}
return true
}
// Run executes all checks against targetDir (where images and build
// workspaces will live).
func Run(targetDir string, selfUID int, run Runner) *Report {
r := &Report{}
sandboxChecks(r, selfUID, run)
toolChecks(r)
diskChecks(r, targetDir, run)
return r
}
// sandboxProbe is the command run inside the sandbox; its three output
// lines are the signals evaluated below.
const sandboxProbe = "id -u && cat /proc/self/uid_map && (touch /usr/ro-test 2>&1 || true)"
func sandboxChecks(r *Report, selfUID int, run Runner) {
if _, err := exec.LookPath("bwrap"); err != nil {
r.fail("bwrap is not installed on this host")
return
}
version, err := run("bwrap", "--version")
if err != nil {
r.fail("bwrap --version failed: %v", err)
return
}
r.pass("bwrap version: %s", strings.TrimSpace(version))
out, err := run("bwrap",
"--unshare-user", "--unshare-pid", "--die-with-parent",
"--uid", "0", "--gid", "0",
"--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp",
"sh", "-c", sandboxProbe)
if err != nil {
r.fail("bwrap invocation failed (no user namespace support?): %s", strings.TrimSpace(out))
return
}
EvaluateSandbox(r, out, selfUID)
}
// EvaluateSandbox checks the three signals of the sandbox probe output:
// uid 0 inside, a uid_map back to the unprivileged user, and an
// enforced read-only root bind.
func EvaluateSandbox(r *Report, output string, selfUID int) {
lines := strings.Split(strings.TrimRight(output, "\n"), "\n")
line := func(i int) string {
if i < len(lines) {
return strings.TrimSpace(lines[i])
}
return ""
}
if line(0) == "0" {
r.pass("build runs as root inside the namespace (uid 0)")
} else {
r.fail("expected uid 0 inside the namespace, got: %s", line(0))
}
mapRe := regexp.MustCompile(`^\s*0\s+` + strconv.Itoa(selfUID) + `\s+1`)
if mapRe.MatchString(line(1)) {
r.pass("uid_map maps root back to the unprivileged user (uid %d)", selfUID)
} else {
r.fail("expected uid_map '0 %d 1', got: %s", selfUID, line(1))
}
if strings.Contains(strings.ToLower(output), "read-only file system") {
r.pass("read-only root bind is enforced")
} else {
r.fail("the read-only root bind did not reject a write to /usr")
}
}
func toolChecks(r *Report) {
if _, err := exec.LookPath("tar"); err != nil {
r.fail("tar is not installed — required to unpack images")
} else {
r.pass("tar is available")
}
if _, err := exec.LookPath("zstd"); err != nil {
r.warn("zstd is not installed — .tar.zst images cannot be unpacked")
}
}
func diskChecks(r *Report, targetDir string, run Runner) {
minGiB := MinFreeKiB / 1024 / 1024
homeFS := ""
if home, err := os.UserHomeDir(); err == nil {
if out, err := run("df", "-Pk", home); err == nil {
homeFS, _, _ = ParseDF(out)
}
}
out, err := run("df", "-Pk", targetDir)
if err != nil {
r.warn("could not measure free space on %s — only the quota check applies", targetDir)
return
}
device, availKiB, mount := ParseDF(out)
if device == "" {
r.warn("could not measure free space on %s — only the quota check applies", targetDir)
} else {
if homeFS != "" && device != homeFS {
r.warn("target dir is on %s (mounted at %s), not the home filesystem (%s) — builds will run on slower storage", device, mount, homeFS)
}
if availKiB < MinFreeKiB {
r.fail("less than %d GiB free space on the build working filesystem (%s)", minGiB, mount)
} else {
r.pass("at least %d GiB free space on the build working filesystem (%s, device %s)", minGiB, mount, device)
}
}
quotaOut, err := run("quota", "-g")
if err != nil || strings.TrimSpace(quotaOut) == "" {
r.warn("no readable group quota tooling on this host — only free space was checked")
return
}
lines := ParseQuota(quotaOut)
if len(lines) == 0 {
r.warn("quota tooling present but no group quota lines could be parsed — only free space was checked")
return
}
ok := true
detail := ""
for _, q := range lines {
// Only the quota of the target filesystem counts — other
// volumes may legitimately be full without affecting builds.
if device != "" && filepath.Base(q.FS) != filepath.Base(device) && q.FS != device {
continue
}
headroom := q.Limit - q.Blocks
if headroom < MinFreeKiB {
ok = false
detail += fmt.Sprintf(" %s: %.1f GiB free of quota;", filepath.Base(q.FS), float64(headroom)/1024/1024)
}
}
if ok {
r.pass("group quota headroom covers the %d GiB build footprint", minGiB)
} else {
r.fail("group quota headroom below the %d GiB build footprint; raise the quota before building.%s", minGiB, detail)
}
}
// ParseDF extracts device, available KiB, and mount point from
// `df -Pk DIR` output.
func ParseDF(output string) (device string, availKiB int64, mount string) {
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) < 2 {
return "", 0, ""
}
fields := strings.Fields(lines[1])
if len(fields) < 6 {
return "", 0, ""
}
avail, err := strconv.ParseInt(fields[3], 10, 64)
if err != nil {
return "", 0, ""
}
return fields[0], avail, fields[5]
}
// QuotaLine is one filesystem's group quota: used blocks and the hard
// limit, both in KiB.
type QuotaLine struct {
FS string
Blocks int64
Limit int64
}
// ParseQuota parses `quota -g` output, including the wrapped form where
// a long device name stands alone on its own line and the numbers
// follow on the next. A '*' suffix on the blocks value (over soft
// quota) is ignored.
func ParseQuota(output string) []QuotaLine {
var result []QuotaLine
pendingFS := ""
for _, raw := range strings.Split(output, "\n") {
fields := strings.Fields(raw)
if len(fields) == 0 {
continue
}
if len(fields) == 1 && strings.HasPrefix(fields[0], "/") {
pendingFS = fields[0]
continue
}
if strings.HasPrefix(fields[0], "/") && len(fields) >= 4 {
if blocks, limit, ok := quotaNumbers(fields[1], fields[3]); ok {
result = append(result, QuotaLine{FS: fields[0], Blocks: blocks, Limit: limit})
pendingFS = ""
}
continue
}
if pendingFS != "" && len(fields) >= 3 {
if blocks, limit, ok := quotaNumbers(fields[0], fields[2]); ok {
result = append(result, QuotaLine{FS: pendingFS, Blocks: blocks, Limit: limit})
pendingFS = ""
}
}
}
return result
}
func quotaNumbers(blocksField, limitField string) (int64, int64, bool) {
blocks, err := strconv.ParseInt(strings.TrimSuffix(blocksField, "*"), 10, 64)
if err != nil {
return 0, 0, false
}
limit, err := strconv.ParseInt(limitField, 10, 64)
if err != nil {
return 0, 0, false
}
return blocks, limit, true
}
// Render writes the report in the PASS/FAIL format of the original
// prerequisites script, ending with a RESULT line.
func (r *Report) Render(w io.Writer) {
for _, c := range r.Checks {
status := "PASS"
if !c.OK {
status = "FAIL"
}
fmt.Fprintf(w, "%s: %s\n", status, c.Msg)
}
for _, warning := range r.Warnings {
fmt.Fprintf(w, "WARNING: %s\n", warning)
}
passed := 0
for _, c := range r.Checks {
if c.OK {
passed++
}
}
fmt.Fprintln(w)
if r.OK() {
fmt.Fprintf(w, "RESULT: PASS (%d/%d) — werkdock sandboxes are usable on this host.\n", passed, len(r.Checks))
} else {
fmt.Fprintf(w, "RESULT: FAIL (%d/%d) — werkdock sandboxes are not usable on this host.\n", passed, len(r.Checks))
}
}
-161
View File
@@ -1,161 +0,0 @@
package doctor
import (
"reflect"
"strings"
"testing"
)
func TestEvaluateSandboxAllSignalsPass(t *testing.T) {
r := &Report{}
output := "0\n 0 120957 1\ntouch: cannot touch '/usr/ro-test': Read-only file system\n"
EvaluateSandbox(r, output, 120957)
if !r.OK() {
t.Errorf("expected all signals to pass, got %+v", r.Checks)
}
if len(r.Checks) != 3 {
t.Errorf("expected 3 checks, got %d", len(r.Checks))
}
}
func TestEvaluateSandboxFailures(t *testing.T) {
tests := []struct {
name string
output string
selfUID int
wantFail string
}{
{
"not root inside",
"1000\n 0 120957 1\nRead-only file system\n",
120957,
"expected uid 0",
},
{
"uid_map maps someone else",
"0\n 0 999999 1\nRead-only file system\n",
120957,
"expected uid_map",
},
{
"writable root bind",
"0\n 0 120957 1\n",
120957,
"did not reject a write",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Report{}
EvaluateSandbox(r, tt.output, tt.selfUID)
found := false
for _, c := range r.Checks {
if !c.OK && strings.Contains(c.Msg, tt.wantFail) {
found = true
}
}
if !found {
t.Errorf("expected a failing check containing %q, got %+v", tt.wantFail, r.Checks)
}
})
}
}
func TestParseDF(t *testing.T) {
output := "Filesystem 1024-blocks Used Available Capacity Mounted on\n" +
"/dev/mapper/vg0-home 959786032 447013936 463941300 50% /home\n"
device, avail, mount := ParseDF(output)
if device != "/dev/mapper/vg0-home" || avail != 463941300 || mount != "/home" {
t.Errorf("got %q %d %q", device, avail, mount)
}
if d, a, m := ParseDF("garbage"); d != "" || a != 0 || m != "" {
t.Errorf("expected empty result for garbage, got %q %d %q", d, a, m)
}
}
func TestParseQuotaPlainAndWrappedLines(t *testing.T) {
output := `Disk quotas for group g123456 (gid 123456):
Filesystem blocks quota limit grace files quota limit grace
/dev/vdb1 123456 900000 1000000 1234 0 0
/dev/mapper/very-long-device-name-that-wraps
654321* 4500000 5000000 4321 0 0
`
want := []QuotaLine{
{FS: "/dev/vdb1", Blocks: 123456, Limit: 1000000},
{FS: "/dev/mapper/very-long-device-name-that-wraps", Blocks: 654321, Limit: 5000000},
}
if got := ParseQuota(output); !reflect.DeepEqual(got, want) {
t.Errorf("got %+v\nwant %+v", got, want)
}
}
func TestParseQuotaIgnoresUnparsableOutput(t *testing.T) {
if got := ParseQuota("no quotas here\n"); len(got) != 0 {
t.Errorf("expected no lines, got %+v", got)
}
}
// fakeRunner serves canned outputs keyed by command name.
func fakeRunner(outputs map[string]string) Runner {
return func(name string, args ...string) (string, error) {
return outputs[name], nil
}
}
func TestDiskChecksFailOnQuotaHeadroomOfTheTargetFilesystem(t *testing.T) {
r := &Report{}
// 1 GiB quota headroom on the home device, plenty on another one.
outputs := map[string]string{
"df": "Filesystem 1024-blocks Used Available Capacity Mounted on\n" +
"/dev/vdb1 100000000 10000000 90000000 10% /home\n",
"quota": "Disk quotas for group g1 (gid 1):\n" +
" Filesystem blocks quota limit grace\n" +
"/dev/vdb1 4000000 5000000 5048576 - - -\n" +
"/dev/other 0 0 99999999 - - -\n",
}
diskChecks(r, "/home/user", fakeRunner(outputs))
if r.OK() {
t.Fatalf("expected the quota check to fail, got %+v", r.Checks)
}
failing := ""
for _, c := range r.Checks {
if !c.OK {
failing = c.Msg
}
}
if !strings.Contains(failing, "quota headroom below") || !strings.Contains(failing, "vdb1") {
t.Errorf("unexpected failure message: %s", failing)
}
}
func TestDiskChecksPassWithSpaceAndQuota(t *testing.T) {
r := &Report{}
outputs := map[string]string{
"df": "Filesystem 1024-blocks Used Available Capacity Mounted on\n" +
"/dev/vdb1 100000000 10000000 90000000 10% /home\n",
"quota": "Disk quotas for group g1 (gid 1):\n" +
" Filesystem blocks quota limit grace\n" +
"/dev/vdb1 1000000 90000000 99000000 - - -\n",
}
diskChecks(r, "/home/user", fakeRunner(outputs))
if !r.OK() {
t.Errorf("expected disk checks to pass, got %+v", r.Checks)
}
if len(r.Checks) != 2 {
t.Errorf("expected free-space and quota checks, got %+v", r.Checks)
}
}
func TestRenderEndsWithTheResultLine(t *testing.T) {
r := &Report{}
r.pass("all good")
r.warn("just saying")
var out strings.Builder
r.Render(&out)
rendered := out.String()
if !strings.Contains(rendered, "PASS: all good\n") ||
!strings.Contains(rendered, "WARNING: just saying\n") ||
!strings.Contains(rendered, "RESULT: PASS (1/1)") {
t.Errorf("unexpected rendering:\n%s", rendered)
}
}
-210
View File
@@ -1,210 +0,0 @@
package engine
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Bwrap runs a RunSpec through the bwrap CLI — filesystem isolation
// only; network, uid mapping target, /proc, /dev, and /tmp come from
// the host by contract.
//
// The invocation is a port of Werkator's BwrapBuildRunner, including
// the parts hardened on a real Hostsharing webspace: bind mountpoints
// are pre-created inside the rootfs (a plain host directory), because
// bwrap cannot mkdir them against the read-only root bind.
type Bwrap struct {
// Path of the bwrap binary; empty means "bwrap" via PATH.
Path string
// Stdio of the sandboxed command; nil fields default to the
// werkdock process's own.
Stdout io.Writer
Stderr io.Writer
Stdin io.Reader
}
// DefaultPATH is the PATH inside the sandbox; the environment is
// cleared (docker semantics), so a sane default must be set explicitly.
const DefaultPATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
// Argv assembles the full bwrap command line for spec.
//
// Mount order: the rootfs first; then /proc, /dev, and the tmpfs
// mounts for /tmp and /root, BEFORE the user binds, so a bind whose
// destination lies below them lands inside instead of being shadowed;
// then the user binds in the given order.
func (b *Bwrap) Argv(spec RunSpec) ([]string, error) {
if spec.RootFS == "" {
return nil, errors.New("rootfs must be set")
}
if !filepath.IsAbs(spec.RootFS) {
return nil, fmt.Errorf("rootfs must be an absolute path: %s", spec.RootFS)
}
if len(spec.Command) == 0 {
return nil, errors.New("no command specified")
}
bin := b.Path
if bin == "" {
bin = "bwrap"
}
args := []string{
bin,
"--unshare-user",
"--unshare-pid",
"--die-with-parent",
"--uid", "0",
"--gid", "0",
"--ro-bind", spec.RootFS, "/",
"--proc", "/proc",
"--dev", "/dev",
"--tmpfs", "/tmp",
"--tmpfs", "/root",
}
for _, m := range spec.Mounts {
if !filepath.IsAbs(m.Dest) {
return nil, fmt.Errorf("mount destination must be an absolute path: %s", m.Dest)
}
switch m.Mode {
case MountBind:
args = append(args, "--bind", m.Source, m.Dest)
case MountRoBind:
args = append(args, "--ro-bind", m.Source, m.Dest)
case MountTmpfs:
args = append(args, "--tmpfs", m.Dest)
default:
return nil, fmt.Errorf("unknown mount mode %d for %s", m.Mode, m.Dest)
}
}
args = append(args,
"--clearenv",
"--setenv", "HOME", "/root",
"--setenv", "PATH", DefaultPATH,
)
for _, e := range spec.Env {
args = append(args, "--setenv", e.Key, e.Value)
}
workdir := spec.Workdir
if workdir == "" {
workdir = "/"
}
args = append(args, "--chdir", workdir, "--")
args = append(args, spec.Command...)
return args, nil
}
// EnsureMountpoints pre-creates the mountpoints of spec inside the
// rootfs directory. bwrap creates mountpoints against the sandbox view,
// which is the read-only rootfs bind — every destination missing from
// the rootfs fails with "Read-only file system". The rootfs directory
// itself is a plain host directory, so the mountpoints are created
// there; bwrap then finds them and has nothing left to mkdir.
//
// Anything that already exists in the rootfs is left alone (e.g.
// /etc/resolv.conf is a file many rootfs archives ship). A bind whose
// source is a regular file gets a file mountpoint, not a directory.
func EnsureMountpoints(spec RunSpec) error {
for _, dest := range []string{"/proc", "/dev", "/tmp", "/root"} {
if err := ensureDir(spec.RootFS, dest); err != nil {
return err
}
}
for _, m := range spec.Mounts {
target, err := rootfsPath(spec.RootFS, m.Dest)
if err != nil {
return err
}
if _, err := os.Lstat(target); err == nil {
continue
}
if m.Mode == MountTmpfs {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
continue
}
src, err := os.Stat(m.Source)
if err != nil {
return fmt.Errorf("bind source %s: %w", m.Source, err)
}
if src.Mode().IsRegular() {
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o644)
if err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
continue
}
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
}
return nil
}
func ensureDir(rootfs, dest string) error {
target, err := rootfsPath(rootfs, dest)
if err != nil {
return err
}
if _, statErr := os.Lstat(target); statErr == nil {
return nil
}
return os.MkdirAll(target, 0o755)
}
// rootfsPath resolves dest inside rootfs and refuses destinations that
// escape it — werkdock assembles mounts from user input, so this must
// hold even for hostile paths.
func rootfsPath(rootfs, dest string) (string, error) {
root := filepath.Clean(rootfs)
target := filepath.Join(root, dest)
prefix := root
if !strings.HasSuffix(prefix, string(filepath.Separator)) {
prefix += string(filepath.Separator)
}
if target != root && !strings.HasPrefix(target, prefix) {
return "", fmt.Errorf("bind destination escapes the rootfs: %s", dest)
}
return target, nil
}
// Run executes spec and returns the command's exit code; bwrap
// propagates the child's code, so the caller can pass it through.
func (b *Bwrap) Run(spec RunSpec) (int, error) {
argv, err := b.Argv(spec)
if err != nil {
return 0, err
}
if err := EnsureMountpoints(spec); err != nil {
return 0, err
}
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdout = b.Stdout
if cmd.Stdout == nil {
cmd.Stdout = os.Stdout
}
cmd.Stderr = b.Stderr
if cmd.Stderr == nil {
cmd.Stderr = os.Stderr
}
cmd.Stdin = b.Stdin
err = cmd.Run()
if err == nil {
return 0, nil
}
var exit *exec.ExitError
if errors.As(err, &exit) {
return exit.ExitCode(), nil
}
return 0, err
}
-192
View File
@@ -1,192 +0,0 @@
package engine
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestArgvAssemblesTheHardenedInvocation(t *testing.T) {
b := &Bwrap{}
spec := RunSpec{
RootFS: "/store/images/buildenv/rootfs",
Mounts: []Mount{
{Mode: MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"},
{Mode: MountRoBind, Source: "/repo/.git", Dest: "/repo/.git"},
{Mode: MountTmpfs, Dest: "/repo/.git/werkator"},
{Mode: MountBind, Source: "/repo", Dest: "/repo"},
{Mode: MountBind, Source: "/cache", Dest: "/root/.gradle"},
},
Env: []EnvVar{{Key: "CI", Value: "true"}, {Key: "TERM", Value: "dumb"}},
Workdir: "/repo",
Command: []string{"/bin/sh", "-c", "./gradlew build"},
}
argv, err := b.Argv(spec)
if err != nil {
t.Fatal(err)
}
want := []string{
"bwrap",
"--unshare-user", "--unshare-pid", "--die-with-parent",
"--uid", "0", "--gid", "0",
"--ro-bind", "/store/images/buildenv/rootfs", "/",
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", "/root",
"--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf",
"--ro-bind", "/repo/.git", "/repo/.git",
"--tmpfs", "/repo/.git/werkator",
"--bind", "/repo", "/repo",
"--bind", "/cache", "/root/.gradle",
"--clearenv",
"--setenv", "HOME", "/root",
"--setenv", "PATH", DefaultPATH,
"--setenv", "CI", "true",
"--setenv", "TERM", "dumb",
"--chdir", "/repo", "--",
"/bin/sh", "-c", "./gradlew build",
}
if !reflect.DeepEqual(argv, want) {
t.Errorf("argv mismatch:\n got %q\nwant %q", argv, want)
}
}
func TestArgvValidation(t *testing.T) {
tests := []struct {
name string
spec RunSpec
wantErr string
}{
{"missing rootfs", RunSpec{Command: []string{"true"}}, "rootfs must be set"},
{"relative rootfs", RunSpec{RootFS: "rootfs", Command: []string{"true"}}, "absolute"},
{"missing command", RunSpec{RootFS: "/r"}, "no command specified"},
{
"relative mount dest",
RunSpec{RootFS: "/r", Mounts: []Mount{{Mode: MountBind, Source: "/s", Dest: "work"}}, Command: []string{"true"}},
"absolute",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := (&Bwrap{}).Argv(tt.spec)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("got error %v, want it to contain %q", err, tt.wantErr)
}
})
}
}
func TestArgvDefaultsWorkdirToRoot(t *testing.T) {
argv, err := (&Bwrap{}).Argv(RunSpec{RootFS: "/r", Command: []string{"true"}})
if err != nil {
t.Fatal(err)
}
joined := strings.Join(argv, " ")
if !strings.Contains(joined, "--chdir / --") {
t.Errorf("expected default workdir /, got: %s", joined)
}
}
func TestEnsureMountpointsCreatesMissingAndSkipsExisting(t *testing.T) {
rootfs := t.TempDir()
// The rootfs ships /etc/resolv.conf as a file with content — it
// must be left alone.
if err := os.MkdirAll(filepath.Join(rootfs, "etc"), 0o755); err != nil {
t.Fatal(err)
}
shipped := filepath.Join(rootfs, "etc", "resolv.conf")
if err := os.WriteFile(shipped, []byte("nameserver 127.0.0.53\n"), 0o644); err != nil {
t.Fatal(err)
}
srcDir := t.TempDir()
srcFile := filepath.Join(srcDir, "hosts")
if err := os.WriteFile(srcFile, []byte("127.0.0.1 localhost\n"), 0o644); err != nil {
t.Fatal(err)
}
spec := RunSpec{
RootFS: rootfs,
Mounts: []Mount{
{Mode: MountRoBind, Source: "/etc", Dest: "/etc/resolv.conf"}, // exists: skipped (source type irrelevant)
{Mode: MountBind, Source: srcDir, Dest: "/repo/workspace"}, // missing dir mountpoint
{Mode: MountBind, Source: srcFile, Dest: "/etc/hosts.werkdock"}, // missing file mountpoint
{Mode: MountTmpfs, Dest: "/repo/.git/werkator"}, // tmpfs mountpoint, no source
},
Command: []string{"true"},
}
if err := EnsureMountpoints(spec); err != nil {
t.Fatal(err)
}
for _, dir := range []string{"proc", "dev", "tmp", "root", "repo/workspace", "repo/.git/werkator"} {
fi, err := os.Stat(filepath.Join(rootfs, dir))
if err != nil || !fi.IsDir() {
t.Errorf("expected directory mountpoint %s in the rootfs: %v", dir, err)
}
}
fi, err := os.Stat(filepath.Join(rootfs, "etc", "hosts.werkdock"))
if err != nil || !fi.Mode().IsRegular() {
t.Errorf("expected file mountpoint etc/hosts.werkdock in the rootfs: %v", err)
}
content, err := os.ReadFile(shipped)
if err != nil || string(content) != "nameserver 127.0.0.53\n" {
t.Errorf("shipped rootfs file was modified: %q, %v", content, err)
}
}
func TestEnsureMountpointsRefusesEscapingDestinations(t *testing.T) {
spec := RunSpec{
RootFS: t.TempDir(),
Mounts: []Mount{{Mode: MountBind, Source: "/tmp", Dest: "/../outside"}},
Command: []string{"true"},
}
err := EnsureMountpoints(spec)
if err == nil || !strings.Contains(err.Error(), "escapes the rootfs") {
t.Errorf("got %v, want an escape refusal", err)
}
}
// TestRunInsideRealSandbox is the gated integration test: it runs only
// where bwrap and unprivileged user namespaces actually work. The host
// / serves as the read-only rootfs, so nothing is unpacked and (all
// mountpoints existing) nothing is written.
func TestRunInsideRealSandbox(t *testing.T) {
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("bwrap not installed")
}
if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil {
t.Skipf("unprivileged user namespaces not usable here: %v", err)
}
var stdout, stderr bytes.Buffer
b := &Bwrap{Stdout: &stdout, Stderr: &stderr}
code, err := b.Run(RunSpec{
RootFS: "/",
Command: []string{"id", "-u"},
})
if err != nil {
t.Fatalf("run failed: %v (stderr: %s)", err, stderr.String())
}
if code != 0 {
t.Fatalf("exit code %d, stderr: %s", code, stderr.String())
}
if got := strings.TrimSpace(stdout.String()); got != "0" {
t.Errorf("expected uid 0 inside the sandbox, got %q", got)
}
}
func TestRunPassesTheExitCodeThrough(t *testing.T) {
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("bwrap not installed")
}
if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil {
t.Skipf("unprivileged user namespaces not usable here: %v", err)
}
b := &Bwrap{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}}
code, err := b.Run(RunSpec{RootFS: "/", Command: []string{"sh", "-c", "exit 42"}})
if err != nil {
t.Fatal(err)
}
if code != 42 {
t.Errorf("expected exit code 42, got %d", code)
}
}
-50
View File
@@ -1,50 +0,0 @@
// Package engine executes sandboxed commands. The CLI verbs are thin
// frontends over this package, so a later daemon can expose the same
// logic without duplicating it (RFC 0002).
package engine
// MountMode distinguishes the mount kinds a RunSpec can carry.
type MountMode int
const (
// MountBind is a read-write bind mount.
MountBind MountMode = iota
// MountRoBind is a read-only bind mount.
MountRoBind
// MountTmpfs is an empty tmpfs at Dest; Source is unused.
MountTmpfs
)
// Mount is one mount, applied in order; later mounts shadow earlier
// ones at their own path, exactly as bwrap layers them — the order of
// -v and --tmpfs flags is therefore significant and preserved.
type Mount struct {
Mode MountMode
Source string
Dest string
}
// EnvVar is one environment variable; order is preserved.
type EnvVar struct {
Key string
Value string
}
// RunSpec describes one sandboxed command, independent of the engine
// that executes it.
type RunSpec struct {
// RootFS is the absolute path to the unpacked image rootfs,
// bound read-only at /.
RootFS string
Mounts []Mount
Env []EnvVar
Workdir string
Command []string
}
// Engine runs a RunSpec and reports the command's exit code.
// bwrap is the first engine; native namespaces may become a second
// (RFC 0001).
type Engine interface {
Run(spec RunSpec) (int, error)
}
-148
View File
@@ -1,148 +0,0 @@
// Package store is the on-disk image store. An image is a rootfs
// archive unpacked under the store root; instance state will live here
// too once persistent instances exist, in a format both the CLI and a
// later daemon can read (RFC 0002).
package store
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// Store is rooted at $WERKDOCK_HOME, defaulting to ~/.werkdock.
type Store struct {
Root string
}
// ImageMeta is written as image.json beside each image's rootfs.
type ImageMeta struct {
Name string `json:"name"`
Source string `json:"source"`
CreatedAt time.Time `json:"createdAt"`
}
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
// Default resolves the store root from the environment.
func Default() (Store, error) {
if root := os.Getenv("WERKDOCK_HOME"); root != "" {
return Store{Root: root}, nil
}
home, err := os.UserHomeDir()
if err != nil {
return Store{}, fmt.Errorf("cannot resolve the store root: %w", err)
}
return Store{Root: filepath.Join(home, ".werkdock")}, nil
}
func (s Store) imageDir(name string) string {
return filepath.Join(s.Root, "images", name)
}
// RootFS resolves an image name to its unpacked rootfs directory.
func (s Store) RootFS(name string) (string, error) {
if !nameRe.MatchString(name) {
return "", fmt.Errorf("invalid image name: %q", name)
}
rootfs := filepath.Join(s.imageDir(name), "rootfs")
if fi, err := os.Stat(rootfs); err != nil || !fi.IsDir() {
return "", fmt.Errorf("no such image: %s (load it with: werkdock load -i ARCHIVE --name %s)", name, name)
}
return rootfs, nil
}
// List returns the names of all loaded images, sorted; half-written
// `.tmp` directories from an interrupted load are not images.
func (s Store) List() ([]string, error) {
entries, err := os.ReadDir(filepath.Join(s.Root, "images"))
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var names []string
for _, e := range entries {
if e.IsDir() && nameRe.MatchString(e.Name()) && !strings.HasSuffix(e.Name(), ".tmp") {
names = append(names, e.Name())
}
}
sort.Strings(names)
return names, nil
}
// Load imports a rootfs archive as an image. The archive is unpacked
// with the tar CLI (compression auto-detected; .tar.zst needs the zstd
// binary, which doctor checks) into a temporary directory and renamed
// into place, so a failed load leaves no half image behind.
func (s Store) Load(archive, name string) error {
if !nameRe.MatchString(name) {
return fmt.Errorf("invalid image name: %q (allowed: lowercase letters, digits, '.', '_', '-')", name)
}
// ".tmp" is the staging suffix of this very function — a legal-looking
// image name ending in it would collide with interrupted loads.
if strings.HasSuffix(name, ".tmp") {
return fmt.Errorf("invalid image name: %q (the .tmp suffix is reserved for staging)", name)
}
archiveAbs, err := filepath.Abs(archive)
if err != nil {
return err
}
if _, err := os.Stat(archiveAbs); err != nil {
return fmt.Errorf("archive: %w", err)
}
dir := s.imageDir(name)
if _, err := os.Stat(dir); err == nil {
return fmt.Errorf("image %q already exists (remove %s to replace it)", name, dir)
}
tmp := dir + ".tmp"
if err := os.RemoveAll(tmp); err != nil {
return err
}
rootfs := filepath.Join(tmp, "rootfs")
if err := os.MkdirAll(rootfs, 0o755); err != nil {
return err
}
cmd := exec.Command("tar", "--no-same-owner", "-xf", archiveAbs, "-C", rootfs)
if out, err := cmd.CombinedOutput(); err != nil {
_ = os.RemoveAll(tmp)
return fmt.Errorf("unpacking %s failed: %w\n%s", archiveAbs, err, strings.TrimSpace(string(out)))
}
meta, err := json.MarshalIndent(ImageMeta{Name: name, Source: archiveAbs, CreatedAt: time.Now().UTC()}, "", " ")
if err != nil {
_ = os.RemoveAll(tmp)
return err
}
if err := os.WriteFile(filepath.Join(tmp, "image.json"), append(meta, '\n'), 0o644); err != nil {
_ = os.RemoveAll(tmp)
return err
}
if err := os.Rename(tmp, dir); err != nil {
_ = os.RemoveAll(tmp)
return err
}
return nil
}
// ImageNameFromArchive derives a default image name from an archive
// file name by stripping the compression and tar extensions:
// "werkator-buildenv-trixie.tar.zst" becomes "werkator-buildenv-trixie".
func ImageNameFromArchive(archive string) string {
name := filepath.Base(archive)
for {
ext := filepath.Ext(name)
switch strings.ToLower(ext) {
case ".tar", ".gz", ".tgz", ".zst", ".xz", ".bz2":
name = strings.TrimSuffix(name, ext)
default:
return strings.ToLower(name)
}
}
}
-149
View File
@@ -1,149 +0,0 @@
package store
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// writeTestArchive builds a minimal rootfs .tar.gz with the stdlib, so
// the tests need no zstd; Load unpacks it with the system tar.
func writeTestArchive(t *testing.T, path string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{Name: "etc/", Mode: 0o755, Typeflag: tar.TypeDir}); err != nil {
t.Fatal(err)
}
content := []byte("hello from the rootfs\n")
if err := tw.WriteHeader(&tar.Header{Name: "etc/hello", Mode: 0o644, Size: int64(len(content))}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(content); err != nil {
t.Fatal(err)
}
for _, c := range []interface{ Close() error }{tw, gz, f} {
if err := c.Close(); err != nil {
t.Fatal(err)
}
}
}
func TestLoadUnpacksArchiveIntoTheStore(t *testing.T) {
if _, err := exec.LookPath("tar"); err != nil {
t.Skip("tar not installed")
}
st := Store{Root: t.TempDir()}
archive := filepath.Join(t.TempDir(), "mini-rootfs.tar.gz")
writeTestArchive(t, archive)
if err := st.Load(archive, "mini"); err != nil {
t.Fatal(err)
}
rootfs, err := st.RootFS("mini")
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(filepath.Join(rootfs, "etc", "hello"))
if err != nil || string(content) != "hello from the rootfs\n" {
t.Errorf("unpacked file: %q, %v", content, err)
}
metaRaw, err := os.ReadFile(filepath.Join(st.Root, "images", "mini", "image.json"))
if err != nil {
t.Fatal(err)
}
var meta ImageMeta
if err := json.Unmarshal(metaRaw, &meta); err != nil {
t.Fatal(err)
}
if meta.Name != "mini" || meta.Source == "" || meta.CreatedAt.IsZero() {
t.Errorf("image.json incomplete: %+v", meta)
}
}
func TestLoadRefusesAnExistingImageName(t *testing.T) {
if _, err := exec.LookPath("tar"); err != nil {
t.Skip("tar not installed")
}
st := Store{Root: t.TempDir()}
archive := filepath.Join(t.TempDir(), "mini.tar.gz")
writeTestArchive(t, archive)
if err := st.Load(archive, "mini"); err != nil {
t.Fatal(err)
}
err := st.Load(archive, "mini")
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Errorf("got %v, want an already-exists refusal", err)
}
}
func TestLoadLeavesNoHalfImageOnFailure(t *testing.T) {
if _, err := exec.LookPath("tar"); err != nil {
t.Skip("tar not installed")
}
st := Store{Root: t.TempDir()}
broken := filepath.Join(t.TempDir(), "broken.tar.gz")
if err := os.WriteFile(broken, []byte("this is not a tar archive"), 0o644); err != nil {
t.Fatal(err)
}
if err := st.Load(broken, "broken"); err == nil {
t.Fatal("expected the load to fail")
}
if _, err := os.Stat(filepath.Join(st.Root, "images", "broken")); !os.IsNotExist(err) {
t.Errorf("expected no image directory, got %v", err)
}
if _, err := os.Stat(filepath.Join(st.Root, "images", "broken.tmp")); !os.IsNotExist(err) {
t.Errorf("expected no leftover tmp directory, got %v", err)
}
}
func TestRootFSValidation(t *testing.T) {
st := Store{Root: t.TempDir()}
if _, err := st.RootFS("no-such-image"); err == nil || !strings.Contains(err.Error(), "no such image") {
t.Errorf("got %v, want a no-such-image error", err)
}
if _, err := st.RootFS("../escape"); err == nil || !strings.Contains(err.Error(), "invalid image name") {
t.Errorf("got %v, want an invalid-name error", err)
}
}
func TestListNamesLoadedImagesAndIgnoresTmpLeftovers(t *testing.T) {
st := Store{Root: t.TempDir()}
if names, err := st.List(); err != nil || names != nil {
t.Fatalf("empty store: got %v, %v", names, err)
}
for _, dir := range []string{"beta", "alpha", "broken.tmp"} {
if err := os.MkdirAll(filepath.Join(st.Root, "images", dir), 0o755); err != nil {
t.Fatal(err)
}
}
names, err := st.List()
if err != nil {
t.Fatal(err)
}
if len(names) != 2 || names[0] != "alpha" || names[1] != "beta" {
t.Errorf("got %v, want [alpha beta]", names)
}
}
func TestImageNameFromArchive(t *testing.T) {
tests := []struct{ in, want string }{
{"werkator-buildenv-trixie.tar.zst", "werkator-buildenv-trixie"},
{"/path/to/Base.TAR.GZ", "base"},
{"rootfs.tgz", "rootfs"},
{"plain", "plain"},
}
for _, tt := range tests {
if got := ImageNameFromArchive(tt.in); got != tt.want {
t.Errorf("ImageNameFromArchive(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
-11
View File
@@ -1,11 +0,0 @@
package main
import (
"os"
"werkdock/internal/cli"
)
func main() {
os.Exit(cli.Main(os.Args[1:]))
}