diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index c976dd7..dbfde90 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -63,11 +63,11 @@ Three places must stay in sync when config keys change: the `WerkatorConfig` dat ## 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 (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 for the still-unscoped controllers, and the `BuildResultRepository`/`ArtifactStore` beans are that context's. 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. Not yet repository-scoped: `RunningBuild` carries no repository, so `currentBuilds()` and the worktree pruning cannot tell repositories apart (session D, with the routes). +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//…` and `/repos//…` 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 show the switcher in the navigation. ## 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/` (`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, `@` 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/` (`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, `@` 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`). diff --git a/AGENTS.md b/AGENTS.md index 2be29fe..7754a94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ All production code lives under `de.hoennig.werkator`, with sub-packages `comman - `exitProcess` is called only from `main()` — never inside `CliRunner.run()`; this keeps the Spring context alive during tests. - 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/`; 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 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. +- 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//…` and `/api/repos//…`, with the unscoped form permanently meaning the served repository; the pages stay per repository and the navigation switches between them. - 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. - 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. diff --git a/docs/deployment.md b/docs/deployment.md index f4f5809..98b949d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -81,6 +81,60 @@ User services stop at logout unless lingering is enabled once per 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//.git ~/repos/ + ``` + +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/ && 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/ + name: # optional; default is the directory basename + ``` + + The name is the route segment (`/repos//…`) 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. werkator repo-add https://github.com//.git [] + ``` + + 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-.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//…`, `/api/repos//…`), 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 ```bash diff --git a/docs/plan/22-multi-repo.md b/docs/plan/22-multi-repo.md index 6cb91d6..095265a 100644 --- a/docs/plan/22-multi-repo.md +++ b/docs/plan/22-multi-repo.md @@ -60,13 +60,15 @@ The pinning model is untouched: pinned keys still come from each repo's machine - ~~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.~~ — done: `--repo ` (`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); the controllers still serve `registry.current()` only; `docs/deployment.md` gets the registry setup with session E. +- 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 -- Routes gain the repo segment (`/api/repos//builds/…`, `/repos//builds/`); with exactly one registered repo the today-routes keep working (redirect or alias) so bookmarks and posted Gitea links survive. -- Latest/branches/history views group by repo or gain a repo column; one instance-wide metrics page; one control token. -- Gitea status links use the repo-scoped URLs. +- ~~Routes gain the repo segment (`/api/repos//builds/…`, `/repos//builds/`); 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.~~ — done 2026-09-03, decided against the column: the pages stay per repository and the navigation gains a **repository switcher** (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~~ — 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 diff --git a/docs/prs/2026-09-03-PR#13-repository-scoped-routes.md b/docs/prs/2026-09-03-PR#13-repository-scoped-routes.md new file mode 100644 index 0000000..71188c4 --- /dev/null +++ b/docs/prs/2026-09-03-PR#13-repository-scoped-routes.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/` for the pages and `/api/repos/` for the API, where `` 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//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`. diff --git a/src/main/kotlin/de/hoennig/werkator/build/BuildExecutor.kt b/src/main/kotlin/de/hoennig/werkator/build/BuildExecutor.kt index 927783f..0de0403 100644 --- a/src/main/kotlin/de/hoennig/werkator/build/BuildExecutor.kt +++ b/src/main/kotlin/de/hoennig/werkator/build/BuildExecutor.kt @@ -5,6 +5,7 @@ import de.hoennig.werkator.config.BuildDefinition import de.hoennig.werkator.config.ConfigLoader import de.hoennig.werkator.gitea.GiteaClient import de.hoennig.werkator.repo.RepoContext +import de.hoennig.werkator.repo.RepoLinks import org.slf4j.LoggerFactory import org.springframework.context.ApplicationEventPublisher import org.springframework.context.event.ContextClosedEvent @@ -36,6 +37,7 @@ import kotlin.concurrent.thread @Service class BuildExecutor( private val configLoader: ConfigLoader, + private val repoLinks: RepoLinks, private val giteaClient: GiteaClient, private val buildRunner: BuildRunner, private val workspaces: BranchWorkspaces, @@ -97,6 +99,7 @@ class BuildExecutor( val stagingDir = Files.createTempDirectory("werkator-build-") val runningBuild = RunningBuild( + repo = repo, branch = branch, build = build, commit = commit, @@ -396,7 +399,7 @@ class BuildExecutor( sha = build.runningBuild.commit, status = status, description = description(status, duration), - targetUrl = null, + targetUrl = targetUrlOf(build), workingDir = build.repo.workingDir, // from the primary config, not the worktree: statusContext is pinned, so a // 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. */ private fun statusContextOf(build: ActiveBuild): String = try { diff --git a/src/main/kotlin/de/hoennig/werkator/build/RunningBuild.kt b/src/main/kotlin/de/hoennig/werkator/build/RunningBuild.kt index d1178f2..dc81a83 100644 --- a/src/main/kotlin/de/hoennig/werkator/build/RunningBuild.kt +++ b/src/main/kotlin/de/hoennig/werkator/build/RunningBuild.kt @@ -1,11 +1,19 @@ package de.hoennig.werkator.build import de.hoennig.werkator.config.BuildDefinition +import de.hoennig.werkator.repo.RepoContext import java.nio.file.Path import java.time.Instant /** Handle to a build accepted by the [BuildExecutor]; log paths become valid once the build runs. */ 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. */ val branch: String, /** The build definition (job) this build runs; its settings are resolved from config at run time. */ diff --git a/src/main/kotlin/de/hoennig/werkator/repo/RepoLinks.kt b/src/main/kotlin/de/hoennig/werkator/repo/RepoLinks.kt new file mode 100644 index 0000000..9a71f00 --- /dev/null +++ b/src/main/kotlin/de/hoennig/werkator/repo/RepoLinks.kt @@ -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" + } +} diff --git a/src/main/kotlin/de/hoennig/werkator/server/ApiDtos.kt b/src/main/kotlin/de/hoennig/werkator/server/ApiDtos.kt index eb88f45..b79d1d0 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/ApiDtos.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/ApiDtos.kt @@ -27,6 +27,7 @@ data class BuildResultDto( fun from( result: BuildResult, isLatestGreen: Boolean = false, + base: String = "", ) = BuildResultDto( branch = result.branch, name = result.name, @@ -36,7 +37,7 @@ data class BuildResultDto( runningSince = result.runningSince, durationSeconds = result.duration?.seconds, artifactKey = result.artifactKey, - latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.name) else null, + latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.name, base) else null, ) } } diff --git a/src/main/kotlin/de/hoennig/werkator/server/ArtifactFileController.kt b/src/main/kotlin/de/hoennig/werkator/server/ArtifactFileController.kt index b03d6c1..011a834 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/ArtifactFileController.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/ArtifactFileController.kt @@ -1,6 +1,7 @@ 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 org.springframework.core.io.FileSystemResource import org.springframework.core.io.Resource @@ -25,17 +26,22 @@ import kotlin.streams.asSequence */ @RestController class ArtifactFileController( - private val artifactStore: ArtifactStore, 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( + @PathVariable(name = "repo", required = false) repoName: String?, @PathVariable artifactKey: String, @PathVariable path: String, request: HttpServletRequest, ): ResponseEntity { val artifactDir = - artifactStore.artifactDir(artifactKey) + repoOf(repoName).artifactStore.artifactDir(artifactKey) ?: return ResponseEntity.notFound().build() val relativePath = path.removePrefix("/").removeSuffix("/") 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 * `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( + @PathVariable(name = "repo", required = false) repoName: String?, @PathVariable branchKey: String, @PathVariable path: String, request: HttpServletRequest, ): ResponseEntity { - val build = branchPermalinks.latestGreenBuild(branchKey) + val repo = repoOf(repoName) + val build = branchPermalinks.latestGreenBuild(repo, branchKey) val artifactDir = - artifactStore.artifactDir(build.artifactKey) + repo.artifactStore.artifactDir(build.artifactKey) ?: throw ResponseStatusException( HttpStatus.NOT_FOUND, "the artifacts of build '${build.artifactKey}' are not stored anymore", diff --git a/src/main/kotlin/de/hoennig/werkator/server/BranchListing.kt b/src/main/kotlin/de/hoennig/werkator/server/BranchListing.kt index 2bd3d65..f80d20f 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/BranchListing.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/BranchListing.kt @@ -17,7 +17,10 @@ import org.springframework.stereotype.Component class BranchListing( private val gitService: GitService, ) { - fun branches(repo: RepoContext): List { + fun branches( + repo: RepoContext, + base: String = "", + ): List { val repository = repo.results val heads = gitService.originBranchHeads(repo.workingDir) 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 val isLatestGreen = 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 } } diff --git a/src/main/kotlin/de/hoennig/werkator/server/BranchPermalinks.kt b/src/main/kotlin/de/hoennig/werkator/server/BranchPermalinks.kt index 38ccd1d..0e25bdb 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/BranchPermalinks.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/BranchPermalinks.kt @@ -2,7 +2,7 @@ package de.hoennig.werkator.server import de.hoennig.werkator.build.ArtifactKeys 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.stereotype.Component import org.springframework.web.server.ResponseStatusException @@ -18,10 +18,12 @@ import org.springframework.web.server.ResponseStatusException * artifacts. */ @Component -class BranchPermalinks( - private val repository: BuildResultRepository, -) { - fun latestGreenBuild(branchKey: String): BuildResult { +class BranchPermalinks { + fun latestGreenBuild( + repo: RepoContext, + branchKey: String, + ): BuildResult { + val repository = repo.results val names = repository .latestPerName() @@ -41,7 +43,16 @@ class BranchPermalinks( } 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/`, 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)}" } } diff --git a/src/main/kotlin/de/hoennig/werkator/server/BuildsApiController.kt b/src/main/kotlin/de/hoennig/werkator/server/BuildsApiController.kt index ce4c5da..1eec728 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/BuildsApiController.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/BuildsApiController.kt @@ -1,16 +1,17 @@ package de.hoennig.werkator.server -import de.hoennig.werkator.build.ArtifactStore import de.hoennig.werkator.build.BuildExecutor import de.hoennig.werkator.build.BuildResult -import de.hoennig.werkator.build.BuildResultRepository import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.config.BuildDefinition import de.hoennig.werkator.git.GitService 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.ResponseEntity 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.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -32,34 +33,72 @@ import java.nio.file.StandardOpenOption */ @RestController class BuildsApiController( - private val repository: BuildResultRepository, private val buildExecutor: BuildExecutor, - private val artifactStore: ArtifactStore, private val controlTokens: ControlTokenService, private val gitService: GitService, 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//…`) 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") - fun latest(): List = repository.latestPerName().map { BuildResultDto.from(it, it.isLatestGreen()) } + private fun RepoContext.isLatestGreen(result: BuildResult): Boolean = + 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 = notFound(e.message ?: "unknown repository") + + @GetMapping("/api/builds/latest", "/api/repos/{repo}/builds/latest") + fun latest( + @PathVariable(name = "repo", required = false) repoName: String?, + ): List { + 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`. */ - @GetMapping("/api/branches") - fun branches(): List = branchListing.branches(repo) + @GetMapping("/api/branches", "/api/repos/{repo}/branches") + fun branches( + @PathVariable(name = "repo", required = false) repoName: String?, + ): List { + val repo = repoOf(repoName) + return branchListing.branches(repo, uiBase(repo)) + } - @GetMapping("/api/builds/history") - fun history(): List = repository.history().map { BuildResultDto.from(it, it.isLatestGreen()) } + @GetMapping("/api/builds/history", "/api/repos/{repo}/builds/history") + fun history( + @PathVariable(name = "repo", required = false) repoName: String?, + ): List { + 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 — several are possible, up to `executor.maxConcurrent`. */ - @GetMapping("/api/builds/current") - fun current(): List { - val results = repository.history() - return buildExecutor.currentBuilds().map { build -> + /** + * The currently executing builds of the served repository — several are possible, + * up to `executor.maxConcurrent`. The executor is instance-global and returns the + * builds of every registered repository, so this view filters: its [repository] + * holds only this repository's results, and a foreign build looked up in them + * would fall back to RUNNING and show a status nobody recorded. + */ + @GetMapping("/api/builds/current", "/api/repos/{repo}/builds/current") + fun current( + @PathVariable(name = "repo", required = false) repoName: String?, + ): List { + val repo = repoOf(repoName) + val results = repo.results.history() + return buildExecutor.currentBuilds().filter { it.repo === repo }.map { build -> CurrentBuildDto( branch = build.branch, name = build.name, @@ -76,13 +115,15 @@ class BuildsApiController( } /** 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( + @PathVariable(name = "repo", required = false) repoName: String?, @PathVariable artifactKey: String, @RequestParam(defaultValue = "0") offset: Long, ): ResponseEntity { + val repo = repoOf(repoName) 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 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 * 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( + @PathVariable(name = "repo", required = false) repoName: String?, @RequestParam branch: String, @RequestParam(defaultValue = "false") atOriginHead: Boolean, @RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?, ): ResponseEntity { 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 val branchName = latest?.branch ?: branch val commit = @@ -143,12 +187,20 @@ class BuildsApiController( } /** 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( + @PathVariable(name = "repo", required = false) repoName: String?, @PathVariable artifactKey: String, @RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?, ): ResponseEntity { 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)) { 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`. */ - @DeleteMapping("/api/builds/{artifactKey}") + @DeleteMapping("/api/builds/{artifactKey}", "/api/repos/{repo}/builds/{artifactKey}") fun delete( + @PathVariable(name = "repo", required = false) repoName: String?, @PathVariable artifactKey: String, @RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?, ): ResponseEntity { 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'") } - artifactStore.prune(repository.history()) + repo.artifactStore.prune(repo.results.history()) return ResponseEntity.ok(mapOf("deleted" to artifactKey)) } diff --git a/src/main/kotlin/de/hoennig/werkator/server/UiController.kt b/src/main/kotlin/de/hoennig/werkator/server/UiController.kt index f5cfe76..d591238 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/UiController.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/UiController.kt @@ -1,21 +1,23 @@ package de.hoennig.werkator.server -import de.hoennig.werkator.build.ArtifactStore import de.hoennig.werkator.build.BuildExecutor import de.hoennig.werkator.build.BuildResult -import de.hoennig.werkator.build.BuildResultRepository import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.config.ConfigFiles import de.hoennig.werkator.config.ConfigLoader import de.hoennig.werkator.git.GitService import de.hoennig.werkator.metrics.SystemMetricsCollector import de.hoennig.werkator.repo.RepoContext +import de.hoennig.werkator.repo.RepoLinks +import de.hoennig.werkator.repo.RepoRegistry import jakarta.servlet.http.HttpServletRequest import org.springframework.beans.factory.ObjectProvider import org.springframework.boot.info.BuildProperties import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller 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.PathVariable import org.springframework.web.server.ResponseStatusException @@ -34,19 +36,32 @@ import kotlin.streams.asSequence */ @Controller class UiController( - private val repository: BuildResultRepository, private val buildExecutor: BuildExecutor, - private val artifactStore: ArtifactStore, private val configLoader: ConfigLoader, private val gitService: GitService, private val metricsCollector: SystemMetricsCollector, private val branchListing: BranchListing, private val branchPermalinks: BranchPermalinks, private val buildProperties: ObjectProvider, - 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//…` 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 = + ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.message) /** * Permanent redirects for the legacy script's static page names, so bookmarks @@ -58,11 +73,15 @@ class UiController( setStatusCode(HttpStatus.MOVED_PERMANENTLY) } - @GetMapping("/") - fun latest(model: Model): String { - val links = baseModel(model, view = "latest", pageTitle = "Latest Builds") - model.addAttribute("rows", repository.latestPerName().map { BuildRowView.from(it, links, permanentUrlOf(it)) }) - model.addAttribute("apiPath", "/api/builds/latest") + @GetMapping("/", "/repos/{repo}") + fun latest( + @PathVariable(name = "repo", required = false) repoName: String?, + model: Model, + ): 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("restartAtOriginHead", false) 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. */ - @GetMapping("/branches") - fun branches(model: Model): String { - val links = baseModel(model, view = "branches", pageTitle = "Branches") - model.addAttribute("rows", branchListing.branches(repo).map { BuildRowView.from(it, links) }) - model.addAttribute("apiPath", "/api/branches") + @GetMapping("/branches", "/repos/{repo}/branches") + fun branches( + @PathVariable(name = "repo", required = false) repoName: String?, + model: Model, + ): 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) // a row here stands for a branch, not for a past run model.addAttribute("restartAtOriginHead", true) @@ -82,11 +105,15 @@ class UiController( return "builds" } - @GetMapping("/history") - fun history(model: Model): String { - val links = baseModel(model, view = "history", pageTitle = "Build History") - model.addAttribute("rows", repository.history().map { BuildRowView.from(it, links, permanentUrlOf(it)) }) - model.addAttribute("apiPath", "/api/builds/history") + @GetMapping("/history", "/repos/{repo}/history") + fun history( + @PathVariable(name = "repo", required = false) repoName: String?, + model: Model, + ): 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("restartAtOriginHead", false) 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. */ - private fun permanentUrlOf(result: BuildResult): String? = - if (repository.latestGreenFor(result.name)?.artifactKey == result.artifactKey) { - BranchPermalinks.permanentUrl(result.name) + private fun permanentUrlOf( + repo: RepoContext, + result: BuildResult, + ): String? = + if (repo.results.latestGreenFor(result.name)?.artifactKey == result.artifactKey) { + BranchPermalinks.permanentUrl(result.name, uiBase(repo)) } else { null } - @GetMapping("/current") - fun current(model: Model): String { - val links = baseModel(model, view = "current", pageTitle = "Current Builds") - val results = repository.history() + @GetMapping("/current", "/repos/{repo}/current") + fun current( + @PathVariable(name = "repo", required = false) repoName: String?, + 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 = - buildExecutor.currentBuilds().map { build -> + buildExecutor.currentBuilds().filter { it.repo === repo }.map { build -> CurrentBuildView( branch = build.branch, name = build.name, @@ -130,35 +164,39 @@ class UiController( /** Hand-maintained release notes (templates/releases.html); linked from the version in the footer. */ @GetMapping("/releases") fun releases(model: Model): String { - baseModel(model, view = "releases", pageTitle = "Release Notes") + baseModel(model, view = "releases", pageTitle = "Release Notes", repo = registry.current()) return "releases" } + /** One metrics page for the whole instance — the resources are the instance's, not a repository's. */ @GetMapping("/system") 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())) return "system" } /** 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( + @PathVariable(name = "repo", required = false) repoName: String?, @PathVariable artifactKey: String, model: Model, ): String { - val result = repository.history().firstOrNull { it.artifactKey == artifactKey } - val artifactDir = artifactStore.artifactDir(artifactKey) + val repo = repoOf(repoName) + val result = repo.results.history().firstOrNull { it.artifactKey == artifactKey } + val artifactDir = repo.artifactStore.artifactDir(artifactKey) if (result == null && artifactDir == null) { throw ResponseStatusException(HttpStatus.NOT_FOUND, "no build with artifact key '$artifactKey'") } return artifactIndexView( model, pageTitle = "Build Artifacts", + repo = repo, result = result, artifactKey = artifactKey, 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 * outlives artifact pruning. */ - @GetMapping("/branches/{branchKey}") + @GetMapping("/branches/{branchKey}", "/repos/{repo}/branches/{branchKey}") fun latestGreenArtifactIndex( + @PathVariable(name = "repo", required = false) repoName: String?, @PathVariable branchKey: String, model: Model, ): String { - val build = branchPermalinks.latestGreenBuild(branchKey) + val repo = repoOf(repoName) + val build = branchPermalinks.latestGreenBuild(repo, branchKey) model.addAttribute("permanentBranch", build.branch) - model.addAttribute("concreteUrl", "/builds/${build.artifactKey}") + model.addAttribute("concreteUrl", uiBase(repo) + "/builds/${build.artifactKey}") return artifactIndexView( model, pageTitle = "Latest Green Build", + repo = repo, result = build, artifactKey = build.artifactKey, - artifactDir = artifactStore.artifactDir(build.artifactKey), - filesBase = "/branches/$branchKey", + artifactDir = repo.artifactStore.artifactDir(build.artifactKey), + filesBase = uiBase(repo) + "/branches/$branchKey", ) } private fun artifactIndexView( model: Model, pageTitle: String, + repo: RepoContext, result: BuildResult?, artifactKey: String, artifactDir: Path?, filesBase: 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("filesBase", filesBase) model.addAttribute("result", result?.let { BuildRowView.from(it, links) }) model.addAttribute("hasArtifacts", artifactDir != null) - model.addAttribute("buildCommand", result?.let { buildCommandOf(it) }) + model.addAttribute("buildCommand", result?.let { buildCommandOf(repo, it) }) model.addAttribute( "logs", artifactDir?.let { logFiles(it, scanForFailure = result != null && result.status != BuildStatus.SUCCESS) } @@ -226,21 +268,41 @@ class UiController( .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. */ private fun baseModel( model: Model, view: String, pageTitle: String, + repo: RepoContext, ): GiteaWebLinks { - val config = configLoader.load(workingDir) + val config = configLoader.load(repo.workingDir) val links = GiteaWebLinks(config.gitea) val repoName = listOf(config.gitea.owner.trim(), config.gitea.repo.trim()) .filter { it.isNotEmpty() } .joinToString("/") + val served = registry.all() model.addAttribute("view", view) model.addAttribute("pageTitle", pageTitle) 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("impressumUrl", config.server.impressumUrl.trim()) 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. * 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 = try { configLoader.loadWithBranchLayer( diff --git a/src/main/kotlin/de/hoennig/werkator/server/UiViews.kt b/src/main/kotlin/de/hoennig/werkator/server/UiViews.kt index cd1dc0b..479a747 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/UiViews.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/UiViews.kt @@ -252,3 +252,10 @@ data class SystemMetricsView( ) } } + +/** One entry of the repository switcher in the navigation (ADR 0009). */ +data class RepoLinkView( + val name: String, + val url: String, + val current: Boolean, +) diff --git a/src/main/kotlin/de/hoennig/werkator/server/UnknownRepositoryException.kt b/src/main/kotlin/de/hoennig/werkator/server/UnknownRepositoryException.kt new file mode 100644 index 0000000..aff166d --- /dev/null +++ b/src/main/kotlin/de/hoennig/werkator/server/UnknownRepositoryException.kt @@ -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'") diff --git a/src/main/kotlin/de/hoennig/werkator/watcher/Watcher.kt b/src/main/kotlin/de/hoennig/werkator/watcher/Watcher.kt index 2f1fcd1..bdb627d 100644 --- a/src/main/kotlin/de/hoennig/werkator/watcher/Watcher.kt +++ b/src/main/kotlin/de/hoennig/werkator/watcher/Watcher.kt @@ -506,7 +506,10 @@ class Watcher( } val keep = originBranches.map { ArtifactKeys.branchKey(it) }.toMutableSet() // 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 .latestPerName() .filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING } diff --git a/src/main/resources/static/werkator.js b/src/main/resources/static/werkator.js index f3b2be9..662f600 100644 --- a/src/main/resources/static/werkator.js +++ b/src/main/resources/static/werkator.js @@ -99,6 +99,17 @@ function metaContent(name) { const giteaRepoUrl = metaContent("werkator-gitea-repo-url"); +// Empty with one served repository, `/repos/` 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 // 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; @@ -389,7 +400,7 @@ function renderBuildRow(build, allowRestart, restartAtOriginHead) { const inProgress = build.status === "running" || build.status === "pending"; if (build.artifactKey) { 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"; artifactsCell.appendChild(artifactLink); } @@ -542,7 +553,7 @@ function initCurrentBuilds() { return; } 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); logOffsets.set(build.artifactKey, tail.nextOffset); if (tail.content) { @@ -690,11 +701,11 @@ document.addEventListener("click", async (event) => { try { if (action === "restart") { 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") { - await sendAction(`/api/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST"); + await sendAction(`${apiBase()}/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST"); } else if (action === "delete") { - await sendAction("/api/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE"); + await sendAction(apiBase() + "/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE"); } if (refreshNow) { refreshNow(); diff --git a/src/main/resources/templates/builds.html b/src/main/resources/templates/builds.html index 777e42d..6d8e0dd 100644 --- a/src/main/resources/templates/builds.html +++ b/src/main/resources/templates/builds.html @@ -52,13 +52,13 @@ 1:23 📄 🔗 - 📡 n/a diff --git a/src/main/resources/templates/current.html b/src/main/resources/templates/current.html index ab1d5f6..2c353f7 100644 --- a/src/main/resources/templates/current.html +++ b/src/main/resources/templates/current.html @@ -5,7 +5,7 @@

-
+

No build is currently running.

diff --git a/src/main/resources/templates/fragments.html b/src/main/resources/templates/fragments.html index d1b3e93..1fecc9a 100644 --- a/src/main/resources/templates/fragments.html +++ b/src/main/resources/templates/fragments.html @@ -7,11 +7,14 @@ + +

- + Latest Builds owner/repo

@@ -20,15 +23,23 @@
+ + static diff --git a/src/test/kotlin/de/hoennig/werkator/artifacts/BuildExecutorArtifactIntegrationTest.kt b/src/test/kotlin/de/hoennig/werkator/artifacts/BuildExecutorArtifactIntegrationTest.kt index d39dc84..1fa7050 100644 --- a/src/test/kotlin/de/hoennig/werkator/artifacts/BuildExecutorArtifactIntegrationTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/artifacts/BuildExecutorArtifactIntegrationTest.kt @@ -7,11 +7,14 @@ import de.hoennig.werkator.build.ProcessBuildRunner import de.hoennig.werkator.config.ConfigLoader import de.hoennig.werkator.gitea.GiteaClient 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.core.spec.style.FunSpec import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain +import io.mockk.every import io.mockk.mockk import org.springframework.context.ApplicationEventPublisher import java.nio.file.Files @@ -42,6 +45,7 @@ class BuildExecutorArtifactIntegrationTest : FunSpec() { val executor = BuildExecutor( configLoader = ConfigLoader(), + repoLinks = RepoLinks(mockk().also { every { it.all() } returns listOf(repo) }), giteaClient = mockk(relaxed = true), buildRunner = ProcessBuildRunner(), workspaces = BranchWorkspaces { _, _, _ -> workspace }, diff --git a/src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt b/src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt index fdc167a..1174a1f 100644 --- a/src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt @@ -3,6 +3,8 @@ package de.hoennig.werkator.build import de.hoennig.werkator.config.ConfigLoader import de.hoennig.werkator.gitea.GiteaClient 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.core.spec.style.FunSpec import io.kotest.matchers.booleans.shouldBeFalse @@ -53,6 +55,7 @@ class BuildExecutorTest : FunSpec() { val executor = BuildExecutor( configLoader = ConfigLoader(), + repoLinks = RepoLinks(mockk().also { every { it.all() } returns listOf(repo) }), giteaClient = giteaClient, buildRunner = buildRunner, workspaces = workspaces, @@ -138,6 +141,35 @@ class BuildExecutorTest : FunSpec() { 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") { val h = harness(buildCommand = "pwd", workspaceSubdir = "branch-workspace") diff --git a/src/test/kotlin/de/hoennig/werkator/commands/ConsoleBuildRunnerTest.kt b/src/test/kotlin/de/hoennig/werkator/commands/ConsoleBuildRunnerTest.kt index 8558f39..01ac78e 100644 --- a/src/test/kotlin/de/hoennig/werkator/commands/ConsoleBuildRunnerTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/commands/ConsoleBuildRunnerTest.kt @@ -34,6 +34,7 @@ class ConsoleBuildRunnerTest : FunSpec() { private fun runningBuild(stagingDir: Path) = RunningBuild( + repo = repo, branch = "main", commit = "0123456789abcdef", artifactKey = "main-key", diff --git a/src/test/kotlin/de/hoennig/werkator/server/ArtifactFileControllerTest.kt b/src/test/kotlin/de/hoennig/werkator/server/ArtifactFileControllerTest.kt index 55c6ada..de1b54d 100644 --- a/src/test/kotlin/de/hoennig/werkator/server/ArtifactFileControllerTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/server/ArtifactFileControllerTest.kt @@ -4,6 +4,8 @@ import com.ninjasquad.springmockk.MockkBean import de.hoennig.werkator.build.ArtifactStore import de.hoennig.werkator.build.BuildResult 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.mockk.clearMocks import io.mockk.every @@ -32,6 +34,12 @@ class ArtifactFileControllerTest : FunSpec() { @MockkBean 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 greenBuild = @@ -46,12 +54,18 @@ class ArtifactFileControllerTest : FunSpec() { init { 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("known-key") } returns artifactDir - every { branchPermalinks.latestGreenBuild(any()) } throws + every { branchPermalinks.latestGreenBuild(any(), any()) } throws 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") { @@ -188,7 +202,7 @@ class ArtifactFileControllerTest : FunSpec() { } 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 .perform(get("/branches/main/build.log")) diff --git a/src/test/kotlin/de/hoennig/werkator/server/BranchPermalinksTest.kt b/src/test/kotlin/de/hoennig/werkator/server/BranchPermalinksTest.kt index 91613db..9058ca0 100644 --- a/src/test/kotlin/de/hoennig/werkator/server/BranchPermalinksTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/server/BranchPermalinksTest.kt @@ -4,6 +4,7 @@ import de.hoennig.werkator.build.ArtifactKeys import de.hoennig.werkator.build.BuildResult import de.hoennig.werkator.build.BuildResultRepository import de.hoennig.werkator.build.BuildStatus +import de.hoennig.werkator.repo.RepoContext import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe @@ -17,7 +18,8 @@ import java.time.Instant class BranchPermalinksTest : FunSpec() { private val repository = mockk() - private val permalinks = BranchPermalinks(repository) + private val repo = mockk().also { every { it.results } returns repository } + private val permalinks = BranchPermalinks() private fun result( branch: String, @@ -36,20 +38,20 @@ class BranchPermalinksTest : FunSpec() { every { repository.latestPerName() } returns listOf(result("feature/x"), result("main")) 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") { every { repository.latestPerName() } returns listOf(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") { every { repository.latestPerName() } returns listOf(result("main")) - val exception = shouldThrow { permalinks.latestGreenBuild("gone") } + val exception = shouldThrow { permalinks.latestGreenBuild(repo, "gone") } 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.latestGreenFor("main") } returns null - val exception = shouldThrow { permalinks.latestGreenBuild("main") } + val exception = shouldThrow { permalinks.latestGreenBuild(repo, "main") } 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") { every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x")) - val exception = shouldThrow { permalinks.latestGreenBuild("feature_x") } + val exception = shouldThrow { permalinks.latestGreenBuild(repo, "feature_x") } exception.statusCode shouldBe HttpStatus.CONFLICT 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.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") { @@ -89,7 +91,7 @@ class BranchPermalinksTest : FunSpec() { every { repository.latestGreenFor("main@nightly") } returns nightly // sanitized like any branch key: the '@' becomes '_' in the URL - permalinks.latestGreenBuild("main_nightly") shouldBe nightly + permalinks.latestGreenBuild(repo, "main_nightly") shouldBe nightly } } } diff --git a/src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt b/src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt index 9dbd121..3516ae2 100644 --- a/src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt @@ -9,12 +9,16 @@ import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.RunningBuild import de.hoennig.werkator.git.GitService 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.mockk.clearMocks import io.mockk.every +import io.mockk.mockk import io.mockk.verify import org.springframework.beans.factory.annotation.Autowired 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.request.MockMvcRequestBuilders.delete import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get @@ -27,6 +31,7 @@ import java.time.Duration import java.time.Instant @WebMvcTest(BuildsApiController::class, properties = ["spring.main.web-application-type=servlet"]) +@Import(RepoLinks::class) class BuildsApiControllerTest : FunSpec() { private val tempDir: Path = Files.createTempDirectory("werkator-server-test") @@ -54,6 +59,9 @@ class BuildsApiControllerTest : FunSpec() { @MockkBean lateinit var repo: RepoContext + @MockkBean + lateinit var registry: RepoRegistry + private val startedAt = Instant.parse("2026-07-07T10:00:00Z") private val successResult = @@ -68,6 +76,7 @@ class BuildsApiControllerTest : FunSpec() { private fun runningBuild(liveLogFile: Path) = RunningBuild( + repo = repo, branch = "main", commit = successResult.commit, artifactKey = "main-abc123-running", @@ -78,8 +87,16 @@ class BuildsApiControllerTest : FunSpec() { init { 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.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() == "secret" } every { repository.latestGreenFor(any()) } returns null } @@ -133,6 +150,35 @@ class BuildsApiControllerTest : FunSpec() { .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(), + 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()) + 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") { val liveLogFile = Files.writeString(tempDir.resolve("tail.log"), "hello world") val build = runningBuild(liveLogFile) @@ -299,6 +345,8 @@ class BuildsApiControllerTest : FunSpec() { } 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("unknown-key") } returns false @@ -311,6 +359,32 @@ class BuildsApiControllerTest : FunSpec() { .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") { mockMvc .perform(post("/api/builds/restart").param("branch", "main").param("token", "secret")) diff --git a/src/test/kotlin/de/hoennig/werkator/server/PermanentBranchRoutesTest.kt b/src/test/kotlin/de/hoennig/werkator/server/PermanentBranchRoutesTest.kt index 6f87757..aa47ab8 100644 --- a/src/test/kotlin/de/hoennig/werkator/server/PermanentBranchRoutesTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/server/PermanentBranchRoutesTest.kt @@ -11,12 +11,15 @@ import de.hoennig.werkator.config.WerkatorConfig import de.hoennig.werkator.git.GitService import de.hoennig.werkator.metrics.SystemMetricsCollector 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.mockk.clearMocks import io.mockk.every import org.hamcrest.Matchers.containsString import org.springframework.beans.factory.annotation.Autowired 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.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content @@ -36,6 +39,7 @@ import java.time.Instant controllers = [UiController::class, ArtifactFileController::class], properties = ["spring.main.web-application-type=servlet"], ) +@Import(RepoLinks::class) class PermanentBranchRoutesTest : FunSpec() { @Autowired lateinit var mockMvc: MockMvc @@ -70,6 +74,9 @@ class PermanentBranchRoutesTest : FunSpec() { @MockkBean lateinit var repo: RepoContext + @MockkBean + lateinit var registry: RepoRegistry + private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test") private val greenBuild = @@ -95,14 +102,22 @@ class PermanentBranchRoutesTest : FunSpec() { branchListing, branchPermalinks, repo, + registry, ) + every { repo.name } returns "test" 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.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig() every { gitService.showFileAtCommit(any(), any(), any()) } returns null every { controlTokens.token() } returns "test-token" 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 } diff --git a/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt b/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt index 6dcfd43..aa2b5f4 100644 --- a/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt @@ -18,16 +18,20 @@ import de.hoennig.werkator.metrics.MetricAggregate import de.hoennig.werkator.metrics.SystemMetrics import de.hoennig.werkator.metrics.SystemMetricsCollector 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.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain import io.mockk.clearMocks import io.mockk.every +import io.mockk.mockk import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.not import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest +import org.springframework.context.annotation.Import import org.springframework.http.HttpStatus import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get @@ -42,6 +46,7 @@ import java.time.Duration import java.time.Instant @WebMvcTest(UiController::class, properties = ["spring.main.web-application-type=servlet"]) +@Import(RepoLinks::class) class UiControllerTest : FunSpec() { private val tempDir: Path = Files.createTempDirectory("werkator-ui-test") @@ -78,6 +83,9 @@ class UiControllerTest : FunSpec() { @MockkBean lateinit var repo: RepoContext + @MockkBean + lateinit var registry: RepoRegistry + private val startedAt = Instant.parse("2026-07-07T10:00:00Z") private val emptySystemMetrics = @@ -119,8 +127,16 @@ class UiControllerTest : FunSpec() { branchListing, branchPermalinks, repo, + registry, ) + every { repo.name } returns "test" 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( server = ServerConfig(impressumUrl = "https://example.org/imprint"), @@ -144,6 +160,40 @@ class UiControllerTest : FunSpec() { .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(""""""))) + .andExpect(content().string(not(containsString("""class="repo-switch"""")))) + } + + test("with several served repositories every link names its repository and the switcher appears") { + val other = mockk() + 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(""""""))) + .andExpect(content().string(containsString("""class="repo-switch""""))) + .andExpect(content().string(containsString("""href="/repos/other""""))) + } + + 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") { every { repository.latestPerName() } returns listOf(successResult) @@ -246,6 +296,7 @@ class UiControllerTest : FunSpec() { test("current view renders a card per running build with cancel button and started-at attribute") { val build = RunningBuild( + repo = repo, branch = "main", commit = successResult.commit, artifactKey = "main-abc123-running", @@ -502,7 +553,7 @@ class UiControllerTest : FunSpec() { Files.writeString(artifactDir.resolve("build.stdout.log"), "out") Files.createDirectories(artifactDir.resolve("reports/tests/test")) Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "") - every { branchPermalinks.latestGreenBuild("main") } returns successResult + every { branchPermalinks.latestGreenBuild(any(), "main") } returns successResult every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir mockMvc @@ -516,7 +567,7 @@ class UiControllerTest : FunSpec() { } 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") mockMvc diff --git a/src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt b/src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt index be58238..218d798 100644 --- a/src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt @@ -94,7 +94,7 @@ class WatcherTest : FunSpec() { val branch = secondArg() val commit = thirdArg() startedBuilds += branch to commit - runningBuild(branch, commit) + runningBuild(repo, branch, commit) } every { artifactStore.prune(any()) } returns emptyList() } @@ -131,11 +131,13 @@ class WatcherTest : FunSpec() { } private fun runningBuild( + repo: RepoContext, branch: String, commit: String, ): RunningBuild { val stagingDir = Files.createTempDirectory("werkator-watcher-staging") return RunningBuild( + repo = repo, branch = branch, commit = commit, 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") { val harness = Harness() 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.localBranches(any()) } returns listOf("main") every { harness.gitService.hasNewCommits("main", any()) } returns true @@ -767,6 +769,28 @@ class WatcherTest : FunSpec() { 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") diff --git a/tools/remote b/tools/remote index c208cb4..bd0083c 100755 --- a/tools/remote +++ b/tools/remote @@ -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-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-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] port-forward start background tunnel to the Werkator UI # tools/remote [--env-file FILE] port-forward stop @@ -273,6 +275,53 @@ repo_init() { 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 [name]" + local name="${2:-$(basename "$url" .git)}" + case "$name" in + */*|"") die "the repository name is one path segment (it becomes the route segment /repos/)" ;; + esac + + ensure_ssh + ssh "$HOST" "test -x '$WERKATOR_BIN'" || die "no instance on $HOST — run instance-install first" + + echo "==> Cloning $url as '$name'" + if ssh "$HOST" "test -d '$TARGET_DIR/$name/.git'"; then + echo " (already cloned, skipping)" + else + ssh "$HOST" "git clone '$url' '$TARGET_DIR/$name'" + fi + + echo "==> Running werkator init in $name" + ssh "$HOST" "cd '$TARGET_DIR/$name' && '$WERKATOR_BIN' init" + + echo "==> Checking the registry" + if ssh "$HOST" "grep -q -- '$TARGET_DIR/$name' ~/.werkator.yml 2>/dev/null"; then + echo " (~/.werkator.yml already names this path)" + else + echo " not registered yet — add this entry to ~/.werkator.yml on $HOST:" + echo + echo " repositories:" + echo " - path: $TARGET_DIR/$name" + echo " name: $name" + echo + fi + + echo "==> Repository prepared." + echo " Repo: $TARGET_DIR/$name" + echo " Next: fill git.account/git.token in $TARGET_DIR/$name/.git/werkator/.werkator.yml if the origin is private" + echo " (or once for all repositories in the 'defaults' block of ~/.werkator.yml)," + echo " then restart the service — the registry is read at start." +} + # Start the server as a systemd user unit behind the managed Apache. All # configuration comes from the instance fragment (server.port, publicBaseUrl, # systemd limits); init generates the units AND the .htaccess — this script @@ -384,6 +433,9 @@ case "$REPO" in repo-init) repo_init ;; + repo-add) + repo_add "${3:-}" "${4:-}" + ;; control-token) control_token ;;