Author SHA1 Message Date
mhoennig 25a36ff5de Merge remote-tracking branch 'origin/main' into werkdock-config-section
# Conflicts:
#	docs/deployment.md
#	tools/remote
2026-09-03 20:40:40 +02:00
3ccc901d1b tools/remote drives any host layout (#18)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #18
2026-09-03 20:37:58 +02:00
mhoennigandClaude Opus 5 3ffc1a8eed docs(prs): PR#19 — the sandbox config section is werkdock
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:27:16 +02:00
mhoennigandClaude Opus 5 34af8ba1bf feat(config): the sandbox section is werkdock, not bwrap
`bwrap` named the mechanism one layer below the tool that actually runs it: since
v1.0.0 Werkator does not invoke bwrap at all, it shells out to the werkdock CLI —
which made `bwrap.werkdock` a key naming its own executor.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:27:16 +02:00
mhoennigandClaude Opus 5 95564587d3 docs(prs): PR#18 — tools/remote drives any host layout
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:27:11 +02:00
mhoennigandClaude Opus 5 618a2acb9f docs(deployment): tools/remote drives any host layout
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:27:11 +02:00
mhoennigandClaude Opus 5 2ffc14a20c fix(remote): upload before stopping the service, and verify the transfer
instance-update stopped the unit and only then started the upload, so a transfer
that dies mid-way leaves the host with no running Werkator and nothing to start
again. That is not theoretical: deploying to vm4006 on 2026-09-03 failed with
"scp: Connection closed" with the service already stopped.

The upload now happens before the stop, and each artifact is transferred to a
.part file whose sha256 is compared with the local one before it is moved into
place, retrying twice. A truncated archive would otherwise unpack into a broken
runtime, which is worse than the failed transfer it came from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:27:11 +02:00
mhoennigandClaude Opus 5 b5df75f844 feat(remote): the host layout is configurable, not the mih convention
tools/remote assumed the layout instance-install creates: the watched repository
in $WERKATOR_PATH/werkator, the runtime in $WERKATOR_PATH/.werkator, a werkdock
binary and a rootfs beside it, and the unit hardcoded as werkator-werkator.service.
An installation that predates the script — vm4006, a docker host with the repository
in ~/hs.hsadmin.ng and the runtime in ~/opt — could not be deployed with it at all.

WERKATOR_REPO_DIR, WERKATOR_INSTALL_DIR and WERKATOR_SANDBOX name the three values
that actually differ; their defaults are what instance-install writes, so the
existing env files resolve to exactly the same paths as before. The unit name is
derived from the repository directory the way SystemdServiceFiles.unitName does it,
instead of being spelled out. With WERKATOR_SANDBOX=docker the werkdock binary and
the rootfs archive are neither built nor uploaded — a docker host has no sandbox to
install, and check-prerequisites asks the docker daemon instead of werkdock doctor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:27:11 +02:00
mhoennigandClaude Fable 5.1 11f6f9bd26 docs(rfcs): RFC 0001 proposes the Instrument Panel web UI redesign
Six explored directions, the chosen one (E) worked out in a light and a dark
teal palette, with a repository strip previewing every served repository,
the ten-repository and the phone case, and the backend it needs (/api/repos).
Renderings of all artboards live next to the RFC; AGENTS.md registers docs/rfcs/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:15:04 +02:00
mhoennigandClaude Opus 5 3b095fd04a docs(prs): PR#17 verified live on mih09
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:00:07 +02:00
mhoennigandClaude Sonnet 5 6521c47092 Bump version to 1.1.2 for the maintenance-page deployment
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:54:23 +02:00
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
115 changed files with 3657 additions and 2489 deletions
+9 -7
View File
@@ -1,6 +1,6 @@
--- ---
name: architecture name: architecture
description: Detailed Werkator subsystem architecture — CLI wiring and exit codes, server mode, web UI, configuration system, git access, build execution (native, Docker, and bwrap), watcher poll cycle, and system metrics. Use when designing or modifying code in the commands, config, git, gitea, build, artifacts, watcher, metrics, or server packages, or when a question goes beyond the overview in AGENTS.md. description: Detailed Werkator subsystem architecture — CLI wiring and exit codes, server mode, web UI, configuration system, git access, build execution (native, Docker, and the werkdock sandbox), watcher poll cycle, and system metrics. Use when designing or modifying code in the commands, config, git, gitea, build, artifacts, watcher, metrics, or server packages, or when a question goes beyond the overview in AGENTS.md.
--- ---
# Werkator Architecture # Werkator Architecture
@@ -49,7 +49,7 @@ Werkator is configured by three YAML files, deep-merged by `ConfigLoader` (later
Every lookup falls back to the pre-rename name (`ConfigFiles`): `.gittally.yml`, and `.git/gittally/.gittally.yml` for the machine layer. Current name first, and where both exist the old one is ignored rather than merged — a missing config is not an error, so an un-renamed installation would otherwise start on defaults without a single failure. Every lookup falls back to the pre-rename name (`ConfigFiles`): `.gittally.yml`, and `.git/gittally/.gittally.yml` for the machine layer. Current name first, and where both exist the old one is ignored rather than merged — a missing config is not an error, so an un-renamed installation would otherwise start on defaults without a single failure.
On top of those comes the **branch layer**: the `.werkator.yml` committed on a branch, applied by `loadWithBranchLayer` (the watcher passes the content read via `git show`, `loadForWorktree` the file in the build worktree). A branch describes its own CI and wins over both layers — the whole `builds` section — so a configuration can be tried out on a branch without touching other branches' builds. `stripPinned` removes what is not a description of this branch's build: `git`, `server`, `gitea`, `executor`, `watcher`, and — inside every `builds` definition as well as every legacy `branches` entry — `requirePullRequest`, `statusContext`, `docker.enabled`/`docker.network`, and `bwrap.enabled`/`bwrap.rootfs`/`bwrap.werkdock`. On top of those comes the **branch layer**: the `.werkator.yml` committed on a branch, applied by `loadWithBranchLayer` (the watcher passes the content read via `git show`, `loadForWorktree` the file in the build worktree). A branch describes its own CI and wins over both layers — the whole `builds` section — so a configuration can be tried out on a branch without touching other branches' builds. `stripPinned` removes what is not a description of this branch's build: `git`, `server`, `gitea`, `executor`, `watcher`, and — inside every `builds` definition as well as every legacy `branches` entry — `requirePullRequest`, `statusContext`, `docker.enabled`/`docker.network`, and `werkdock.enabled`/`werkdock.rootfs`/`werkdock.binary` (the section was called `bwrap` until v1.2.0; `renameLegacySandbox` maps the old name onto the new one on every raw layer, before merging).
Each file is version-checked before merging (`werkator.version.since`/`below`, `ConfigVersions.verdict`), so the message can name the file to fix: `since` is hard in both directions — too old a Werkator, or a file written before `ConfigVersions.FORMAT_BROKE_IN` and read after it — while `below` only warns. There is no format version (`apiVersion`) on purpose: only one configuration generation is supported, and the declared version exists to make the incompatibility nameable. Each file is version-checked before merging (`werkator.version.since`/`below`, `ConfigVersions.verdict`), so the message can name the file to fix: `since` is hard in both directions — too old a Werkator, or a file written before `ConfigVersions.FORMAT_BROKE_IN` and read after it — while `below` only warns. There is no format version (`apiVersion`) on purpose: only one configuration generation is supported, and the declared version exists to make the incompatibility nameable.
@@ -63,23 +63,25 @@ Three places must stay in sync when config keys change: the `WerkatorConfig` dat
## Repository Context ## Repository Context
Everything repository-scoped goes through a `RepoContext` (`repo` package, ADR 0009): the primary checkout (`workingDir`), the repository's `BuildResultRepository` (`.git/werkator/build-results.json`), its `ArtifactStore` (keyed by the repository path), and a short `name` defaulting to the directory basename — the future route segment. `RepoContexts.open(dir)` builds one; `RepoConfiguration` provides the single current-directory context as a bean, and the `BuildResultRepository`/`ArtifactStore` beans are that context's, so code that still injects them sees the same objects. Git access and config loading stay path-based services taking `repo.workingDir`. The context object is the identity (executor pools, watcher memory are keyed by it), so exactly one is opened per repository; the registry of step 22 session C opens one per entry. Not yet repository-scoped and left for that session: `StateDirMigration` (once per process on the cwd), the metrics collector's repository size, `ServerCommand`'s config, and `RunningBuild`, which carries no repository yet. Everything repository-scoped goes through a `RepoContext` (`repo` package, ADR 0009): the primary checkout (`workingDir`), the repository's `BuildResultRepository` (`.git/werkator/build-results.json`), its `ArtifactStore` (keyed by the repository path), and a short `name` defaulting to the directory basename — the future route segment. `RepoContexts.open(dir)` builds one (running the pre-rename state-dir migration for that repository on the way); `RepoRegistry` opens one per entry of the instance configuration's `repositories` — or the current directory without a registry — lazily on first use and loudly: a non-repository entry or a duplicate name aborts the start naming the home file, a repository whose config must not be read (`ConfigException`) is skipped with an error. `RepoConfiguration` provides `registry.current()` (the cwd when served, else the first entry) as the `RepoContext` bean; the controllers no longer take it — they resolve per request from the `{repo}` path segment, and `registry.current()` is what the unscoped routes mean. The `--repo` mixin (`RepoOption`) selects by name in `build`, `retry`, and `status`. Git access and config loading stay path-based services taking `repo.workingDir`; the instance configuration (`~/.werkator.yml`, `ConfigLoader.homeDir`/`WERKATOR_HOME`, bound as `InstanceConfig`) is folded in by `ConfigLoader.loadRaw` itself — its `defaults` below every repository layer, its `server`/`executor`/`watcher.pollInterval` overlaid on top and stripped from the repository files with one warning — so every consumer of `load(dir)` sees the instance values without knowing the file. The context object is the identity (executor pools, watcher memory are keyed by it), so exactly one is opened per repository — `RunningBuild` carries it too, so `currentBuilds()` says which repository a running build belongs to: the current-builds view and API serve only the served repository's builds, and the watcher's worktree pruning is protected by its own repository's builds alone. Routes, pages, and artifact files are repository-scoped (session D): every mapping exists twice, `/api/repos/<name>/…` and `/repos/<name>/…` beside the unscoped form, an unknown name is a 404, and the link prefix follows the number of served repositories — one repository keeps its existing URLs, several make every link name its repository and replace the repository name in the page title with a drop-down that switches repositories, keeping the current view.
## Build Execution ## Build Execution
`BuildExecutor` runs builds asynchronously: `startBuild(repo, branch, commit, build)` takes the `RepoContext` first; up to `executor.maxConcurrent` builds run concurrently across all repositories (default 1, sized once from the first build's config — an instance-level setting), but never more than one build per (repository, branch) at a time. Each branch builds in its own reusable git worktree at `.git/werkator/worktrees/<branchKey>` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted in the build's own `RepoContext.results` (JSON file under that repository's `.git/werkator/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. Every run belongs to a named build definition (job, ADR 0007): the YAML `builds` section defines triggers (`onPush`, `atTimes`), branch selectors (`branches` globs, `activeWithin`), and build-setting overrides applied last over the merged branch config; the implicit `default` build (`onPush`, all branches) preserves the job-less behavior. Definitions are part of the branch layer — a branch may add and override its own, and they apply to that branch alone (its selectors are evaluated for it only) — while `executor.maxConcurrent` stays pinned. `BuildResult.build` records the job; restart, retry, and startup recovery re-run by that name, resolving settings from the *current* config. `BuildResult.name` — the pool, `<branch>@<build>` for non-default builds — keys everything display- and retention-side (repository grouping via `latestPerName`, retention pools, branches-view rows, permanent latest-green links), while `BuildResult.branch` keys everything git-side: origin lookups, gone-from-origin pruning, worktrees (every build runs in its branch's worktree, serialized per branch), and Gitea links/statuses. `branches.*.autoBuild` survives as a deprecated alias for a scheduled default-pool rebuild. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build. `BuildExecutor` runs builds asynchronously: `startBuild(repo, branch, commit, build)` takes the `RepoContext` first; up to `executor.maxConcurrent` builds run concurrently across all repositories (default 1, sized once from the first build's config — an instance-level setting), but never more than one build per (repository, branch) at a time. Each branch builds in its own reusable git worktree at `.git/werkator/worktrees/<branchKey>` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted in the build's own `RepoContext.results` (JSON file under that repository's `.git/werkator/`), published to Gitea non-fatally — with the build's artifact page as `target_url` when `server.publicBaseUrl` is set, repository-scoped through `RepoLinks` and emitted as `BuildStatusChangedEvent`s. Every run belongs to a named build definition (job, ADR 0007): the YAML `builds` section defines triggers (`onPush`, `atTimes`), branch selectors (`branches` globs, `activeWithin`), and build-setting overrides applied last over the merged branch config; the implicit `default` build (`onPush`, all branches) preserves the job-less behavior. Definitions are part of the branch layer — a branch may add and override its own, and they apply to that branch alone (its selectors are evaluated for it only) — while `executor.maxConcurrent` stays pinned. `BuildResult.build` records the job; restart, retry, and startup recovery re-run by that name, resolving settings from the *current* config. `BuildResult.name` — the pool, `<branch>@<build>` for non-default builds — keys everything display- and retention-side (repository grouping via `latestPerName`, retention pools, branches-view rows, permanent latest-green links), while `BuildResult.branch` keys everything git-side: origin lookups, gone-from-origin pruning, worktrees (every build runs in its branch's worktree, serialized per branch), and Gitea links/statuses. `branches.*.autoBuild` survives as a deprecated alias for a scheduled default-pool rebuild. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build.
On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`). On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`).
The runtime is selected per build behind the `BuildRunner` interface: `DispatchingBuildRunner` (`@Primary`) routes to native `ProcessBuildRunner` (the default), to `DockerBuildRunner` when `docker.enabled`, or to `BwrapBuildRunner` when `bwrap.enabled` — docker and bwrap are mutually exclusive per build and rejected in `buildSettings`, never picked silently. The Docker runner shells out to the `docker` CLI (no SDK): it (re)builds the configured image when the Dockerfile inputs changed (tracked via the `org.werkator.build-inputs-sha256` image label), maintains a per-repo Gradle cache volume, mounts the worktree and the Docker socket into a labelled (`org.hoennig.werkator`) `--rm --init` container, and repairs workspace ownership in-container after each command (under a rootless daemon the container runs as root, which is the host user, and the repair degenerates to `0:0`). Git works inside the container: the primary `.git` is mounted read-only with `.git/werkator/` masked by an empty tmpfs (credential isolation) and the worktree's admin dir mounted read-write (`gitMetadataMounts`). The returned `Process` is the attached `docker run` client, so log streaming and termination work exactly like native builds. The runtime is selected per build behind the `BuildRunner` interface: `DispatchingBuildRunner` (`@Primary`) routes to native `ProcessBuildRunner` (the default), to `DockerBuildRunner` when `docker.enabled`, or to `WerkdockBuildRunner` when `werkdock.enabled` — docker and werkdock are mutually exclusive per build and rejected in `buildSettings`, never picked silently. The Docker runner shells out to the `docker` CLI (no SDK): it (re)builds the configured image when the Dockerfile inputs changed (tracked via the `org.werkator.build-inputs-sha256` image label), maintains a per-repo Gradle cache volume, mounts the worktree and the Docker socket into a labelled (`org.hoennig.werkator`) `--rm --init` container, and repairs workspace ownership in-container after each command (under a rootless daemon the container runs as root, which is the host user, and the repair degenerates to `0:0`). Git works inside the container: the primary `.git` is mounted read-only with `.git/werkator/` masked by an empty tmpfs (credential isolation) and the worktree's admin dir mounted read-write (`gitMetadataMounts`). The returned `Process` is the attached `docker run` client, so log streaming and termination work exactly like native builds.
`BwrapBuildRunner` (ADR 0008) is the third runtime, for hosts without root and without Docker — Hostsharing Managed Webspaces. It shells out to the `bwrap` CLI (no library): a prepared rootfs archive (`bwrap.rootfs`, built by `tools/build-bwrap-rootfs.sh`) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`, with uid 0 inside mapped to the calling user; isolation is filesystem-only — network, uid, `/proc`, `/dev` are the host's by contract. It reuses the Docker runner's `gitMetadataMounts`; mount order matters (repo dir read-write before the metadata mounts and the workspace), and bind mountpoints missing from the rootfs are pre-created there, since the rootfs is a plain host directory while bwrap cannot mkdir against the read-only sandbox root. `bwrap.enabled`/`bwrap.rootfs` are pinned like the docker sandbox policy. The returned `Process` is the attached `bwrap` process, so streaming and cancellation are unchanged. Plan step 21 will extract the generic sandbox machinery into the standalone tool Werkdock (grown in `werkdock/`); the runner then delegates to the `werkdock` CLI. `WerkdockBuildRunner` (ADR 0008) is the third runtime, for hosts without root and without Docker — Hostsharing Managed Webspaces. It shells out to the `bwrap` CLI (no library): a prepared rootfs archive (`werkdock.rootfs`, built by `tools/build-bwrap-rootfs.sh`) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs` and bound read-only at `/`, with uid 0 inside mapped to the calling user; isolation is filesystem-only — network, uid, `/proc`, `/dev` are the host's by contract. It reuses the Docker runner's `gitMetadataMounts`; mount order matters (repo dir read-write before the metadata mounts and the workspace), and bind mountpoints missing from the rootfs are pre-created there, since the rootfs is a plain host directory while bwrap cannot mkdir against the read-only sandbox root. `werkdock.enabled`/`werkdock.rootfs` are pinned like the docker sandbox policy. The returned `Process` is the attached `bwrap` process, so streaming and cancellation are unchanged. The generic sandbox machinery is the standalone tool [Werkdock](https://git.javagil.de/mi/werkdock) (plan step 21: grown in `werkdock/`, consumed via the CLI since session C, its own repository since session E); the runner delegates to the `werkdock` CLI and this repository no longer carries its source.
## Watcher ## Watcher
`Watcher` replaces the legacy blocking main loop with a non-blocking fixed-delay poll cycle over a `RepoContext` (`start(repo)`, `poll(repo)`, `recoverOnStartup(repo)`; what it remembers per repository — the logged fetch error, the deprecation warning, the cached branch definitions — lives in a `RepoWatch` keyed by context, while `WatcherState` is still one per instance): fetch origin, enqueue due branches (changed local, recent new origin, due auto-build slots) via `BuildExecutor`, then prune results, artifacts, and stale worktrees. Branches whose build has `requirePullRequest` are enqueued only while their head commit matches a pull-request head, detected without an API token by listing `refs/pull/*/head` via `git ls-remote` (lazily, at most once per poll cycle); manual `build` commands bypass this gate, and `watcher.pullRequestGate: false` disables it globally for plain-git origins without pull-request refs. Which builds are due is decided per branch from that branch's own definitions (`definitionsFor`): the primary config with the branch's committed `.werkator.yml` merged on top, cached per branch by its head commit *and* the primary config it was merged with, so the `git show` runs only when the branch moved while an edited machine or project config still takes effect on the next poll, and falling back to the primary definitions when that config is unreadable. Nothing is scheduled until `Watcher.start()` is called explicitly (server/watch mode) — CLI commands and tests never start the loop. "Already built" is tracked via the result repository, not by moving local branch refs. `Watcher` replaces the legacy blocking main loop with a non-blocking fixed-delay poll cycle over the registry's contexts (`start(repos)`, `pollAll(repos)`, `poll(repo)` for one; `recoverOnStartup(repo)` per repository, each in its own guard). Every repository is polled in its own guard — a crash or an unreachable origin is that repository's report in `WatcherState.repositories`, and the next one is polled regardless; the top-level `WatcherState` fields aggregate the reports, reading exactly as before with one repository and prefixed by the repository name with several. What the watcher remembers per repository — the logged fetch error, the deprecation warning, the cached branch definitions — lives in a `RepoWatch` keyed by context. Per repository the cycle is: fetch origin, enqueue due branches (changed local, recent new origin, due auto-build slots) via `BuildExecutor`, then prune results, artifacts, and stale worktrees. Branches whose build has `requirePullRequest` are enqueued only while their head commit matches a pull-request head, detected without an API token by listing `refs/pull/*/head` via `git ls-remote` (lazily, at most once per poll cycle); manual `build` commands bypass this gate, and `watcher.pullRequestGate: false` disables it globally for plain-git origins without pull-request refs. Which builds are due is decided per branch from that branch's own definitions (`definitionsFor`): the primary config with the branch's committed `.werkator.yml` merged on top, cached per branch by its head commit *and* the primary config it was merged with, so the `git show` runs only when the branch moved while an edited machine or project config still takes effect on the next poll, and falling back to the primary definitions when that config is unreadable. Nothing is scheduled until `Watcher.start()` is called explicitly (server/watch mode) — CLI commands and tests never start the loop. "Already built" is tracked via the result repository, not by moving local branch refs.
After the enqueue decision — and only after it, because a local ref lagging behind origin *is* the change signal — the cycle fast-forwards the primary checkout's local branch refs to their origin counterparts (`watcher.fastForwardLocalRefs`, `GitService.fastForwardLocalBranches`), so build tools reading the shared `.git` from a worktree see the refs they expect; diverged or ahead branches are never touched. Auto-build slot state lives in `.git/werkator/auto-builds.json`; watcher health is exposed via `Watcher.state()`. After the enqueue decision — and only after it, because a local ref lagging behind origin *is* the change signal — the cycle fast-forwards the primary checkout's local branch refs to their origin counterparts (`watcher.fastForwardLocalRefs`, `GitService.fastForwardLocalBranches`), so build tools reading the shared `.git` from a worktree see the refs they expect; diverged or ahead branches are never touched. Auto-build slot state lives in `.git/werkator/auto-builds.json`; watcher health is exposed via `Watcher.state()`.
## System Metrics ## System Metrics
`SystemMetricsCollector` samples CPU (`/proc/stat` deltas), RAM (`/proc/meminfo`), disk, and repository size every 60s, but only after `ServerMetricsLifecycle` (server profile) calls `start()` — like the watcher, nothing is scheduled in CLI runs or tests. Min/max/avg aggregation state persists as JSON in the artifact root (`ArtifactStore.rootDir()`), so restarts continue the series. Unavailable sources (e.g. no `/proc` outside Linux) yield null metrics served as HTTP 200 by `GET /api/system` — the `/system` page shows `n/a`, never an error. `SystemMetricsCollector` samples CPU (`/proc/stat` deltas), RAM (`/proc/meminfo`), disk, and repository size every 60s, but only after `ServerMetricsLifecycle` (server profile) calls `start()` — like the watcher, nothing is scheduled in CLI runs or tests. Min/max/avg aggregation state persists as JSON in the artifact root (`ArtifactStore.rootDir()`), so restarts continue the series. Unavailable sources (e.g. no `/proc` outside Linux) yield null metrics served as HTTP 200 by `GET /api/system` — the `/system` page shows `n/a`, never an error.
The disk metric is the tightest of the user quota, the group quota, and the volume itself (`DiskQuota`, PR#16): `readDisk()` shells out `quota -u -g --no-wrap --raw-grace` next to the `Files.getFileStore` read, parses it into candidate budgets, and `bindingDiskSpace` picks whichever has the smallest headroom — the volume by default, a quota on a Hostsharing Managed Webspace where it is the actual limit (`werkdock doctor` already reads the same number in Go, as a one-off pre-build check). `diskTotalGib`/`diskUsedGib`/`diskFreeGib` always come from that one binding source; `SystemMetrics.diskSource` names it (`UiFormats.diskTotal`/`werkator.js`'s `formatDiskTotal` render the same info-line suffix), and a source change resets the disk min/max/avg (`PersistedMetricsState.diskSourceKey`) so a stale volume-sized ceiling never survives a newly detected quota.
-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
+4 -3
View File
@@ -37,12 +37,12 @@ All production code lives under `de.hoennig.werkator`, with sub-packages `comman
- `exitProcess` is called only from `main()` — never inside `CliRunner.run()`; this keeps the Spring context alive during tests. - `exitProcess` is called only from `main()` — never inside `CliRunner.run()`; this keeps the Spring context alive during tests.
- Nothing is scheduled during CLI runs or tests: the watcher poll loop and metrics sampling start only via an explicit `start()` in the `server` profile. - Nothing is scheduled during CLI runs or tests: the watcher poll loop and metrics sampling start only via an explicit `start()` in the `server` profile.
- Builds run detached in worktrees under `.git/werkator/worktrees/<branchKey>`; the primary checkout is never used for builds; never assume a single running build. - Builds run detached in worktrees under `.git/werkator/worktrees/<branchKey>`; the primary checkout is never used for builds; never assume a single running build.
- Everything repository-scoped (results, artifacts, worktrees, git and config access) goes through a `RepoContext`, never through an implicit current directory: the executor serializes per (context, branch) under one global `maxConcurrent`, the watcher polls a context. Today exactly one context exists, the current working directory; the registry (step 22 session C) opens one per entry. - Everything repository-scoped (results, artifacts, worktrees, git and config access) goes through a `RepoContext`, never through an implicit current directory: the executor serializes per (context, branch) under one global `maxConcurrent`, the watcher polls every context in its own guard. `RepoRegistry` opens one context per entry of the instance configuration `~/.werkator.yml` (ADR 0009), or the current directory without one; the instance-level keys (`server`, `executor`, `watcher.pollInterval`) and the `defaults` block are folded into every repository's effective config by `ConfigLoader` itself, so no consumer reads the home file directly. Server routes carry the repository as `/repos/<name>/…` and `/api/repos/<name>/…`, with the unscoped form permanently meaning the served repository; the pages stay per repository and a drop-down in the page title switches between them.
- When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`. - When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`.
- Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config. - Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config.
- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and bubblewrap (`bwrap.enabled`, `bwrap.rootfs`, `bwrap.werkdock`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone. - A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and werkdock (`werkdock.enabled`, `werkdock.rootfs`, `werkdock.binary`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone.
- A build definition carries the complete description of its build, split in two: the `trigger` block (`onPush`, `atTimes`, `branches`, `activeWithin`) says when and for which branches it runs, everything else what it does. `builds.default` is the base every other definition inherits its settings — never its `trigger` — from. The split is structural so that a selector added to `TriggerConfig` later is non-inheritable by construction; writing a trigger key flat is refused, never ignored, because ignoring it leaves a build that silently stops running. A `!` prefix in `trigger.branches` excludes and always wins. - A build definition carries the complete description of its build, split in two: the `trigger` block (`onPush`, `atTimes`, `branches`, `activeWithin`) says when and for which branches it runs, everything else what it does. `builds.default` is the base every other definition inherits its settings — never its `trigger` — from. The split is structural so that a selector added to `TriggerConfig` later is non-inheritable by construction; writing a trigger key flat is refused, never ignored, because ignoring it leaves a build that silently stops running. A `!` prefix in `trigger.branches` excludes and always wins.
- The inheritance is applied after all layers are merged: that order is what makes a build a branch invents inherit the host's sandbox policy instead of the data-class default, so the pinning also holds for a build the host has never heard of. Pinned are `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, `bwrap.rootfs`, and `bwrap.werkdock`. Docker and bwrap are mutually exclusive per branch — enabling both is rejected at start. - The inheritance is applied after all layers are merged: that order is what makes a build a branch invents inherit the host's sandbox policy instead of the data-class default, so the pinning also holds for a build the host has never heard of. Pinned are `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `werkdock.enabled`, `werkdock.rootfs`, and `werkdock.binary`. Docker and werkdock are mutually exclusive per branch — enabling both is rejected at start. The section was called `bwrap` until v1.2.0 and is still read under that name, with a warning; the hard refusal waits for the release that sets `ConfigVersions.FORMAT_BROKE_IN`.
- `builds` or the legacy `branches`, never both: `branches` is read only while the merged config defines no build at all (`builds.maxConcurrent` is not one), and ignored with a warning as soon as one exists. The section is deprecated and goes away once the repositories have migrated; then `ConfigVersions.FORMAT_BROKE_IN` gets set and a leftover `branches:` key must be rejected by name — the version check alone cannot catch a file that declares no version. - `builds` or the legacy `branches`, never both: `branches` is read only while the merged config defines no build at all (`builds.maxConcurrent` is not one), and ignored with a warning as soon as one exists. The section is deprecated and goes away once the repositories have migrated; then `ConfigVersions.FORMAT_BROKE_IN` gets set and a leftover `branches:` key must be rejected by name — the version check alone cannot catch a file that declares no version.
- Web UI: server-rendered Thymeleaf plus one hand-written `static/werkator.js` — no SPA framework, no frontend build pipeline; every fetch has a timeout and an explicit error badge; `UiFormats` and `werkator.js` must produce identical display formats. - Web UI: server-rendered Thymeleaf plus one hand-written `static/werkator.js` — no SPA framework, no frontend build pipeline; every fetch has a timeout and an explicit error badge; `UiFormats` and `werkator.js` must produce identical display formats.
- Git and Docker access shells out to the CLIs (`GitCommandRunner`, `docker`) — no JGit, no Docker SDK. - Git and Docker access shells out to the CLIs (`GitCommandRunner`, `docker`) — no JGit, no Docker SDK.
@@ -68,6 +68,7 @@ Keep sentences short.
- `docs/deployment.md` — running Werkator as a systemd user service behind an existing reverse proxy (`init --systemd` generates the unit). - `docs/deployment.md` — running Werkator as a systemd user service behind an existing reverse proxy (`init --systemd` generates the unit).
- `docs/werkator-migrationsplan.md` — renaming a running installation from GitTally to Werkator: what the name fallback covers and what has to be moved by hand. - `docs/werkator-migrationsplan.md` — renaming a running installation from GitTally to Werkator: what the name fallback covers and what has to be moved by hand.
- `docs/plan/` — the step-by-step rewrite plan; `docs/plan/README.md` explains how to execute a step, `docs/plan/00-legacy-analysis.md` summarizes the legacy bash script. - `docs/plan/` — the step-by-step rewrite plan; `docs/plan/README.md` explains how to execute a step, `docs/plan/00-legacy-analysis.md` summarizes the legacy bash script.
- `docs/rfcs/` — requests for comments: proposals that are larger than one PR and not yet a decision (an accepted RFC becomes an ADR or a plan step).
- `docs/prs/` — one document per pull request; every PR needs one. IMPORTANT: Before opening or finishing a pull request, load the [pr-doc skill](.claude/skills/pr-doc/SKILL.md) and write the PR-doc. - `docs/prs/` — one document per pull request; every PR needs one. IMPORTANT: Before opening or finishing a pull request, load the [pr-doc skill](.claude/skills/pr-doc/SKILL.md) and write the PR-doc.
## Key Architectural Decisions ## Key Architectural Decisions
+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.2.0"
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
+59 -10
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 |
@@ -70,7 +71,7 @@ above, giving the precedence **branch > repo install > project**. It takes prece
everything that describes how this branch is built: the whole `builds` section — its own everything that describes how this branch is built: the whole `builds` section — its own
definitions and its overrides of the definitions from the project config, with definitions and its overrides of the definitions from the project config, with
`buildCommand`, `cleanCommand`, `artifactDirs`, log file names, and `buildCommand`, `cleanCommand`, `artifactDirs`, log file names, and
`docker.image`/`dockerfile`/`context`/`env` and `bwrap.env` inside them. That is how a new configuration is tried out: change it on a branch, and `docker.image`/`dockerfile`/`context`/`env` and `werkdock.env` inside them. That is how a new configuration is tried out: change it on a branch, and
no other branch's builds are affected. no other branch's builds are affected.
The branch layer is used in both places where it matters: the watcher reads the committed The branch layer is used in both places where it matters: the watcher reads the committed
@@ -98,7 +99,7 @@ single branch may decide it:
- the repository-side settings: the whole `gitea`, `executor`, and `watcher` sections; - the repository-side settings: the whole `gitea`, `executor`, and `watcher` sections;
- the trust gate: `requirePullRequest`, and the Gitea status context: `statusContext`; - the trust gate: `requirePullRequest`, and the Gitea status context: `statusContext`;
- the container sandbox policy: `docker.enabled`/`docker.network` and - the container sandbox policy: `docker.enabled`/`docker.network` and
`bwrap.enabled`/`bwrap.rootfs`/`bwrap.werkdock` — host-pinned as `werkdock.enabled`/`werkdock.rootfs`/`werkdock.binary` — host-pinned as
long as only the host's configuration sets them, master-pinned once the committed long as only the host's configuration sets them, master-pinned once the committed
configuration does. configuration does.
@@ -115,6 +116,53 @@ The pinned settings are stripped wherever they appear, in a build definition as
a legacy `branches` entry. The deprecated `branches` section itself is read from the repo a legacy `branches` entry. The deprecated `branches` section itself is read from the repo
install/project config only, and only while nothing defines a build at all. install/project config only, and only while nothing defines a build at all.
## `~/.werkator.yml` — the instance configuration
One Werkator instance serves a *set* of repositories (ADR 0009): one service, one port, one UI, one watcher loop.
The set and everything shared by it live in `.werkator.yml` in the home directory of the user running Werkator — one instance per OS user.
The file name is deliberately the same everywhere; only the location carries the meaning: home is the instance, the repository root is the project, `.git` is the machine.
`WERKATOR_HOME` overrides the directory the file is looked up in.
Without this file Werkator serves the current working directory exactly as before.
With it, the registry wins over the current directory: `werkator server` serves the registered repositories wherever it is started.
```yaml
werkator:
version:
since: "0.9.16" # like every other config file
repositories: # the registry; each entry is one served repository
- path: ~/repos/werkator # absolute, or relative to the home directory (~ expands)
- path: ~/repos/werkbaum
name: baum # optional; default is the directory basename
server: # the instance's server section — port, bind address, public URL, nginx
port: 18080
executor:
maxConcurrent: 2 # the global cap over all repositories
watcher:
pollInterval: 10s # one loop, one delay
defaults: # optional: repository-level keys merged BELOW every repository's own layers
git:
account: ci-bot
token: "…" # secrets may then live here instead of in each repository
gitea:
baseUrl: https://git.example.org
```
Key ownership once this file exists:
- **Instance-level** — read from this file alone: the whole `server` section, `executor.maxConcurrent`, and `watcher.pollInterval`.
A repository file still carrying one of them is ignored on that key, with one warning naming both files; it is never merged silently.
- **Repository defaults** — the `defaults` block, in the repository config schema: merged below each repository's project config, applied fragment, machine config, and branch layer, so a repository's own value always wins.
Pinning is unchanged: home defaults and the repository's machine config are both host-side layers, and a branch still cannot reach a pinned key.
- **Repository-level** — everything else stays in the repository's own files: `gitea.*`, `git.*` credentials, `builds`, retention, the other `watcher` keys (`pullRequestGate`, `newBranchMaxAge`, `fastForwardLocalRefs`), and the sandbox policy.
Repository names are the `--repo` selector of `werkator build`, `retry`, and `status` (without it a command means the current directory when served, otherwise the first registered repository) and will become the route segment of the web UI.
Two entries resolving to the same name abort the start, as does an entry that is not a git repository.
A repository whose configuration this Werkator must not read (see the version declaration) is skipped with an error; the others are served.
## Inspect the Effective Config ## Inspect the Effective Config
```bash ```bash
@@ -358,9 +406,9 @@ That is how a branch gets a build of its own without being built by the default
`activeWithin` (e.g. `24h`) additionally keeps only branches whose origin head commit is younger than the duration — useful to run a nightly deep check over all recently active branches. `activeWithin` (e.g. `24h`) additionally keeps only branches whose origin head commit is younger than the duration — useful to run a nightly deep check over all recently active branches.
Both parts combine as an intersection. Both parts combine as an intersection.
Settings: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, `requirePullRequest`, `statusContext`, and `docker` and `bwrap` with all their keys. Settings: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, `requirePullRequest`, `statusContext`, and `docker` and `werkdock` with all their keys.
A definition carries the complete description of its build; unset keys fall back to `builds.default` and then to Werkator's own defaults. A definition carries the complete description of its build; unset keys fall back to `builds.default` and then to Werkator's own defaults.
`requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, `bwrap.rootfs`, and `bwrap.werkdock` are pinned (master-pinned, see [the branch layer](#the-branch-layer-a-branch-describes-its-own-ci)): they are read from the repo install/project config even when a branch sets them in its own committed config. `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `werkdock.enabled`, `werkdock.rootfs`, and `werkdock.binary` are pinned (master-pinned, see [the branch layer](#the-branch-layer-a-branch-describes-its-own-ci)): they are read from the repo install/project config even when a branch sets them in its own committed config.
Inheritance from `builds.default` covers the settings only — the `trigger` block says when and where *this* build runs and is never inherited. Inheritance from `builds.default` covers the settings only — the `trigger` block says when and where *this* build runs and is never inherited.
Definitions are part of the branch layer: a branch may add its own and override those from the project config, for its own builds only. Definitions are part of the branch layer: a branch may add its own and override those from the project config, for its own builds only.
Because the inheritance is applied after all layers are merged, a build a branch invents still inherits the host's `builds.default` — its sandbox policy included, which is what keeps the pinning effective for a build the host has never heard of. Because the inheritance is applied after all layers are merged, a build a branch invents still inherits the host's `builds.default` — its sandbox policy included, which is what keeps the pinning effective for a build the host has never heard of.
@@ -416,22 +464,23 @@ Note that the rest of `.git` — including `.git/config` — is visible to build
The Docker socket is mounted into the container and `DOCKER_HOST`/`TESTCONTAINERS_*` variables are set, so Testcontainers-based builds work inside the container. The Docker socket is mounted into the container and `DOCKER_HOST`/`TESTCONTAINERS_*` variables are set, so Testcontainers-based builds work inside the container.
All Werkator containers carry `org.hoennig.werkator` labels; stale build containers of the repository are removed before the first Docker build after a restart. All Werkator containers carry `org.hoennig.werkator` labels; stale build containers of the repository are removed before the first Docker build after a restart.
### Notes on `builds.<name>.bwrap` ### Notes on `builds.<name>.werkdock`
With `bwrap.enabled`, Werkator runs the build in a bubblewrap sandbox instead of native execution. With `werkdock.enabled`, Werkator runs the build in a bubblewrap sandbox instead of native execution.
This is the third runtime, for hosts without root and without a Docker daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md` and ADR 0008. This is the third runtime, for hosts without root and without a Docker daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md` and ADR 0008.
Since step 21 session C the sandbox is executed by the `werkdock` CLI (`bwrap.werkdock`, default: resolved via `PATH`) — Werkator no longer invokes `bwrap` itself; `bwrap` must be installed for werkdock. Since step 21 session C the sandbox is executed by the `werkdock` CLI (`werkdock.binary`, default: resolved via `PATH`) — Werkator no longer invokes `bwrap` itself; `bwrap` must be installed for werkdock.
The section was called `bwrap` and its binary key `bwrap.werkdock` until v1.2.0; both are still read, with a warning naming the file, so an installation can be migrated at its next configuration edit rather than at the next update.
`werkdock doctor` checks the host's capability (it replaced the retired `tools/werkator-build-prerequisites.sh` in step 23). `werkdock doctor` checks the host's capability (it replaced the retired `tools/werkator-build-prerequisites.sh` in step 23).
`bwrap.rootfs` names the prepared root filesystem archive — a Debian-base rootfs with the build tools (JDK, git, locales, project-specific tooling) built elsewhere, since `debootstrap` is unavailable on the target. `werkdock.rootfs` names the prepared root filesystem archive — a Debian-base rootfs with the build tools (JDK, git, locales, project-specific tooling) built elsewhere, since `debootstrap` is unavailable on the target.
It is a local path or an `http(s)` URL; a URL is downloaded once into `.git/werkator/buildenv/`. It is a local path or an `http(s)` URL; a URL is downloaded once into `.git/werkator/buildenv/`.
Build the archive with `tools/build-bwrap-rootfs.sh` on any machine with Docker. Build the archive with `tools/build-bwrap-rootfs.sh` on any machine with Docker.
The archive is loaded once per source as the werkdock image `werkator-buildenv-<hash>` into werkdock's store (`$WERKDOCK_HOME`, default `~/.werkdock`) — shared by every repository of this OS user; the hash derives from the source string, so a changed `rootfs` loads a fresh image and stale ones can be removed from the store. The archive is loaded once per source as the werkdock image `werkator-buildenv-<hash>` into werkdock's store (`$WERKDOCK_HOME`, default `~/.werkdock`) — shared by every repository of this OS user; the hash derives from the source string, so a changed `rootfs` loads a fresh image and stale ones can be removed from the store.
Per-repo Gradle caches persist in `.git/werkator/buildenv/home`, bound as `/root`. Per-repo Gradle caches persist in `.git/werkator/buildenv/home`, bound as `/root`.
`bwrap.env` adds environment variables inside the sandbox; the environment is otherwise cleared (docker semantics) — the server's environment does not leak in. `werkdock.env` adds environment variables inside the sandbox; the environment is otherwise cleared (docker semantics) — the server's environment does not leak in.
Files created inside the sandbox are owned by the host user, because uid 0 maps back to the unprivileged webspace user. Files created inside the sandbox are owned by the host user, because uid 0 maps back to the unprivileged webspace user.
`docker` and `bwrap` are mutually exclusive per branch: enabling both is rejected at start, not silently picked. `docker` and `werkdock` are mutually exclusive per branch: enabling both is rejected at start, not silently picked.
Git works inside the sandbox exactly as inside the Docker container: the primary `.git` is mounted read-only with `.git/werkator/` masked, so builds can run read-only git commands but never reach the machine config or the control token. Git works inside the sandbox exactly as inside the Docker container: the primary `.git` is mounted read-only with `.git/werkator/` masked, so builds can run read-only git commands but never reach the machine config or the control token.
## `.git/werkator/.werkator.yml` (not committed) ## `.git/werkator/.werkator.yml` (not committed)
+65 -2
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
@@ -130,6 +184,11 @@ ssh <user>@<host>
The tarball unpacks to a `werkator/` directory, so it must not be extracted over `~/opt` directly — unpack it in `/tmp` and move it into place, as above. The tarball unpacks to a `werkator/` directory, so it must not be extracted over `~/opt` directly — unpack it in `/tmp` and move it into place, as above.
Rollback is the reverse: stop, remove the new directory (or jar), move `.bak` back, start. Rollback is the reverse: stop, remove the new directory (or jar), move `.bak` back, start.
`tools/remote --env-file .env.<instance> werkator instance-update` does the same sequence for any host, not only the webspace layout it was written for.
Three optional keys in the env file name what differs (see the script's header): `WERKATOR_REPO_DIR` (directory of the watched repository, which also names the systemd unit), `WERKATOR_INSTALL_DIR` (where the runtime bundle is unpacked), and `WERKATOR_SANDBOX` (`werkdock`, the default, or `docker` — a Docker host has no werkdock binary and no rootfs archive to upload).
Their defaults are the layout `instance-install` creates, so an env file that names none of them behaves exactly as before.
The upload happens before the service is stopped and every artifact is checksum-verified after the transfer, so a dropped connection costs the transfer and not the running service.
Then check `https://<public-url>/` for the new version in the footer, and `journalctl --user -u werkator-<repo-name>.service -n 50` for a clean start. Then check `https://<public-url>/` for the new version in the footer, and `journalctl --user -u werkator-<repo-name>.service -n 50` for a clean start.
Config file changes are not needed for an update; new keys take their defaults. Config file changes are not needed for an update; new keys take their defaults.
@@ -265,12 +324,12 @@ See [configuration.md](configuration.md) for all `server.nginx.*` keys.
## Hostsharing Managed Webspace ## Hostsharing Managed Webspace
The third deployment variant (plan step 21, verified live on a real webspace): no root, no Docker daemon, no own reverse proxy. The third deployment variant (plan step 21, verified live on a real webspace): no root, no Docker daemon, no own reverse proxy.
Werkator runs as a systemd *user* service on the assigned localhost port ("eigener Serverdienst"), the platform's managed Apache terminates TLS and proxies via `.htaccess`, and builds run in the bubblewrap sandbox executed by the [werkdock](../werkdock/README.md) CLI (ADR 0008, step 21 session C). Werkator runs as a systemd *user* service on the assigned localhost port ("eigener Serverdienst"), the platform's managed Apache terminates TLS and proxies via `.htaccess`, and builds run in the bubblewrap sandbox executed by the [werkdock](https://git.javagil.de/mi/werkdock) CLI (ADR 0008, step 21 session C).
Werkator is never built on the webspace: the runtime bundle and the werkdock binary are built locally and uploaded (ADR 0006). Werkator is never built on the webspace: the runtime bundle and the werkdock binary are built locally and uploaded (ADR 0006).
All steps are driven by `tools/remote`; commands name their role — `instance-*` manages the installed Werkator, `repo-*` the repository it watches. All steps are driven by `tools/remote`; commands name their role — `instance-*` manages the installed Werkator, `repo-*` the repository it watches.
Each instance is a pair of files (step 23): a transport env file selected with `--env-file` (default `.env`), and a YAML fragment in the configuration schema, named by its `WERKATOR_INIT_CONFIG` key and installed remotely via `werkator init --apply` — e.g. `.env.mih34` + `.env.mih34.yml`, both gitignored. Each instance is a pair of files (step 23): a transport env file selected with `--env-file` (default `.env`), and a YAML fragment in the configuration schema, named by its `WERKATOR_INIT_CONFIG` key and installed remotely via `werkator init --apply` — e.g. `.env.mih34` + `.env.mih34.yml`, both gitignored.
The fragment carries the Werkator configuration (`server.port`, `publicBaseUrl`, systemd limits, `builds.default.bwrap.*`); the env file only says where and how to reach the host. The fragment carries the Werkator configuration (`server.port`, `publicBaseUrl`, systemd limits, `builds.default.werkdock.*`); the env file only says where and how to reach the host.
```bash ```bash
tools/remote --env-file .env.mih34 werkator check-prerequisites # uploads werkdock, runs its doctor tools/remote --env-file .env.mih34 werkator check-prerequisites # uploads werkdock, runs its doctor
@@ -281,6 +340,7 @@ tools/remote --env-file .env.mih34 port-forward start # browser tunne
``` ```
Layout on the host: the watched repository at `$WERKATOR_PATH/werkator/`, the unpacked runtime at `$WERKATOR_PATH/.werkator/werkator/`, the werkdock binary at `$WERKATOR_PATH/.werkator/bin/werkdock`. Layout on the host: the watched repository at `$WERKATOR_PATH/werkator/`, the unpacked runtime at `$WERKATOR_PATH/.werkator/werkator/`, the werkdock binary at `$WERKATOR_PATH/.werkator/bin/werkdock`.
That is the default, not a requirement: `WERKATOR_REPO_DIR`, `WERKATOR_INSTALL_DIR` and `WERKATOR_SANDBOX` bend it to an installation that predates the script, see [Updating an Existing Installation](#updating-an-existing-installation).
The rootfs archive is loaded once per source into werkdock's image store (`~/.werkdock`), shared by every repository of the user. The rootfs archive is loaded once per source into werkdock's image store (`~/.werkdock`), shared by every repository of the user.
Fill `git.account`/`git.token` in the machine config when the origin is private, and make the user's services survive logout with `loginctl enable-linger`. Fill `git.account`/`git.token` in the machine config when the origin is private, and make the user's services survive logout with `loginctl enable-linger`.
@@ -291,3 +351,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,91 @@
> **WARNING:** This document describes only the change applied in this PR.
> It may already be outdated once the next PR is merged.
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
## The Problem
`instance-update` restarts the systemd unit: for a few seconds nothing listens on Werkator's port at all.
On the Hostsharing Managed Webspace deployment (see [deployment.md](../deployment.md#hostsharing-managed-webspace)) the platform's Apache sits in front and proxies via `.htaccess` (`SystemdServiceFiles.htaccessContent`); with the backend refusing connections, Apache answers with its own bare 502 error page, or the request just hangs until it times out.
Either way the instance looks dead rather than "back in a moment", which is confusing during a deploy that is otherwise routine and fast (the restart itself is under a second; the rest of `instance-update`'s time is upload).
## Non-Goals
- A live progress indicator ("2 of 10 seconds left") — the maintenance page is a static file with no way to know how far the restart has gotten.
- Zero-downtime / blue-green deployment — the restart gap itself is not eliminated, only made to look intentional instead of broken.
- The Docker-host and bwrap-webspace-without-a-domain deployment variants, which do not go through Apache/`.htaccess` at all.
## The Scenarios
### Feature: a refused connection shows a maintenance page instead of a bare error
#### Background
- `init --systemd` writes the `.htaccess` only when `server.publicBaseUrl` is set — unchanged; the maintenance page is generated under the same condition, next to it.
- The generated `.htaccess` already proxies every request to `http://127.0.0.1:<port>` via `mod_rewrite [P]`; a refused connection surfaces as Apache's own 502 (and, depending on Apache's timeout handling, 503/504).
#### Scenario#17.01: A refused backend connection serves the maintenance page
So that a deployment's restart window reads "please retry" instead of a bare error or a hang.
- **Given** the generated `.htaccess` and `werkator-maintenance.html` are in a domain's docroot
- **When** the proxied backend refuses the connection (Apache's 502/503/504)
- **Then** Apache serves `werkator-maintenance.html` instead of its default error page
##### Verified by
- [SystemdServiceFilesTest — "the htaccess maps a refused connection to the static maintenance page"](../../src/test/kotlin/de/hoennig/werkator/commands/SystemdServiceFilesTest.kt)
- Manual on `mih09` (2026-09-03): with the service stopped, `https://werkator.javagil.de/` answered **HTTP 503 with the maintenance page in 0.14 s** — Apache reaches the error path immediately, it does not wait for a timeout. With the service running, the same URL is 200 as before, and `/werkator-maintenance.html` is directly reachable (the `RewriteCond` keeps it out of the proxy).
#### Scenario#17.02: The maintenance page itself is never proxied
So that `ErrorDocument`'s internal sub-request for the page does not loop back into the same rewrite rule (which would proxy it to the — still down — backend and fail again).
- **Given** the generated `.htaccess`
- **When** Apache serves `werkator-maintenance.html` as an `ErrorDocument`
- **Then** the `RewriteCond` excludes exactly that path from the proxy `RewriteRule`
##### Verified by
- [SystemdServiceFilesTest — "the htaccess maps a refused connection to the static maintenance page"](../../src/test/kotlin/de/hoennig/werkator/commands/SystemdServiceFilesTest.kt)
#### Scenario#17.03: A host without a configured public base URL is unaffected
So that a developer machine or a Docker-host deployment, which never went through `.htaccess`, sees no new file and no behavior change.
- **Given** `server.publicBaseUrl` is blank
- **When** `init --systemd` runs
- **Then** neither `.htaccess` nor `werkator-maintenance.html` is written — unchanged from before this PR
##### Verified by
- Existing `InitCommand` behavior (`server.publicBaseUrl.isNotBlank()` gate); not separately tested, as before this PR.
## The Solution
`SystemdServiceFiles.htaccessContent` gains three `ErrorDocument` lines (502/503/504) pointing at a new static `werkator-maintenance.html`, plus one `RewriteCond` excluding that file from the catch-all proxy rule.
`SystemdServiceFiles.maintenancePageContent()` is a small, self-contained HTML page ("Werkator is restarting — please retry in a few minutes") — no external CSS/fonts/images, since nothing would be there to serve them while Werkator itself is the thing that is down.
`InitCommand.createSystemdFiles` writes it under the same `server.publicBaseUrl.isNotBlank()` gate as the `.htaccess`, next to it in `.git/werkator/`.
`tools/remote instance-start` copies both files into the domain docroot in the same step (it already copied the `.htaccess` there; the maintenance page rides along).
No config key was added: like the `.htaccess` itself, the maintenance page is generated whenever a `publicBaseUrl` is configured, not behind a separate toggle nobody is expected to turn off.
## Open Questions
- **Does Apache actually reach the error path, or does the client just time out first?** Answered by the live test above: on `mih09` a refused connection surfaces as **503 within 0.14 s**, not a hang — a connection *refused* is immediate, unlike a connection that is accepted and then stalls. The `ErrorDocument` covers 502/503/504 so the exact code Apache picks does not matter.
## Additional Changes
- None beyond the feature itself.
## Follow-up work discovered while deploying this
- `tools/remote instance-start` places the `.htaccess` (and now the maintenance page) into `doms/<domain>/subs/www/`, but the live `mih09` instance serves from `doms/<domain>/htdocs-ssl/` — a pre-existing path mismatch, unrelated to this PR. Both files were therefore copied into `htdocs-ssl/` by hand for this deployment; `instance-start` still needs fixing separately.
- `InitCommand.loadedServerConfig()` swallows every config-loading exception and falls back to a blank `ServerConfig`, so any unrelated config error makes `init --systemd` silently skip both files as if no `publicBaseUrl` were set. Noticed while smoke-testing this change; worth a warning on that path.
## Prerequisite PRs
- None; builds on the existing `.htaccess` generation (`SystemdServiceFiles`, `InitCommand`), unchanged in shape.
## Follow-up PRs
- An explicit Apache `ProxyTimeout` if the client-side hang (see Open Questions) turns out to matter in practice.
@@ -0,0 +1,98 @@
> **WARNING:** This document describes only the change applied in this PR.
> It may already be outdated once the next PR is merged.
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
## The Problem
`tools/remote` grew with the Hostsharing Managed Webspace rollout (steps 21 and 23) and encoded that rollout's layout as if it were the only one.
The watched repository had to be `$WERKATOR_PATH/werkator`, the runtime `$WERKATOR_PATH/.werkator/werkator`, and the systemd unit was spelled out as `werkator-werkator.service`; a werkdock binary and a bwrap rootfs archive were built and uploaded unconditionally.
`vm4006`, the Docker host that has been running Werkator since long before the script existed, matches none of that: its watched repository is `~/hs.hsadmin.ng` (so its unit is `werkator-hs.hsadmin.ng.service`), its runtime lives in `~/opt/werkator`, and it has neither werkdock nor a rootfs because its builds run in Docker.
So that host could only be deployed by hand, and had drifted nine releases behind — the question that started this PR was whether the Werkdock and multi-repo work had broken it, which it had not: what was broken was the deployment tooling.
The second problem surfaced while deploying: `instance-update` stopped the systemd unit and *then* started the upload.
The 66 MB transfer to `vm4006` died with `scp: Connection closed`, leaving the host with no running Werkator and nothing new to start.
## Non-Goals
- `instance-start` for a Docker host: it places a Hostsharing `.htaccess` into a `doms/<domain>/` docroot, which only exists on a Managed Webspace. `vm4006` uses the managed nginx container instead, and keeps its existing units.
- `repo-init` for `vm4006` — the repository has been cloned and configured there for months; only `instance-update` was needed.
- Making the machine configuration of `vm4006` current: it still carries the pre-rename `gitTally:` meta key, which is read by nothing today. Harmless while `ConfigVersions.FORMAT_BROKE_IN` is empty, a trap on the day it is not.
## The Scenarios
### Feature: one deployment command for every host layout
#### Background
- The env file carries transport values only; the three new keys describe *where* things are on the host, not what Werkator does.
- The defaults are exactly the layout `instance-install` creates, so an env file naming none of them resolves as before.
#### Scenario#18.01: A host that predates the script can be deployed with it
So that an installation is not condemned to hand-typed `scp` sequences because it was set up before the tooling existed.
- **Given** an env file with `WERKATOR_REPO_DIR=hs.hsadmin.ng`, `WERKATOR_INSTALL_DIR=/home/tallyman/opt` and `WERKATOR_SANDBOX=docker`
- **When** `tools/remote --env-file .env.vm4006 werkator instance-update` runs
- **Then** it addresses `werkator-hs.hsadmin.ng.service`, unpacks into `~/opt`, and uploads neither a werkdock binary nor a rootfs archive
##### Verified by
- Live on `vm4006` (2026-09-03): `check-prerequisites` reported the Docker daemon instead of running `werkdock doctor`; the update swapped `~/opt/werkator` from v1.0.1 to **v1.1.2**, the unit came up `active`, `/api/watcher` polls without errors, and `/api/system` still reports real disk figures (`diskSource.kind: volume`) rather than the quota shape from PR#16.
#### Scenario#18.02: The webspace hosts are unaffected
So that making the script layout-aware does not break the deployment path that is actually in production.
- **Given** `.env.mih09` and `.env.mih34`, neither naming any of the new keys
- **When** the layout is resolved
- **Then** repository directory, install directory, unit name and sandbox are identical to the hardcoded values they replace
##### Verified by
- Resolution measured for both env files (2026-09-03): `REPO_DIR=$WERKATOR_PATH/werkator`, `INSTALL_DIR=$WERKATOR_PATH/.werkator`, `UNIT=werkator-werkator.service`, `SANDBOX=bwrap`.
- Live on `mih09` (2026-09-03): a full `instance-update` ran through the bwrap path — werkdock uploaded, `werkdock 0.1.0-dev` reported after the swap, service `active`, `https://werkator.javagil.de/` answering 200.
#### Scenario#18.03: A failed transfer does not take the service down
So that a dropped connection costs the upload and nothing else.
- **Given** an instance whose service is running
- **When** the runtime bundle cannot be transferred
- **Then** the service is still running, because the upload happens before the stop, and a partially transferred file is never moved into place
##### Verified by
- The failure itself on `vm4006` (2026-09-03), which is what this scenario is written from: with the old order, `scp: Connection closed` left the unit stopped and the host without Werkator.
- Live on `mih09` (2026-09-03): the bundle already on the host was recognised by its sha256 and skipped, so the upload step cost nothing and the stop followed only after it.
## The Solution
Three optional env keys replace three hardcoded assumptions.
`WERKATOR_REPO_DIR` and `WERKATOR_INSTALL_DIR` are resolved absolute-or-relative-to-`WERKATOR_PATH`, and the unit name is now *derived* from the repository directory the way `SystemdServiceFiles.unitName` derives it (basename, every character outside `[A-Za-z0-9_.-]` replaced by a dash) instead of being spelled out — one rule, in two places, with the script naming the Kotlin function it mirrors.
`WERKATOR_SANDBOX=docker` skips everything bwrap-shaped: no werkdock build, no werkdock upload, no rootfs archive, and `check-prerequisites` asks the Docker daemon instead of running `werkdock doctor`.
`repo-add` clones beside the watched repository rather than into `WERKATOR_PATH`, which is the same directory whenever the default layout is used.
`deploy_instance` is split into `upload_instance_artifacts` and `swap_instance_runtime`, and `instance_update` calls the first *before* stopping the unit.
Every artifact is uploaded to `<name>.part`, compared by sha256 with the local file, and only then moved into place; three attempts, and an unchanged artifact is skipped entirely.
The checksum is not belt-and-braces: a truncated tarball would unpack into a runtime that starts and misbehaves, which is far worse than the failed transfer it came from.
## Open Questions
- **Should `instance-start` learn the Docker-host shape too?** Not answered here. `vm4006` keeps its existing units and its managed nginx; the day it needs regenerating, `init --systemd` on the host is the documented path.
## Additional Changes
- None beyond the feature itself.
## Follow-up work discovered while deploying this
- `https://vm4006.hostsharing.net:8443/` is not reachable from outside the host, while `https://127.0.0.1:8443/` answers 200 and the `werkator-nginx-hs.hsadmin.ng` container publishes both ports. Pre-existing and unrelated to this PR — the host's own firewall, not Werkator.
- The machine configuration on `vm4006` still declares `gitTally: version: since: "0.9.20"`; the current code reads only `werkator:`, so the file's version claim is silently ignored (`werkator.version.since` prints empty). It costs nothing while `ConfigVersions.FORMAT_BROKE_IN` is `""`, and stops protecting that host the moment plan step 18 sets it.
## Prerequisite PRs
- None; it changes only `tools/remote` and the documentation.
## Follow-up PRs
- None planned.
@@ -0,0 +1,98 @@
> **WARNING:** This document describes only the change applied in this PR.
> It may already be outdated once the next PR is merged.
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
## The Problem
The build sandbox for hosts without Docker was configured as `bwrap`, named after the mechanism rather than after the thing Werkator runs.
Since step 21 session C (v1.0.0) Werkator does not invoke `bwrap` at all: it shells out to the [werkdock](https://git.javagil.de/mi/werkdock) CLI, which assembles the bubblewrap invocation and owns the image store.
The name outlived its truth, and the clearest symptom was the key `bwrap.werkdock` — a section naming its own executor.
It also leaked outward.
`tools/remote` gained a `WERKATOR_SANDBOX` key in PR#18 whose value had to be `bwrap` while the very thing it switches on is *uploading the werkdock binary*, and the question that prompted this PR — "is there also `WERKATOR_SANDBOX=werkdock`?" — is one nobody would ask about a name that matched.
## Non-Goals
- Refusing the old name. That belongs to the release which sets `ConfigVersions.FORMAT_BROKE_IN` (plan step 18): only there can a file that declares no version be caught by name at all, and only there is one migration asked of the operator instead of two.
- Renaming `tools/build-bwrap-rootfs.sh` or `docs/plan/17-bwrap-build-runtime.md`. The script really does build a bubblewrap rootfs, and plan documents are historic records.
- Touching ADR 0008, which decided the *runtime* and is a snapshot of that decision.
## The Scenarios
### Feature: the sandbox is named after the tool that runs it
#### Background
- `builds.<name>.werkdock` replaces `builds.<name>.bwrap`, and `werkdock.binary` replaces `bwrap.werkdock`.
- The pinned set is unchanged in meaning: `enabled`, `rootfs` and the binary stay host-pinned, under their new names.
#### Scenario#19.01: A configuration written for the old name keeps working
So that no installation has to be edited before it can be updated — the section lives in machine configurations that no repository tracks.
- **Given** a configuration writing `builds.default.bwrap` with `enabled`, `rootfs`, `werkdock` and `env`
- **When** the configuration is loaded
- **Then** the settings appear as `werkdock.enabled`, `werkdock.rootfs`, `werkdock.binary` and `werkdock.env`, and the file is named once in a warning
##### Verified by
- [ConfigLoaderTest — "the legacy bwrap section is read as werkdock, its werkdock key as binary"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
#### Scenario#19.02: The old name is not a way around the pinning
So that a branch cannot escape its sandbox by writing the section a branch is not allowed to write under its previous name.
- **Given** a host configuration with `werkdock.enabled: true` and a rootfs
- **When** a branch's committed config sets `bwrap.enabled: false` with a foreign rootfs
- **Then** the sandbox stays enabled and the host's rootfs is used
##### Verified by
- [ConfigLoaderTest — "a legacy bwrap section on a branch is pinned exactly like the new name"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt)
#### Scenario#19.03: The new name behaves exactly as the old one did
So that the rename is a rename, not a change of behavior.
- **Given** configurations using `werkdock` throughout
- **When** builds are dispatched, pinned keys stripped, and both sandboxes enabled at once
- **Then** the werkdock runner is selected, the branch cannot override the pinned keys, and enabling docker and werkdock together is rejected naming both
##### Verified by
- [DispatchingBuildRunnerTest — "runs in the werkdock sandbox when the branch enables it (and not Docker)"](../../src/test/kotlin/de/hoennig/werkator/build/DispatchingBuildRunnerTest.kt)
- [ConfigLoaderTest — "a branch cannot disable its werkdock sandbox …"](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt) and "enabling both docker and werkdock on a build is rejected, not picked silently"
- [WerkdockBuildRunnerTest](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerTest.kt), unchanged in substance and renamed with the runner
## The Solution
`BwrapConfig``WerkdockConfig` (field `werkdock``binary`), `BwrapOverrides``WerkdockOverrides`, `BranchConfig.bwrap``.werkdock`, `BwrapBuildRunner``WerkdockBuildRunner`, and `PINNED_BWRAP_KEYS``PINNED_WERKDOCK_KEYS` with `binary` in place of `werkdock`.
The compatibility lives in exactly one function, `ConfigLoader.renameLegacySandbox`, applied in `loadFile` and `parseYaml` — the two places a raw layer enters — so it runs before merging, before pinning and before binding, and every consumer downstream knows one name.
That placement is what makes Scenario#19.02 hold without a second thought: the branch layer is normalised *before* `stripPinned` reads it, so the old name cannot smuggle a pinned key past a check that looks for the new one.
Where a file writes both sections, the explicit `werkdock` one wins, because it is the name that is meant.
The warning is emitted once per file (`warnedSections`), like the other section-level warnings, since the config is re-read on every poll cycle.
The version is bumped to 1.2.0 with a release note: the minor, because this changes the configuration schema, and a deployment must be identifiable as the one that introduced it.
## Open Questions
- **Should `WERKATOR_SANDBOX` keep accepting `bwrap`?** It does today, normalised on read and documented as the former name. The env files are local and gitignored, so this alias costs one line and can go whenever the config alias does.
## Additional Changes
- None beyond the rename and its documentation.
## Deployment note
The order matters, and only in one direction: deploy v1.2.0 to a host *before* rewriting its init fragment (`.env.<instance>.yml`) to the new key names.
A fragment carrying `werkdock:` applied by an older Werkator fails the fragment's strict schema validation — which is the safe outcome, but a failed `repo-init` nonetheless.
The reverse never breaks: v1.2.0 reads every existing `bwrap:` fragment and machine config as before.
## Prerequisite PRs
- [PR#18](2026-09-03-PR%2318-remote-host-layout.md) introduced `WERKATOR_SANDBOX`, whose value this PR renames.
## Follow-up PRs
- Plan step 18 (removing the legacy `branches` section) sets `ConfigVersions.FORMAT_BROKE_IN`; the `bwrap` alias should be dropped in the same release, refusing the key by name.
+151
View File
@@ -0,0 +1,151 @@
# RFC 0001: Web UI Redesign — the Instrument Panel
**Status:**
- proposed: 2026-09-03
- accepted: -
- rejected: -
**Proposal:** The Werkator web UI adopts the **Instrument Panel** direction: a teal palette in a light and a dark mode, IBM Plex typography, a repository strip that previews the state of every served repository, a title hierarchy that names the view first and explains it second, and a tab bar at the foot that becomes the mobile navigation.
The architecture does not change: server-rendered Thymeleaf, one `werkator.css`, one hand-written `werkator.js`, JSON polling, no framework, no frontend build pipeline.
## Context and Problem Statement
The current UI is a functional port of the legacy generated pages: a table per view, pill badges, system font, blue links.
It is correct and calm, but it looks like every other CI page and gives no hint of the other repositories an instance serves (ADR 0009).
The brief for this RFC was "fancy, but serious and trustworthy", with two references from the same author for visual kinship:
- [werkbaum.javagil.de](https://werkbaum.javagil.de/) — light paper with a fine grid, IBM Plex, a petrol accent, panel labels in small caps.
- [javagil.de/vibe-engineering](https://javagil.de/vibe-engineering) — a dark instrument panel: ink and petrol, clay for warnings, monospaced spaced labels, a tab bar at the foot.
A hard constraint of this RFC is honesty towards the data.
The mockups show only what the API delivers today; nothing is invented to make a screen look richer.
### What the UI Has to Work With
Per build row (`BuildRowView`, `BuildResultDto`): status, branch name, commit (12-character abbreviation, full id for copying), started at (`yyyy-MM-dd HH:mm`), duration (`m:ss`; a pending build shows its wait time in italics), artifact key with the artifact, permalink and live-log links, and the actions restart and delete (history has no restart).
Statuses: `pending`, `running`, `success`, `failed`, `interrupted`, `cancelled`, plus `unknown` for a never-built branch and the client-side `finished` on a card whose build has left the current list.
Views: Latest (one build per name), Branches (every origin branch and its latest build), History (all stored builds), Current (running builds with their live log), System (seven metric rows with current/min/max/avg, warn from 80 %, critical from 90 %), the artifact page, and the release notes.
Live state: the indicator is `static`, `live` or `error`; the watcher banner reports `watcher stopped`, `origin unreachable` or `poll cycle failed`.
Multi-repo: the repository switcher is a server-rendered `<select>` of names; `WatcherState.repositories` already carries a per-repository watcher state that the UI does not show.
What does **not** exist, and therefore appears in no mockup: a commit subject line, a typical or expected duration, an ETA, a per-branch build history, test counts on a row, and any cross-repository status summary in the API.
## Considered Options
Six directions were sketched on a shared design canvas, two rounds of three, all with the same sample rows.
| Option | Idea | Why | Tradeoff |
|---|---|---|---|
| A · Quiet Console | Today's design refined: top bar, dot-plus-word statuses, hover actions | Smallest step, everything stays valid | Least distinctive |
| B · Mission Board | Health tiles, one card per branch with a history strip, running build with progress | Answers "is everything fine?" at a glance | Needs data the API does not have (history, typical duration) |
| C · Ledger | Warm paper, serif masthead, hairline rules, typographic status marks | The most "serious"; reads like a signed record | Leaves the system font, needs its own dark theme |
| D · Paper Rail | Werkbaum's paper and grid, a repository rail on the left with per-branch dots | Family resemblance to Werkbaum; other repositories visible | 250 px of table width lost; empty with one repository |
| **E · Instrument Panel** | Vibe-Engineering's dark panel, repositories as tiles, tab bar at the foot | Reads like a control room; failures in clay stay serious without alarm | Dark-only as drawn; needs a light palette |
| F · Fleet Overview | A new landing page with one panel per repository, ledger typography on paper | One page answers the question for the whole instance | Becomes a list beyond five repositories |
Round one (AC) still contained invented data; it is kept on the canvas for the visual ideas only.
**E was chosen**, and round three worked out what it lacked: the light mode, the ten-repository case, the title hierarchy, and the phone layout.
## The Design
### Palette
Both modes are CSS custom properties on `:root`, switched by `prefers-color-scheme` as today (`color-scheme: light dark`).
Failures use clay, not red, so they stay serious without shouting; the accent is teal in both modes.
| Token | Dark | Light | Used for |
|---|---|---|---|
| bg | `#061C1F` | `#EAF4F2` | page ground |
| panel | `#0A2A2E` | `#FFFFFF` | tables, cards, chips |
| panel-2 | `#0F3A3D` | `#D6ECE8` | the current repository, the active tab |
| line | `#17474B` | `#C9DFDB` | borders and rules |
| text | `#E4EEEC` | `#0B2B2E` | body text |
| text-2 | `#B4CBC8` | `#35595B` | timestamps |
| muted | `#7DA19E` | `#5E8583` | labels, footers |
| accent | `#5FD3C7` | `#0E8079` | links, success, running, the live indicator |
| accent-2 | `#1E9A93` | `#149A90` | underlines, the current repository's border |
| clay | `#E09070` | `#B0563B` | failed, error, delete, watcher warnings |
| clay-2 | `#C4664A` | `#C4664A` | the border of a failing repository chip |
| ghost | `#4A7370` | `#BFD4D1` | cancelled, interrupted, unknown |
Tinted rows: a running row gets 16 % (dark) or 10 % (light) of accent-2 as background, a failed row 12 % or 10 % of clay-2.
The reference's background grid was tried and dropped: it competes with the table, especially in light mode.
### Typography
IBM Plex Sans for text, IBM Plex Mono for commits, timestamps, durations and every label.
Labels are 10 px Mono, uppercase, letter-spaced 0.12 em, in `muted`; statuses are 11 px Mono uppercase in their status color, each preceded by an 8 px dot (outlined for pending, pulsing for running).
Fallback stacks: `"IBM Plex Sans", "Segoe UI", system-ui, sans-serif` and `"IBM Plex Mono", ui-monospace, Consolas, monospace`.
Whether Plex is bundled under `static/` or the fallback stack is accepted is an open question below.
### Anatomy of a Page (desktop)
1. **Header**, 52 px: logo, `Werkator` with the Gitea repository name in accent, a small label `updated HH:mm:ss`; right: the live indicator as an outlined chip with a pulsing dot, the reload button.
2. **Repository strip**: see below.
3. **Panel** with the view's title: the view name at 22 px semibold with a 2 px accent-2 underline, followed by a one-line label that explains it (`Latest``one build per branch, newest first`; `Branches``every origin branch and its latest build`; `History``all stored builds, newest first`; `System``instance metrics since first start`); on the right a Mono line with the row count, the last poll and the watcher state.
4. **Table**, columns as today (Status, Branch, Commit, Started, Duration, Artifacts, Actions), rows 9 px padding on a 1 px `line` rule; copy buttons as outlined 13 px icons; artifact links and actions as stroke icons (no emoji).
5. **Footer**: version and copyright left, the navigation as a Mono tab bar in the middle (Latest, Branches, History, System with icons; the active tab in panel-2 with an accent underline), Impressum and Privacy right.
### The Repository Strip
The `<select>` switcher is replaced by a strip below the header that shows every served repository with its state, so a failure elsewhere is visible without leaving the page.
- Up to about three repositories: **tiles** (220 px), each with `current` or `repo` label, the name, one dot per branch in the branch's latest status, a summary line (`6 builds · 1 failed · 1 running`), and the watcher warning in clay when that repository's watcher reports an error.
- More repositories: **chips** (30 px), each with one dot for the worst status in the repository, the name, an optional short finding (`1 failed`, `main`, `never built`), and a warning triangle when the watcher reports an error; the current repository has an accent-2 border on panel-2, a failing one a clay-2 border on the clay tint.
- Order is *failing first*: the current repository, then failing, running, then green; a summary line above (`10 served · 2 failing · 1 unreachable · 2 running`) and a sort control on the right.
- The strip **scrolls**: horizontally on the phone, and on the desktop it wraps to a second row up to about ten repositories and becomes a horizontally scrollable band beyond that, with the failing chips pinned at the front so they never scroll out of view.
- Beyond roughly twenty repositories the strip shows only the conspicuous chips (failing, running, unreachable) plus a search field for the rest.
- With a single served repository the strip is omitted, as the switcher is today.
### Phone (below 680 px)
The existing breakpoint behaviour is kept and restyled: rows become cards with the `data-label` captions, the live indicator collapses to a dot.
The header stacks `Werkator` over the repository name; the repository strip scrolls horizontally under its summary line; the panel title keeps its hierarchy; each card carries the status line with the branch, then commit, started and duration, then the artifact icons and the actions as 44 px targets.
The footer tab bar becomes a fixed bottom tab bar with icons — the same four entries as on the desktop.
No painted status bar or keyboard; the device provides those.
### What Is Deliberately Not in the Proposal
- The `DE` language button in the mockups is a leftover of the reference; the UI stays English-only.
- No commit subjects, typical durations, ETAs or history strips: they need data the server does not have, and each would be its own RFC with its own storage.
- No manual theme toggle; `prefers-color-scheme` decides, as today.
## Consequences
### Backend
- One new endpoint, `GET /api/repos`: for every served repository its name, its UI root (`/repos/<name>`), whether it is the current one, the latest status per build name (the Latest view's `latestPerName` reduced to counts, plus the worst status), and its `RepoWatcherState` (`lastFetchError`, `lastPollError`, `lastPollAt`).
With one served repository the endpoint returns a list of one and the strip stays hidden.
- `werkator.js` polls it on the table interval (10 s) and renders the strip; every fetch keeps the timeout and the explicit error badge.
- `UiFormats` and `werkator.js` keep producing identical formats; the palette and the title labels are template and CSS only.
### Rollout, One Concern per Pull Request
1. Palette, typography and the title hierarchy in `werkator.css` and the fragments — no data change, both modes.
2. Header and footer tab bar, including the phone tab bar.
3. `GET /api/repos` and the repository strip, replacing the `<select>`.
4. Card refinements on the phone and the System and artifact pages in the new vocabulary.
Each step leaves the UI usable, and the tests in `server` that assert on markup are adjusted with the step that changes it.
## Open Questions
- **Fonts:** bundle IBM Plex Sans and Mono under `static/fonts/` (about 100150 KB in WOFF2 for the four faces), or accept the fallback stack on hosts without the font; the reference sites load Plex from a CDN, which the deployment behind a strict reverse proxy may not want.
- **Current view:** it is reachable today only from a running row's live icon; the tab bar has room for it as a fifth entry with a count badge, or it stays a link from the row.
- **Instance pages:** `/system` and `/releases` are instance-level; in the tab bar they sit next to the per-repository views, which the strip makes visible enough, or they move to the footer's right side.
## Design Sources
The design canvas with all eleven artboards (rounds one to three, desktop and phone) is a private Claude artifact of the author; its renderings live next to this RFC under `0001-web-ui-instrument-panel/`.
The sample rows are real field shapes with invented values; the repositories other than `werkator` are invented.
The proposal:
- [E · dark, desktop](0001-web-ui-instrument-panel/e-dark-desktop.png) · [E · light, desktop](0001-web-ui-instrument-panel/e-light-desktop.png)
- [E · dark, ten repositories](0001-web-ui-instrument-panel/e-dark-10-repos.png) · [E · light, ten repositories](0001-web-ui-instrument-panel/e-light-10-repos.png)
- [E · dark, phone](0001-web-ui-instrument-panel/e-dark-phone.png) · [E · light, phone](0001-web-ui-instrument-panel/e-light-phone.png)
The alternatives, for the record:
- [A · Quiet Console](0001-web-ui-instrument-panel/a-quiet-console.png), [B · Mission Board](0001-web-ui-instrument-panel/b-mission-board.png), [C · Ledger](0001-web-ui-instrument-panel/c-ledger.png) — round one, still with invented data.
- [D · Paper Rail](0001-web-ui-instrument-panel/d-paper-rail.png), [F · Fleet Overview](0001-web-ui-instrument-panel/f-fleet-overview.png) — round two.
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

@@ -10,7 +10,6 @@ import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import picocli.CommandLine import picocli.CommandLine
import picocli.CommandLine.IFactory import picocli.CommandLine.IFactory
import java.nio.file.Paths
import kotlin.system.exitProcess import kotlin.system.exitProcess
@SpringBootApplication @SpringBootApplication
@@ -27,8 +26,6 @@ class CliRunner(
private var exitCode = 0 private var exitCode = 0
override fun run(vararg args: String) { override fun run(vararg args: String) {
// before any command resolves a path under it, and once per process
StateDirMigration.migrateIfNeeded(Paths.get("."))
exitCode = exitCode =
CommandLine(rootCommand, factory) CommandLine(rootCommand, factory)
.setExecutionExceptionHandler { exception, commandLine, _ -> .setExecutionExceptionHandler { exception, commandLine, _ ->
@@ -5,6 +5,7 @@ import de.hoennig.werkator.config.BuildDefinition
import de.hoennig.werkator.config.ConfigLoader import de.hoennig.werkator.config.ConfigLoader
import de.hoennig.werkator.gitea.GiteaClient import de.hoennig.werkator.gitea.GiteaClient
import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoLinks
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.context.event.ContextClosedEvent import org.springframework.context.event.ContextClosedEvent
@@ -36,6 +37,7 @@ import kotlin.concurrent.thread
@Service @Service
class BuildExecutor( class BuildExecutor(
private val configLoader: ConfigLoader, private val configLoader: ConfigLoader,
private val repoLinks: RepoLinks,
private val giteaClient: GiteaClient, private val giteaClient: GiteaClient,
private val buildRunner: BuildRunner, private val buildRunner: BuildRunner,
private val workspaces: BranchWorkspaces, private val workspaces: BranchWorkspaces,
@@ -97,6 +99,7 @@ class BuildExecutor(
val stagingDir = Files.createTempDirectory("werkator-build-") val stagingDir = Files.createTempDirectory("werkator-build-")
val runningBuild = val runningBuild =
RunningBuild( RunningBuild(
repo = repo,
branch = branch, branch = branch,
build = build, build = build,
commit = commit, commit = commit,
@@ -396,7 +399,7 @@ class BuildExecutor(
sha = build.runningBuild.commit, sha = build.runningBuild.commit,
status = status, status = status,
description = description(status, duration), description = description(status, duration),
targetUrl = null, targetUrl = targetUrlOf(build),
workingDir = build.repo.workingDir, workingDir = build.repo.workingDir,
// from the primary config, not the worktree: statusContext is pinned, so a // from the primary config, not the worktree: statusContext is pinned, so a
// branch cannot report under a check name it was not given // branch cannot report under a check name it was not given
@@ -407,6 +410,25 @@ class BuildExecutor(
} }
} }
/**
* The artifact page of this build, so a commit status in the forge leads to the
* logs it is about — that is what `server.publicBaseUrl` is documented for, and
* until now nothing posted a link at all. Repository-scoped (ADR 0009): with
* several served repositories the unscoped path would resolve against whichever
* one the instance serves by default, which is the wrong build's page.
*/
private fun targetUrlOf(build: ActiveBuild): String? =
try {
repoLinks.buildUrl(
repo = build.repo,
publicBaseUrl = configLoader.load(build.repo.workingDir).server.publicBaseUrl,
artifactKey = build.runningBuild.artifactKey,
)
} catch (e: Exception) {
log.warn("could not build the status target URL of {}: {}", build.runningBuild.branch, e.message)
null
}
/** The build's own Gitea status context, empty when it uses the repository-wide one. */ /** The build's own Gitea status context, empty when it uses the repository-wide one. */
private fun statusContextOf(build: ActiveBuild): String = private fun statusContextOf(build: ActiveBuild): String =
try { try {
@@ -43,17 +43,17 @@ class ProcessBuildRunner : BuildRunner {
} }
/** /**
* Selects the runtime per branch: Docker when `branches.<name>.docker.enabled`, * Selects the runtime per branch: Docker when `builds.<name>.docker.enabled`, the
* bubblewrap when `branches.<name>.bwrap.enabled`, native shell execution otherwise * werkdock sandbox when `builds.<name>.werkdock.enabled`, native shell execution
* (the unchanged default). Docker and bwrap are mutually exclusive per branch and are * otherwise (the unchanged default). Docker and werkdock are mutually exclusive per
* rejected together at config load, so the branch order here never has to "pick". * branch and are rejected together at config load, so the order here never has to "pick".
*/ */
@Primary @Primary
@Component @Component
class DispatchingBuildRunner( class DispatchingBuildRunner(
private val processBuildRunner: ProcessBuildRunner, private val processBuildRunner: ProcessBuildRunner,
private val dockerBuildRunner: DockerBuildRunner, private val dockerBuildRunner: DockerBuildRunner,
private val bwrapBuildRunner: BwrapBuildRunner, private val werkdockBuildRunner: WerkdockBuildRunner,
) : BuildRunner { ) : BuildRunner {
override fun start( override fun start(
command: String, command: String,
@@ -66,7 +66,7 @@ class DispatchingBuildRunner(
val runner = val runner =
when { when {
branchConfig.docker.enabled -> dockerBuildRunner branchConfig.docker.enabled -> dockerBuildRunner
branchConfig.bwrap.enabled -> bwrapBuildRunner branchConfig.werkdock.enabled -> werkdockBuildRunner
else -> processBuildRunner else -> processBuildRunner
} }
return runner.start(command, workingDir, environment, repoDir, branchConfig, onAuxProcess) return runner.start(command, workingDir, environment, repoDir, branchConfig, onAuxProcess)
@@ -1,11 +1,19 @@
package de.hoennig.werkator.build package de.hoennig.werkator.build
import de.hoennig.werkator.config.BuildDefinition import de.hoennig.werkator.config.BuildDefinition
import de.hoennig.werkator.repo.RepoContext
import java.nio.file.Path import java.nio.file.Path
import java.time.Instant import java.time.Instant
/** Handle to a build accepted by the [BuildExecutor]; log paths become valid once the build runs. */ /** Handle to a build accepted by the [BuildExecutor]; log paths become valid once the build runs. */
data class RunningBuild( data class RunningBuild(
/**
* The repository this build belongs to; the context object is the identity
* (ADR 0009), so it compares by reference. Without it neither the current-builds
* view nor the watcher's worktree pruning could tell two repositories apart —
* both would see every repository's running builds as their own.
*/
val repo: RepoContext,
/** The git branch being built. */ /** The git branch being built. */
val branch: String, val branch: String,
/** The build definition (job) this build runs; its settings are resolved from config at run time. */ /** The build definition (job) this build runs; its settings are resolved from config at run time. */
@@ -1,7 +1,7 @@
package de.hoennig.werkator.build package de.hoennig.werkator.build
import de.hoennig.werkator.config.BranchConfig import de.hoennig.werkator.config.BranchConfig
import de.hoennig.werkator.config.BwrapConfig import de.hoennig.werkator.config.WerkdockConfig
import de.hoennig.werkator.git.GitCommandRunner import de.hoennig.werkator.git.GitCommandRunner
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@@ -13,7 +13,7 @@ import java.security.MessageDigest
* Runs build commands inside a bubblewrap user-namespace sandbox (Step 17 / ADR 0008), * Runs build commands inside a bubblewrap user-namespace sandbox (Step 17 / ADR 0008),
* for hosts without root and without a Docker daemon (e.g. Hostsharing managed * for hosts without root and without a Docker daemon (e.g. Hostsharing managed
* webspaces). Since step 21 session C it no longer assembles the raw `bwrap` argv: * webspaces). Since step 21 session C it no longer assembles the raw `bwrap` argv:
* it shells out to the `werkdock` CLI (`bwrap.werkdock`, default via PATH) the same * it shells out to the `werkdock` CLI (`werkdock.binary`, default via PATH) the same
* pattern as git and docker, CLI, no library. * pattern as git and docker, CLI, no library.
* *
* The rootfs archive becomes a werkdock *image*, loaded once per source * The rootfs archive becomes a werkdock *image*, loaded once per source
@@ -36,10 +36,10 @@ import java.security.MessageDigest
* native builds. * native builds.
*/ */
@Component @Component
class BwrapBuildRunner( class WerkdockBuildRunner(
private val commandRunner: GitCommandRunner, private val commandRunner: GitCommandRunner,
) : BuildRunner { ) : BuildRunner {
private val log = LoggerFactory.getLogger(BwrapBuildRunner::class.java) private val log = LoggerFactory.getLogger(WerkdockBuildRunner::class.java)
/** Replaceable process launcher so unit tests can capture the assembled `werkdock` argv. */ /** Replaceable process launcher so unit tests can capture the assembled `werkdock` argv. */
internal var processStarter: (List<String>, Path) -> Process = { command, dir -> internal var processStarter: (List<String>, Path) -> Process = { command, dir ->
@@ -54,14 +54,14 @@ class BwrapBuildRunner(
branchConfig: BranchConfig, branchConfig: BranchConfig,
onAuxProcess: (Process) -> Unit, onAuxProcess: (Process) -> Unit,
): Process { ): Process {
val bwrap = branchConfig.bwrap val sandbox = branchConfig.werkdock
require(bwrap.rootfs.isNotBlank()) { "branches.<name>.bwrap.rootfs must be set when bwrap.enabled is true" } require(sandbox.rootfs.isNotBlank()) { "builds.<name>.werkdock.rootfs must be set when werkdock.enabled is true" }
val werkdock = bwrap.werkdock.ifBlank { "werkdock" } val werkdock = sandbox.binary.ifBlank { "werkdock" }
val image = imageName(bwrap.rootfs) val image = imageName(sandbox.rootfs)
ensureImage(werkdock, image, bwrap, repoDir, onAuxProcess) ensureImage(werkdock, image, sandbox, repoDir, onAuxProcess)
val homeDir = repoDir.resolve(BUILDENV_DIR).resolve(HOME_DIR) val homeDir = repoDir.resolve(BUILDENV_DIR).resolve(HOME_DIR)
Files.createDirectories(homeDir) Files.createDirectories(homeDir)
val args = invocation(command, workingDir, environment, repoDir, bwrap, werkdock, image, homeDir) val args = invocation(command, workingDir, environment, repoDir, sandbox, werkdock, image, homeDir)
return processStarter(args, repoDir) return processStarter(args, repoDir)
} }
@@ -73,7 +73,7 @@ class BwrapBuildRunner(
private fun ensureImage( private fun ensureImage(
werkdock: String, werkdock: String,
image: String, image: String,
bwrap: BwrapConfig, sandbox: WerkdockConfig,
repoDir: Path, repoDir: Path,
onAuxProcess: (Process) -> Unit, onAuxProcess: (Process) -> Unit,
) { ) {
@@ -81,10 +81,10 @@ class BwrapBuildRunner(
if (image in loaded) { if (image in loaded) {
return return
} }
val envDir = repoDir.resolve(BUILDENV_DIR).resolve(sourceKey(bwrap.rootfs)) val envDir = repoDir.resolve(BUILDENV_DIR).resolve(sourceKey(sandbox.rootfs))
Files.createDirectories(envDir) Files.createDirectories(envDir)
val archive = localArchive(bwrap.rootfs, envDir, repoDir, onAuxProcess) val archive = localArchive(sandbox.rootfs, envDir, repoDir, onAuxProcess)
log.info("loading build environment {} as werkdock image {}", bwrap.rootfs, image) log.info("loading build environment {} as werkdock image {}", sandbox.rootfs, image)
commandRunner.runOrThrow( commandRunner.runOrThrow(
listOf(werkdock, "load", "-i", archive, "--name", image), listOf(werkdock, "load", "-i", archive, "--name", image),
repoDir, repoDir,
@@ -93,7 +93,7 @@ class BwrapBuildRunner(
} }
/** /**
* Resolves [BwrapConfig.rootfs] to a local archive path: a bare or `file:` path is * Resolves [WerkdockConfig.rootfs] to a local archive path: a bare or `file:` path is
* used as-is; an `http(s)` URL is downloaded once into the buildenv cache. * used as-is; an `http(s)` URL is downloaded once into the buildenv cache.
*/ */
private fun localArchive( private fun localArchive(
@@ -123,7 +123,7 @@ class BwrapBuildRunner(
workspace: Path, workspace: Path,
environment: Map<String, String>, environment: Map<String, String>,
repoDir: Path, repoDir: Path,
bwrap: BwrapConfig, sandbox: WerkdockConfig,
werkdock: String, werkdock: String,
image: String, image: String,
homeDir: Path, homeDir: Path,
@@ -148,7 +148,7 @@ class BwrapBuildRunner(
for ((key, value) in environment) { for ((key, value) in environment) {
args += listOf("-e", "$key=$value") args += listOf("-e", "$key=$value")
} }
for ((key, value) in bwrap.env) { for ((key, value) in sandbox.env) {
args += listOf("-e", "$key=$value") args += listOf("-e", "$key=$value")
} }
args += listOf("-w", "$workspaceAbs") args += listOf("-w", "$workspaceAbs")
@@ -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
@@ -242,12 +242,13 @@ class InitCommand(
context: "." # Docker build context used with dockerfile context: "." # Docker build context used with dockerfile
network: "" # Docker network mode for the build container; empty = Docker default (pinned) network: "" # Docker network mode for the build container; empty = Docker default (pinned)
env: {} # additional environment variables set inside the build container env: {} # additional environment variables set inside the build container
# bubblewrap user-namespace sandbox — for hosts without root and without a # werkdock sandbox (bubblewrap user namespace) — for hosts without root and
# Docker daemon (e.g. Hostsharing managed webspaces). Mutually exclusive with docker. # without a Docker daemon (e.g. Hostsharing managed webspaces). Mutually
bwrap: # exclusive with docker. Called bwrap before v1.2.0, still read under that name.
enabled: false # run clean/build in a bwrap sandbox instead of natively (pinned) werkdock:
enabled: false # run clean/build in the sandbox instead of natively (pinned)
rootfs: "" # prepared rootfs archive (path or URL); required when enabled (pinned) rootfs: "" # prepared rootfs archive (path or URL); required when enabled (pinned)
werkdock: werkdock # the werkdock CLI executing the sandbox; default resolves via PATH (pinned) binary: werkdock # the werkdock CLI executing the sandbox; default resolves via PATH (pinned)
env: {} # additional environment variables set inside the sandbox env: {} # additional environment variables set inside the sandbox
# Gitea check this build reports as; empty uses gitea.statusContext. # Gitea check this build reports as; empty uses gitea.statusContext.
# Two builds of one commit under the same context overwrite each other. # Two builds of one commit under the same context overwrite each other.
@@ -359,6 +360,13 @@ class InitCommand(
normalizedWorkingDir.toFile(), normalizedWorkingDir.toFile(),
)} (Apache reverse proxy; copy it into the domain docroot on a managed webspace)", )} (Apache reverse proxy; copy it into the domain docroot on a managed webspace)",
) )
val maintenancePageFile = werkatorDir.resolve(SystemdServiceFiles.MAINTENANCE_PAGE_NAME)
maintenancePageFile.toFile().writeText(SystemdServiceFiles.maintenancePageContent())
println(
"created ${maintenancePageFile.toFile().relativeTo(
normalizedWorkingDir.toFile(),
)} (shown by Apache while the service restarts; copy it next to the .htaccess)",
)
} }
println("install and start the service and the nightly Docker cleanup with:") println("install and start the service and the nightly Docker cleanup with:")
@@ -0,0 +1,28 @@
package de.hoennig.werkator.commands
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry
import picocli.CommandLine.Option
/**
* The `--repo` selector of the repository-scoped commands (ADR 0009): names an entry
* of the instance registry. Without it a command means the current working directory
* when that is served, otherwise the first registered repository — so inside a
* repository every command behaves exactly as it did with one.
*/
class RepoOption {
@Option(
names = ["--repo"],
paramLabel = "<name>",
description = ["registered repository to act on (default: the current directory)"],
)
var name: String? = null
fun select(registry: RepoRegistry): RepoContext {
val wanted = name?.trim()?.takeIf { it.isNotEmpty() } ?: return registry.current()
return registry.byName(wanted)
?: throw IllegalArgumentException(
"no repository named '$wanted' is registered (registered: ${registry.all().joinToString(", ") { it.name }})",
)
}
}
@@ -4,9 +4,11 @@ import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import picocli.CommandLine.Command import picocli.CommandLine.Command
import picocli.CommandLine.ExitCode import picocli.CommandLine.ExitCode
import picocli.CommandLine.Mixin
import java.nio.file.Path import java.nio.file.Path
import java.util.concurrent.Callable import java.util.concurrent.Callable
@@ -25,15 +27,20 @@ import java.util.concurrent.Callable
class RetryCommand( class RetryCommand(
private val gitService: GitService, private val gitService: GitService,
private val consoleBuildRunner: ConsoleBuildRunner, private val consoleBuildRunner: ConsoleBuildRunner,
/** The repository to retry in: the current working directory (a repo selector comes with the registry). */ private val registry: RepoRegistry,
var repo: RepoContext,
) : Callable<Int> { ) : Callable<Int> {
@Mixin
var repoOption = RepoOption()
private lateinit var repo: RepoContext
private val workingDir: Path private val workingDir: Path
get() = repo.workingDir get() = repo.workingDir
override fun call(): Int { override fun call(): Int {
val failed: List<BuildResult> val failed: List<BuildResult>
try { try {
repo = repoOption.select(registry)
fetchBestEffort() fetchBestEffort()
failed = repo.results.latestPerName().filter { it.status == BuildStatus.FAILED } failed = repo.results.latestPerName().filter { it.status == BuildStatus.FAILED }
} catch (e: Exception) { } catch (e: Exception) {
@@ -1,11 +1,12 @@
package de.hoennig.werkator.commands package de.hoennig.werkator.commands
import de.hoennig.werkator.build.BuildResult import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository import de.hoennig.werkator.repo.RepoRegistry
import de.hoennig.werkator.server.UiFormats import de.hoennig.werkator.server.UiFormats
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import picocli.CommandLine.Command import picocli.CommandLine.Command
import picocli.CommandLine.ExitCode import picocli.CommandLine.ExitCode
import picocli.CommandLine.Mixin
import picocli.CommandLine.Option import picocli.CommandLine.Option
import java.util.concurrent.Callable import java.util.concurrent.Callable
@@ -20,12 +21,22 @@ import java.util.concurrent.Callable
mixinStandardHelpOptions = true, mixinStandardHelpOptions = true,
) )
class StatusCommand( class StatusCommand(
private val repository: BuildResultRepository, private val registry: RepoRegistry,
) : Callable<Int> { ) : Callable<Int> {
@Option(names = ["--history"], description = ["Print all recorded builds, not only the latest per branch"]) @Option(names = ["--history"], description = ["Print all recorded builds, not only the latest per branch"])
var history: Boolean = false var history: Boolean = false
@Mixin
var repoOption = RepoOption()
override fun call(): Int { override fun call(): Int {
val repository =
try {
repoOption.select(registry).results
} catch (e: IllegalArgumentException) {
System.err.println("error: ${e.message}")
return ExitCode.USAGE
}
val results = if (history) repository.history() else repository.latestPerName() val results = if (history) repository.history() else repository.latestPerName()
if (results.isEmpty()) { if (results.isEmpty()) {
println("(no builds recorded)") println("(no builds recorded)")
@@ -13,6 +13,9 @@ object SystemdServiceFiles {
/** Host-global unit names of the nightly Docker cleanup — shared by all Werkator repositories on the host. */ /** Host-global unit names of the nightly Docker cleanup — shared by all Werkator repositories on the host. */
const val HTACCESS_NAME = "werkator.htaccess" const val HTACCESS_NAME = "werkator.htaccess"
/** Static page [maintenancePageContent] serves at, referenced by [htaccessContent]'s `ErrorDocument`s. */
const val MAINTENANCE_PAGE_NAME = "werkator-maintenance.html"
const val PRUNE_SERVICE_NAME = "werkator-docker-prune.service" const val PRUNE_SERVICE_NAME = "werkator-docker-prune.service"
const val PRUNE_TIMER_NAME = "werkator-docker-prune.timer" const val PRUNE_TIMER_NAME = "werkator-docker-prune.timer"
@@ -96,15 +99,53 @@ object SystemdServiceFiles {
* Apache terminates TLS for the domain and forwards everything to the * Apache terminates TLS for the domain and forwards everything to the
* localhost port of the "eigener Serverdienst". Generated host integration * localhost port of the "eigener Serverdienst". Generated host integration
* like the units — the wrapper copies it into the domain's docroot. * like the units — the wrapper copies it into the domain's docroot.
* `ErrorDocument` maps a refused connection (Apache's 502/503/504 while the
* service restarts) to the static [maintenancePageContent] instead of Apache's
* default error page; the `RewriteCond` keeps that one file from being proxied
* itself, since `ErrorDocument` serves it as a sub-request through the same rules.
*/ */
fun htaccessContent(port: Int): String = fun htaccessContent(port: Int): String =
""" """
DirectoryIndex disabled DirectoryIndex disabled
ErrorDocument 502 /$MAINTENANCE_PAGE_NAME
ErrorDocument 503 /$MAINTENANCE_PAGE_NAME
ErrorDocument 504 /$MAINTENANCE_PAGE_NAME
RewriteEngine On RewriteEngine On
RewriteBase / RewriteBase /
RewriteCond %{REQUEST_URI} !^/${MAINTENANCE_PAGE_NAME}${'$'}
RewriteRule .* http://127.0.0.1:$port%{REQUEST_URI} [proxy] RewriteRule .* http://127.0.0.1:$port%{REQUEST_URI} [proxy]
""".trimIndent() + "\n" """.trimIndent() + "\n"
/**
* Static fallback for [htaccessContent]'s `ErrorDocument`s: shown by Apache directly,
* without involving Werkator, during the brief window where the service restarts and
* nothing listens on its port yet (`instance-update`). Self-contained — no external
* assets, since nothing would be there to serve them while Werkator itself is down.
*/
fun maintenancePageContent(): String =
"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Werkator — Maintenance</title>
<style>
body { font-family: sans-serif; background: #1e1e1e; color: #eee; display: flex;
align-items: center; justify-content: center; height: 100vh; margin: 0; }
div { text-align: center; }
h1 { font-size: 1.4rem; margin-bottom: .5rem; }
p { color: #aaa; }
</style>
</head>
<body>
<div>
<h1>Werkator is restarting</h1>
<p>A deployment is in progress. Please retry in a few minutes.</p>
</div>
</body>
</html>
""".trimIndent() + "\n"
private fun sanitize(name: String): String = name.replace(Regex("[^A-Za-z0-9_.-]"), "-") private fun sanitize(name: String): String = name.replace(Regex("[^A-Za-z0-9_.-]"), "-")
/** Escape `%` specifiers in systemd unit values (legacy `systemd_path`). */ /** Escape `%` specifiers in systemd unit values (legacy `systemd_path`). */
@@ -42,8 +42,8 @@ data class BuildDefinition(
val statusContext: String? = null, val statusContext: String? = null,
/** Overrides of the docker settings; null inherits them. */ /** Overrides of the docker settings; null inherits them. */
val docker: DockerOverrides? = null, val docker: DockerOverrides? = null,
/** Overrides of the bwrap settings; null inherits them. */ /** Overrides of the werkdock settings; null inherits them. */
val bwrap: BwrapOverrides? = null, val werkdock: WerkdockOverrides? = null,
) { ) {
/** The settings this build runs with: [branchConfig] with this definition applied; unset values fall through. */ /** The settings this build runs with: [branchConfig] with this definition applied; unset values fall through. */
fun applyTo(branchConfig: BranchConfig): BranchConfig = fun applyTo(branchConfig: BranchConfig): BranchConfig =
@@ -64,12 +64,12 @@ data class BuildDefinition(
network = docker?.network ?: branchConfig.docker.network, network = docker?.network ?: branchConfig.docker.network,
env = docker?.env ?: branchConfig.docker.env, env = docker?.env ?: branchConfig.docker.env,
), ),
bwrap = werkdock =
branchConfig.bwrap.copy( branchConfig.werkdock.copy(
enabled = bwrap?.enabled ?: branchConfig.bwrap.enabled, enabled = werkdock?.enabled ?: branchConfig.werkdock.enabled,
rootfs = bwrap?.rootfs ?: branchConfig.bwrap.rootfs, rootfs = werkdock?.rootfs ?: branchConfig.werkdock.rootfs,
werkdock = bwrap?.werkdock ?: branchConfig.bwrap.werkdock, binary = werkdock?.binary ?: branchConfig.werkdock.binary,
env = bwrap?.env ?: branchConfig.bwrap.env, env = werkdock?.env ?: branchConfig.werkdock.env,
), ),
) )
@@ -169,13 +169,13 @@ data class DockerOverrides(
val env: Map<String, String>? = null, val env: Map<String, String>? = null,
) )
/** Nullable bubblewrap overrides of a [BuildDefinition]; null values inherit the branch's setting. */ /** Nullable werkdock overrides of a [BuildDefinition]; null values inherit the branch's setting. */
data class BwrapOverrides( data class WerkdockOverrides(
/** Run the build in the bwrap sandbox instead of natively. Pinned — a branch must not escape its sandbox. */ /** Run the build in the sandbox instead of natively. Pinned — a branch must not escape its sandbox. */
val enabled: Boolean? = null, val enabled: Boolean? = null,
/** Rootfs archive source. Pinned — a branch must not substitute a foreign rootfs. */ /** Rootfs archive source. Pinned — a branch must not substitute a foreign rootfs. */
val rootfs: String? = null, val rootfs: String? = null,
/** The werkdock CLI executing the sandbox. Pinned — a branch must not substitute the executing binary. */ /** The werkdock CLI executing the sandbox. Pinned — a branch must not substitute the executing binary. */
val werkdock: String? = null, val binary: String? = null,
val env: Map<String, String>? = null, val env: Map<String, String>? = null,
) )
@@ -19,6 +19,14 @@ object ConfigFiles {
/** The machine-specific configuration inside `.git`; secrets live here. */ /** The machine-specific configuration inside `.git`; secrets live here. */
const val REPO_INSTALL = ".git/werkator/$COMMITTED" const val REPO_INSTALL = ".git/werkator/$COMMITTED"
/**
* The instance configuration (ADR 0009), relative to the home directory of the user
* running Werkator. Deliberately the same file name: only the location carries the
* meaning — home is the instance, the repository root is the project, `.git` is the
* machine.
*/
const val INSTANCE = COMMITTED
/** /**
* The applied instance fragment (`init --apply`, step 23): a config-schema YAML * The applied instance fragment (`init --apply`, step 23): a config-schema YAML
* fragment installed verbatim as its own layer — above the committed project * fragment installed verbatim as its own layer — above the committed project
@@ -50,6 +50,35 @@ class ConfigLoader(
/** Section-level warnings already reported, keyed by a fixed slug; the config is loaded on every poll cycle. */ /** Section-level warnings already reported, keyed by a fixed slug; the config is loaded on every poll cycle. */
private val warnedSections = ConcurrentHashMap.newKeySet<String>() private val warnedSections = ConcurrentHashMap.newKeySet<String>()
/**
* Where the instance configuration lives (ADR 0009): the home directory of the user
* running Werkator, `WERKATOR_HOME` overriding it for tests and unusual layouts.
*/
@Volatile
var homeDir: Path = Paths.get(System.getenv("WERKATOR_HOME")?.takeIf { it.isNotBlank() } ?: System.getProperty("user.home"))
/** The instance configuration file, whether or not it exists. */
fun instanceFile(): Path = homeDir.resolve(ConfigFiles.INSTANCE)
/**
* The instance configuration, or null without a home file — the single-repository
* case, in which the current directory is served exactly as before ADR 0009.
*/
fun loadInstance(): InstanceConfig? {
val raw = loadInstanceRaw()
if (raw.isEmpty()) {
return null
}
return yaml.convertValue(raw, InstanceConfig::class.java)
}
private fun loadInstanceRaw(): Map<String, Any?> {
val file = instanceFile()
val raw = loadFile(file.toFile())
checkVersion(raw, file.toString(), ROLLBACK_HINT)
return raw
}
fun load(workingDir: Path = Paths.get(".")): WerkatorConfig = toConfig(loadRaw(workingDir)) fun load(workingDir: Path = Paths.get(".")): WerkatorConfig = toConfig(loadRaw(workingDir))
/** /**
@@ -172,10 +201,10 @@ class ConfigLoader(
val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } } val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } }
if (strippedDocker.isEmpty()) result.remove("docker") else result["docker"] = strippedDocker if (strippedDocker.isEmpty()) result.remove("docker") else result["docker"] = strippedDocker
} }
val bwrap = entry["bwrap"] as? Map<String, Any?> val werkdock = entry["werkdock"] as? Map<String, Any?>
if (bwrap != null) { if (werkdock != null) {
val strippedBwrap = bwrap.toMutableMap().apply { PINNED_BWRAP_KEYS.forEach { remove(it) } } val strippedWerkdock = werkdock.toMutableMap().apply { PINNED_WERKDOCK_KEYS.forEach { remove(it) } }
if (strippedBwrap.isEmpty()) result.remove("bwrap") else result["bwrap"] = strippedBwrap if (strippedWerkdock.isEmpty()) result.remove("werkdock") else result["werkdock"] = strippedWerkdock
} }
return result return result
} }
@@ -307,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
@@ -384,14 +481,62 @@ class ConfigLoader(
private fun loadFile(file: File): Map<String, Any?> { private fun loadFile(file: File): Map<String, Any?> {
if (!file.exists()) return emptyMap() if (!file.exists()) return emptyMap()
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return yaml.readValue(file, Map::class.java) as Map<String, Any?> return renameLegacySandbox(yaml.readValue(file, Map::class.java) as Map<String, Any?>, file.toString())
} }
/** Parses a `.werkator.yml` read from git (not from disk); blank or null yields no layer. */ /** Parses a `.werkator.yml` read from git (not from disk); blank or null yields no layer. */
private fun parseYaml(text: String?): Map<String, Any?> { private fun parseYaml(text: String?): Map<String, Any?> {
if (text.isNullOrBlank()) return emptyMap() if (text.isNullOrBlank()) return emptyMap()
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return yaml.readValue(text, Map::class.java) as? Map<String, Any?> ?: emptyMap() val raw = yaml.readValue(text, Map::class.java) as? Map<String, Any?> ?: emptyMap()
return renameLegacySandbox(raw, "the branch configuration")
}
/**
* Reads the pre-PR#19 `bwrap` section under its new name `werkdock`, including its
* `werkdock` key which is `binary` now. Done on the raw map of every layer, before
* merging, so nothing downstream — merging, pinning, binding — knows two names.
*
* Renaming rather than rejecting: the section is written in the machine configuration
* of every webspace instance, which no repository tracks. The warning is what makes
* the old name go away; the hard refusal belongs to the release that sets
* [ConfigVersions.FORMAT_BROKE_IN], where a file declaring no version can be caught
* by name at all.
*/
@Suppress("UNCHECKED_CAST")
private fun renameLegacySandbox(
raw: Map<String, Any?>,
source: String,
): Map<String, Any?> {
var renamed = false
val result =
raw.mapValues { (section, value) ->
if (section != "builds" && section != "branches") {
return@mapValues value
}
val entries = value as? Map<String, Any?> ?: return@mapValues value
entries.mapValues inner@{ (_, entry) ->
val settings = entry as? Map<String, Any?> ?: return@inner entry
val legacy = settings["bwrap"] as? Map<String, Any?> ?: return@inner entry
renamed = true
val moved =
legacy.mapKeys { (key, _) -> if (key == "werkdock") "binary" else key }
val existing = settings["werkdock"] as? Map<String, Any?> ?: emptyMap()
settings.toMutableMap().apply {
remove("bwrap")
// an explicit werkdock section wins: the new name is the one meant
put("werkdock", moved + existing)
}
}
}
if (renamed && warnedSections.add("bwrap-renamed:$source")) {
log.warn(
"reading the 'bwrap' section of {} as 'werkdock' (and 'bwrap.werkdock' as 'werkdock.binary'); " +
"rename it — the old name goes away with the next breaking configuration change",
source,
)
}
return result
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -453,8 +598,8 @@ class ConfigLoader(
/** `docker` keys a branch must never override: the sandbox policy. */ /** `docker` keys a branch must never override: the sandbox policy. */
private val PINNED_DOCKER_KEYS = setOf("enabled", "network") private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
/** `bwrap` keys a branch must never override: the sandbox policy (Step 17) and its executing binary. */ /** `werkdock` keys a branch must never override: the sandbox policy (Step 17) and its executing binary. */
private val PINNED_BWRAP_KEYS = setOf("enabled", "rootfs", "werkdock") private val PINNED_WERKDOCK_KEYS = setOf("enabled", "rootfs", "binary")
/** /**
* The one key of a build definition that says *when* and *for which branches* it * The one key of a build definition that says *when* and *for which branches* it
@@ -467,6 +612,16 @@ class ConfigLoader(
/** The keys that moved into [TRIGGER_KEYS]; still writing them flat is refused, not ignored. */ /** The keys that moved into [TRIGGER_KEYS]; still writing them flat is refused, not ignored. */
private val FLAT_TRIGGER_KEYS = setOf("onPush", "atTimes", "branches", "activeWithin") private val FLAT_TRIGGER_KEYS = setOf("onPush", "atTimes", "branches", "activeWithin")
/**
* Top-level sections owned by the instance once a home config exists (ADR 0009):
* the one server and the one global concurrency cap. Read from the home file,
* ignored with a warning in a repository's files.
*/
private val INSTANCE_SECTIONS = setOf("server", "executor")
/** The `watcher` keys owned by the instance: one loop, one delay; the gates stay per repository. */
private val INSTANCE_WATCHER_KEYS = setOf("pollInterval")
private const val LEGACY_BRANCHES_WARNING = "legacy-branches-ignored" private const val LEGACY_BRANCHES_WARNING = "legacy-branches-ignored"
private const val NO_TRIGGER_WARNING = "no-build-triggered" private const val NO_TRIGGER_WARNING = "no-build-triggered"
@@ -0,0 +1,45 @@
package de.hoennig.werkator.config
/**
* The instance configuration (ADR 0009): `~/.werkator.yml` in the home directory of the
* user running Werkator — one instance per OS user. It owns what is shared by every
* repository the instance serves: the repository registry, the `server` section, the
* global `executor.maxConcurrent`, and the watcher poll interval. Everything else in
* the file is either `defaults` — a fragment in the repository config schema merged
* *below* every repository's own layers — or ignored.
*
* Without this file, Werkator serves the current working directory exactly as before.
* With it, the registry wins over the current directory (`werkator server` serves the
* registered repositories wherever it is started), and the instance-level keys of a
* repository's own files are ignored with a warning naming both files, never merged.
*/
data class InstanceConfig(
/** What this file declares about the Werkator that reads it; see [VersionRequirement]. */
val werkator: WerkatorMeta = WerkatorMeta(),
val server: ServerConfig = ServerConfig(),
val executor: ExecutorConfig = ExecutorConfig(),
val watcher: InstanceWatcherConfig = InstanceWatcherConfig(),
/** The registry: the repositories this instance serves; empty means the current directory. */
val repositories: List<RepositoryEntry> = emptyList(),
/**
* Repository-level keys in the repository config schema (e.g. one `git.account`/`git.token`
* for every repository of the same forge), merged below each repository's own layers.
* Raw on purpose: it is a fragment, not a configuration, and binds through the same
* path as every other layer.
*/
val defaults: Map<String, Any?> = emptyMap(),
)
/** The watcher settings that are the instance's, not a repository's: one loop, one delay. */
data class InstanceWatcherConfig(
/** Delay between poll cycles over all repositories, e.g. `10s` or `1m`. */
val pollInterval: String = "10s",
)
/** One registry entry: a repository directory and the name it is known by. */
data class RepositoryEntry(
/** The repository's primary checkout, absolute or relative to the home directory; `~` expands. */
val path: String = "",
/** Short unique name for display and routes; empty means the directory basename. */
val name: String = "",
)
@@ -38,9 +38,9 @@ data class WerkatorConfig(
): BranchConfig { ): BranchConfig {
val branchConfig = branches[branch] ?: branches["default"] ?: BranchConfig() val branchConfig = branches[branch] ?: branches["default"] ?: BranchConfig()
val settings = effectiveBuildDefinitions()[build]?.applyTo(branchConfig) ?: branchConfig val settings = effectiveBuildDefinitions()[build]?.applyTo(branchConfig) ?: branchConfig
if (settings.docker.enabled && settings.bwrap.enabled) { if (settings.docker.enabled && settings.werkdock.enabled) {
throw IllegalArgumentException( throw IllegalArgumentException(
"builds.$build on '$branch' enables both docker and bwrap; a build runs in exactly one sandbox. " + "builds.$build on '$branch' enables both docker and werkdock; a build runs in exactly one sandbox. " +
"Disable one of them.", "Disable one of them.",
) )
} }
@@ -179,17 +179,22 @@ data class BranchConfig(
val statusContext: String = "", val statusContext: String = "",
val autoBuild: AutoBuildConfig = AutoBuildConfig(), val autoBuild: AutoBuildConfig = AutoBuildConfig(),
val docker: DockerConfig = DockerConfig(), val docker: DockerConfig = DockerConfig(),
/** bubblewrap user-namespace sandbox; mutually exclusive with [docker]. */ /** werkdock sandbox (bubblewrap user namespace); mutually exclusive with [docker]. */
val bwrap: BwrapConfig = BwrapConfig(), val werkdock: WerkdockConfig = WerkdockConfig(),
) )
/** /**
* bubblewrap build sandbox (Step 17): runs the build in an unprivileged user namespace * The werkdock build sandbox (Step 17, executed by the werkdock CLI since step 21):
* with a prepared Debian root filesystem. For hosts without root and without a Docker * runs the build in an unprivileged bubblewrap user namespace over a prepared Debian
* daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md`. * root filesystem. For hosts without root and without a Docker daemon (e.g. Hostsharing
* managed webspaces); see `docs/plan/17-bwrap-build-runtime.md`.
*
* The section was called `bwrap` until PR#19 and is still read under that name, with a
* warning: `bwrap` named the mechanism one layer below the tool that actually runs it,
* which made `bwrap.werkdock` the key naming its own executor.
*/ */
data class BwrapConfig( data class WerkdockConfig(
/** Run the clean and build commands in a bwrap sandbox instead of natively. */ /** Run the clean and build commands in the sandbox instead of natively. */
val enabled: Boolean = false, val enabled: Boolean = false,
/** /**
* Path or URL of the prepared rootfs archive (e.g. `werkator-buildenv-trixie-java21.tar.zst`), * Path or URL of the prepared rootfs archive (e.g. `werkator-buildenv-trixie-java21.tar.zst`),
@@ -200,8 +205,9 @@ data class BwrapConfig(
/** /**
* The werkdock CLI executing the sandbox (step 21 session C); empty or the default * The werkdock CLI executing the sandbox (step 21 session C); empty or the default
* resolves via PATH. Pinned — a branch must not substitute the executing binary. * resolves via PATH. Pinned — a branch must not substitute the executing binary.
* Was `bwrap.werkdock` until PR#19.
*/ */
val werkdock: String = "werkdock", val binary: String = "werkdock",
/** Additional environment variables set inside the sandbox. */ /** Additional environment variables set inside the sandbox. */
val env: Map<String, String> = emptyMap(), val env: Map<String, String> = emptyMap(),
) )
@@ -0,0 +1,133 @@
package de.hoennig.werkator.metrics
/** One `blocks`/`quota`/`limit` line of `quota -u -g --no-wrap --raw-grace`, KiB throughout. */
data class QuotaLine(
val kind: String,
val subject: String,
val filesystem: String,
val blocksKib: Long,
val softKib: Long,
val hardKib: Long,
)
/**
* Where the binding [SystemMetricsCollector.DiskSpace] came from: the volume itself, or a
* user/group quota. `kind` is `"volume"`, `"user"` or `"group"`; `subject`/`filesystem`/the
* limits are set only for a quota. Serialized as-is into `GET /api/system`.
*/
data class DiskSource(
val kind: String,
val subject: String? = null,
val filesystem: String? = null,
val softLimitGib: Double? = null,
val hardLimitGib: Double? = null,
) {
companion object {
fun volume() = DiskSource(kind = "volume")
}
}
/** One candidate budget for a directory: a quota line or the volume, each with its source. */
data class DiskCandidate(
val space: SystemMetricsCollector.DiskSpace,
val source: DiskSource,
)
/**
* Parses `quota -u -g --no-wrap --raw-grace` and picks the tightest of user quota, group
* quota, and the volume — pure functions over strings and numbers, see PR#16.
*/
object DiskQuota {
private val subjectHeader = Regex("""^Disk quotas for (user|group) (\S+) \([ug]id \d+\):\s*(none)?\s*$""")
/** `null`/blank output, and a subject reported `none`, both yield no line for that subject. */
fun parse(output: String): List<QuotaLine> {
val lines = mutableListOf<QuotaLine>()
var kind: String? = null
var subject: String? = null
for (rawLine in output.lines()) {
val header = subjectHeader.find(rawLine.trimEnd())
if (header != null) {
kind = header.groupValues[1]
subject = header.groupValues[2]
continue
}
val currentKind = kind ?: continue
val currentSubject = subject ?: continue
val trimmed = rawLine.trim()
if (trimmed.isEmpty() || trimmed.startsWith("Filesystem")) {
continue
}
val fields = trimmed.split(Regex("\\s+"))
if (fields.size < 4) {
continue
}
val blocksKib = fields[1].trimEnd('*').toLongOrNull() ?: continue
val softKib = fields[2].toLongOrNull() ?: continue
val hardKib = fields[3].toLongOrNull() ?: continue
lines +=
QuotaLine(
kind = currentKind,
subject = currentSubject,
filesystem = fields[0],
blocksKib = blocksKib,
softKib = softKib,
hardKib = hardKib,
)
}
return lines
}
/**
* The lines whose filesystem matches [directoryFilesystem] — exactly, or by the last path
* segment when one side is a resolved device path (`/dev/sdb1` vs `/dev/disk/by-id/…`) — each
* turned into a candidate. A line with no soft and no hard limit (both zero) is no candidate;
* a line with only a hard limit uses it as the total.
*/
fun candidatesFor(
lines: List<QuotaLine>,
directoryFilesystem: String,
): List<DiskCandidate> =
lines
.filter { matchesFilesystem(it.filesystem, directoryFilesystem) }
.mapNotNull { line ->
val softBytes = line.softKib * BYTES_PER_KIB
val hardBytes = line.hardKib * BYTES_PER_KIB
val totalBytes = if (softBytes > 0) softBytes else hardBytes
if (totalBytes <= 0) {
return@mapNotNull null
}
val usedBytes = line.blocksKib * BYTES_PER_KIB
val freeBytes = maxOf(0L, totalBytes - usedBytes)
DiskCandidate(
space =
SystemMetricsCollector.DiskSpace(
totalBytes = totalBytes,
usedBytes = usedBytes,
freeBytes = freeBytes,
),
source =
DiskSource(
kind = line.kind,
subject = line.subject,
filesystem = line.filesystem,
softLimitGib = softBytes / BYTES_PER_GIB,
hardLimitGib = hardBytes / BYTES_PER_GIB,
),
)
}
private fun matchesFilesystem(
quotaFilesystem: String,
directoryFilesystem: String,
): Boolean =
quotaFilesystem == directoryFilesystem ||
quotaFilesystem.substringAfterLast('/') == directoryFilesystem.substringAfterLast('/')
/** The candidate with the smallest headroom; the tightest budget always wins. */
fun bindingDiskSpace(candidates: List<DiskCandidate>): DiskCandidate = candidates.minBy { it.space.freeBytes }
private const val BYTES_PER_KIB = 1024L
private const val BYTES_PER_GIB = 1_073_741_824.0
}
@@ -1,6 +1,7 @@
package de.hoennig.werkator.metrics package de.hoennig.werkator.metrics
import de.hoennig.werkator.build.ArtifactStore import de.hoennig.werkator.build.ArtifactStore
import de.hoennig.werkator.repo.RepoRegistry
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import java.time.Clock import java.time.Clock
@@ -16,10 +17,12 @@ class MetricsConfiguration {
@Bean @Bean
fun systemMetricsCollector( fun systemMetricsCollector(
artifactStore: ArtifactStore, artifactStore: ArtifactStore,
registry: RepoRegistry,
clock: Clock, clock: Clock,
): SystemMetricsCollector = ): SystemMetricsCollector =
SystemMetricsCollector( SystemMetricsCollector(
stateFile = { artifactStore.rootDir().resolve(SystemMetricsCollector.STATE_FILE_NAME) }, stateFile = { artifactStore.rootDir().resolve(SystemMetricsCollector.STATE_FILE_NAME) },
repoDirs = { registry.all().map { it.workingDir } },
clock = clock, clock = clock,
) )
} }
@@ -22,6 +22,10 @@ data class SystemMetrics(
val cpuCount: Int, val cpuCount: Int,
val ramTotalGib: Double?, val ramTotalGib: Double?,
val diskTotalGib: Double?, val diskTotalGib: Double?,
/** What `diskTotalGib`/`diskUsedGib`/`diskFreeGib` describe: the volume, or a binding quota. */
val diskSource: DiskSource? = null,
/** True when a user or group quota existed but the volume was still the tighter budget. */
val quotasPresent: Boolean = false,
val cpuUsed: MetricAggregate?, val cpuUsed: MetricAggregate?,
val cpuIdle: MetricAggregate?, val cpuIdle: MetricAggregate?,
val ramUsedGib: MetricAggregate?, val ramUsedGib: MetricAggregate?,
@@ -19,10 +19,15 @@ import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** The aggregation state persisted in the artifact root (the legacy `system_state.dat`, but as JSON). */ /**
* The aggregation state persisted in the artifact root (the legacy `system_state.dat`, but as JSON).
* [diskSourceKey] defaults to `"volume"` — the only source before quota-awareness existed — so an
* older state file upgrades cleanly: it only triggers the disk-series reset when a quota now binds.
*/
data class PersistedMetricsState( data class PersistedMetricsState(
val sampleCount: Long = 0, val sampleCount: Long = 0,
val series: Map<String, MetricSeries> = emptyMap(), val series: Map<String, MetricSeries> = emptyMap(),
val diskSourceKey: String = "volume",
) )
/** /**
@@ -35,12 +40,15 @@ data class PersistedMetricsState(
*/ */
class SystemMetricsCollector( class SystemMetricsCollector(
private val stateFile: () -> Path, private val stateFile: () -> Path,
private val workingDir: Path = Paths.get("."), /** The served repositories: the disk metric is the first one's file store, the repository size their sum. */
private val repoDirs: () -> List<Path> = { listOf(Paths.get(".")) },
private val clock: Clock = Clock.systemUTC(), private val clock: Clock = Clock.systemUTC(),
private val procStat: Path = Paths.get("/proc/stat"), private val procStat: Path = Paths.get("/proc/stat"),
private val procMeminfo: Path = Paths.get("/proc/meminfo"), private val procMeminfo: Path = Paths.get("/proc/meminfo"),
private val cpuCount: Int = Runtime.getRuntime().availableProcessors(), private val cpuCount: Int = Runtime.getRuntime().availableProcessors(),
private val diskSpace: (Path) -> DiskSpace = { dir -> fileStoreDiskSpace(dir) }, private val diskSpace: (Path) -> DiskSpace = { dir -> fileStoreDiskSpace(dir) },
private val fileStoreName: (Path) -> String = { dir -> Files.getFileStore(dir).name() },
private val quotaOutput: () -> String? = { readQuotaOutput() },
private val repoSizeBytes: (Path) -> Long = { dir -> directorySizeBytes(dir) }, private val repoSizeBytes: (Path) -> Long = { dir -> directorySizeBytes(dir) },
) { ) {
/** Disk usage in bytes; used/free follow `df` semantics (free is what a user can still allocate). */ /** Disk usage in bytes; used/free follow `df` semantics (free is what a user can still allocate). */
@@ -121,6 +129,13 @@ class SystemMetricsCollector(
val cpu = readCpuLoad() val cpu = readCpuLoad()
val ram = readRam() val ram = readRam()
val disk = readDisk() val disk = readDisk()
val diskSourceKey = disk?.let { diskSourceKey(it.source) } ?: previousState.diskSourceKey
if (disk != null && diskSourceKey != previousState.diskSourceKey) {
// the previous binary/binding source measured a different budget (Scenario#16.05):
// continuing its min/max/avg would poison the new series with a stale ceiling
series.remove("diskUsedGib")
series.remove("diskFreeGib")
}
val repoSizeGib = readRepoSizeThrottled() val repoSizeGib = readRepoSizeThrottled()
snapshot = snapshot =
SystemMetrics( SystemMetrics(
@@ -128,19 +143,25 @@ class SystemMetricsCollector(
sampleCount = sampleCount, sampleCount = sampleCount,
cpuCount = cpuCount, cpuCount = cpuCount,
ramTotalGib = ram?.totalGib, ramTotalGib = ram?.totalGib,
diskTotalGib = disk?.totalBytes?.let { it / BYTES_PER_GIB }, diskTotalGib = disk?.space?.totalBytes?.let { it / BYTES_PER_GIB },
diskSource = disk?.source,
quotasPresent = disk?.quotasPresent ?: false,
cpuUsed = record("cpuUsed", cpu?.usedCores), cpuUsed = record("cpuUsed", cpu?.usedCores),
cpuIdle = record("cpuIdle", cpu?.idleCores), cpuIdle = record("cpuIdle", cpu?.idleCores),
ramUsedGib = record("ramUsedGib", ram?.usedGib), ramUsedGib = record("ramUsedGib", ram?.usedGib),
ramFreeGib = record("ramFreeGib", ram?.freeGib), ramFreeGib = record("ramFreeGib", ram?.freeGib),
diskUsedGib = record("diskUsedGib", disk?.usedBytes?.let { it / BYTES_PER_GIB }), diskUsedGib = record("diskUsedGib", disk?.space?.usedBytes?.let { it / BYTES_PER_GIB }),
diskFreeGib = record("diskFreeGib", disk?.freeBytes?.let { it / BYTES_PER_GIB }), diskFreeGib = record("diskFreeGib", disk?.space?.freeBytes?.let { it / BYTES_PER_GIB }),
repoSizeGib = record("repoSizeGib", repoSizeGib), repoSizeGib = record("repoSizeGib", repoSizeGib),
) )
state = PersistedMetricsState(sampleCount = sampleCount, series = series) state = PersistedMetricsState(sampleCount = sampleCount, series = series, diskSourceKey = diskSourceKey)
saveState(state!!) saveState(state!!)
} }
/** `"volume"`, or `"quota:<kind>:<subject>:<filesystem>"` — the reset trigger of Scenario#16.05. */
private fun diskSourceKey(source: DiskSource): String =
if (source.kind == "volume") "volume" else "quota:${source.kind}:${source.subject}:${source.filesystem}"
private fun sampleSafely() { private fun sampleSafely() {
try { try {
sample() sample()
@@ -156,6 +177,8 @@ class SystemMetricsCollector(
cpuCount = cpuCount, cpuCount = cpuCount,
ramTotalGib = null, ramTotalGib = null,
diskTotalGib = null, diskTotalGib = null,
diskSource = null,
quotasPresent = false,
cpuUsed = null, cpuUsed = null,
cpuIdle = null, cpuIdle = null,
ramUsedGib = null, ramUsedGib = null,
@@ -239,7 +262,27 @@ class SystemMetricsCollector(
.removeSuffix(" kB") .removeSuffix(" kB")
.toLong() .toLong()
private fun readDisk(): DiskSpace? = readSource("disk") { diskSpace(workingDir) } /** Disk reading plus whether a quota lost against the volume, for the info line (Scenario#16.06). */
private data class DiskReading(
val space: DiskSpace,
val source: DiskSource,
val quotasPresent: Boolean,
)
/**
* The tightest of the user quota, the group quota, and the volume itself (Scenario#16.02):
* a failing or absent `quota` binary just leaves the volume as the only candidate, logged
* once under its own "quota" source, separately from a failing file-store read.
*/
private fun readDisk(): DiskReading? =
readSource("disk") {
val dir = repoDirs().first()
val volume = DiskCandidate(diskSpace(dir), DiskSource.volume())
val quotaLines = readSource("quota") { quotaOutput()?.let(DiskQuota::parse) ?: emptyList() } ?: emptyList()
val quotaCandidates = DiskQuota.candidatesFor(quotaLines, fileStoreName(dir))
val binding = DiskQuota.bindingDiskSpace(quotaCandidates + volume)
DiskReading(space = binding.space, source = binding.source, quotasPresent = quotaCandidates.isNotEmpty())
}
/** /**
* The repository size is expensive to determine (a full file walk), so unlike * The repository size is expensive to determine (a full file walk), so unlike
@@ -251,7 +294,7 @@ class SystemMetricsCollector(
samplesSinceRepoSizeProbe++ samplesSinceRepoSizeProbe++
return lastRepoSizeGib return lastRepoSizeGib
} }
lastRepoSizeGib = readSource("repo size") { repoSizeBytes(workingDir) / BYTES_PER_GIB } lastRepoSizeGib = readSource("repo size") { repoDirs().sumOf(repoSizeBytes) / BYTES_PER_GIB }
samplesSinceRepoSizeProbe = 1 samplesSinceRepoSizeProbe = 1
return lastRepoSizeGib return lastRepoSizeGib
} }
@@ -324,6 +367,26 @@ class SystemMetricsCollector(
) )
} }
/**
* `quota -u -g --no-wrap --raw-grace`: `--no-wrap` keeps long device names on one line,
* `--raw-grace` prints the grace columns as numbers, so every filesystem line has the
* same nine fields ([DiskQuota] needs no column heuristics). The exit status is not read —
* `quota` also uses it to say "over quota" — only the parsed output counts; an absent
* binary throws (caught by the caller's `readSource`, logged once), a hung process is
* killed after 5s and treated the same way.
*/
fun readQuotaOutput(): String {
val process =
ProcessBuilder("quota", "-u", "-g", "--no-wrap", "--raw-grace")
.redirectErrorStream(true)
.start()
if (!process.waitFor(5, TimeUnit.SECONDS)) {
process.destroyForcibly()
error("quota command timed out")
}
return process.inputStream.bufferedReader().readText()
}
/** File-walk replacement for the legacy `du -sk`; unreadable subtrees are skipped, links are not followed. */ /** File-walk replacement for the legacy `du -sk`; unreadable subtrees are skipped, links are not followed. */
fun directorySizeBytes(dir: Path): Long { fun directorySizeBytes(dir: Path): Long {
var size = 0L var size = 0L
@@ -2,15 +2,14 @@ package de.hoennig.werkator.repo
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import java.nio.file.Paths
@Configuration @Configuration
class RepoConfiguration { class RepoConfiguration {
/** /**
* The single-repository case: the current working directory, which is how every * The repository the unscoped code paths mean — the current working directory
* CLI command and the server resolve their files. Only paths are computed here, so * when it is served, see [RepoRegistry.current]. Without a registry only paths are
* the bean is safe outside a git repository. * computed here, so the bean is safe outside a git repository.
*/ */
@Bean @Bean
fun currentRepo(repoContexts: RepoContexts): RepoContext = repoContexts.open(Paths.get(".")) fun currentRepo(registry: RepoRegistry): RepoContext = registry.current()
} }
@@ -1,12 +1,17 @@
package de.hoennig.werkator.repo package de.hoennig.werkator.repo
import de.hoennig.werkator.StateDirMigration
import de.hoennig.werkator.artifacts.FileArtifactStore import de.hoennig.werkator.artifacts.FileArtifactStore
import de.hoennig.werkator.build.FileBuildResultRepository import de.hoennig.werkator.build.FileBuildResultRepository
import de.hoennig.werkator.config.ConfigLoader import de.hoennig.werkator.config.ConfigLoader
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.nio.file.Path import java.nio.file.Path
/** Opens a [RepoContext] over a repository directory; nothing is touched until the first build. */ /**
* Opens a [RepoContext] over a repository directory. Nothing is created until the first
* build; only a pre-rename state directory is moved to its current name on the way, per
* repository, before any path under it is resolved.
*/
@Component @Component
class RepoContexts( class RepoContexts(
private val configLoader: ConfigLoader, private val configLoader: ConfigLoader,
@@ -14,13 +19,15 @@ class RepoContexts(
fun open( fun open(
workingDir: Path, workingDir: Path,
name: String = defaultName(workingDir), name: String = defaultName(workingDir),
): RepoContext = ): RepoContext {
RepoContext( StateDirMigration.migrateIfNeeded(workingDir)
return RepoContext(
name = name, name = name,
workingDir = workingDir, workingDir = workingDir,
results = FileBuildResultRepository(workingDir.resolve(RESULTS_FILE)), results = FileBuildResultRepository(workingDir.resolve(RESULTS_FILE)),
artifactStore = FileArtifactStore(configLoader, workingDir), artifactStore = FileArtifactStore(configLoader, workingDir),
) )
}
companion object { companion object {
/** Results file relative to the repository, next to the machine config in `.git/werkator/`. */ /** Results file relative to the repository, next to the machine config in `.git/werkator/`. */
@@ -0,0 +1,37 @@
package de.hoennig.werkator.repo
import org.springframework.stereotype.Component
/**
* The path prefix a link to a repository carries (ADR 0009). One place knows the
* rule, because three do the same thing with it: the pages, the API answers, and
* the target URLs posted to Gitea — and a rule spelled out three times is a rule
* that drifts.
*
* It follows the NUMBER of served repositories, not the route a request arrived
* through: with one repository an installation keeps the URLs it always had, with
* several every link names its repository.
*/
@Component
class RepoLinks(
private val registry: RepoRegistry,
) {
fun base(repo: RepoContext): String = if (registry.all().size > 1) "/repos/${repo.name}" else ""
fun apiBase(repo: RepoContext): String = if (registry.all().size > 1) "/api/repos/${repo.name}" else "/api"
/**
* The absolute artifact-page URL of a build, for links Werkator posts elsewhere
* (`server.publicBaseUrl`). Null without a public base URL: a relative link in a
* Gitea status is worse than none — it would resolve against the forge.
*/
fun buildUrl(
repo: RepoContext,
publicBaseUrl: String,
artifactKey: String,
): String? {
val root = publicBaseUrl.trim().trimEnd('/')
if (root.isEmpty()) return null
return "$root${base(repo)}/builds/$artifactKey"
}
}
@@ -0,0 +1,100 @@
package de.hoennig.werkator.repo
import de.hoennig.werkator.config.ConfigException
import de.hoennig.werkator.config.ConfigLoader
import de.hoennig.werkator.config.RepositoryEntry
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
/**
* The repositories this instance serves (ADR 0009): one [RepoContext] per entry of the
* home configuration's `repositories`, or — without a home config or with an empty
* registry — the current working directory, exactly as before.
*
* Opened once, on first use, and loudly: an entry that is no git repository or a name
* used twice aborts the start with a message naming the home file, because an instance
* serving the wrong set is worse than one that does not come up. A repository whose
* configuration Werkator must not read (version or format violation) is the exception:
* it is skipped with an error, like a branch config violation fails only that branch —
* the other repositories keep building.
*/
@Component
class RepoRegistry(
private val configLoader: ConfigLoader,
private val repoContexts: RepoContexts,
) {
private val log = LoggerFactory.getLogger(RepoRegistry::class.java)
private val contexts: List<RepoContext> by lazy { open() }
/** Every served repository, in registry order. */
fun all(): List<RepoContext> = contexts
/** The repository registered under [name], or null. */
fun byName(name: String): RepoContext? = contexts.firstOrNull { it.name == name }
/**
* The repository a command without a selector means: the current working directory
* when it is served (so `werkator status` inside a repository behaves as today),
* otherwise the first registered one.
*/
fun current(): RepoContext {
val cwd = Paths.get(".").toAbsolutePath().normalize()
return contexts.firstOrNull { it.workingDir.toAbsolutePath().normalize() == cwd } ?: contexts.first()
}
private fun open(): List<RepoContext> {
val entries = configLoader.loadInstance()?.repositories.orEmpty()
if (entries.isEmpty()) {
return listOf(repoContexts.open(Paths.get(".")))
}
val home = configLoader.instanceFile()
val opened = entries.mapNotNull { openEntry(it, home) }
val duplicates = opened.groupBy { it.name }.filterValues { it.size > 1 }
if (duplicates.isNotEmpty()) {
val listed =
duplicates.entries.joinToString(
"; ",
) { (name, repos) -> "$name: ${repos.joinToString(", ") { it.workingDir.toString() }}" }
throw IllegalStateException("$home registers the same repository name more than once ($listed); set a distinct name per entry")
}
check(opened.isNotEmpty()) { "$home registers no readable repository" }
return opened
}
private fun openEntry(
entry: RepositoryEntry,
home: Path,
): RepoContext? {
val dir = resolve(entry.path)
if (!Files.isDirectory(dir) || !Files.exists(dir.resolve(".git"))) {
throw IllegalStateException(
"$home registers ${entry.path.ifBlank { "an entry without a path" }}, which is not a git repository ($dir)",
)
}
val name = entry.name.trim().ifEmpty { RepoContexts.defaultName(dir) }
try {
// the configuration is read here only to find out whether Werkator may read it at all
configLoader.load(dir)
} catch (e: ConfigException) {
log.error("not serving repository {} ({}): {}", name, dir, e.message)
return null
}
return repoContexts.open(dir, name)
}
/** `~` expands to the home directory; a relative path is relative to the home directory, not the cwd. */
private fun resolve(path: String): Path {
val home = configLoader.homeDir
val expanded =
when {
path == "~" -> home
path.startsWith("~/") -> home.resolve(path.removePrefix("~/"))
else -> home.resolve(path)
}
return expanded.toAbsolutePath().normalize()
}
}
@@ -27,6 +27,7 @@ data class BuildResultDto(
fun from( fun from(
result: BuildResult, result: BuildResult,
isLatestGreen: Boolean = false, isLatestGreen: Boolean = false,
base: String = "",
) = BuildResultDto( ) = BuildResultDto(
branch = result.branch, branch = result.branch,
name = result.name, name = result.name,
@@ -36,7 +37,7 @@ data class BuildResultDto(
runningSince = result.runningSince, runningSince = result.runningSince,
durationSeconds = result.duration?.seconds, durationSeconds = result.duration?.seconds,
artifactKey = result.artifactKey, artifactKey = result.artifactKey,
latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.name) else null, latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.name, base) else null,
) )
} }
} }
@@ -1,6 +1,7 @@
package de.hoennig.werkator.server package de.hoennig.werkator.server
import de.hoennig.werkator.build.ArtifactStore import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.springframework.core.io.FileSystemResource import org.springframework.core.io.FileSystemResource
import org.springframework.core.io.Resource import org.springframework.core.io.Resource
@@ -25,17 +26,22 @@ import kotlin.streams.asSequence
*/ */
@RestController @RestController
class ArtifactFileController( class ArtifactFileController(
private val artifactStore: ArtifactStore,
private val branchPermalinks: BranchPermalinks, private val branchPermalinks: BranchPermalinks,
private val registry: RepoRegistry,
) { ) {
@GetMapping("/artifacts/{artifactKey}/{*path}") /** Scoped and unscoped, like every other route (ADR 0009); unscoped means the served repository. */
private fun repoOf(name: String?): RepoContext =
if (name == null) registry.current() else registry.byName(name) ?: throw UnknownRepositoryException(name)
@GetMapping("/artifacts/{artifactKey}/{*path}", "/repos/{repo}/artifacts/{artifactKey}/{*path}")
fun serve( fun serve(
@PathVariable(name = "repo", required = false) repoName: String?,
@PathVariable artifactKey: String, @PathVariable artifactKey: String,
@PathVariable path: String, @PathVariable path: String,
request: HttpServletRequest, request: HttpServletRequest,
): ResponseEntity<Resource> { ): ResponseEntity<Resource> {
val artifactDir = val artifactDir =
artifactStore.artifactDir(artifactKey) repoOf(repoName).artifactStore.artifactDir(artifactKey)
?: return ResponseEntity.notFound().build() ?: return ResponseEntity.notFound().build()
val relativePath = path.removePrefix("/").removeSuffix("/") val relativePath = path.removePrefix("/").removeSuffix("/")
directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it } directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it }
@@ -52,15 +58,17 @@ class ArtifactFileController(
* slash, so relative links inside reports resolve correctly), and everything is * slash, so relative links inside reports resolve correctly), and everything is
* `no-store` because the content behind a URL changes with every new green build. * `no-store` because the content behind a URL changes with every new green build.
*/ */
@GetMapping("/branches/{branchKey}/{*path}") @GetMapping("/branches/{branchKey}/{*path}", "/repos/{repo}/branches/{branchKey}/{*path}")
fun serveLatestGreen( fun serveLatestGreen(
@PathVariable(name = "repo", required = false) repoName: String?,
@PathVariable branchKey: String, @PathVariable branchKey: String,
@PathVariable path: String, @PathVariable path: String,
request: HttpServletRequest, request: HttpServletRequest,
): ResponseEntity<Resource> { ): ResponseEntity<Resource> {
val build = branchPermalinks.latestGreenBuild(branchKey) val repo = repoOf(repoName)
val build = branchPermalinks.latestGreenBuild(repo, branchKey)
val artifactDir = val artifactDir =
artifactStore.artifactDir(build.artifactKey) repo.artifactStore.artifactDir(build.artifactKey)
?: throw ResponseStatusException( ?: throw ResponseStatusException(
HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND,
"the artifacts of build '${build.artifactKey}' are not stored anymore", "the artifacts of build '${build.artifactKey}' are not stored anymore",
@@ -17,7 +17,10 @@ import org.springframework.stereotype.Component
class BranchListing( class BranchListing(
private val gitService: GitService, private val gitService: GitService,
) { ) {
fun branches(repo: RepoContext): List<BranchDto> { fun branches(
repo: RepoContext,
base: String = "",
): List<BranchDto> {
val repository = repo.results val repository = repo.results
val heads = gitService.originBranchHeads(repo.workingDir) val heads = gitService.originBranchHeads(repo.workingDir)
val namedResults = repository.latestPerName().filter { it.name != it.branch && it.branch in heads } val namedResults = repository.latestPerName().filter { it.name != it.branch && it.branch in heads }
@@ -45,7 +48,7 @@ class BranchListing(
// the permanent link belongs to the build it resolves to, not to every build of the name // the permanent link belongs to the build it resolves to, not to every build of the name
val isLatestGreen = val isLatestGreen =
row.artifactKey.isNotEmpty() && row.artifactKey == repository.latestGreenFor(row.name)?.artifactKey row.artifactKey.isNotEmpty() && row.artifactKey == repository.latestGreenFor(row.name)?.artifactKey
if (isLatestGreen) row.copy(latestGreenUrl = BranchPermalinks.permanentUrl(row.name)) else row if (isLatestGreen) row.copy(latestGreenUrl = BranchPermalinks.permanentUrl(row.name, base)) else row
} }
} }
@@ -2,7 +2,7 @@ package de.hoennig.werkator.server
import de.hoennig.werkator.build.ArtifactKeys import de.hoennig.werkator.build.ArtifactKeys
import de.hoennig.werkator.build.BuildResult import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository import de.hoennig.werkator.repo.RepoContext
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
@@ -18,10 +18,12 @@ import org.springframework.web.server.ResponseStatusException
* artifacts. * artifacts.
*/ */
@Component @Component
class BranchPermalinks( class BranchPermalinks {
private val repository: BuildResultRepository, fun latestGreenBuild(
) { repo: RepoContext,
fun latestGreenBuild(branchKey: String): BuildResult { branchKey: String,
): BuildResult {
val repository = repo.results
val names = val names =
repository repository
.latestPerName() .latestPerName()
@@ -41,7 +43,16 @@ class BranchPermalinks(
} }
companion object { companion object {
/** The permanent artifact-index URL of the build name (branch or named slot), shown in the branches view. */ /**
fun permanentUrl(name: String): String = "/branches/${ArtifactKeys.permanentBranchKey(name)}" * The permanent artifact-index URL of the build name (branch or named slot), shown
* in the branches view. [base] is the repository prefix (`/repos/<name>`, empty with
* one served repository): the key is a hash of the name alone, so two repositories
* both having `main` would otherwise share one permanent URL — and it would resolve
* against whichever repository the instance happens to serve unscoped.
*/
fun permanentUrl(
name: String,
base: String = "",
): String = "$base/branches/${ArtifactKeys.permanentBranchKey(name)}"
} }
} }
@@ -1,16 +1,17 @@
package de.hoennig.werkator.server package de.hoennig.werkator.server
import de.hoennig.werkator.build.ArtifactStore
import de.hoennig.werkator.build.BuildExecutor import de.hoennig.werkator.build.BuildExecutor
import de.hoennig.werkator.build.BuildResult import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.config.BuildDefinition import de.hoennig.werkator.config.BuildDefinition
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoLinks
import de.hoennig.werkator.repo.RepoRegistry
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
@@ -32,34 +33,72 @@ import java.nio.file.StandardOpenOption
*/ */
@RestController @RestController
class BuildsApiController( class BuildsApiController(
private val repository: BuildResultRepository,
private val buildExecutor: BuildExecutor, private val buildExecutor: BuildExecutor,
private val artifactStore: ArtifactStore,
private val controlTokens: ControlTokenService, private val controlTokens: ControlTokenService,
private val gitService: GitService, private val gitService: GitService,
private val branchListing: BranchListing, private val branchListing: BranchListing,
private val repo: RepoContext, private val registry: RepoRegistry,
private val repoLinks: RepoLinks,
) { ) {
private val workingDir: Path /**
get() = repo.workingDir * Every route exists twice: repository-scoped (`/api/repos/<name>/…`) and unscoped.
* The unscoped form means the served repository ([RepoRegistry.current]) and stays
* for good — bookmarks, the legacy UI, and the links already posted to Gitea were
* written without a repository segment, and a CI that breaks its own old links is
* a CI nobody trusts.
*/
private fun repoOf(name: String?): RepoContext =
if (name == null) registry.current() else registry.byName(name) ?: throw UnknownRepositoryException(name)
@GetMapping("/api/builds/latest") private fun RepoContext.isLatestGreen(result: BuildResult): Boolean =
fun latest(): List<BuildResultDto> = repository.latestPerName().map { BuildResultDto.from(it, it.isLatestGreen()) } results.latestGreenFor(result.name)?.artifactKey == result.artifactKey
/** The prefix the permanent links in the answers carry; empty with one served repository. */
private fun uiBase(repo: RepoContext): String = repoLinks.base(repo)
/** An unknown repository name answers like every other miss of this API: 404 with `error`. */
@ExceptionHandler(UnknownRepositoryException::class)
fun unknownRepository(e: UnknownRepositoryException): ResponseEntity<Any> = notFound(e.message ?: "unknown repository")
@GetMapping("/api/builds/latest", "/api/repos/{repo}/builds/latest")
fun latest(
@PathVariable(name = "repo", required = false) repoName: String?,
): List<BuildResultDto> {
val repo = repoOf(repoName)
return repo.results.latestPerName().map { BuildResultDto.from(it, repo.isLatestGreen(it), uiBase(repo)) }
}
/** The legacy branches view: every origin branch with its latest build or `unknown`. */ /** The legacy branches view: every origin branch with its latest build or `unknown`. */
@GetMapping("/api/branches") @GetMapping("/api/branches", "/api/repos/{repo}/branches")
fun branches(): List<BranchDto> = branchListing.branches(repo) fun branches(
@PathVariable(name = "repo", required = false) repoName: String?,
): List<BranchDto> {
val repo = repoOf(repoName)
return branchListing.branches(repo, uiBase(repo))
}
@GetMapping("/api/builds/history") @GetMapping("/api/builds/history", "/api/repos/{repo}/builds/history")
fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it, it.isLatestGreen()) } fun history(
@PathVariable(name = "repo", required = false) repoName: String?,
): List<BuildResultDto> {
val repo = repoOf(repoName)
return repo.results.history().map { BuildResultDto.from(it, repo.isLatestGreen(it), uiBase(repo)) }
}
private fun BuildResult.isLatestGreen(): Boolean = repository.latestGreenFor(name)?.artifactKey == artifactKey /**
* The currently executing builds of the served repository — several are possible,
/** The currently executing builds — several are possible, up to `executor.maxConcurrent`. */ * up to `executor.maxConcurrent`. The executor is instance-global and returns the
@GetMapping("/api/builds/current") * builds of every registered repository, so this view filters: its [repository]
fun current(): List<CurrentBuildDto> { * holds only this repository's results, and a foreign build looked up in them
val results = repository.history() * would fall back to RUNNING and show a status nobody recorded.
return buildExecutor.currentBuilds().map { build -> */
@GetMapping("/api/builds/current", "/api/repos/{repo}/builds/current")
fun current(
@PathVariable(name = "repo", required = false) repoName: String?,
): List<CurrentBuildDto> {
val repo = repoOf(repoName)
val results = repo.results.history()
return buildExecutor.currentBuilds().filter { it.repo === repo }.map { build ->
CurrentBuildDto( CurrentBuildDto(
branch = build.branch, branch = build.branch,
name = build.name, name = build.name,
@@ -76,13 +115,15 @@ class BuildsApiController(
} }
/** Incremental live-log fetch of one running build; poll again with `offset = nextOffset`. */ /** Incremental live-log fetch of one running build; poll again with `offset = nextOffset`. */
@GetMapping("/api/builds/current/{artifactKey}/log") @GetMapping("/api/builds/current/{artifactKey}/log", "/api/repos/{repo}/builds/current/{artifactKey}/log")
fun currentLog( fun currentLog(
@PathVariable(name = "repo", required = false) repoName: String?,
@PathVariable artifactKey: String, @PathVariable artifactKey: String,
@RequestParam(defaultValue = "0") offset: Long, @RequestParam(defaultValue = "0") offset: Long,
): ResponseEntity<Any> { ): ResponseEntity<Any> {
val repo = repoOf(repoName)
val build = val build =
buildExecutor.currentBuilds().firstOrNull { it.artifactKey == artifactKey } buildExecutor.currentBuilds().firstOrNull { it.repo === repo && it.artifactKey == artifactKey }
?: return notFound("no running build with artifact key '$artifactKey'") ?: return notFound("no running build with artifact key '$artifactKey'")
return ResponseEntity.ok(readLogTail(artifactKey, build.liveLogFile, offset)) return ResponseEntity.ok(readLogTail(artifactKey, build.liveLogFile, offset))
} }
@@ -102,14 +143,17 @@ class BuildsApiController(
* The name is a parameter, not a path variable, because branch names may contain * The name is a parameter, not a path variable, because branch names may contain
* slashes (Tomcat rejects encoded slashes in the path by default). * slashes (Tomcat rejects encoded slashes in the path by default).
*/ */
@PostMapping("/api/builds/restart") @PostMapping("/api/builds/restart", "/api/repos/{repo}/builds/restart")
fun restart( fun restart(
@PathVariable(name = "repo", required = false) repoName: String?,
@RequestParam branch: String, @RequestParam branch: String,
@RequestParam(defaultValue = "false") atOriginHead: Boolean, @RequestParam(defaultValue = "false") atOriginHead: Boolean,
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?, @RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
): ResponseEntity<Any> { ): ResponseEntity<Any> {
rejectBadToken(headerToken)?.let { return it } rejectBadToken(headerToken)?.let { return it }
val latest = repository.latestFor(branch) val repo = repoOf(repoName)
val workingDir = repo.workingDir
val latest = repo.results.latestFor(branch)
// the name may be a pool like `main@pitest`; the branch to build is the recorded one // the name may be a pool like `main@pitest`; the branch to build is the recorded one
val branchName = latest?.branch ?: branch val branchName = latest?.branch ?: branch
val commit = val commit =
@@ -143,12 +187,20 @@ class BuildsApiController(
} }
/** Cancels by artifact key because multiple builds can run concurrently. */ /** Cancels by artifact key because multiple builds can run concurrently. */
@PostMapping("/api/builds/{artifactKey}/cancel") @PostMapping("/api/builds/{artifactKey}/cancel", "/api/repos/{repo}/builds/{artifactKey}/cancel")
fun cancel( fun cancel(
@PathVariable(name = "repo", required = false) repoName: String?,
@PathVariable artifactKey: String, @PathVariable artifactKey: String,
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?, @RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
): ResponseEntity<Any> { ): ResponseEntity<Any> {
rejectBadToken(headerToken)?.let { return it } rejectBadToken(headerToken)?.let { return it }
val repo = repoOf(repoName)
// the executor cancels by key across all repositories; a route that names a
// repository must not reach into another one, and a queued or running build
// always has its PENDING/RUNNING result recorded in its own repository
if (repo.results.history().none { it.artifactKey == artifactKey }) {
return notFound("no queued or running build with artifact key '$artifactKey'")
}
if (!buildExecutor.cancel(artifactKey)) { if (!buildExecutor.cancel(artifactKey)) {
return notFound("no queued or running build with artifact key '$artifactKey'") return notFound("no queued or running build with artifact key '$artifactKey'")
} }
@@ -156,16 +208,18 @@ class BuildsApiController(
} }
/** Removes the stored result and its artifact directory, like the legacy `/control/delete`. */ /** Removes the stored result and its artifact directory, like the legacy `/control/delete`. */
@DeleteMapping("/api/builds/{artifactKey}") @DeleteMapping("/api/builds/{artifactKey}", "/api/repos/{repo}/builds/{artifactKey}")
fun delete( fun delete(
@PathVariable(name = "repo", required = false) repoName: String?,
@PathVariable artifactKey: String, @PathVariable artifactKey: String,
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?, @RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
): ResponseEntity<Any> { ): ResponseEntity<Any> {
rejectBadToken(headerToken)?.let { return it } rejectBadToken(headerToken)?.let { return it }
if (!repository.delete(artifactKey)) { val repo = repoOf(repoName)
if (!repo.results.delete(artifactKey)) {
return notFound("no build with artifact key '$artifactKey'") return notFound("no build with artifact key '$artifactKey'")
} }
artifactStore.prune(repository.history()) repo.artifactStore.prune(repo.results.history())
return ResponseEntity.ok(mapOf("deleted" to artifactKey)) return ResponseEntity.ok(mapOf("deleted" to artifactKey))
} }
@@ -1,6 +1,6 @@
package de.hoennig.werkator.server package de.hoennig.werkator.server
import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoRegistry
import de.hoennig.werkator.watcher.Watcher import de.hoennig.werkator.watcher.Watcher
import jakarta.annotation.PreDestroy import jakarta.annotation.PreDestroy
import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.boot.context.event.ApplicationReadyEvent
@@ -9,7 +9,7 @@ import org.springframework.context.event.EventListener
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
/** /**
* Starts the watcher poll loop over the served repository once the server context * Starts the watcher poll loop over the served repositories once the server context
* is ready and stops it on shutdown. Only in the `server` profile CLI commands * is ready and stops it on shutdown. Only in the `server` profile CLI commands
* and tests never start the loop (see [Watcher]). * and tests never start the loop (see [Watcher]).
*/ */
@@ -17,11 +17,11 @@ import org.springframework.stereotype.Component
@Profile("server") @Profile("server")
class ServerWatcherLifecycle( class ServerWatcherLifecycle(
private val watcher: Watcher, private val watcher: Watcher,
private val repo: RepoContext, private val registry: RepoRegistry,
) { ) {
@EventListener(ApplicationReadyEvent::class) @EventListener(ApplicationReadyEvent::class)
fun onApplicationReady() { fun onApplicationReady() {
watcher.start(repo) watcher.start(registry.all())
} }
@PreDestroy @PreDestroy
@@ -1,21 +1,23 @@
package de.hoennig.werkator.server package de.hoennig.werkator.server
import de.hoennig.werkator.build.ArtifactStore
import de.hoennig.werkator.build.BuildExecutor import de.hoennig.werkator.build.BuildExecutor
import de.hoennig.werkator.build.BuildResult import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.config.ConfigFiles import de.hoennig.werkator.config.ConfigFiles
import de.hoennig.werkator.config.ConfigLoader import de.hoennig.werkator.config.ConfigLoader
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.metrics.SystemMetricsCollector import de.hoennig.werkator.metrics.SystemMetricsCollector
import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoLinks
import de.hoennig.werkator.repo.RepoRegistry
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.springframework.beans.factory.ObjectProvider import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.info.BuildProperties import org.springframework.boot.info.BuildProperties
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller import org.springframework.stereotype.Controller
import org.springframework.ui.Model import org.springframework.ui.Model
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
@@ -34,19 +36,32 @@ import kotlin.streams.asSequence
*/ */
@Controller @Controller
class UiController( class UiController(
private val repository: BuildResultRepository,
private val buildExecutor: BuildExecutor, private val buildExecutor: BuildExecutor,
private val artifactStore: ArtifactStore,
private val configLoader: ConfigLoader, private val configLoader: ConfigLoader,
private val gitService: GitService, private val gitService: GitService,
private val metricsCollector: SystemMetricsCollector, private val metricsCollector: SystemMetricsCollector,
private val branchListing: BranchListing, private val branchListing: BranchListing,
private val branchPermalinks: BranchPermalinks, private val branchPermalinks: BranchPermalinks,
private val buildProperties: ObjectProvider<BuildProperties>, private val buildProperties: ObjectProvider<BuildProperties>,
private val repo: RepoContext, private val registry: RepoRegistry,
private val repoLinks: RepoLinks,
) { ) {
private val workingDir: Path /**
get() = repo.workingDir * Every page exists twice, like the API (ADR 0009): repository-scoped under
* `/repos/<name>/` and unscoped, which means the served repository. Pages stay
* per repository instead of merging every repository's rows into one table with a
* repository column: a row's actions (restart, cancel, delete) need the repository
* anyway, branches come from one origin and artifacts from one store and with
* the one repository that most installations have, such a column is pure noise.
* What makes the instance one UI is the repository switcher in the navigation.
*/
private fun repoOf(name: String?): RepoContext =
if (name == null) registry.current() else registry.byName(name) ?: throw UnknownRepositoryException(name)
/** An unknown repository name is a 404 page, not a server error. */
@ExceptionHandler(UnknownRepositoryException::class)
fun unknownRepository(e: UnknownRepositoryException): ResponseEntity<String> =
ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.message)
/** /**
* Permanent redirects for the legacy script's static page names, so bookmarks * Permanent redirects for the legacy script's static page names, so bookmarks
@@ -58,11 +73,15 @@ class UiController(
setStatusCode(HttpStatus.MOVED_PERMANENTLY) setStatusCode(HttpStatus.MOVED_PERMANENTLY)
} }
@GetMapping("/") @GetMapping("/", "/repos/{repo}")
fun latest(model: Model): String { fun latest(
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds") @PathVariable(name = "repo", required = false) repoName: String?,
model.addAttribute("rows", repository.latestPerName().map { BuildRowView.from(it, links, permanentUrlOf(it)) }) model: Model,
model.addAttribute("apiPath", "/api/builds/latest") ): String {
val repo = repoOf(repoName)
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds", repo = repo)
model.addAttribute("rows", repo.results.latestPerName().map { BuildRowView.from(it, links, permanentUrlOf(repo, it)) })
model.addAttribute("apiPath", apiBase(repo) + "/builds/latest")
model.addAttribute("allowRestart", true) model.addAttribute("allowRestart", true)
model.addAttribute("restartAtOriginHead", false) model.addAttribute("restartAtOriginHead", false)
model.addAttribute("emptyMessage", "No builds recorded yet.") model.addAttribute("emptyMessage", "No builds recorded yet.")
@@ -70,11 +89,15 @@ class UiController(
} }
/** The legacy branches view: every origin branch with its latest build or an `unknown` row. */ /** The legacy branches view: every origin branch with its latest build or an `unknown` row. */
@GetMapping("/branches") @GetMapping("/branches", "/repos/{repo}/branches")
fun branches(model: Model): String { fun branches(
val links = baseModel(model, view = "branches", pageTitle = "Branches") @PathVariable(name = "repo", required = false) repoName: String?,
model.addAttribute("rows", branchListing.branches(repo).map { BuildRowView.from(it, links) }) model: Model,
model.addAttribute("apiPath", "/api/branches") ): String {
val repo = repoOf(repoName)
val links = baseModel(model, view = "branches", pageTitle = "Branches", repo = repo)
model.addAttribute("rows", branchListing.branches(repo, uiBase(repo)).map { BuildRowView.from(it, links) })
model.addAttribute("apiPath", apiBase(repo) + "/branches")
model.addAttribute("allowRestart", true) model.addAttribute("allowRestart", true)
// a row here stands for a branch, not for a past run // a row here stands for a branch, not for a past run
model.addAttribute("restartAtOriginHead", true) model.addAttribute("restartAtOriginHead", true)
@@ -82,11 +105,15 @@ class UiController(
return "builds" return "builds"
} }
@GetMapping("/history") @GetMapping("/history", "/repos/{repo}/history")
fun history(model: Model): String { fun history(
val links = baseModel(model, view = "history", pageTitle = "Build History") @PathVariable(name = "repo", required = false) repoName: String?,
model.addAttribute("rows", repository.history().map { BuildRowView.from(it, links, permanentUrlOf(it)) }) model: Model,
model.addAttribute("apiPath", "/api/builds/history") ): String {
val repo = repoOf(repoName)
val links = baseModel(model, view = "history", pageTitle = "Build History", repo = repo)
model.addAttribute("rows", repo.results.history().map { BuildRowView.from(it, links, permanentUrlOf(repo, it)) })
model.addAttribute("apiPath", apiBase(repo) + "/builds/history")
model.addAttribute("allowRestart", false) model.addAttribute("allowRestart", false)
model.addAttribute("restartAtOriginHead", false) model.addAttribute("restartAtOriginHead", false)
model.addAttribute("emptyMessage", "No builds archived yet.") model.addAttribute("emptyMessage", "No builds archived yet.")
@@ -94,19 +121,26 @@ class UiController(
} }
/** The permanent branch URL belongs to the build it resolves to — the name's latest green build. */ /** The permanent branch URL belongs to the build it resolves to — the name's latest green build. */
private fun permanentUrlOf(result: BuildResult): String? = private fun permanentUrlOf(
if (repository.latestGreenFor(result.name)?.artifactKey == result.artifactKey) { repo: RepoContext,
BranchPermalinks.permanentUrl(result.name) result: BuildResult,
): String? =
if (repo.results.latestGreenFor(result.name)?.artifactKey == result.artifactKey) {
BranchPermalinks.permanentUrl(result.name, uiBase(repo))
} else { } else {
null null
} }
@GetMapping("/current") @GetMapping("/current", "/repos/{repo}/current")
fun current(model: Model): String { fun current(
val links = baseModel(model, view = "current", pageTitle = "Current Builds") @PathVariable(name = "repo", required = false) repoName: String?,
val results = repository.history() model: Model,
): String {
val repo = repoOf(repoName)
val links = baseModel(model, view = "current", pageTitle = "Current Builds", repo = repo)
val results = repo.results.history()
val currentBuilds = val currentBuilds =
buildExecutor.currentBuilds().map { build -> buildExecutor.currentBuilds().filter { it.repo === repo }.map { build ->
CurrentBuildView( CurrentBuildView(
branch = build.branch, branch = build.branch,
name = build.name, name = build.name,
@@ -130,35 +164,39 @@ class UiController(
/** Hand-maintained release notes (templates/releases.html); linked from the version in the footer. */ /** Hand-maintained release notes (templates/releases.html); linked from the version in the footer. */
@GetMapping("/releases") @GetMapping("/releases")
fun releases(model: Model): String { fun releases(model: Model): String {
baseModel(model, view = "releases", pageTitle = "Release Notes") baseModel(model, view = "releases", pageTitle = "Release Notes", repo = registry.current())
return "releases" return "releases"
} }
/** One metrics page for the whole instance — the resources are the instance's, not a repository's. */
@GetMapping("/system") @GetMapping("/system")
fun system(model: Model): String { fun system(model: Model): String {
baseModel(model, view = "system", pageTitle = "System Metrics") baseModel(model, view = "system", pageTitle = "System Metrics", repo = registry.current())
model.addAttribute("metrics", SystemMetricsView.from(metricsCollector.snapshot())) model.addAttribute("metrics", SystemMetricsView.from(metricsCollector.snapshot()))
return "system" return "system"
} }
/** Artifact index rendered from the artifact store — legacy pre-generated this page as static HTML. */ /** Artifact index rendered from the artifact store — legacy pre-generated this page as static HTML. */
@GetMapping("/builds/{artifactKey}") @GetMapping("/builds/{artifactKey}", "/repos/{repo}/builds/{artifactKey}")
fun artifactIndex( fun artifactIndex(
@PathVariable(name = "repo", required = false) repoName: String?,
@PathVariable artifactKey: String, @PathVariable artifactKey: String,
model: Model, model: Model,
): String { ): String {
val result = repository.history().firstOrNull { it.artifactKey == artifactKey } val repo = repoOf(repoName)
val artifactDir = artifactStore.artifactDir(artifactKey) val result = repo.results.history().firstOrNull { it.artifactKey == artifactKey }
val artifactDir = repo.artifactStore.artifactDir(artifactKey)
if (result == null && artifactDir == null) { if (result == null && artifactDir == null) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "no build with artifact key '$artifactKey'") throw ResponseStatusException(HttpStatus.NOT_FOUND, "no build with artifact key '$artifactKey'")
} }
return artifactIndexView( return artifactIndexView(
model, model,
pageTitle = "Build Artifacts", pageTitle = "Build Artifacts",
repo = repo,
result = result, result = result,
artifactKey = artifactKey, artifactKey = artifactKey,
artifactDir = artifactDir, artifactDir = artifactDir,
filesBase = "/artifacts/$artifactKey", filesBase = uiBase(repo) + "/artifacts/$artifactKey",
) )
} }
@@ -167,38 +205,42 @@ class UiController(
* stay on the permanent `/branches/` paths, so every link copied from this page * stay on the permanent `/branches/` paths, so every link copied from this page
* outlives artifact pruning. * outlives artifact pruning.
*/ */
@GetMapping("/branches/{branchKey}") @GetMapping("/branches/{branchKey}", "/repos/{repo}/branches/{branchKey}")
fun latestGreenArtifactIndex( fun latestGreenArtifactIndex(
@PathVariable(name = "repo", required = false) repoName: String?,
@PathVariable branchKey: String, @PathVariable branchKey: String,
model: Model, model: Model,
): String { ): String {
val build = branchPermalinks.latestGreenBuild(branchKey) val repo = repoOf(repoName)
val build = branchPermalinks.latestGreenBuild(repo, branchKey)
model.addAttribute("permanentBranch", build.branch) model.addAttribute("permanentBranch", build.branch)
model.addAttribute("concreteUrl", "/builds/${build.artifactKey}") model.addAttribute("concreteUrl", uiBase(repo) + "/builds/${build.artifactKey}")
return artifactIndexView( return artifactIndexView(
model, model,
pageTitle = "Latest Green Build", pageTitle = "Latest Green Build",
repo = repo,
result = build, result = build,
artifactKey = build.artifactKey, artifactKey = build.artifactKey,
artifactDir = artifactStore.artifactDir(build.artifactKey), artifactDir = repo.artifactStore.artifactDir(build.artifactKey),
filesBase = "/branches/$branchKey", filesBase = uiBase(repo) + "/branches/$branchKey",
) )
} }
private fun artifactIndexView( private fun artifactIndexView(
model: Model, model: Model,
pageTitle: String, pageTitle: String,
repo: RepoContext,
result: BuildResult?, result: BuildResult?,
artifactKey: String, artifactKey: String,
artifactDir: Path?, artifactDir: Path?,
filesBase: String, filesBase: String,
): String { ): String {
val links = baseModel(model, view = "artifact", pageTitle = pageTitle) val links = baseModel(model, view = "artifact", pageTitle = pageTitle, repo = repo)
model.addAttribute("artifactKey", artifactKey) model.addAttribute("artifactKey", artifactKey)
model.addAttribute("filesBase", filesBase) model.addAttribute("filesBase", filesBase)
model.addAttribute("result", result?.let { BuildRowView.from(it, links) }) model.addAttribute("result", result?.let { BuildRowView.from(it, links) })
model.addAttribute("hasArtifacts", artifactDir != null) model.addAttribute("hasArtifacts", artifactDir != null)
model.addAttribute("buildCommand", result?.let { buildCommandOf(it) }) model.addAttribute("buildCommand", result?.let { buildCommandOf(repo, it) })
model.addAttribute( model.addAttribute(
"logs", "logs",
artifactDir?.let { logFiles(it, scanForFailure = result != null && result.status != BuildStatus.SUCCESS) } artifactDir?.let { logFiles(it, scanForFailure = result != null && result.status != BuildStatus.SUCCESS) }
@@ -226,21 +268,41 @@ class UiController(
.toList() .toList()
} }
/**
* The prefix every in-page link and API path is built from. It follows the number
* of served repositories, not the route the page was reached through: with one
* repository the installation keeps its existing URLs (the session-D acceptance
* criterion), with several every link names its repository.
*/
private fun uiBase(repo: RepoContext): String = repoLinks.base(repo)
private fun apiBase(repo: RepoContext): String = repoLinks.apiBase(repo)
/** Adds the attributes every page needs and returns the Gitea link helper for row building. */ /** Adds the attributes every page needs and returns the Gitea link helper for row building. */
private fun baseModel( private fun baseModel(
model: Model, model: Model,
view: String, view: String,
pageTitle: String, pageTitle: String,
repo: RepoContext,
): GiteaWebLinks { ): GiteaWebLinks {
val config = configLoader.load(workingDir) val config = configLoader.load(repo.workingDir)
val links = GiteaWebLinks(config.gitea) val links = GiteaWebLinks(config.gitea)
val repoName = val repoName =
listOf(config.gitea.owner.trim(), config.gitea.repo.trim()) listOf(config.gitea.owner.trim(), config.gitea.repo.trim())
.filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
.joinToString("/") .joinToString("/")
val served = registry.all()
model.addAttribute("view", view) model.addAttribute("view", view)
model.addAttribute("pageTitle", pageTitle) model.addAttribute("pageTitle", pageTitle)
model.addAttribute("repoName", repoName) model.addAttribute("repoName", repoName)
// every in-page link is built from this prefix, so a scoped page stays scoped
model.addAttribute("repoBase", uiBase(repo))
model.addAttribute("homeUrl", uiBase(repo).ifEmpty { "/" })
model.addAttribute("apiBase", apiBase(repo))
model.addAttribute("repoKey", repo.name)
// the switcher is what makes several repositories one UI; with one there is nothing to switch
model.addAttribute("multiRepo", served.size > 1)
model.addAttribute("repos", served.map { RepoLinkView(name = it.name, url = "/repos/${it.name}", current = it === repo) })
model.addAttribute("version", buildProperties.getIfAvailable()?.version ?: "dev") model.addAttribute("version", buildProperties.getIfAvailable()?.version ?: "dev")
model.addAttribute("impressumUrl", config.server.impressumUrl.trim()) model.addAttribute("impressumUrl", config.server.impressumUrl.trim())
model.addAttribute("giteaRepoUrl", links.repoUrl ?: "") model.addAttribute("giteaRepoUrl", links.repoUrl ?: "")
@@ -254,7 +316,11 @@ class UiController(
* build of this pool ever ran the branch and its job usually override it. * build of this pool ever ran the branch and its job usually override it.
* The command used by a past run is not persisted, so this is the current answer. * The command used by a past run is not persisted, so this is the current answer.
*/ */
private fun buildCommandOf(result: BuildResult): String { private fun buildCommandOf(
repo: RepoContext,
result: BuildResult,
): String {
val workingDir = repo.workingDir
val config = val config =
try { try {
configLoader.loadWithBranchLayer( configLoader.loadWithBranchLayer(
@@ -88,6 +88,22 @@ object UiFormats {
private const val UTILIZATION_WARN = 0.80 private const val UTILIZATION_WARN = 0.80
private const val UTILIZATION_CRIT = 0.90 private const val UTILIZATION_CRIT = 0.90
/**
* `8.00 GiB (group quota mih09, hard limit 12.00 GiB)`, `70.99 GiB (volume, tighter than the
* quotas)`, or the plain total when no quota exists `werkator.js` (`formatDiskTotal`) mirrors
* this exactly, since it renders the same field from the polled `GET /api/system`.
*/
fun diskTotal(metrics: SystemMetrics): String {
val total = metrics.diskTotalGib ?: return "n/a"
val totalText = "${metric(total)} GiB"
val source = metrics.diskSource
if (source == null || source.kind == "volume") {
return if (metrics.quotasPresent) "$totalText (volume, tighter than the quotas)" else totalText
}
val hardLimit = metric(source.hardLimitGib)
return "$totalText (${source.kind} quota ${source.subject}, hard limit $hardLimit GiB)"
}
} }
/** One row of the build tables; [latestGreenUrl] only on the build that permanent link resolves to. */ /** One row of the build tables; [latestGreenUrl] only on the build that permanent link resolves to. */
@@ -247,8 +263,15 @@ data class SystemMetricsView(
), ),
cpuCount = "${metrics.cpuCount} cores", cpuCount = "${metrics.cpuCount} cores",
ramTotal = metrics.ramTotalGib?.let { "${UiFormats.metric(it)} GiB" } ?: "n/a", ramTotal = metrics.ramTotalGib?.let { "${UiFormats.metric(it)} GiB" } ?: "n/a",
diskTotal = metrics.diskTotalGib?.let { "${UiFormats.metric(it)} GiB" } ?: "n/a", diskTotal = UiFormats.diskTotal(metrics),
updated = metrics.timestamp?.let { UiFormats.timeOfDay(it) } ?: "n/a", updated = metrics.timestamp?.let { UiFormats.timeOfDay(it) } ?: "n/a",
) )
} }
} }
/** One entry of the repository switcher in the navigation (ADR 0009). */
data class RepoLinkView(
val name: String,
val url: String,
val current: Boolean,
)
@@ -0,0 +1,11 @@
package de.hoennig.werkator.server
/**
* A route named a repository this instance does not serve (ADR 0009). Thrown by the
* repository-scoped controllers and turned into their own 404 shape by their exception
* handlers a name that is simply not registered is a miss like any other, not a
* server error.
*/
class UnknownRepositoryException(
val name: String,
) : RuntimeException("no repository named '$name'")
@@ -26,10 +26,12 @@ import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
* Replaces the legacy blocking main loop: a non-blocking fixed-delay poll cycle that * Replaces the legacy blocking main loop: a non-blocking fixed-delay poll cycle that,
* fetches origin, enqueues due branches via the async [BuildExecutor], and prunes * for every served repository, fetches origin, enqueues due branches via the async
* retention it never waits for a build and never builds in the primary checkout * [BuildExecutor], and prunes retention it never waits for a build and never builds
* (whose branch refs it does fast-forward, see [fastForwardLocalRefs]). * in the primary checkout (whose branch refs it does fast-forward, see
* [fastForwardLocalRefs]). One repository's failure never reaches another: each is
* polled in its own guard and reports on its own in [WatcherState.repositories].
* The loop only runs after an explicit [start] (server/watch mode, step 07); * The loop only runs after an explicit [start] (server/watch mode, step 07);
* nothing is scheduled during CLI commands or tests. * nothing is scheduled during CLI commands or tests.
*/ */
@@ -78,24 +80,35 @@ class Watcher(
} }
/** /**
* Runs the startup recovery and schedules the poll loop with the fixed delay * Runs the startup recovery of every repository and schedules the poll loop with the
* `watcher.pollInterval`; the first poll runs immediately. * fixed delay `watcher.pollInterval` one loop, one delay: the instance's setting,
* which every repository's effective config carries; the first poll runs immediately.
*/ */
@Synchronized @Synchronized
fun start(repo: RepoContext) { fun start(repos: List<RepoContext>) {
check(scheduler == null) { "watcher is already running" } check(scheduler == null) { "watcher is already running" }
recoverOnStartup(repo) require(repos.isNotEmpty()) { "no repository to watch" }
val interval = DurationParser.parse(configLoader.load(repo.workingDir).watcher.pollInterval) repos.forEach { recoverSafely(it) }
val interval = DurationParser.parse(configLoader.load(repos.first().workingDir).watcher.pollInterval)
scheduler = scheduler =
Executors Executors
.newSingleThreadScheduledExecutor { runnable -> .newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "werkator-watcher").apply { isDaemon = true } Thread(runnable, "werkator-watcher").apply { isDaemon = true }
}.also { }.also {
it.scheduleWithFixedDelay({ pollSafely(repo) }, 0, interval.toMillis(), TimeUnit.MILLISECONDS) it.scheduleWithFixedDelay({ pollAll(repos) }, 0, interval.toMillis(), TimeUnit.MILLISECONDS)
} }
state = state.copy(running = true) state = state.copy(running = true)
} }
/** A repository whose recovery crashes is still polled; the others' recovery is never skipped. */
private fun recoverSafely(repo: RepoContext) {
try {
recoverOnStartup(repo)
} catch (e: Exception) {
log.error("[{}] startup recovery failed", repo.name, e)
}
}
@Synchronized @Synchronized
fun stop() { fun stop() {
scheduler?.shutdownNow() scheduler?.shutdownNow()
@@ -144,31 +157,73 @@ class Watcher(
} }
} }
/** One poll cycle over a single repository; see [pollAll]. */
fun poll(repo: RepoContext) = pollAll(listOf(repo))
/** /**
* One poll cycle, never blocking on a build: fetch origin (on failure: log once per * One poll cycle over all served repositories, never blocking on a build. Each
* message, expose in [state], retry next cycle), enqueue due branches changed local branches * repository is polled in its own guard a crash or an unreachable origin is that
* first, then recent new origin branches, then due auto-build slots then * repository's report, and the next one is polled regardless and the cycle's
* fast-forward the local branch refs, and finally prune results, artifacts, and * [state] aggregates the reports: the top-level fields read as before with one
* worktrees of branches gone from origin. * repository, and name the repository in front of every message with several.
*/ */
fun poll(repo: RepoContext) { fun pollAll(repos: List<RepoContext>) {
val startedAt = clock.instant() val startedAt = clock.instant()
val reports = repos.map { pollSafely(it, startedAt) }
val several = repos.size > 1
fun named(
report: RepoWatcherState,
message: String,
): String = if (several) "${report.name}: $message" else message
state =
state.copy(
lastPollAt = startedAt,
lastFetchError = reports.mapNotNull { report -> report.lastFetchError?.let { named(report, it) } }.joinOrNull(),
lastPollError = reports.mapNotNull { report -> report.lastPollError?.let { named(report, it) } }.joinOrNull(),
queuedBranches = reports.flatMap { it.queuedBranches },
repositories = reports,
)
}
private fun List<String>.joinOrNull(): String? = takeIf { it.isNotEmpty() }?.joinToString("; ")
private fun pollSafely(
repo: RepoContext,
startedAt: Instant,
): RepoWatcherState =
try {
pollRepo(repo, startedAt)
} catch (e: Exception) {
log.error("[{}] poll cycle failed", repo.name, e)
RepoWatcherState(repo.name, lastPollAt = startedAt, lastPollError = e.message ?: e.javaClass.simpleName)
}
/**
* One repository's poll: fetch origin (on failure: log once per message, report it,
* retry next cycle), enqueue due branches changed local branches first, then recent
* new origin branches, then due auto-build slots then fast-forward the local branch
* refs, and finally prune results, artifacts, and worktrees of branches gone from origin.
*/
private fun pollRepo(
repo: RepoContext,
startedAt: Instant,
): RepoWatcherState {
val workingDir = repo.workingDir val workingDir = repo.workingDir
val watch = watchOf(repo) val watch = watchOf(repo)
try { try {
gitService.fetchOrigin(workingDir) gitService.fetchOrigin(workingDir)
if (watch.loggedFetchError != null) { if (watch.loggedFetchError != null) {
log.info("fetching origin succeeded again") log.info("[{}] fetching origin succeeded again", repo.name)
watch.loggedFetchError = null watch.loggedFetchError = null
} }
} catch (e: Exception) { } catch (e: Exception) {
val failure = e.message ?: e.javaClass.simpleName val failure = e.message ?: e.javaClass.simpleName
if (watch.loggedFetchError != failure) { if (watch.loggedFetchError != failure) {
log.warn("fetching origin failed; retrying every cycle until it succeeds: {}", failure) log.warn("[{}] fetching origin failed; retrying every cycle until it succeeds: {}", repo.name, failure)
watch.loggedFetchError = failure watch.loggedFetchError = failure
} }
state = state.copy(lastPollAt = startedAt, lastFetchError = failure) return RepoWatcherState(repo.name, lastPollAt = startedAt, lastFetchError = failure)
return
} }
val config = configLoader.load(workingDir) val config = configLoader.load(workingDir)
val originBranches = gitService.originBranches(workingDir) val originBranches = gitService.originBranches(workingDir)
@@ -177,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,57 @@
<div th:replace="~{fragments :: nav(${view})}"></div> <div th:replace="~{fragments :: nav(${view})}"></div>
<div class="panel release-notes"> <div class="panel release-notes">
<h2>v1.2.0 <span class="muted">— 2026-09-03</span></h2>
<ul>
<li>The build sandbox for hosts without Docker is configured as <code>werkdock</code> now,
not <code>bwrap</code> (PR#19), and its <code>bwrap.werkdock</code> key — which named its
own executor — is <code>werkdock.binary</code>. The old section is still read, with a
warning naming the file, so no installation has to be changed before its next
configuration edit. <code>bwrap</code> named the mechanism one layer below the tool that
actually runs it: builds have been executed by the werkdock CLI since v1.0.0.</li>
</ul>
<h2>v1.1.2 <span class="muted">— 2026-09-03</span></h2>
<ul>
<li>On a Hostsharing Managed Webspace, an <code>instance-update</code> restart no longer looks
dead while nothing is listening on the port for a moment: the generated
<code>.htaccess</code> (PR#17) now maps a refused connection to a static
"Werkator is restarting — please retry in a few minutes" page instead of Apache's default
error page. Generated only where a <code>server.publicBaseUrl</code> is configured, next to
the existing <code>.htaccess</code>; nothing changes for a Docker-host deployment.</li>
</ul>
<h2>v1.1.1 <span class="muted">— 2026-09-03</span></h2>
<ul>
<li>The system page's disk metric is now quota-aware (PR#16): it shows the tightest of the
user quota, the group quota, and the volume itself, instead of always the volume. On a
Hostsharing Managed Webspace that is usually a group quota, far tighter than the shared
host disk — the info line names the binding source and its hard limit, and the
warn/critical highlighting now fires against the real budget. A host without a binding
quota renders exactly as before.</li>
</ul>
<h2>v1.1.0 <span class="muted">— 2026-09-03</span></h2>
<ul>
<li>One instance can now serve several repositories (ADR 0009): a <code>~/.werkator.yml</code>
lists them, and each is worked on through its own <code>RepoContext</code> — checkout,
results, artifact store, and worktrees never share state across repositories. The watcher
polls every registered repository in its own guard, and the executor's
<code>maxConcurrent</code> still applies per <code>(repository, branch)</code>, not just
per branch.</li>
<li>Server routes carry the repository as <code>/repos/&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")
@@ -1,8 +1,8 @@
package de.hoennig.werkator.build package de.hoennig.werkator.build
import de.hoennig.werkator.config.BranchConfig import de.hoennig.werkator.config.BranchConfig
import de.hoennig.werkator.config.BwrapConfig
import de.hoennig.werkator.config.DockerConfig import de.hoennig.werkator.config.DockerConfig
import de.hoennig.werkator.config.WerkdockConfig
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.Called import io.mockk.Called
@@ -15,13 +15,13 @@ import java.nio.file.Paths
class DispatchingBuildRunnerTest : FunSpec() { class DispatchingBuildRunnerTest : FunSpec() {
private val processBuildRunner = mockk<ProcessBuildRunner>() private val processBuildRunner = mockk<ProcessBuildRunner>()
private val dockerBuildRunner = mockk<DockerBuildRunner>() private val dockerBuildRunner = mockk<DockerBuildRunner>()
private val bwrapBuildRunner = mockk<BwrapBuildRunner>() private val werkdockBuildRunner = mockk<WerkdockBuildRunner>()
private val dispatcher = DispatchingBuildRunner(processBuildRunner, dockerBuildRunner, bwrapBuildRunner) private val dispatcher = DispatchingBuildRunner(processBuildRunner, dockerBuildRunner, werkdockBuildRunner)
private val process = mockk<Process>() private val process = mockk<Process>()
private val dir = Paths.get(".") private val dir = Paths.get(".")
init { init {
beforeEach { clearMocks(processBuildRunner, dockerBuildRunner, bwrapBuildRunner) } beforeEach { clearMocks(processBuildRunner, dockerBuildRunner, werkdockBuildRunner) }
test("runs natively by default") { test("runs natively by default") {
val branchConfig = BranchConfig() val branchConfig = BranchConfig()
@@ -30,7 +30,7 @@ class DispatchingBuildRunnerTest : FunSpec() {
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
verify { dockerBuildRunner wasNot Called } verify { dockerBuildRunner wasNot Called }
verify { bwrapBuildRunner wasNot Called } verify { werkdockBuildRunner wasNot Called }
} }
test("runs in Docker when the branch enables it") { test("runs in Docker when the branch enables it") {
@@ -40,12 +40,12 @@ class DispatchingBuildRunnerTest : FunSpec() {
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
verify { processBuildRunner wasNot Called } verify { processBuildRunner wasNot Called }
verify { bwrapBuildRunner wasNot Called } verify { werkdockBuildRunner wasNot Called }
} }
test("runs in bwrap when the branch enables it (and not Docker)") { test("runs in the werkdock sandbox when the branch enables it (and not Docker)") {
val branchConfig = BranchConfig(bwrap = BwrapConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst")) val branchConfig = BranchConfig(werkdock = WerkdockConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst"))
every { bwrapBuildRunner.start("cmd", dir, emptyMap(), dir, branchConfig) } returns process every { werkdockBuildRunner.start("cmd", dir, emptyMap(), dir, branchConfig) } returns process
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
@@ -1,7 +1,7 @@
package de.hoennig.werkator.build package de.hoennig.werkator.build
import de.hoennig.werkator.config.BranchConfig import de.hoennig.werkator.config.BranchConfig
import de.hoennig.werkator.config.BwrapConfig import de.hoennig.werkator.config.WerkdockConfig
import de.hoennig.werkator.git.GitCommandResult import de.hoennig.werkator.git.GitCommandResult
import de.hoennig.werkator.git.GitCommandRunner import de.hoennig.werkator.git.GitCommandRunner
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
@@ -15,20 +15,20 @@ import io.mockk.verify
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
class BwrapBuildRunnerTest : FunSpec() { class WerkdockBuildRunnerTest : FunSpec() {
private val commandRunner = mockk<GitCommandRunner>() private val commandRunner = mockk<GitCommandRunner>()
private lateinit var runner: BwrapBuildRunner private lateinit var runner: WerkdockBuildRunner
private lateinit var repoDir: Path private lateinit var repoDir: Path
private lateinit var workspace: Path private lateinit var workspace: Path
private val captured = mutableListOf<List<String>>() private val captured = mutableListOf<List<String>>()
private fun bwrapBranchConfig( private fun werkdockBranchConfig(
rootfs: String = "/srv/buildenv.tar.zst", rootfs: String = "/srv/buildenv.tar.zst",
env: Map<String, String> = emptyMap(), env: Map<String, String> = emptyMap(),
): BranchConfig = ): BranchConfig =
BranchConfig( BranchConfig(
bwrap = werkdock =
BwrapConfig( WerkdockConfig(
enabled = true, enabled = true,
rootfs = rootfs, rootfs = rootfs,
env = env, env = env,
@@ -52,9 +52,9 @@ class BwrapBuildRunnerTest : FunSpec() {
beforeEach { beforeEach {
clearMocks(commandRunner) clearMocks(commandRunner)
captured.clear() captured.clear()
repoDir = Files.createTempDirectory("werkator-bwrap-runner") repoDir = Files.createTempDirectory("werkator-werkdock-runner")
workspace = repoDir.resolve("workspace") workspace = repoDir.resolve("workspace")
runner = BwrapBuildRunner(commandRunner) runner = WerkdockBuildRunner(commandRunner)
runner.processStarter = { command, _ -> runner.processStarter = { command, _ ->
captured += command captured += command
ProcessBuilder("true").start() ProcessBuilder("true").start()
@@ -64,7 +64,7 @@ class BwrapBuildRunnerTest : FunSpec() {
test("assembles the exact werkdock run command for a loaded image") { test("assembles the exact werkdock run command for a loaded image") {
givenImageLoaded() givenImageLoaded()
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig()) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
captured.single() shouldBe captured.single() shouldBe
listOf( listOf(
@@ -99,7 +99,7 @@ class BwrapBuildRunnerTest : FunSpec() {
) )
} returns GitCommandResult(0, "", "") } returns GitCommandResult(0, "", "")
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig()) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
verify { verify {
commandRunner.runOrThrow( commandRunner.runOrThrow(
@@ -114,7 +114,7 @@ class BwrapBuildRunnerTest : FunSpec() {
test("does not load an image werkdock already has") { test("does not load an image werkdock already has") {
givenImageLoaded() givenImageLoaded()
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig()) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) } verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) }
} }
@@ -124,7 +124,7 @@ class BwrapBuildRunnerTest : FunSpec() {
GitCommandResult(0, imageName() + "\n", "") GitCommandResult(0, imageName() + "\n", "")
val branchConfig = val branchConfig =
BranchConfig( BranchConfig(
bwrap = BwrapConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst", werkdock = "/opt/bin/werkdock"), werkdock = WerkdockConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst", binary = "/opt/bin/werkdock"),
) )
runner.start("./gradlew test", workspace, emptyMap(), repoDir, branchConfig) runner.start("./gradlew test", workspace, emptyMap(), repoDir, branchConfig)
@@ -136,7 +136,7 @@ class BwrapBuildRunnerTest : FunSpec() {
givenImageLoaded() givenImageLoaded()
val relativeWorkspace = repoDir.relativize(workspace) val relativeWorkspace = repoDir.relativize(workspace)
runner.start("./gradlew test", relativeWorkspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig()) runner.start("./gradlew test", relativeWorkspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
val args = captured.single() val args = captured.single()
val absolute = workspace.toAbsolutePath().normalize().toString() val absolute = workspace.toAbsolutePath().normalize().toString()
@@ -145,7 +145,7 @@ class BwrapBuildRunnerTest : FunSpec() {
args.count { it == "$absolute:$absolute" } shouldBe 1 args.count { it == "$absolute:$absolute" } shouldBe 1
} }
test("adds bwrap env after the branch environment") { test("adds the sandbox env after the branch environment") {
givenImageLoaded() givenImageLoaded()
runner.start( runner.start(
@@ -153,7 +153,7 @@ class BwrapBuildRunnerTest : FunSpec() {
workspace, workspace,
mapOf("branch" to "main"), mapOf("branch" to "main"),
repoDir, repoDir,
bwrapBranchConfig(env = mapOf("FOO" to "bar")), werkdockBranchConfig(env = mapOf("FOO" to "bar")),
) )
val args = captured.single() val args = captured.single()
@@ -171,7 +171,7 @@ class BwrapBuildRunnerTest : FunSpec() {
Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n") Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n")
givenImageLoaded() givenImageLoaded()
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig()) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
val args = captured.single() val args = captured.single()
args[args.indexOf("$gitDir:$gitDir:ro") - 1] shouldBe "-v" args[args.indexOf("$gitDir:$gitDir:ro") - 1] shouldBe "-v"
@@ -190,7 +190,7 @@ class BwrapBuildRunnerTest : FunSpec() {
givenImageLoaded() givenImageLoaded()
Files.createDirectories(workspace) Files.createDirectories(workspace)
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig()) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig())
val args = captured.single() val args = captured.single()
val gitDir = repoDir.resolve(".git") val gitDir = repoDir.resolve(".git")
@@ -199,21 +199,21 @@ class BwrapBuildRunnerTest : FunSpec() {
} }
test("fails without a configured rootfs") { test("fails without a configured rootfs") {
val branchConfig = BranchConfig(bwrap = BwrapConfig(enabled = true)) val branchConfig = BranchConfig(werkdock = WerkdockConfig(enabled = true))
val exception = val exception =
shouldThrow<IllegalArgumentException> { shouldThrow<IllegalArgumentException> {
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, branchConfig) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, branchConfig)
} }
exception.message shouldContain "bwrap.rootfs" exception.message shouldContain "werkdock.rootfs"
} }
test("downloads a URL rootfs once before loading it") { test("downloads a URL rootfs once before loading it") {
val url = "https://example.test/buildenv.tar.zst" val url = "https://example.test/buildenv.tar.zst"
val downloadTarget = val downloadTarget =
repoDir repoDir
.resolve(BwrapBuildRunner.BUILDENV_DIR) .resolve(WerkdockBuildRunner.BUILDENV_DIR)
.resolve(url.sha12()) .resolve(url.sha12())
.resolve("buildenv.tar.zst") .resolve("buildenv.tar.zst")
givenImageMissing() givenImageMissing()
@@ -222,7 +222,7 @@ class BwrapBuildRunnerTest : FunSpec() {
every { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) } returns every { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) } returns
GitCommandResult(0, "", "") GitCommandResult(0, "", "")
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig(rootfs = url)) runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, werkdockBranchConfig(rootfs = url))
verify { verify {
commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any()) commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any())
@@ -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")
@@ -407,7 +496,7 @@ class ConfigLoaderTest : FunSpec() {
settings.docker.image shouldBe "attacker-image" settings.docker.image shouldBe "attacker-image"
} }
test("a branch cannot disable its bwrap sandbox or substitute a foreign rootfs through a build definition") { test("the legacy bwrap section is read as werkdock, its werkdock key as binary") {
val dir = Files.createTempDirectory("werkator-test") val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText( dir.resolve(".werkator.yml").toFile().writeText(
""" """
@@ -416,6 +505,58 @@ class ConfigLoaderTest : FunSpec() {
bwrap: bwrap:
enabled: true enabled: true
rootfs: /host/rootfs.tar.zst rootfs: /host/rootfs.tar.zst
werkdock: /opt/bin/werkdock
env:
FOO: bar
""".trimIndent(),
)
val settings = loader.load(dir).buildSettings("any-branch", "default")
settings.werkdock.enabled shouldBe true
settings.werkdock.rootfs shouldBe "/host/rootfs.tar.zst"
settings.werkdock.binary shouldBe "/opt/bin/werkdock"
settings.werkdock.env shouldBe mapOf("FOO" to "bar")
}
test("a legacy bwrap section on a branch is pinned exactly like the new name") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText(
"""
builds:
default:
werkdock:
enabled: true
rootfs: /host/rootfs.tar.zst
""".trimIndent(),
)
val worktree = Files.createTempDirectory("werkator-test-worktree")
// the old name must not become a way around the pinning
worktree.resolve(".werkator.yml").toFile().writeText(
"""
builds:
default:
bwrap:
enabled: false
rootfs: /attacker/rootfs.tar.zst
""".trimIndent(),
)
val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default")
settings.werkdock.enabled shouldBe true
settings.werkdock.rootfs shouldBe "/host/rootfs.tar.zst"
}
test("a branch cannot disable its werkdock sandbox or substitute a foreign rootfs through a build definition") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText(
"""
builds:
default:
werkdock:
enabled: true
rootfs: /host/rootfs.tar.zst
""".trimIndent(), """.trimIndent(),
) )
val worktree = Files.createTempDirectory("werkator-test-worktree") val worktree = Files.createTempDirectory("werkator-test-worktree")
@@ -423,7 +564,7 @@ class ConfigLoaderTest : FunSpec() {
""" """
builds: builds:
default: default:
bwrap: werkdock:
enabled: false enabled: false
rootfs: /attacker/rootfs.tar.zst rootfs: /attacker/rootfs.tar.zst
env: env:
@@ -434,10 +575,10 @@ class ConfigLoaderTest : FunSpec() {
val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default") val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default")
// pinned: the sandbox can neither be switched off nor pointed at a foreign rootfs // pinned: the sandbox can neither be switched off nor pointed at a foreign rootfs
settings.bwrap.enabled shouldBe true settings.werkdock.enabled shouldBe true
settings.bwrap.rootfs shouldBe "/host/rootfs.tar.zst" settings.werkdock.rootfs shouldBe "/host/rootfs.tar.zst"
// everything that describes the build itself stays the branch's own business // everything that describes the build itself stays the branch's own business
settings.bwrap.env shouldBe mapOf("FOO" to "from-branch") settings.werkdock.env shouldBe mapOf("FOO" to "from-branch")
} }
test("a build the branch invents inherits the host's sandbox policy") { test("a build the branch invents inherits the host's sandbox policy") {
@@ -476,7 +617,7 @@ class ConfigLoaderTest : FunSpec() {
settings.requirePullRequest shouldBe true settings.requirePullRequest shouldBe true
} }
test("enabling both docker and bwrap on a build is rejected, not picked silently") { test("enabling both docker and werkdock on a build is rejected, not picked silently") {
val dir = Files.createTempDirectory("werkator-test") val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText( dir.resolve(".werkator.yml").toFile().writeText(
""" """
@@ -485,7 +626,7 @@ class ConfigLoaderTest : FunSpec() {
docker: docker:
enabled: true enabled: true
image: build-env image: build-env
bwrap: werkdock:
enabled: true enabled: true
rootfs: /srv/rootfs.tar.zst rootfs: /srv/rootfs.tar.zst
""".trimIndent(), """.trimIndent(),
@@ -497,7 +638,7 @@ class ConfigLoaderTest : FunSpec() {
config.buildSettings("any-branch", "default") config.buildSettings("any-branch", "default")
} }
exception.message shouldContain "both docker and bwrap" exception.message shouldContain "both docker and werkdock"
exception.message shouldContain "builds.default" exception.message shouldContain "builds.default"
} }
@@ -0,0 +1,153 @@
package de.hoennig.werkator.metrics
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
/** `quota -u -g --no-wrap --raw-grace` on mih09, 2026-09-03 (the PR#16 attachment); the parser fixture. */
private val MIH09_QUOTA_OUTPUT =
"""
Disk quotas for user mih09-werkator (uid 120974): none
Disk quotas for group mih09 (gid 102180):
Filesystem blocks quota limit grace files quota limit grace
/dev/disk/by-id/wwn-0x0000000000000001-part2 1088116 8388608 12582912 0 14226 16777216 25165824 0
/dev/sdb1 1456376 10485760 15728640 0 52983 20971520 31457280 0
""".trimIndent()
class DiskQuotaTest : FunSpec() {
init {
test("the mih09 output parses into one group line per filesystem and no user line") {
val lines = DiskQuota.parse(MIH09_QUOTA_OUTPUT)
lines shouldHaveSize 2
lines.none { it.kind == "user" } shouldBe true
lines[0] shouldBe
QuotaLine(
kind = "group",
subject = "mih09",
filesystem = "/dev/disk/by-id/wwn-0x0000000000000001-part2",
blocksKib = 1_088_116,
softKib = 8_388_608,
hardKib = 12_582_912,
)
lines[1].filesystem shouldBe "/dev/sdb1"
}
test("no quota tooling, an empty output, and a 'none'-only output all parse to no lines") {
DiskQuota.parse("").shouldBeEmpty()
DiskQuota.parse("Disk quotas for user mih09-werkator (uid 120974): none").shouldBeEmpty()
DiskQuota
.parse(
"""
Disk quotas for user mih09-werkator (uid 120974): none
Disk quotas for group mih09 (gid 102180): none
""".trimIndent(),
).shouldBeEmpty()
}
test("only the lines of the directory's file store are considered, matched exactly or by device name") {
val lines = DiskQuota.parse(MIH09_QUOTA_OUTPUT)
val exact = DiskQuota.candidatesFor(lines, "/dev/disk/by-id/wwn-0x0000000000000001-part2")
exact shouldHaveSize 1
exact[0].source.filesystem shouldBe "/dev/disk/by-id/wwn-0x0000000000000001-part2"
val byDeviceName = DiskQuota.candidatesFor(lines, "/dev/sdb1")
byDeviceName shouldHaveSize 1
byDeviceName[0].source.filesystem shouldBe "/dev/sdb1"
DiskQuota.candidatesFor(lines, "/dev/mapper/unrelated").shouldBeEmpty()
}
test("a group quota candidate reports soft limit as total, blocks as used, and the hard limit alongside") {
val lines = DiskQuota.parse(MIH09_QUOTA_OUTPUT)
val candidates = DiskQuota.candidatesFor(lines, "/dev/disk/by-id/wwn-0x0000000000000001-part2")
val candidate = candidates.single()
candidate.space.totalBytes shouldBe 8_388_608L * 1024
candidate.space.usedBytes shouldBe 1_088_116L * 1024
candidate.space.freeBytes shouldBe (8_388_608L - 1_088_116L) * 1024
candidate.source.kind shouldBe "group"
candidate.source.subject shouldBe "mih09"
candidate.source.softLimitGib!! shouldBe (8_388_608.0 * 1024 / GIB)
candidate.source.hardLimitGib!! shouldBe (12_582_912.0 * 1024 / GIB)
}
test("an over-quota '*' marker on the blocks field does not break parsing") {
val output =
"""
Disk quotas for group mih09 (gid 102180):
Filesystem blocks quota limit grace files quota limit grace
/dev/sda1 9000000* 8388608 12582912 604800 14226 16777216 25165824 0
""".trimIndent()
val lines = DiskQuota.parse(output)
lines.single().blocksKib shouldBe 9_000_000
}
test("a line with only a hard limit uses it as the total; a line with neither is no candidate") {
val output =
"""
Disk quotas for group mih09 (gid 102180):
Filesystem blocks quota limit grace files quota limit grace
/dev/sda1 1000000 0 2000000 0 1 0 0 0
/dev/sdc1 1000000 0 0 0 1 0 0 0
""".trimIndent()
val lines = DiskQuota.parse(output)
val hardOnly = DiskQuota.candidatesFor(lines, "/dev/sda1")
hardOnly.single().space.totalBytes shouldBe 2_000_000L * 1024
DiskQuota.candidatesFor(lines, "/dev/sdc1").shouldBeEmpty()
}
test("among user quota, group quota and volume the smallest headroom binds") {
val userQuota =
DiskCandidate(
space =
SystemMetricsCollector.DiskSpace(
totalBytes = 10L * GIB.toLong(),
usedBytes = 8L * GIB.toLong(),
freeBytes =
2L * GIB.toLong(),
),
source = DiskSource(kind = "user", subject = "mih09-werkator", filesystem = "/dev/x"),
)
val groupQuota =
DiskCandidate(
space =
SystemMetricsCollector.DiskSpace(
totalBytes = 15L * GIB.toLong(),
usedBytes = 8L * GIB.toLong(),
freeBytes =
7L * GIB.toLong(),
),
source = DiskSource(kind = "group", subject = "mih09", filesystem = "/dev/x"),
)
val roomyVolume =
DiskCandidate(
space =
SystemMetricsCollector.DiskSpace(
totalBytes = 71L * GIB.toLong(),
usedBytes = 37L * GIB.toLong(),
freeBytes =
34L * GIB.toLong(),
),
source = DiskSource.volume(),
)
val tightVolume = roomyVolume.copy(space = roomyVolume.space.copy(freeBytes = 1L * GIB.toLong()))
DiskQuota.bindingDiskSpace(listOf(userQuota, groupQuota, roomyVolume)).source.kind shouldBe "user"
DiskQuota.bindingDiskSpace(listOf(groupQuota, roomyVolume)).source.kind shouldBe "group"
DiskQuota.bindingDiskSpace(listOf(groupQuota, tightVolume)).source.kind shouldBe "volume"
}
}
companion object {
private const val GIB = 1_073_741_824.0
}
}
@@ -17,15 +17,19 @@ class SystemMetricsCollectorTest : FunSpec() {
private fun collector( private fun collector(
cpuCount: Int = 4, cpuCount: Int = 4,
diskSpace: (Path) -> SystemMetricsCollector.DiskSpace = { GIB_100_DISK }, diskSpace: (Path) -> SystemMetricsCollector.DiskSpace = { GIB_100_DISK },
fileStoreName: (Path) -> String = { "/dev/volume" },
quotaOutput: () -> String? = { null },
repoSizeBytes: (Path) -> Long = { HALF_GIB_BYTES }, repoSizeBytes: (Path) -> Long = { HALF_GIB_BYTES },
) = SystemMetricsCollector( ) = SystemMetricsCollector(
stateFile = { tempDir.resolve("system-metrics-state.json") }, stateFile = { tempDir.resolve("system-metrics-state.json") },
workingDir = tempDir, repoDirs = { listOf(tempDir) },
clock = Clock.fixed(now, ZoneOffset.UTC), clock = Clock.fixed(now, ZoneOffset.UTC),
procStat = tempDir.resolve("stat"), procStat = tempDir.resolve("stat"),
procMeminfo = tempDir.resolve("meminfo"), procMeminfo = tempDir.resolve("meminfo"),
cpuCount = cpuCount, cpuCount = cpuCount,
diskSpace = diskSpace, diskSpace = diskSpace,
fileStoreName = fileStoreName,
quotaOutput = quotaOutput,
repoSizeBytes = repoSizeBytes, repoSizeBytes = repoSizeBytes,
) )
@@ -200,6 +204,94 @@ class SystemMetricsCollectorTest : FunSpec() {
snapshot.cpuCount shouldBe 4 snapshot.cpuCount shouldBe 4
snapshot.cpuUsed shouldBe null snapshot.cpuUsed shouldBe null
} }
test("a group quota on the repository's filesystem replaces the file-store disk numbers") {
writeStat(total = 1000, idle = 700)
writeMeminfo(totalKib = 33_554_432, availableKib = 25_165_824)
val collector =
collector(
diskSpace = { SystemMetricsCollector.DiskSpace(totalBytes = 71L * GIB, usedBytes = 37L * GIB, freeBytes = 34L * GIB) },
fileStoreName = { MIH09_FILESYSTEM },
quotaOutput = { MIH09_QUOTA_OUTPUT },
)
collector.sample()
val snapshot = collector.snapshot()
snapshot.diskTotalGib shouldBe 8_388_608.0 * 1024 / GIB
snapshot.diskUsedGib.shouldNotBeNull().current shouldBe 1_088_116.0 * 1024 / GIB
snapshot.diskFreeGib.shouldNotBeNull().current shouldBe (8_388_608.0 - 1_088_116.0) * 1024 / GIB
snapshot.diskSource.shouldNotBeNull().let {
it.kind shouldBe "group"
it.subject shouldBe "mih09"
it.hardLimitGib shouldBe 12_582_912.0 * 1024 / GIB
}
snapshot.quotasPresent shouldBe true
}
test("without a quota the volume stays the disk source") {
writeStat(total = 1000, idle = 700)
writeMeminfo(totalKib = 33_554_432, availableKib = 25_165_824)
val collector = collector(quotaOutput = { null })
collector.sample()
val snapshot = collector.snapshot()
snapshot.diskTotalGib shouldBe 100.0
snapshot.diskSource.shouldNotBeNull().kind shouldBe "volume"
snapshot.quotasPresent shouldBe false
}
test("a failing quota command degrades to the volume, logged once, the sample still succeeds") {
writeStat(total = 1000, idle = 700)
writeMeminfo(totalKib = 33_554_432, availableKib = 25_165_824)
val collector = collector(quotaOutput = { error("quota: command not found") })
collector.sample()
val snapshot = collector.snapshot()
snapshot.diskTotalGib shouldBe 100.0
snapshot.diskSource.shouldNotBeNull().kind shouldBe "volume"
}
test("a changed disk source restarts the disk series and keeps the others") {
writeStat(total = 1000, idle = 700)
writeMeminfo(totalKib = 33_554_432, availableKib = 25_165_824)
// sample 1, on the legacy volume-only binary: disk used 37 GiB, poisoning any later max
collector(
diskSpace = { SystemMetricsCollector.DiskSpace(totalBytes = 71L * GIB, usedBytes = 37L * GIB, freeBytes = 34L * GIB) },
).sample()
// sample 2, after the update: the first sample to find a binding group quota (~1 GiB used)
val quotaCollector = { output: String ->
collector(
diskSpace = { SystemMetricsCollector.DiskSpace(totalBytes = 71L * GIB, usedBytes = 37L * GIB, freeBytes = 34L * GIB) },
fileStoreName = { MIH09_FILESYSTEM },
quotaOutput = { output },
)
}
val sample2 = quotaCollector(MIH09_QUOTA_OUTPUT)
sample2.sample()
val afterUpdate = sample2.snapshot()
afterUpdate.sampleCount shouldBe 2
val diskUsedAfterUpdate = afterUpdate.diskUsedGib.shouldNotBeNull()
// a fresh series of one sample: min/max are the quota's ~1 GiB, not the 37 GiB volume history
diskUsedAfterUpdate.max shouldBe diskUsedAfterUpdate.current
diskUsedAfterUpdate.min shouldBe diskUsedAfterUpdate.current
// ram, unaffected by the disk-source change, keeps aggregating across both samples
afterUpdate.ramUsedGib.shouldNotBeNull().min shouldBe 8.0
// sample 3, same (unchanged) quota source but higher usage: the series must now continue
val sample3 = quotaCollector(MIH09_QUOTA_OUTPUT_HIGHER_USAGE)
sample3.sample()
val afterRestart = sample3.snapshot()
afterRestart.sampleCount shouldBe 3
val diskUsedAfterRestart = afterRestart.diskUsedGib.shouldNotBeNull()
diskUsedAfterRestart.min shouldBe diskUsedAfterUpdate.current
diskUsedAfterRestart.max shouldBe (2_000_000.0 * 1024 / GIB)
}
} }
companion object { companion object {
@@ -213,5 +305,19 @@ class SystemMetricsCollectorTest : FunSpec() {
usedBytes = 40L * GIB, usedBytes = 40L * GIB,
freeBytes = 55L * GIB, freeBytes = 55L * GIB,
) )
private const val MIH09_FILESYSTEM = "/dev/disk/by-id/wwn-0x0000000000000001-part2"
/** `quota -u -g --no-wrap --raw-grace` on mih09, 2026-09-03 (the PR#16 attachment). */
private val MIH09_QUOTA_OUTPUT =
"""
Disk quotas for user mih09-werkator (uid 120974): none
Disk quotas for group mih09 (gid 102180):
Filesystem blocks quota limit grace files quota limit grace
$MIH09_FILESYSTEM 1088116 8388608 12582912 0 14226 16777216 25165824 0
""".trimIndent()
/** Same group quota, but usage risen from 1088116 to 2000000 KiB — for the series-continuation test. */
private val MIH09_QUOTA_OUTPUT_HIGHER_USAGE = MIH09_QUOTA_OUTPUT.replace("1088116", "2000000")
} }
} }
@@ -0,0 +1,100 @@
package de.hoennig.werkator.repo
import de.hoennig.werkator.config.ConfigLoader
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.every
import io.mockk.mockk
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.info.BuildProperties
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.Properties
class RepoRegistryTest : FunSpec() {
private fun loaderWithHome(
home: Path,
version: String = "1.0.0",
): ConfigLoader {
val provider = mockk<ObjectProvider<BuildProperties>>()
every { provider.getIfAvailable() } returns BuildProperties(Properties().apply { setProperty("version", version) })
return ConfigLoader(provider).apply { homeDir = home }
}
private fun gitRepo(
parent: Path,
name: String,
): Path = Files.createDirectories(parent.resolve(name)).also { Files.createDirectories(it.resolve(".git")) }
private fun registry(loader: ConfigLoader) = RepoRegistry(loader, RepoContexts(loader))
init {
test("without a home config the registry is the current directory alone") {
val home = Files.createTempDirectory("werkator-home")
val registry = registry(loaderWithHome(home))
registry.all().map { it.workingDir } shouldBe listOf(Paths.get("."))
registry.current().workingDir shouldBe Paths.get(".")
}
test("every registry entry becomes a context, named after its directory unless the entry says otherwise") {
val home = Files.createTempDirectory("werkator-home")
val one = gitRepo(home, "repos/werkator")
val two = gitRepo(home, "repos/werkbaum")
home.resolve(".werkator.yml").toFile().writeText(
"""
repositories:
- path: ~/repos/werkator
- path: $two
name: baum
""".trimIndent(),
)
val registry = registry(loaderWithHome(home))
registry.all().map { it.name to it.workingDir } shouldBe listOf("werkator" to one, "baum" to two)
registry.byName("baum")?.workingDir shouldBe two
registry.byName("nope") shouldBe null
// the cwd is not registered, so the first entry is the default
registry.current().name shouldBe "werkator"
}
test("an entry that is not a git repository aborts the start, naming the home file") {
val home = Files.createTempDirectory("werkator-home")
Files.createDirectories(home.resolve("not-a-repo"))
home.resolve(".werkator.yml").toFile().writeText("repositories:\n - path: ~/not-a-repo\n")
val error = shouldThrow<IllegalStateException> { registry(loaderWithHome(home)).all() }
error.message shouldContain home.resolve(".werkator.yml").toString()
error.message shouldContain "not a git repository"
}
test("two entries resolving to the same name abort the start") {
val home = Files.createTempDirectory("werkator-home")
gitRepo(home, "a/werkator")
gitRepo(home, "b/werkator")
home.resolve(".werkator.yml").toFile().writeText("repositories:\n - path: ~/a/werkator\n - path: ~/b/werkator\n")
val error = shouldThrow<IllegalStateException> { registry(loaderWithHome(home)).all() }
error.message shouldContain "werkator"
error.message shouldContain "distinct name"
}
test("a repository whose configuration must not be read is skipped, the others are served") {
val home = Files.createTempDirectory("werkator-home")
val fine = gitRepo(home, "fine")
val broken = gitRepo(home, "broken")
broken.resolve(".werkator.yml").toFile().writeText("werkator:\n version:\n since: \"9.9\"\n")
home.resolve(".werkator.yml").toFile().writeText("repositories:\n - path: ~/broken\n - path: ~/fine\n")
val registry = registry(loaderWithHome(home, version = "1.0.0"))
registry.all().map { it.workingDir } shouldBe listOf(fine)
}
}
}
@@ -4,6 +4,8 @@ import com.ninjasquad.springmockk.MockkBean
import de.hoennig.werkator.build.ArtifactStore import de.hoennig.werkator.build.ArtifactStore
import de.hoennig.werkator.build.BuildResult import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.every import io.mockk.every
@@ -32,6 +34,12 @@ class ArtifactFileControllerTest : FunSpec() {
@MockkBean @MockkBean
lateinit var branchPermalinks: BranchPermalinks lateinit var branchPermalinks: BranchPermalinks
@MockkBean
lateinit var repo: RepoContext
@MockkBean
lateinit var registry: RepoRegistry
private val artifactDir: Path = Files.createTempDirectory("werkator-artifact-serve-test") private val artifactDir: Path = Files.createTempDirectory("werkator-artifact-serve-test")
private val greenBuild = private val greenBuild =
@@ -46,12 +54,18 @@ class ArtifactFileControllerTest : FunSpec() {
init { init {
beforeEach { beforeEach {
clearMocks(artifactStore, branchPermalinks) clearMocks(artifactStore, branchPermalinks, repo, registry)
every { repo.name } returns "test"
every { repo.artifactStore } returns artifactStore
every { registry.current() } returns repo
every { registry.all() } returns listOf(repo)
every { registry.byName(any()) } returns null
every { registry.byName("test") } returns repo
every { artifactStore.artifactDir(any()) } returns null every { artifactStore.artifactDir(any()) } returns null
every { artifactStore.artifactDir("known-key") } returns artifactDir every { artifactStore.artifactDir("known-key") } returns artifactDir
every { branchPermalinks.latestGreenBuild(any()) } throws every { branchPermalinks.latestGreenBuild(any(), any()) } throws
ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds") ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds")
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild
} }
test("serves an html artifact with no-cache headers") { test("serves an html artifact with no-cache headers") {
@@ -188,7 +202,7 @@ class ArtifactFileControllerTest : FunSpec() {
} }
test("permanent URL answers 404 when the green build's artifacts are gone") { test("permanent URL answers 404 when the green build's artifacts are gone") {
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild.copy(artifactKey = "pruned-key") every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild.copy(artifactKey = "pruned-key")
mockMvc mockMvc
.perform(get("/branches/main/build.log")) .perform(get("/branches/main/build.log"))
@@ -4,6 +4,7 @@ import de.hoennig.werkator.build.ArtifactKeys
import de.hoennig.werkator.build.BuildResult import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.repo.RepoContext
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
@@ -17,7 +18,8 @@ import java.time.Instant
class BranchPermalinksTest : FunSpec() { class BranchPermalinksTest : FunSpec() {
private val repository = mockk<BuildResultRepository>() private val repository = mockk<BuildResultRepository>()
private val permalinks = BranchPermalinks(repository) private val repo = mockk<RepoContext>().also { every { it.results } returns repository }
private val permalinks = BranchPermalinks()
private fun result( private fun result(
branch: String, branch: String,
@@ -36,20 +38,20 @@ class BranchPermalinksTest : FunSpec() {
every { repository.latestPerName() } returns listOf(result("feature/x"), result("main")) every { repository.latestPerName() } returns listOf(result("feature/x"), result("main"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x") every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild("feature_x") shouldBe result("feature/x") permalinks.latestGreenBuild(repo, "feature_x") shouldBe result("feature/x")
} }
test("resolves the full branch key with hash suffix") { test("resolves the full branch key with hash suffix") {
every { repository.latestPerName() } returns listOf(result("feature/x")) every { repository.latestPerName() } returns listOf(result("feature/x"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x") every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x") permalinks.latestGreenBuild(repo, ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
} }
test("an unknown branch key answers 404") { test("an unknown branch key answers 404") {
every { repository.latestPerName() } returns listOf(result("main")) every { repository.latestPerName() } returns listOf(result("main"))
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("gone") } val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "gone") }
exception.statusCode shouldBe HttpStatus.NOT_FOUND exception.statusCode shouldBe HttpStatus.NOT_FOUND
} }
@@ -58,7 +60,7 @@ class BranchPermalinksTest : FunSpec() {
every { repository.latestPerName() } returns listOf(result("main", status = BuildStatus.FAILED)) every { repository.latestPerName() } returns listOf(result("main", status = BuildStatus.FAILED))
every { repository.latestGreenFor("main") } returns null every { repository.latestGreenFor("main") } returns null
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("main") } val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "main") }
exception.statusCode shouldBe HttpStatus.NOT_FOUND exception.statusCode shouldBe HttpStatus.NOT_FOUND
} }
@@ -66,7 +68,7 @@ class BranchPermalinksTest : FunSpec() {
test("a permanent key matching several branches answers 409 and names the candidates") { test("a permanent key matching several branches answers 409 and names the candidates") {
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x")) every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("feature_x") } val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "feature_x") }
exception.statusCode shouldBe HttpStatus.CONFLICT exception.statusCode shouldBe HttpStatus.CONFLICT
exception.reason.orEmpty() shouldContain "feature/x" exception.reason.orEmpty() shouldContain "feature/x"
@@ -76,7 +78,7 @@ class BranchPermalinksTest : FunSpec() {
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x")) every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x") every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x") permalinks.latestGreenBuild(repo, ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
} }
test("permanentUrl uses the hash-free branch key") { test("permanentUrl uses the hash-free branch key") {
@@ -89,7 +91,7 @@ class BranchPermalinksTest : FunSpec() {
every { repository.latestGreenFor("main@nightly") } returns nightly every { repository.latestGreenFor("main@nightly") } returns nightly
// sanitized like any branch key: the '@' becomes '_' in the URL // sanitized like any branch key: the '@' becomes '_' in the URL
permalinks.latestGreenBuild("main_nightly") shouldBe nightly permalinks.latestGreenBuild(repo, "main_nightly") shouldBe nightly
} }
} }
} }
@@ -9,12 +9,16 @@ import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.build.RunningBuild import de.hoennig.werkator.build.RunningBuild
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoLinks
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.every import io.mockk.every
import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
@@ -27,6 +31,7 @@ import java.time.Duration
import java.time.Instant import java.time.Instant
@WebMvcTest(BuildsApiController::class, properties = ["spring.main.web-application-type=servlet"]) @WebMvcTest(BuildsApiController::class, properties = ["spring.main.web-application-type=servlet"])
@Import(RepoLinks::class)
class BuildsApiControllerTest : FunSpec() { class BuildsApiControllerTest : FunSpec() {
private val tempDir: Path = Files.createTempDirectory("werkator-server-test") private val tempDir: Path = Files.createTempDirectory("werkator-server-test")
@@ -54,6 +59,9 @@ class BuildsApiControllerTest : FunSpec() {
@MockkBean @MockkBean
lateinit var repo: RepoContext lateinit var repo: RepoContext
@MockkBean
lateinit var registry: RepoRegistry
private val startedAt = Instant.parse("2026-07-07T10:00:00Z") private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
private val successResult = private val successResult =
@@ -68,6 +76,7 @@ class BuildsApiControllerTest : FunSpec() {
private fun runningBuild(liveLogFile: Path) = private fun runningBuild(liveLogFile: Path) =
RunningBuild( RunningBuild(
repo = repo,
branch = "main", branch = "main",
commit = successResult.commit, commit = successResult.commit,
artifactKey = "main-abc123-running", artifactKey = "main-abc123-running",
@@ -78,8 +87,16 @@ class BuildsApiControllerTest : FunSpec() {
init { init {
beforeEach { beforeEach {
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing, repo) clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing, repo, registry)
every { repo.name } returns "test"
every { repo.workingDir } returns tempDir every { repo.workingDir } returns tempDir
every { repo.results } returns repository
every { repo.artifactStore } returns artifactStore
// the unscoped routes mean the served repository; `/api/repos/test/…` names it
every { registry.all() } returns listOf(repo)
every { registry.current() } returns repo
every { registry.byName(any()) } returns null
every { registry.byName("test") } returns repo
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" } every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
every { repository.latestGreenFor(any()) } returns null every { repository.latestGreenFor(any()) } returns null
} }
@@ -133,6 +150,35 @@ class BuildsApiControllerTest : FunSpec() {
.andExpect(jsonPath("$[0].logSize").value(5)) .andExpect(jsonPath("$[0].logSize").value(5))
} }
test("current answers only the served repository's builds") {
val liveLogFile = Files.writeString(tempDir.resolve("mine.log"), "12345")
val mine = runningBuild(liveLogFile)
val foreign =
runningBuild(liveLogFile).copy(
repo = mockk<RepoContext>(),
artifactKey = "other-repo-running",
)
every { buildExecutor.currentBuilds() } returns listOf(mine, foreign)
every { repository.history() } returns
listOf(successResult.copy(status = BuildStatus.RUNNING, artifactKey = mine.artifactKey))
mockMvc
.perform(get("/api/builds/current"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].artifactKey").value(mine.artifactKey))
}
test("current log of a build in another repository answers 404") {
val liveLogFile = Files.writeString(tempDir.resolve("foreign.log"), "hello world")
val foreign = runningBuild(liveLogFile).copy(repo = mockk<RepoContext>())
every { buildExecutor.currentBuilds() } returns listOf(foreign)
mockMvc
.perform(get("/api/builds/current/${foreign.artifactKey}/log"))
.andExpect(status().isNotFound)
}
test("current log answers the tail from the requested offset") { test("current log answers the tail from the requested offset") {
val liveLogFile = Files.writeString(tempDir.resolve("tail.log"), "hello world") val liveLogFile = Files.writeString(tempDir.resolve("tail.log"), "hello world")
val build = runningBuild(liveLogFile) val build = runningBuild(liveLogFile)
@@ -299,6 +345,8 @@ class BuildsApiControllerTest : FunSpec() {
} }
test("cancel answers 202 for a cancellable build and 404 otherwise") { test("cancel answers 202 for a cancellable build and 404 otherwise") {
every { repository.history() } returns
listOf(successResult.copy(artifactKey = "known-key"), successResult.copy(artifactKey = "unknown-key"))
every { buildExecutor.cancel("known-key") } returns true every { buildExecutor.cancel("known-key") } returns true
every { buildExecutor.cancel("unknown-key") } returns false every { buildExecutor.cancel("unknown-key") } returns false
@@ -311,6 +359,32 @@ class BuildsApiControllerTest : FunSpec() {
.andExpect(status().isNotFound) .andExpect(status().isNotFound)
} }
test("cancel does not reach a build of another repository") {
// the key exists in the executor, but not in this repository's results
every { repository.history() } returns listOf(successResult)
every { buildExecutor.cancel(any()) } returns true
mockMvc
.perform(
post("/api/repos/test/builds/other-repo-key/cancel")
.header(BuildsApiController.TOKEN_HEADER, "secret"),
).andExpect(status().isNotFound)
verify(exactly = 0) { buildExecutor.cancel(any()) }
}
test("the repository-scoped routes answer for the named repository and 404 for an unknown name") {
every { repository.latestPerName() } returns listOf(successResult)
mockMvc
.perform(get("/api/repos/test/builds/latest"))
.andExpect(status().isOk)
.andExpect(jsonPath("$[0].artifactKey").value("main-abc123-key"))
mockMvc
.perform(get("/api/repos/no-such-repo/builds/latest"))
.andExpect(status().isNotFound)
.andExpect(jsonPath("$.error").value("no repository named 'no-such-repo'"))
}
test("a token in the query string is not accepted — the header is the only way") { test("a token in the query string is not accepted — the header is the only way") {
mockMvc mockMvc
.perform(post("/api/builds/restart").param("branch", "main").param("token", "secret")) .perform(post("/api/builds/restart").param("branch", "main").param("token", "secret"))
@@ -11,12 +11,15 @@ import de.hoennig.werkator.config.WerkatorConfig
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.metrics.SystemMetricsCollector import de.hoennig.werkator.metrics.SystemMetricsCollector
import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoLinks
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.every import io.mockk.every
import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.containsString
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
@@ -36,6 +39,7 @@ import java.time.Instant
controllers = [UiController::class, ArtifactFileController::class], controllers = [UiController::class, ArtifactFileController::class],
properties = ["spring.main.web-application-type=servlet"], properties = ["spring.main.web-application-type=servlet"],
) )
@Import(RepoLinks::class)
class PermanentBranchRoutesTest : FunSpec() { class PermanentBranchRoutesTest : FunSpec() {
@Autowired @Autowired
lateinit var mockMvc: MockMvc lateinit var mockMvc: MockMvc
@@ -70,6 +74,9 @@ class PermanentBranchRoutesTest : FunSpec() {
@MockkBean @MockkBean
lateinit var repo: RepoContext lateinit var repo: RepoContext
@MockkBean
lateinit var registry: RepoRegistry
private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test") private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test")
private val greenBuild = private val greenBuild =
@@ -95,14 +102,22 @@ class PermanentBranchRoutesTest : FunSpec() {
branchListing, branchListing,
branchPermalinks, branchPermalinks,
repo, repo,
registry,
) )
every { repo.name } returns "test"
every { repo.workingDir } returns Paths.get(".") every { repo.workingDir } returns Paths.get(".")
every { repo.results } returns repository
every { repo.artifactStore } returns artifactStore
every { registry.all() } returns listOf(repo)
every { registry.current() } returns repo
every { registry.byName(any()) } returns null
every { registry.byName("test") } returns repo
every { configLoader.load(any()) } returns WerkatorConfig() every { configLoader.load(any()) } returns WerkatorConfig()
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig() every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig()
every { gitService.showFileAtCommit(any(), any(), any()) } returns null every { gitService.showFileAtCommit(any(), any(), any()) } returns null
every { controlTokens.token() } returns "test-token" every { controlTokens.token() } returns "test-token"
every { branchListing.branches(any()) } returns emptyList() every { branchListing.branches(any()) } returns emptyList()
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild
every { artifactStore.artifactDir("main-key") } returns artifactDir every { artifactStore.artifactDir("main-key") } returns artifactDir
} }
@@ -18,16 +18,20 @@ import de.hoennig.werkator.metrics.MetricAggregate
import de.hoennig.werkator.metrics.SystemMetrics import de.hoennig.werkator.metrics.SystemMetrics
import de.hoennig.werkator.metrics.SystemMetricsCollector import de.hoennig.werkator.metrics.SystemMetricsCollector
import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoLinks
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain import io.kotest.matchers.string.shouldNotContain
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.every import io.mockk.every
import io.mockk.mockk
import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.not import org.hamcrest.Matchers.not
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
@@ -42,6 +46,7 @@ import java.time.Duration
import java.time.Instant import java.time.Instant
@WebMvcTest(UiController::class, properties = ["spring.main.web-application-type=servlet"]) @WebMvcTest(UiController::class, properties = ["spring.main.web-application-type=servlet"])
@Import(RepoLinks::class)
class UiControllerTest : FunSpec() { class UiControllerTest : FunSpec() {
private val tempDir: Path = Files.createTempDirectory("werkator-ui-test") private val tempDir: Path = Files.createTempDirectory("werkator-ui-test")
@@ -78,6 +83,9 @@ class UiControllerTest : FunSpec() {
@MockkBean @MockkBean
lateinit var repo: RepoContext lateinit var repo: RepoContext
@MockkBean
lateinit var registry: RepoRegistry
private val startedAt = Instant.parse("2026-07-07T10:00:00Z") private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
private val emptySystemMetrics = private val emptySystemMetrics =
@@ -119,8 +127,16 @@ class UiControllerTest : FunSpec() {
branchListing, branchListing,
branchPermalinks, branchPermalinks,
repo, repo,
registry,
) )
every { repo.name } returns "test"
every { repo.workingDir } returns Paths.get(".") every { repo.workingDir } returns Paths.get(".")
every { repo.results } returns repository
every { repo.artifactStore } returns artifactStore
every { registry.all() } returns listOf(repo)
every { registry.current() } returns repo
every { registry.byName(any()) } returns null
every { registry.byName("test") } returns repo
every { configLoader.load(any()) } returns every { configLoader.load(any()) } returns
WerkatorConfig( WerkatorConfig(
server = ServerConfig(impressumUrl = "https://example.org/imprint"), server = ServerConfig(impressumUrl = "https://example.org/imprint"),
@@ -144,6 +160,43 @@ class UiControllerTest : FunSpec() {
.andExpect(content().string(not(containsString("""href="/current"""")))) .andExpect(content().string(not(containsString("""href="/current""""))))
} }
test("with one served repository the pages keep their existing URLs and show no switcher") {
every { repository.latestPerName() } returns listOf(successResult)
mockMvc
.perform(get("/"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("""href="/branches"""")))
// Thymeleaf drops an attribute whose value is empty, and werkator.js falls back to ""
.andExpect(content().string(containsString("""<meta name="werkator-repo-base">""")))
.andExpect(content().string(not(containsString("""class="repo-select""""))))
.andExpect(content().string(containsString("""class="repo-name"""")))
}
test("with several served repositories every link names its repository and the switcher appears") {
val other = mockk<RepoContext>()
every { other.name } returns "other"
every { registry.all() } returns listOf(repo, other)
every { repository.latestPerName() } returns listOf(successResult)
mockMvc
.perform(get("/repos/test"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("""href="/repos/test/branches"""")))
.andExpect(content().string(containsString("""data-api="/api/repos/test/builds/latest"""")))
.andExpect(content().string(containsString("""<meta name="werkator-repo-base" content="/repos/test">""")))
.andExpect(content().string(containsString("""class="repo-select"""")))
.andExpect(content().string(containsString("""<option value="/repos/test" selected="selected">test</option>""")))
.andExpect(content().string(containsString("""<option value="/repos/other">other</option>""")))
.andExpect(content().string(not(containsString("""class="repo-name""""))))
}
test("a page of a repository this instance does not serve answers 404") {
mockMvc
.perform(get("/repos/no-such-repo"))
.andExpect(status().isNotFound)
}
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") { test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
every { repository.latestPerName() } returns listOf(successResult) every { repository.latestPerName() } returns listOf(successResult)
@@ -246,6 +299,7 @@ class UiControllerTest : FunSpec() {
test("current view renders a card per running build with cancel button and started-at attribute") { test("current view renders a card per running build with cancel button and started-at attribute") {
val build = val build =
RunningBuild( RunningBuild(
repo = repo,
branch = "main", branch = "main",
commit = successResult.commit, commit = successResult.commit,
artifactKey = "main-abc123-running", artifactKey = "main-abc123-running",
@@ -502,7 +556,7 @@ class UiControllerTest : FunSpec() {
Files.writeString(artifactDir.resolve("build.stdout.log"), "out") Files.writeString(artifactDir.resolve("build.stdout.log"), "out")
Files.createDirectories(artifactDir.resolve("reports/tests/test")) Files.createDirectories(artifactDir.resolve("reports/tests/test"))
Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "<html></html>") Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "<html></html>")
every { branchPermalinks.latestGreenBuild("main") } returns successResult every { branchPermalinks.latestGreenBuild(any(), "main") } returns successResult
every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir
mockMvc mockMvc
@@ -516,7 +570,7 @@ class UiControllerTest : FunSpec() {
} }
test("permanent artifact index of a branch without a green build answers 404") { test("permanent artifact index of a branch without a green build answers 404") {
every { branchPermalinks.latestGreenBuild("main") } throws every { branchPermalinks.latestGreenBuild(any(), "main") } throws
ResponseStatusException(HttpStatus.NOT_FOUND, "branch 'main' has no successful build") ResponseStatusException(HttpStatus.NOT_FOUND, "branch 'main' has no successful build")
mockMvc mockMvc
@@ -1,6 +1,7 @@
package de.hoennig.werkator.server package de.hoennig.werkator.server
import de.hoennig.werkator.config.GiteaConfig import de.hoennig.werkator.config.GiteaConfig
import de.hoennig.werkator.metrics.DiskSource
import de.hoennig.werkator.metrics.MetricAggregate import de.hoennig.werkator.metrics.MetricAggregate
import de.hoennig.werkator.metrics.SystemMetrics import de.hoennig.werkator.metrics.SystemMetrics
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
@@ -8,6 +9,27 @@ import io.kotest.matchers.shouldBe
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
private fun metricsWithDisk(
diskTotalGib: Double,
diskSource: DiskSource? = null,
quotasPresent: Boolean = false,
) = SystemMetrics(
timestamp = Instant.parse("2026-09-03T12:00:00Z"),
sampleCount = 1,
cpuCount = 4,
ramTotalGib = 8.0,
diskTotalGib = diskTotalGib,
diskSource = diskSource,
quotasPresent = quotasPresent,
cpuUsed = null,
cpuIdle = null,
ramUsedGib = null,
ramFreeGib = null,
diskUsedGib = null,
diskFreeGib = null,
repoSizeGib = null,
)
class UiViewsTest : FunSpec() { class UiViewsTest : FunSpec() {
init { init {
test("durations format as m:ss and h:mm:ss like the legacy duration column") { test("durations format as m:ss and h:mm:ss like the legacy duration column") {
@@ -71,6 +93,36 @@ class UiViewsTest : FunSpec() {
classesByKey["repoSizeGib"] shouldBe "" classesByKey["repoSizeGib"] shouldBe ""
} }
test("the disk total names the binding source: user quota, group quota, or the volume") {
UiFormats.diskTotal(metricsWithDisk(70.99, diskSource = null)) shouldBe "70.99 GiB"
UiFormats.diskTotal(
metricsWithDisk(
8.0,
diskSource = DiskSource(kind = "group", subject = "mih09", hardLimitGib = 12.0),
),
) shouldBe "8.00 GiB (group quota mih09, hard limit 12.00 GiB)"
UiFormats.diskTotal(
metricsWithDisk(
4.0,
diskSource = DiskSource(kind = "user", subject = "mih09-werkator", hardLimitGib = 6.0),
),
) shouldBe "4.00 GiB (user quota mih09-werkator, hard limit 6.00 GiB)"
UiFormats.diskTotal(
metricsWithDisk(70.99, diskSource = DiskSource.volume(), quotasPresent = true),
) shouldBe "70.99 GiB (volume, tighter than the quotas)"
UiFormats.diskTotal(
metricsWithDisk(70.99, diskSource = DiskSource.volume(), quotasPresent = false),
) shouldBe "70.99 GiB"
}
test("the disk total is n/a when the disk metric itself is unavailable") {
UiFormats.diskTotal(metricsWithDisk(0.0, diskSource = null).copy(diskTotalGib = null)) shouldBe "n/a"
}
test("Gitea web links escape branch segments but keep slashes") { test("Gitea web links escape branch segments but keep slashes") {
val links = val links =
GiteaWebLinks(GiteaConfig(baseUrl = "https://git.example.org/", owner = "acme", repo = "widget")) GiteaWebLinks(GiteaConfig(baseUrl = "https://git.example.org/", owner = "acme", repo = "widget"))
@@ -94,7 +94,7 @@ class WatcherTest : FunSpec() {
val branch = secondArg<String>() val branch = secondArg<String>()
val commit = thirdArg<String>() val commit = thirdArg<String>()
startedBuilds += branch to commit startedBuilds += branch to commit
runningBuild(branch, commit) runningBuild(repo, branch, commit)
} }
every { artifactStore.prune(any()) } returns emptyList() every { artifactStore.prune(any()) } returns emptyList()
} }
@@ -131,11 +131,13 @@ class WatcherTest : FunSpec() {
} }
private fun runningBuild( private fun runningBuild(
repo: RepoContext,
branch: String, branch: String,
commit: String, commit: String,
): RunningBuild { ): RunningBuild {
val stagingDir = Files.createTempDirectory("werkator-watcher-staging") val stagingDir = Files.createTempDirectory("werkator-watcher-staging")
return RunningBuild( return RunningBuild(
repo = repo,
branch = branch, branch = branch,
commit = commit, commit = commit,
artifactKey = ArtifactKeys.buildKey(branch, Instant.now()), artifactKey = ArtifactKeys.buildKey(branch, Instant.now()),
@@ -272,7 +274,7 @@ class WatcherTest : FunSpec() {
test("a poll cycle completes while a build is running and still enqueues other branches") { test("a poll cycle completes while a build is running and still enqueues other branches") {
val harness = Harness() val harness = Harness()
harness.seed("main", BuildStatus.RUNNING, commit = "commit-1") harness.seed("main", BuildStatus.RUNNING, commit = "commit-1")
every { harness.buildExecutor.currentBuilds() } returns listOf(runningBuild("main", "commit-1")) every { harness.buildExecutor.currentBuilds() } returns listOf(runningBuild(harness.repo, "main", "commit-1"))
every { harness.gitService.originBranches(any()) } returns listOf("main", "feature/other") every { harness.gitService.originBranches(any()) } returns listOf("main", "feature/other")
every { harness.gitService.localBranches(any()) } returns listOf("main") every { harness.gitService.localBranches(any()) } returns listOf("main")
every { harness.gitService.hasNewCommits("main", any()) } returns true every { harness.gitService.hasNewCommits("main", any()) } returns true
@@ -767,19 +769,84 @@ class WatcherTest : FunSpec() {
Files.exists(busyWorktree).shouldBeTrue() Files.exists(busyWorktree).shouldBeTrue()
} }
test("a running build of another repository does not keep this repository's worktree") {
val harness = Harness()
harness.seed("gone", BuildStatus.SUCCESS, commit = "commit-1")
val goneWorktree = harness.worktreeDir("gone")
val otherDir = Files.createTempDirectory("werkator-watcher-other-running")
val other =
RepoContext(
"other",
otherDir,
FileBuildResultRepository(otherDir.resolve(".git/werkator/build-results.json")),
harness.artifactStore,
)
// the other repository builds a branch of the same name — its build must not
// protect this repository's worktree, whose branch is gone from origin
every { harness.buildExecutor.currentBuilds() } returns listOf(runningBuild(other, "gone", "commit-other"))
every { harness.gitService.originBranches(any()) } returns emptyList()
harness.watcher.poll(harness.repo)
Files.exists(goneWorktree).shouldBeFalse()
}
test("one repository's unreachable origin neither stops nor silences the other") {
val harness = Harness()
val otherDir = Files.createTempDirectory("werkator-watcher-other")
val other =
RepoContext(
"other",
otherDir,
FileBuildResultRepository(otherDir.resolve(".git/werkator/build-results.json")),
harness.artifactStore,
)
every { harness.gitService.fetchOrigin(harness.workingDir) } throws RuntimeException("origin unreachable")
every { harness.gitService.originBranches(otherDir) } returns listOf("main")
every { harness.gitService.originBranchHeads(otherDir) } returns mapOf("main" to "commit-other")
every { harness.gitService.localBranches(otherDir) } returns listOf("main")
every { harness.gitService.hasNewCommits("main", otherDir) } returns true
every { harness.gitService.originHeadCommit("main", otherDir) } returns "commit-other"
harness.watcher.pollAll(listOf(harness.repo, other))
verify { harness.buildExecutor.startBuild(other, "main", "commit-other", BuildDefinition.DEFAULT) }
verify(exactly = 0) { harness.buildExecutor.startBuild(harness.repo, any(), any(), any()) }
val state = harness.watcher.state()
state.lastFetchError shouldBe "test: origin unreachable"
state.lastPollError shouldBe null
state.repositories.map { it.name } shouldBe listOf("test", "other")
state.repositories[0].lastFetchError shouldBe "origin unreachable"
state.repositories[1].lastFetchError shouldBe null
}
test("a repository whose poll crashes reports it by name and the cycle goes on") {
val harness = Harness()
val otherDir = Files.createTempDirectory("werkator-watcher-other")
val other = RepoContext("other", otherDir, harness.repository, harness.artifactStore)
every { harness.gitService.originBranches(otherDir) } throws IllegalStateException("corrupt refs")
harness.watcher.pollAll(listOf(harness.repo, other))
val state = harness.watcher.state()
state.lastPollError shouldBe "other: corrupt refs"
state.repositories[1].lastPollError shouldBe "corrupt refs"
state.repositories[0].lastPollError shouldBe null
}
test("start runs recovery plus an immediate first poll; stop halts the loop") { test("start runs recovery plus an immediate first poll; stop halts the loop") {
val harness = Harness() val harness = Harness()
val fetches = CountDownLatch(2) val fetches = CountDownLatch(2)
every { harness.gitService.fetchOrigin(any()) } answers { fetches.countDown() } every { harness.gitService.fetchOrigin(any()) } answers { fetches.countDown() }
harness.watcher.start(harness.repo) harness.watcher.start(listOf(harness.repo))
fetches.await(5, TimeUnit.SECONDS).shouldBeTrue() fetches.await(5, TimeUnit.SECONDS).shouldBeTrue()
harness.watcher harness.watcher
.state() .state()
.running .running
.shouldBeTrue() .shouldBeTrue()
shouldThrow<IllegalStateException> { harness.watcher.start(harness.repo) } shouldThrow<IllegalStateException> { harness.watcher.start(listOf(harness.repo)) }
harness.watcher.stop() harness.watcher.stop()
+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
+212 -43
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,10 +45,23 @@
# WERKATOR_INIT_CONFIG the init fragment to apply (repo-init, instance-start) # WERKATOR_INIT_CONFIG the init fragment to apply (repo-init, instance-start)
# WERKATOR_REPO_URL https clone URL of the watched repository # WERKATOR_REPO_URL https clone URL of the watched repository
# (default: https://github.com/mhoennig/werkator.git) # (default: https://github.com/mhoennig/werkator.git)
# WERKATOR_REPO_DIR directory of the watched repository, absolute or relative to
# WERKATOR_PATH (default: werkator); it also names the systemd
# unit, exactly as `init --systemd` derives it
# WERKATOR_INSTALL_DIR directory holding the unpacked runtime bundle, absolute or
# relative to WERKATOR_PATH (default: .werkator)
# WERKATOR_SANDBOX build runtime of the host: werkdock (default, the bubblewrap
# sandbox; `bwrap` is accepted as its former name) or docker —
# a docker host needs neither the werkdock binary nor a rootfs
# WERKDOCK_REPO checkout of the werkdock repository, whose binary the
# 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)
# #
# Install layout on the host: # Install layout on the host — the default, which the three keys above bend to an
# installation that predates this script (e.g. the docker host vm4006: the watched
# repository is ~/hs.hsadmin.ng, the runtime lives in ~/opt, there is no werkdock):
# $WERKATOR_PATH/werkator/ the watched repository (clone) # $WERKATOR_PATH/werkator/ the watched repository (clone)
# $WERKATOR_PATH/.werkator/werkator/ the unpacked runtime bundle # $WERKATOR_PATH/.werkator/werkator/ the unpacked runtime bundle
# $WERKATOR_PATH/.werkator/bin/ the werkdock binary # $WERKATOR_PATH/.werkator/bin/ the werkdock binary
@@ -68,7 +83,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 +109,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"
@@ -102,9 +123,36 @@ HOST="$WERKATOR_REMOTE"
TARGET_DIR="$WERKATOR_PATH" TARGET_DIR="$WERKATOR_PATH"
ROOTFS="${WERKATOR_ROOTFS:-$REPO_ROOT/build/werkator-buildenv-trixie-java-go-node.tar.zst}" ROOTFS="${WERKATOR_ROOTFS:-$REPO_ROOT/build/werkator-buildenv-trixie-java-go-node.tar.zst}"
REPO_URL="${WERKATOR_REPO_URL:-https://github.com/mhoennig/werkator.git}" REPO_URL="${WERKATOR_REPO_URL:-https://github.com/mhoennig/werkator.git}"
MACHINE_CONFIG="$TARGET_DIR/werkator/.git/werkator/.werkator.yml"
WERKATOR_BIN="$TARGET_DIR/.werkator/werkator/bin/werkator" # The host layout is three values, not one convention: an installation that grew
UNIT="werkator-werkator.service" # before this script existed puts them elsewhere, and the defaults are exactly what
# `instance-install` creates, so an env file that names none of them behaves as before.
# Both directories may be absolute; a bare name is taken relative to WERKATOR_PATH.
resolve_dir() {
case "$1" in
/*) echo "$1" ;;
*) echo "$TARGET_DIR/$1" ;;
esac
}
REPO_DIR="$(resolve_dir "${WERKATOR_REPO_DIR:-werkator}")"
INSTALL_DIR="$(resolve_dir "${WERKATOR_INSTALL_DIR:-.werkator}")"
# where `repo-add` puts a further repository of the registry: beside the watched one
SIBLING_DIR="$(dirname "$REPO_DIR")"
SANDBOX="${WERKATOR_SANDBOX:-werkdock}"
# `bwrap` was the name of the config section until Werkator v1.2.0; accepted so an env
# file written for the older script keeps working, normalised so only one name is used.
[ "$SANDBOX" = "bwrap" ] && SANDBOX="werkdock"
case "$SANDBOX" in
werkdock|docker) ;;
*) die "WERKATOR_SANDBOX is 'werkdock' or 'docker', not '$SANDBOX'" ;;
esac
MACHINE_CONFIG="$REPO_DIR/.git/werkator/.werkator.yml"
WERKATOR_BIN="$INSTALL_DIR/werkator/bin/werkator"
# mirrors SystemdServiceFiles.unitName: the repository's directory name, every
# character outside [A-Za-z0-9_.-] replaced by a dash — the unit `init --systemd`
# writes, which is the one this script may stop and start.
UNIT="werkator-$(basename "$REPO_DIR" | sed 's/[^A-Za-z0-9_.-]/-/g').service"
ssh_present() { ssh_present() {
ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" true 2>/dev/null ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" true 2>/dev/null
@@ -122,21 +170,32 @@ ensure_ssh() {
# Werkdock owns the host checks (`werkdock doctor` ports the old prerequisites # Werkdock owns the host checks (`werkdock doctor` ports the old prerequisites
# script); the binary is uploaded first, so the check works pre-install. # script); the binary is uploaded first, so the check works pre-install.
# On a docker host there is no werkdock and no sandbox to check: the build runtime
# is the docker daemon, so the check is that the daemon answers this user.
check_prerequisites() { check_prerequisites() {
if [ "$SANDBOX" = "docker" ]; then
echo "==> Checking the docker build runtime on $HOST (WERKATOR_SANDBOX=docker)"
ssh "$HOST" "docker info >/dev/null" || die "docker is not usable by this user on $HOST"
ssh "$HOST" "docker --version"
return 0
fi
ensure_werkdock_binary ensure_werkdock_binary
echo "==> Uploading werkdock and running its doctor on $HOST (target dir: $TARGET_DIR)" echo "==> Uploading werkdock and running its doctor on $HOST (target dir: $TARGET_DIR)"
ssh "$HOST" "mkdir -p '$TARGET_DIR/.werkator/bin'" ssh "$HOST" "mkdir -p '$INSTALL_DIR/bin'"
scp -q "$WERKDOCK_BINARY" "$HOST:$TARGET_DIR/.werkator/bin/werkdock.new" scp -q "$WERKDOCK_BINARY" "$HOST:$INSTALL_DIR/bin/werkdock.new"
ssh "$HOST" "mv '$TARGET_DIR/.werkator/bin/werkdock.new' '$TARGET_DIR/.werkator/bin/werkdock' && chmod 755 '$TARGET_DIR/.werkator/bin/werkdock'" ssh "$HOST" "mv '$INSTALL_DIR/bin/werkdock.new' '$INSTALL_DIR/bin/werkdock' && chmod 755 '$INSTALL_DIR/bin/werkdock'"
if ! ssh "$HOST" "'$TARGET_DIR/.werkator/bin/werkdock' doctor '$TARGET_DIR'"; then if ! ssh "$HOST" "'$INSTALL_DIR/bin/werkdock' doctor '$TARGET_DIR'"; then
die "werkdock doctor failed on $HOST — install aborted" die "werkdock doctor failed on $HOST — install aborted"
fi fi
} }
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"
} }
@@ -146,7 +205,7 @@ ensure_werkdock_binary() {
upload_fragment() { upload_fragment() {
[ -n "${WERKATOR_INIT_CONFIG:-}" ] || { echo ""; return 0; } [ -n "${WERKATOR_INIT_CONFIG:-}" ] || { echo ""; return 0; }
[ -f "$WERKATOR_INIT_CONFIG" ] || die "init fragment missing: $WERKATOR_INIT_CONFIG" [ -f "$WERKATOR_INIT_CONFIG" ] || die "init fragment missing: $WERKATOR_INIT_CONFIG"
local remote="$TARGET_DIR/.werkator/$(basename "$WERKATOR_INIT_CONFIG")" local remote="$INSTALL_DIR/$(basename "$WERKATOR_INIT_CONFIG")"
scp -q "$WERKATOR_INIT_CONFIG" "$HOST:$remote" scp -q "$WERKATOR_INIT_CONFIG" "$HOST:$remote"
echo "$remote" echo "$remote"
} }
@@ -160,43 +219,85 @@ ensure_instance_artifacts() {
(cd "$REPO_ROOT" && ./gradlew runtimeBundle --console=plain -q) (cd "$REPO_ROOT" && ./gradlew runtimeBundle --console=plain -q)
fi fi
[ -f "$RUNTIME_BUNDLE" ] || die "runtime bundle missing: $RUNTIME_BUNDLE" [ -f "$RUNTIME_BUNDLE" ] || die "runtime bundle missing: $RUNTIME_BUNDLE"
ensure_werkdock_binary [ "$SANDBOX" = "docker" ] || ensure_werkdock_binary
} }
# Uploads and unpacks the instance artifacts. The previous runtime stays as # Uploads one file and verifies it arrived whole: a transfer that dies mid-way
# werkator.prev for one deployment as the rollback asset. # (scp: Connection closed) otherwise leaves a truncated archive that unpacks into
deploy_instance() { # a broken runtime. Retries twice, because a dropped WAN connection is not a reason
# to abort a deployment.
upload_verified() {
local src="$1" dest="$2"
local local_sha remote_sha attempt
local_sha="$(sha256sum "$src" | cut -d' ' -f1)"
remote_sha="$(ssh "$HOST" "sha256sum '$dest' 2>/dev/null | cut -d' ' -f1" || true)"
if [ "$local_sha" = "$remote_sha" ]; then
echo " $(basename "$src"): already on the host, skipping"
return 0
fi
for attempt in 1 2 3; do
if scp -q "$src" "$HOST:$dest.part"; then
remote_sha="$(ssh "$HOST" "sha256sum '$dest.part' 2>/dev/null | cut -d' ' -f1" || true)"
if [ "$local_sha" = "$remote_sha" ]; then
ssh "$HOST" "mv '$dest.part' '$dest'"
return 0
fi
echo " checksum mismatch after transfer $attempt of $(basename "$src")" >&2
else
echo " transfer $attempt of $(basename "$src") failed" >&2
fi
done
ssh "$HOST" "rm -f '$dest.part'" || true
die "cannot upload $src to $HOST:$dest — three attempts failed"
}
# Uploads the instance artifacts, without touching the installed runtime: the
# service keeps running until swap_instance_runtime replaces it, so a failed
# transfer costs nothing but the transfer.
upload_instance_artifacts() {
if [ "$SANDBOX" = "docker" ]; then
echo "==> Uploading runtime bundle"
else
echo "==> Uploading runtime bundle and werkdock binary" echo "==> Uploading runtime bundle and werkdock binary"
ssh "$HOST" "mkdir -p '$TARGET_DIR/.werkator/bin'" fi
scp -q "$RUNTIME_BUNDLE" "$HOST:$TARGET_DIR/.werkator/" ssh "$HOST" "mkdir -p '$INSTALL_DIR/bin'"
scp -q "$WERKDOCK_BINARY" "$HOST:$TARGET_DIR/.werkator/bin/werkdock.new" upload_verified "$RUNTIME_BUNDLE" "$INSTALL_DIR/$(basename "$RUNTIME_BUNDLE")"
if [ "$SANDBOX" != "docker" ]; then
upload_verified "$WERKDOCK_BINARY" "$INSTALL_DIR/bin/werkdock.new"
fi
}
# Swaps in the uploaded artifacts. The previous runtime stays as werkator.prev
# for one deployment as the rollback asset.
swap_instance_runtime() {
echo "==> Unpacking" echo "==> Unpacking"
ssh "$HOST" "set -e ssh "$HOST" "set -e
cd '$TARGET_DIR/.werkator' cd '$INSTALL_DIR'
mv bin/werkdock.new bin/werkdock && chmod 755 bin/werkdock [ ! -f bin/werkdock.new ] || { mv bin/werkdock.new bin/werkdock && chmod 755 bin/werkdock; }
rm -rf werkator.prev rm -rf werkator.prev
[ ! -d werkator ] || mv werkator werkator.prev [ ! -d werkator ] || mv werkator werkator.prev
tar xzf '$(basename "$RUNTIME_BUNDLE")' tar xzf '$(basename "$RUNTIME_BUNDLE")'
'./werkator/bin/werkator' --version './werkator/bin/werkator' --version
'./bin/werkdock' version" [ ! -x bin/werkdock ] || './bin/werkdock' version"
} }
instance_install() { instance_install() {
ensure_ssh ensure_ssh
check_prerequisites check_prerequisites
ensure_instance_artifacts ensure_instance_artifacts
deploy_instance upload_instance_artifacts
swap_instance_runtime
echo echo
echo "==> Instance installed." echo "==> Instance installed."
echo " Runtime: $WERKATOR_BIN" echo " Runtime: $WERKATOR_BIN"
echo " werkdock: $TARGET_DIR/.werkator/bin/werkdock" [ "$SANDBOX" = "docker" ] || echo " werkdock: $INSTALL_DIR/bin/werkdock"
echo " Next: tools/remote werkator repo-init, then instance-start" echo " Next: tools/remote werkator repo-init, then instance-start"
} }
# Refuse to swap the runtime under a running build; FORCE=1 overrides. # Refuse to swap the runtime under a running build; FORCE=1 overrides.
require_idle() { require_idle() {
local port local port
port="$(ssh "$HOST" "cd '$TARGET_DIR/werkator' 2>/dev/null && '$WERKATOR_BIN' config:print 2>/dev/null" | awk '/^server:/{f=1;next} f && /^ port:/{print $2; exit}' | tr -d '"' || true)" port="$(ssh "$HOST" "cd '$REPO_DIR' 2>/dev/null && '$WERKATOR_BIN' config:print 2>/dev/null" | awk '/^server:/{f=1;next} f && /^ port:/{print $2; exit}' | tr -d '"' || true)"
[ -n "$port" ] || return 0 [ -n "$port" ] || return 0
local current local current
current="$(ssh "$HOST" "curl -s --max-time 5 http://127.0.0.1:$port/api/builds/current" || true)" current="$(ssh "$HOST" "curl -s --max-time 5 http://127.0.0.1:$port/api/builds/current" || true)"
@@ -210,6 +311,9 @@ instance_update() {
ensure_ssh ensure_ssh
ensure_instance_artifacts ensure_instance_artifacts
require_idle require_idle
# upload first, stop second: a transfer that fails must not leave the host
# without a running service (measured on vm4006, 2026-09-03)
upload_instance_artifacts
local was_active=0 local was_active=0
if ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user is-active --quiet '$UNIT'"; then if ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user is-active --quiet '$UNIT'"; then
was_active=1 was_active=1
@@ -218,7 +322,7 @@ instance_update() {
echo "==> Stopping $UNIT" echo "==> Stopping $UNIT"
ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user stop '$UNIT'" ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user stop '$UNIT'"
fi fi
deploy_instance swap_instance_runtime
if [ "$was_active" = "1" ]; then if [ "$was_active" = "1" ]; then
echo "==> Starting $UNIT" echo "==> Starting $UNIT"
ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user start '$UNIT' && sleep 3 && systemctl --user is-active '$UNIT'" ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user start '$UNIT' && sleep 3 && systemctl --user is-active '$UNIT'"
@@ -235,18 +339,22 @@ instance_update() {
# writing is init's — this script transports and invokes (step 23). # writing is init's — this script transports and invokes (step 23).
repo_init() { repo_init() {
ensure_ssh ensure_ssh
[ -f "$ROOTFS" ] || die "rootfs archive missing: $ROOTFS — build it with tools/build-bwrap-rootfs.sh or set WERKATOR_ROOTFS" [ "$SANDBOX" = "docker" ] || [ -f "$ROOTFS" ] ||
die "rootfs archive missing: $ROOTFS — build it with tools/build-bwrap-rootfs.sh or set WERKATOR_ROOTFS"
ssh "$HOST" "test -x '$WERKATOR_BIN'" || die "no instance on $HOST — run instance-install first" ssh "$HOST" "test -x '$WERKATOR_BIN'" || die "no instance on $HOST — run instance-install first"
echo "==> Cloning the watched repository" echo "==> Cloning the watched repository"
if ssh "$HOST" "test -d '$TARGET_DIR/werkator/.git'"; then if ssh "$HOST" "test -d '$REPO_DIR/.git'"; then
echo " (already cloned, skipping)" echo " (already cloned, skipping)"
else else
ssh "$HOST" "git clone '$REPO_URL' '$TARGET_DIR/werkator'" ssh "$HOST" "git clone '$REPO_URL' '$REPO_DIR'"
fi fi
if [ "$SANDBOX" = "docker" ]; then
echo "==> No rootfs needed (WERKATOR_SANDBOX=docker) — the build image is the repository's own Dockerfile"
else
echo "==> Uploading the rootfs archive (skipped when unchanged)" echo "==> Uploading the rootfs archive (skipped when unchanged)"
local rootfs_remote="$TARGET_DIR/.werkator/$(basename "$ROOTFS")" local rootfs_remote="$INSTALL_DIR/$(basename "$ROOTFS")"
local local_sha remote_sha local local_sha remote_sha
local_sha="$(sha256sum "$ROOTFS" | cut -d' ' -f1)" local_sha="$(sha256sum "$ROOTFS" | cut -d' ' -f1)"
remote_sha="$(ssh "$HOST" "sha256sum '$rootfs_remote' 2>/dev/null | cut -d' ' -f1" || true)" remote_sha="$(ssh "$HOST" "sha256sum '$rootfs_remote' 2>/dev/null | cut -d' ' -f1" || true)"
@@ -257,22 +365,78 @@ repo_init() {
remote_sha="$(ssh "$HOST" "sha256sum '$rootfs_remote' | cut -d' ' -f1")" remote_sha="$(ssh "$HOST" "sha256sum '$rootfs_remote' | cut -d' ' -f1")"
[ "$local_sha" = "$remote_sha" ] || die "rootfs upload checksum mismatch" [ "$local_sha" = "$remote_sha" ] || die "rootfs upload checksum mismatch"
fi fi
fi
echo "==> Running werkator init${WERKATOR_INIT_CONFIG:+ --apply $(basename "${WERKATOR_INIT_CONFIG}")}" echo "==> Running werkator init${WERKATOR_INIT_CONFIG:+ --apply $(basename "${WERKATOR_INIT_CONFIG}")}"
local fragment_remote local fragment_remote
fragment_remote="$(upload_fragment)" fragment_remote="$(upload_fragment)"
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' init ${fragment_remote:+--apply '$fragment_remote'}" ssh "$HOST" "cd '$REPO_DIR' && '$WERKATOR_BIN' init ${fragment_remote:+--apply '$fragment_remote'}"
echo "==> Verifying the effective configuration" echo "==> Verifying the effective configuration"
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' config:print 2>/dev/null | grep -A4 'bwrap:' | head -5" ssh "$HOST" "cd '$REPO_DIR' && '$WERKATOR_BIN' config:print 2>/dev/null | grep -A4 '$SANDBOX:' | head -5"
echo echo
echo "==> Repository ready." echo "==> Repository ready."
echo " Repo: $TARGET_DIR/werkator" echo " Repo: $REPO_DIR"
echo " Next: fill git.account/git.token in $MACHINE_CONFIG if the origin is private," echo " Next: fill git.account/git.token in $MACHINE_CONFIG if the origin is private,"
echo " then tools/remote werkator instance-start" echo " then tools/remote werkator instance-start"
} }
# 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 '$SIBLING_DIR/$name/.git'"; then
echo " (already cloned, skipping)"
else
ssh "$HOST" "git clone '$url' '$SIBLING_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 '$SIBLING_DIR/$name' && '$WERKATOR_BIN' init ${fragment_remote:+--apply '$fragment_remote'}"
echo "==> Checking the registry"
# Grepped locally: the entry may name the path absolute or as ~/<name>, and
# matching both is easier without a second layer of remote shell quoting.
if ssh "$HOST" "cat ~/.werkator.yml 2>/dev/null" |
grep -qE "path: *(~|$SIBLING_DIR)/$name[[:space:]]*$"; then
echo " (~/.werkator.yml already names this path)"
else
echo " not registered yet — add this entry to ~/.werkator.yml on $HOST:"
echo
echo " repositories:"
echo " - path: $SIBLING_DIR/$name"
echo " name: $name"
echo
fi
echo "==> Repository prepared."
echo " Repo: $SIBLING_DIR/$name"
echo " Next: fill git.account/git.token in $SIBLING_DIR/$name/.git/werkator/.werkator.yml if the origin is private"
echo " (or once for all repositories in the 'defaults' block of ~/.werkator.yml),"
echo " then restart the service — the registry is read at start."
}
# 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
@@ -284,22 +448,24 @@ instance_start() {
echo "==> Applying the instance fragment and generating the host integration (init --systemd)" echo "==> Applying the instance fragment and generating the host integration (init --systemd)"
local fragment_remote local fragment_remote
fragment_remote="$(upload_fragment)" fragment_remote="$(upload_fragment)"
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' init ${fragment_remote:+--apply '$fragment_remote'} --systemd" ssh "$HOST" "cd '$REPO_DIR' && '$WERKATOR_BIN' init ${fragment_remote:+--apply '$fragment_remote'} --systemd"
local htaccess_src="$TARGET_DIR/werkator/.git/werkator/werkator.htaccess" local htaccess_src="$REPO_DIR/.git/werkator/werkator.htaccess"
local htaccess="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/.htaccess" local htaccess="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/.htaccess"
local maintenance_src="$REPO_DIR/.git/werkator/werkator-maintenance.html"
local maintenance="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/werkator-maintenance.html"
if ssh "$HOST" "test -f '$htaccess_src'"; then 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
echo "==> Linking the units into ~/.config/systemd/user and enabling the service" echo "==> Linking the units into ~/.config/systemd/user and enabling the service"
ssh "$HOST" "mkdir -p ~/.config/systemd/user && \ ssh "$HOST" "mkdir -p ~/.config/systemd/user && \
ln -sf '$TARGET_DIR/werkator/.git/werkator/$UNIT' ~/.config/systemd/user/ && \ ln -sf '$REPO_DIR/.git/werkator/$UNIT' ~/.config/systemd/user/ && \
ln -sf '$TARGET_DIR/werkator/.git/werkator/werkator-docker-prune.service' ~/.config/systemd/user/ && \ ln -sf '$REPO_DIR/.git/werkator/werkator-docker-prune.service' ~/.config/systemd/user/ && \
ln -sf '$TARGET_DIR/werkator/.git/werkator/werkator-docker-prune.timer' ~/.config/systemd/user/ && \ ln -sf '$REPO_DIR/.git/werkator/werkator-docker-prune.timer' ~/.config/systemd/user/ && \
XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user daemon-reload && \ XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user daemon-reload && \
XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user restart '$UNIT' && \ XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user restart '$UNIT' && \
XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user status '$UNIT' --no-pager -l | head -12" XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user status '$UNIT' --no-pager -l | head -12"
@@ -317,7 +483,7 @@ port_forward() {
# the effective port, wherever it is configured (machine config or applied # the effective port, wherever it is configured (machine config or applied
# fragment) — config:print is the single answer, not this script's parser # fragment) — config:print is the single answer, not this script's parser
local remote_port local remote_port
remote_port="$(ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' config:print 2>/dev/null" | awk '/^server:/{f=1;next} f && /^ port:/{print $2; exit}' | tr -d '"')" remote_port="$(ssh "$HOST" "cd '$REPO_DIR' && '$WERKATOR_BIN' config:print 2>/dev/null" | awk '/^server:/{f=1;next} f && /^ port:/{print $2; exit}' | tr -d '"')"
[ -n "$remote_port" ] || die "no server.port configured — run 'tools/remote werkator instance-start' first" [ -n "$remote_port" ] || die "no server.port configured — run 'tools/remote werkator instance-start' first"
case "$COMMAND" in case "$COMMAND" in
@@ -359,7 +525,7 @@ port_forward() {
# CLI owns creation and format (step 23), this script only invokes it. # CLI owns creation and format (step 23), this script only invokes it.
control_token() { control_token() {
ensure_ssh ensure_ssh
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' control-token" ssh "$HOST" "cd '$REPO_DIR' && '$WERKATOR_BIN' control-token"
} }
case "$REPO" in case "$REPO" in
@@ -384,6 +550,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.

Some files were not shown because too many files have changed in this diff Show More