Merge PR #13: Routen, UI und Artefakt-Links tragen das Repository
/repos/<name>/… und /api/repos/<name>/… neben den bisherigen Pfaden; die Kopfzeile bekommt einen Repo-Umschalter, sobald mehr als ein Repository registriert ist, und die Gitea-Statusmeldung trägt eine dauerhafte URL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -63,11 +63,11 @@ Three places must stay in sync when config keys change: the `WerkatorConfig` dat
|
|||||||
|
|
||||||
## Repository Context
|
## Repository Context
|
||||||
|
|
||||||
Everything repository-scoped goes through a `RepoContext` (`repo` package, ADR 0009): the primary checkout (`workingDir`), the repository's `BuildResultRepository` (`.git/werkator/build-results.json`), its `ArtifactStore` (keyed by the repository path), and a short `name` defaulting to the directory basename — the future route segment. `RepoContexts.open(dir)` builds one (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/<name>/…` and `/repos/<name>/…` beside the unscoped form, an unknown name is a 404, and the link prefix follows the number of served repositories — one repository keeps its existing URLs, several make every link name its repository and show the switcher in the navigation.
|
||||||
|
|
||||||
## Build Execution
|
## Build Execution
|
||||||
|
|
||||||
`BuildExecutor` runs builds asynchronously: `startBuild(repo, branch, commit, build)` takes the `RepoContext` first; up to `executor.maxConcurrent` builds run concurrently across all repositories (default 1, sized once from the first build's config — an instance-level setting), but never more than one build per (repository, branch) at a time. Each branch builds in its own reusable git worktree at `.git/werkator/worktrees/<branchKey>` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted in the build's own `RepoContext.results` (JSON file under that repository's `.git/werkator/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. Every run belongs to a named build definition (job, ADR 0007): the YAML `builds` section defines triggers (`onPush`, `atTimes`), branch selectors (`branches` globs, `activeWithin`), and build-setting overrides applied last over the merged branch config; the implicit `default` build (`onPush`, all branches) preserves the job-less behavior. Definitions are part of the branch layer — a branch may add and override its own, and they apply to that branch alone (its selectors are evaluated for it only) — while `executor.maxConcurrent` stays pinned. `BuildResult.build` records the job; restart, retry, and startup recovery re-run by that name, resolving settings from the *current* config. `BuildResult.name` — the pool, `<branch>@<build>` for non-default builds — keys everything display- and retention-side (repository grouping via `latestPerName`, retention pools, branches-view rows, permanent latest-green links), while `BuildResult.branch` keys everything git-side: origin lookups, gone-from-origin pruning, worktrees (every build runs in its branch's worktree, serialized per branch), and Gitea links/statuses. `branches.*.autoBuild` survives as a deprecated alias for a scheduled default-pool rebuild. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build.
|
`BuildExecutor` runs builds asynchronously: `startBuild(repo, branch, commit, build)` takes the `RepoContext` first; up to `executor.maxConcurrent` builds run concurrently across all repositories (default 1, sized once from the first build's config — an instance-level setting), but never more than one build per (repository, branch) at a time. Each branch builds in its own reusable git worktree at `.git/werkator/worktrees/<branchKey>` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted in the build's own `RepoContext.results` (JSON file under that repository's `.git/werkator/`), published to Gitea non-fatally — with the build's artifact page as `target_url` when `server.publicBaseUrl` is set, repository-scoped through `RepoLinks` — and emitted as `BuildStatusChangedEvent`s. Every run belongs to a named build definition (job, ADR 0007): the YAML `builds` section defines triggers (`onPush`, `atTimes`), branch selectors (`branches` globs, `activeWithin`), and build-setting overrides applied last over the merged branch config; the implicit `default` build (`onPush`, all branches) preserves the job-less behavior. Definitions are part of the branch layer — a branch may add and override its own, and they apply to that branch alone (its selectors are evaluated for it only) — while `executor.maxConcurrent` stays pinned. `BuildResult.build` records the job; restart, retry, and startup recovery re-run by that name, resolving settings from the *current* config. `BuildResult.name` — the pool, `<branch>@<build>` for non-default builds — keys everything display- and retention-side (repository grouping via `latestPerName`, retention pools, branches-view rows, permanent latest-green links), while `BuildResult.branch` keys everything git-side: origin lookups, gone-from-origin pruning, worktrees (every build runs in its branch's worktree, serialized per branch), and Gitea links/statuses. `branches.*.autoBuild` survives as a deprecated alias for a scheduled default-pool rebuild. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build.
|
||||||
|
|
||||||
On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`).
|
On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`).
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ All production code lives under `de.hoennig.werkator`, with sub-packages `comman
|
|||||||
- `exitProcess` is called only from `main()` — never inside `CliRunner.run()`; this keeps the Spring context alive during tests.
|
- `exitProcess` is called only from `main()` — never inside `CliRunner.run()`; this keeps the Spring context alive during tests.
|
||||||
- Nothing is scheduled during CLI runs or tests: the watcher poll loop and metrics sampling start only via an explicit `start()` in the `server` profile.
|
- Nothing is scheduled during CLI runs or tests: the watcher poll loop and metrics sampling start only via an explicit `start()` in the `server` profile.
|
||||||
- Builds run detached in worktrees under `.git/werkator/worktrees/<branchKey>`; the primary checkout is never used for builds; never assume a single running build.
|
- Builds run detached in worktrees under `.git/werkator/worktrees/<branchKey>`; the primary checkout is never used for builds; never assume a single running build.
|
||||||
- Everything repository-scoped (results, artifacts, worktrees, git and config access) goes through a `RepoContext`, never through an implicit current directory: the executor serializes per (context, branch) under one global `maxConcurrent`, the watcher polls 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/<name>/…` and `/api/repos/<name>/…`, 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`.
|
- When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`.
|
||||||
- Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config.
|
- Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config.
|
||||||
- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and bubblewrap (`bwrap.enabled`, `bwrap.rootfs`, `bwrap.werkdock`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone.
|
- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and bubblewrap (`bwrap.enabled`, `bwrap.rootfs`, `bwrap.werkdock`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone.
|
||||||
|
|||||||
@@ -81,6 +81,60 @@ User services stop at logout unless lingering is enabled once per user:
|
|||||||
loginctl enable-linger "$USER"
|
loginctl enable-linger "$USER"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Serving Several Repositories
|
||||||
|
|
||||||
|
One instance serves a *set* of repositories (ADR 0009): one service, one port, one UI, one watcher loop, one control token.
|
||||||
|
Adding a repository is editing a registry entry — never a data migration, because everything repository-specific already lives inside the repository (`.git/werkator/`: machine config with secrets, build results, auto-build slots, worktrees).
|
||||||
|
|
||||||
|
1. **Clone it** on the host, next to the ones already served:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/<owner>/<repo>.git ~/repos/<repo>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Prepare it** like any watched repository — the machine config with the git credentials, and the sandbox policy if the host needs one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/repos/<repo> && java -jar ~/bin/werkator.jar init
|
||||||
|
# fill in git.account and git.token in .git/werkator/.werkator.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Credentials shared by every repository of the same forge can live once in the instance file's `defaults` block instead (`docs/configuration.md`); they merge below each repository's own layers, so a repository may still override them.
|
||||||
|
The repository's own `.werkator.yml` — its builds — is committed and comes with the clone.
|
||||||
|
|
||||||
|
3. **Register it** in `~/.werkator.yml` of the user running the service:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
repositories:
|
||||||
|
- path: ~/repos/werkator
|
||||||
|
- path: ~/repos/<repo>
|
||||||
|
name: <short-name> # optional; default is the directory basename
|
||||||
|
```
|
||||||
|
|
||||||
|
The name is the route segment (`/repos/<name>/…`) and the UI's switcher entry, so it must be unique: a duplicate aborts the start naming this file, and so does an entry that is no git repository.
|
||||||
|
A repository whose configuration Werkator must not read (a version violation) is skipped with an error — the others keep building.
|
||||||
|
|
||||||
|
Steps 1 and 2 are mechanical and can be done from the workstation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/remote --env-file .env.<instance> werkator repo-add https://github.com/<owner>/<repo>.git [<name>]
|
||||||
|
```
|
||||||
|
|
||||||
|
It clones the repository next to the ones already served, runs `init` in it, and **prints** the registry entry.
|
||||||
|
It does not write `~/.werkator.yml`: that file is the instance's own — port, global concurrency, possibly shared credentials — and a script editing it in place would rewrite the operator's configuration behind their back.
|
||||||
|
Cloning and initialising is mechanical; registering is a decision.
|
||||||
|
|
||||||
|
4. **Restart** the service; startup recovery re-enqueues what was in flight:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user restart werkator-<repo-name>.service
|
||||||
|
```
|
||||||
|
|
||||||
|
The service unit keeps the name it was installed with — it is the instance's unit, not one repository's.
|
||||||
|
With one registered repository every URL stays what it was; with several, the pages and the API carry the repository (`/repos/<name>/…`, `/api/repos/<name>/…`), the unscoped paths keep meaning the repository the instance was started in, and the navigation shows a switcher.
|
||||||
|
The watcher polls every repository in its own guard: an unreachable origin is that repository's report in the health banner, and the others are polled regardless.
|
||||||
|
`executor.maxConcurrent` is the global cap across all of them, and builds take slots in enqueue order (FIFO).
|
||||||
|
|
||||||
## Operating the Service
|
## Operating the Service
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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.
|
- ~~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 <name>` (`RepoOption` mixin) on `build`, `retry`, `status`; default is the cwd when served, else the first registered repository.
|
- ~~CLI commands gain an optional repo selector and default to the current working directory, so `werkator status` inside a repo behaves as today.~~ — done: `--repo <name>` (`RepoOption` mixin) on `build`, `retry`, `status`; default is the cwd when served, else the first registered repository.
|
||||||
- Also done: the pre-rename state-dir migration runs per opened repository; the metrics page's repository size sums the registered repositories (the disk metric is the first one's file store).
|
- 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
|
### D — Server, API, and UI scoping
|
||||||
|
|
||||||
- Routes gain the repo segment (`/api/repos/<name>/builds/…`, `/repos/<name>/builds/<key>`); with exactly one registered repo the today-routes keep working (redirect or alias) so bookmarks and posted Gitea links survive.
|
- ~~Routes gain the repo segment (`/api/repos/<name>/builds/…`, `/repos/<name>/builds/<key>`); with exactly one registered repo the today-routes keep working (redirect or alias) so bookmarks and posted Gitea links survive.~~ — done 2026-09-03 (PR #13): every route of the builds API, the pages, and the artifact files is mapped twice; the unscoped form is not an alias with an expiry date but the permanent way to say "the served repository", and an unknown name is a 404 in each controller's own shape.
|
||||||
- Latest/branches/history views group by repo or gain a repo column; one instance-wide metrics page; one control token.
|
- ~~Latest/branches/history views group by repo or gain a repo column; one instance-wide metrics page; one control token.~~ — done 2026-09-03, decided against the column: the pages stay per repository and the 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.
|
- ~~Gitea status links use the repo-scoped URLs~~ — done 2026-09-03: the permanent artifact links take the prefix (`BranchPermalinks.permanentUrl`; the key is a hash of the build name alone, so two repositories both having `main` would share one URL), and the **commit status now carries a target URL at all** — `server.publicBaseUrl` was documented as "used for all links posted to Gitea" while the executor posted `targetUrl = null`. It is the build's artifact page, repository-scoped.
|
||||||
|
- Also done: `RunningBuild` carries its `RepoContext` (the carry-over from session C), so the current-builds views and the watcher's worktree pruning tell repositories apart, and `cancel` refuses a key that is not recorded in the named repository.
|
||||||
|
- ~~`docs/deployment.md` gets the registry setup~~ — done 2026-09-03: section "Serving Several Repositories" (clone, prepare, register, restart) with the name rules, the per-repository guard, and what the URLs look like with one repository and with several.
|
||||||
|
|
||||||
### E — Rollout on mih34: Werkbaum joins
|
### E — Rollout on mih34: Werkbaum joins
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
> **WARNING:** This document describes only the change applied in this PR.
|
||||||
|
> It may already be outdated once the next PR is merged.
|
||||||
|
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
PR #12 gave the instance a registry of repositories, but the server still served exactly one of them.
|
||||||
|
Every route — API, pages, artifact files — worked on `registry.current()`, so a second registered repository was built and polled, yet invisible and unreachable.
|
||||||
|
Two consequences went beyond "not browsable": the current-builds views listed the running builds of *all* repositories while looking their status up in *one* repository's results, and `cancel` addressed a build by key across the whole instance.
|
||||||
|
Step 22 session D is the repository dimension in the server: routes, links, and the UI.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- The rollout on the instance and the deployment documentation of the registry (session E).
|
||||||
|
- Merging several repositories into one table: the pages stay per repository (see The Solution).
|
||||||
|
- A per-repository control token or per-repository metrics: one instance, one token, one metrics page (ADR 0009).
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: every route carries the repository
|
||||||
|
|
||||||
|
#### Background
|
||||||
|
|
||||||
|
- The instance serves a registry of repositories (ADR 0009); the *served* repository is `RepoRegistry.current()` — the current working directory when it is served, else the first entry.
|
||||||
|
- The prefix is `/repos/<name>` for the pages and `/api/repos/<name>` for the API, where `<name>` is the registry entry's short name.
|
||||||
|
|
||||||
|
#### Scenario#13.01: The repository-scoped API answers for the named repository
|
||||||
|
|
||||||
|
So that a second registered repository is reachable at all.
|
||||||
|
|
||||||
|
- **Given** an instance serving a repository named `test`
|
||||||
|
- **When** `GET /api/repos/test/builds/latest` is requested
|
||||||
|
- **Then** the answer holds that repository's builds
|
||||||
|
- **and** `GET /api/builds/latest` still answers the same, because the unscoped form means the served repository
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [BuildsApiControllerTest — "the repository-scoped routes answer for the named repository and 404 for an unknown name"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#13.02: A name the instance does not serve is a miss, not an error
|
||||||
|
|
||||||
|
So that a typo in a URL reads like every other miss of this API.
|
||||||
|
|
||||||
|
- **Given** an instance that serves no repository named `no-such-repo`
|
||||||
|
- **When** `GET /api/repos/no-such-repo/builds/latest` is requested
|
||||||
|
- **Then** the answer is 404 with `{"error": "no repository named 'no-such-repo'"}`
|
||||||
|
- **and** the page `/repos/no-such-repo` answers 404 as well
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [BuildsApiControllerTest — "the repository-scoped routes answer for the named repository and 404 for an unknown name"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||||
|
- [UiControllerTest — "a page of a repository this instance does not serve answers 404"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#13.03: A single-repository installation keeps its existing URLs
|
||||||
|
|
||||||
|
So that no bookmark, no posted Gitea link, and no operator habit breaks on an installation that has exactly what it had before.
|
||||||
|
|
||||||
|
- **Given** an instance serving exactly one repository
|
||||||
|
- **When** any page is rendered
|
||||||
|
- **Then** every link it contains is unscoped (`/branches`, `/history`, `/api/builds/latest`)
|
||||||
|
- **and** no repository switcher is shown, because there is nothing to switch
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [UiControllerTest — "with one served repository the pages keep their existing URLs and show no switcher"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#13.04: With several repositories every link names its repository
|
||||||
|
|
||||||
|
So that a click inside a repository's page stays inside that repository.
|
||||||
|
|
||||||
|
- **Given** an instance serving the repositories `test` and `other`
|
||||||
|
- **When** the page `/repos/test` is rendered
|
||||||
|
- **Then** its navigation links, its `data-api`, and its `werkator-repo-base` meta carry `/repos/test`
|
||||||
|
- **and** the switcher offers `/repos/other`
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [UiControllerTest — "with several served repositories every link names its repository and the switcher appears"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#13.05: A repository-named route never reaches another repository
|
||||||
|
|
||||||
|
So that the repository in the path is a boundary, not a label.
|
||||||
|
|
||||||
|
- **Given** a build whose artifact key is not recorded in the repository named in the route
|
||||||
|
- **When** that build is cancelled through `/api/repos/test/builds/<key>/cancel`
|
||||||
|
- **Then** the answer is 404
|
||||||
|
- **and** the executor is not asked to cancel anything
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [BuildsApiControllerTest — "cancel does not reach a build of another repository"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||||
|
- [BuildsApiControllerTest — "current answers only the served repository's builds"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||||
|
- [WatcherTest — "a running build of another repository does not keep this repository's worktree"](../../src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt)
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
Every controller resolves its `RepoContext` per request instead of holding the served one as a bean: `repoOf(name)` is `registry.current()` without a name and `registry.byName(name)` with one, and an unknown name throws `UnknownRepositoryException`, which each controller turns into its own 404 shape.
|
||||||
|
Each route is mapped twice — scoped and unscoped — so the unscoped form is not a transitional alias but the permanent way to say "the served repository".
|
||||||
|
|
||||||
|
`RunningBuild` carries its `RepoContext`, so the executor's instance-global `currentBuilds()` can be filtered: the current-builds view and API show their own repository's builds, and the watcher's worktree pruning is protected by its own repository's builds alone.
|
||||||
|
`cancel` additionally verifies that the artifact key is recorded in the named repository — a queued or running build always has its PENDING/RUNNING result there.
|
||||||
|
|
||||||
|
The pages stay **per repository** instead of merging every repository's rows into one table with a repository column: a row's actions need the repository anyway, branches come from one origin and artifacts from one store, and with the single repository most installations have, such a column is pure noise.
|
||||||
|
What makes the instance one UI is the switcher in the navigation.
|
||||||
|
|
||||||
|
The link prefix follows the *number of served repositories*, not the route a page was reached through — with one repository the installation keeps its existing URLs, with several every link names its repository.
|
||||||
|
`werkator.js` reads that prefix once from a `werkator-repo-base` meta and builds its action and artifact URLs from it; the paths rendered into the DOM already carry it.
|
||||||
|
`BranchPermalinks.permanentUrl` takes the prefix too: the permanent key is a hash of the build name alone, so two repositories both having `main` would otherwise share one permanent URL.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- The metrics page and the control token stay instance-wide (ADR 0009); a per-repository token is not planned.
|
||||||
|
- `/api/watcher` stays unscoped — its state already carries the per-repository reports, and the UI banner is instance-wide.
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- PR #12 — the repository registry (step 22 session C).
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- Step 22 session E — the rollout: the registry with Werkator and Werkbaum under one service, and `docs/deployment.md`.
|
||||||
@@ -5,6 +5,7 @@ import de.hoennig.werkator.config.BuildDefinition
|
|||||||
import de.hoennig.werkator.config.ConfigLoader
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
import de.hoennig.werkator.gitea.GiteaClient
|
import de.hoennig.werkator.gitea.GiteaClient
|
||||||
import de.hoennig.werkator.repo.RepoContext
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.context.event.ContextClosedEvent
|
import org.springframework.context.event.ContextClosedEvent
|
||||||
@@ -36,6 +37,7 @@ import kotlin.concurrent.thread
|
|||||||
@Service
|
@Service
|
||||||
class BuildExecutor(
|
class BuildExecutor(
|
||||||
private val configLoader: ConfigLoader,
|
private val configLoader: ConfigLoader,
|
||||||
|
private val repoLinks: RepoLinks,
|
||||||
private val giteaClient: GiteaClient,
|
private val giteaClient: GiteaClient,
|
||||||
private val buildRunner: BuildRunner,
|
private val buildRunner: BuildRunner,
|
||||||
private val workspaces: BranchWorkspaces,
|
private val workspaces: BranchWorkspaces,
|
||||||
@@ -97,6 +99,7 @@ class BuildExecutor(
|
|||||||
val stagingDir = Files.createTempDirectory("werkator-build-")
|
val stagingDir = Files.createTempDirectory("werkator-build-")
|
||||||
val runningBuild =
|
val runningBuild =
|
||||||
RunningBuild(
|
RunningBuild(
|
||||||
|
repo = repo,
|
||||||
branch = branch,
|
branch = branch,
|
||||||
build = build,
|
build = build,
|
||||||
commit = commit,
|
commit = commit,
|
||||||
@@ -396,7 +399,7 @@ class BuildExecutor(
|
|||||||
sha = build.runningBuild.commit,
|
sha = build.runningBuild.commit,
|
||||||
status = status,
|
status = status,
|
||||||
description = description(status, duration),
|
description = description(status, duration),
|
||||||
targetUrl = null,
|
targetUrl = targetUrlOf(build),
|
||||||
workingDir = build.repo.workingDir,
|
workingDir = build.repo.workingDir,
|
||||||
// from the primary config, not the worktree: statusContext is pinned, so a
|
// from the primary config, not the worktree: statusContext is pinned, so a
|
||||||
// branch cannot report under a check name it was not given
|
// branch cannot report under a check name it was not given
|
||||||
@@ -407,6 +410,25 @@ class BuildExecutor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The artifact page of this build, so a commit status in the forge leads to the
|
||||||
|
* logs it is about — that is what `server.publicBaseUrl` is documented for, and
|
||||||
|
* until now nothing posted a link at all. Repository-scoped (ADR 0009): with
|
||||||
|
* several served repositories the unscoped path would resolve against whichever
|
||||||
|
* one the instance serves by default, which is the wrong build's page.
|
||||||
|
*/
|
||||||
|
private fun targetUrlOf(build: ActiveBuild): String? =
|
||||||
|
try {
|
||||||
|
repoLinks.buildUrl(
|
||||||
|
repo = build.repo,
|
||||||
|
publicBaseUrl = configLoader.load(build.repo.workingDir).server.publicBaseUrl,
|
||||||
|
artifactKey = build.runningBuild.artifactKey,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warn("could not build the status target URL of {}: {}", build.runningBuild.branch, e.message)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
/** The build's own Gitea status context, empty when it uses the repository-wide one. */
|
/** The build's own Gitea status context, empty when it uses the repository-wide one. */
|
||||||
private fun statusContextOf(build: ActiveBuild): String =
|
private fun statusContextOf(build: ActiveBuild): String =
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package de.hoennig.werkator.build
|
package de.hoennig.werkator.build
|
||||||
|
|
||||||
import de.hoennig.werkator.config.BuildDefinition
|
import de.hoennig.werkator.config.BuildDefinition
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
/** Handle to a build accepted by the [BuildExecutor]; log paths become valid once the build runs. */
|
/** Handle to a build accepted by the [BuildExecutor]; log paths become valid once the build runs. */
|
||||||
data class RunningBuild(
|
data class RunningBuild(
|
||||||
|
/**
|
||||||
|
* The repository this build belongs to; the context object is the identity
|
||||||
|
* (ADR 0009), so it compares by reference. Without it neither the current-builds
|
||||||
|
* view nor the watcher's worktree pruning could tell two repositories apart —
|
||||||
|
* both would see every repository's running builds as their own.
|
||||||
|
*/
|
||||||
|
val repo: RepoContext,
|
||||||
/** The git branch being built. */
|
/** The git branch being built. */
|
||||||
val branch: String,
|
val branch: String,
|
||||||
/** The build definition (job) this build runs; its settings are resolved from config at run time. */
|
/** The build definition (job) this build runs; its settings are resolved from config at run time. */
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ data class BuildResultDto(
|
|||||||
fun from(
|
fun from(
|
||||||
result: BuildResult,
|
result: BuildResult,
|
||||||
isLatestGreen: Boolean = false,
|
isLatestGreen: Boolean = false,
|
||||||
|
base: String = "",
|
||||||
) = BuildResultDto(
|
) = BuildResultDto(
|
||||||
branch = result.branch,
|
branch = result.branch,
|
||||||
name = result.name,
|
name = result.name,
|
||||||
@@ -36,7 +37,7 @@ data class BuildResultDto(
|
|||||||
runningSince = result.runningSince,
|
runningSince = result.runningSince,
|
||||||
durationSeconds = result.duration?.seconds,
|
durationSeconds = result.duration?.seconds,
|
||||||
artifactKey = result.artifactKey,
|
artifactKey = result.artifactKey,
|
||||||
latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.name) else null,
|
latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.name, base) else null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.hoennig.werkator.server
|
package de.hoennig.werkator.server
|
||||||
|
|
||||||
import de.hoennig.werkator.build.ArtifactStore
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import org.springframework.core.io.FileSystemResource
|
import org.springframework.core.io.FileSystemResource
|
||||||
import org.springframework.core.io.Resource
|
import org.springframework.core.io.Resource
|
||||||
@@ -25,17 +26,22 @@ import kotlin.streams.asSequence
|
|||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
class ArtifactFileController(
|
class ArtifactFileController(
|
||||||
private val artifactStore: ArtifactStore,
|
|
||||||
private val branchPermalinks: BranchPermalinks,
|
private val branchPermalinks: BranchPermalinks,
|
||||||
|
private val registry: RepoRegistry,
|
||||||
) {
|
) {
|
||||||
@GetMapping("/artifacts/{artifactKey}/{*path}")
|
/** Scoped and unscoped, like every other route (ADR 0009); unscoped means the served repository. */
|
||||||
|
private fun repoOf(name: String?): RepoContext =
|
||||||
|
if (name == null) registry.current() else registry.byName(name) ?: throw UnknownRepositoryException(name)
|
||||||
|
|
||||||
|
@GetMapping("/artifacts/{artifactKey}/{*path}", "/repos/{repo}/artifacts/{artifactKey}/{*path}")
|
||||||
fun serve(
|
fun serve(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable artifactKey: String,
|
@PathVariable artifactKey: String,
|
||||||
@PathVariable path: String,
|
@PathVariable path: String,
|
||||||
request: HttpServletRequest,
|
request: HttpServletRequest,
|
||||||
): ResponseEntity<Resource> {
|
): ResponseEntity<Resource> {
|
||||||
val artifactDir =
|
val artifactDir =
|
||||||
artifactStore.artifactDir(artifactKey)
|
repoOf(repoName).artifactStore.artifactDir(artifactKey)
|
||||||
?: return ResponseEntity.notFound().build()
|
?: return ResponseEntity.notFound().build()
|
||||||
val relativePath = path.removePrefix("/").removeSuffix("/")
|
val relativePath = path.removePrefix("/").removeSuffix("/")
|
||||||
directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it }
|
directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it }
|
||||||
@@ -52,15 +58,17 @@ class ArtifactFileController(
|
|||||||
* slash, so relative links inside reports resolve correctly), and everything is
|
* slash, so relative links inside reports resolve correctly), and everything is
|
||||||
* `no-store` because the content behind a URL changes with every new green build.
|
* `no-store` because the content behind a URL changes with every new green build.
|
||||||
*/
|
*/
|
||||||
@GetMapping("/branches/{branchKey}/{*path}")
|
@GetMapping("/branches/{branchKey}/{*path}", "/repos/{repo}/branches/{branchKey}/{*path}")
|
||||||
fun serveLatestGreen(
|
fun serveLatestGreen(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable branchKey: String,
|
@PathVariable branchKey: String,
|
||||||
@PathVariable path: String,
|
@PathVariable path: String,
|
||||||
request: HttpServletRequest,
|
request: HttpServletRequest,
|
||||||
): ResponseEntity<Resource> {
|
): ResponseEntity<Resource> {
|
||||||
val build = branchPermalinks.latestGreenBuild(branchKey)
|
val repo = repoOf(repoName)
|
||||||
|
val build = branchPermalinks.latestGreenBuild(repo, branchKey)
|
||||||
val artifactDir =
|
val artifactDir =
|
||||||
artifactStore.artifactDir(build.artifactKey)
|
repo.artifactStore.artifactDir(build.artifactKey)
|
||||||
?: throw ResponseStatusException(
|
?: throw ResponseStatusException(
|
||||||
HttpStatus.NOT_FOUND,
|
HttpStatus.NOT_FOUND,
|
||||||
"the artifacts of build '${build.artifactKey}' are not stored anymore",
|
"the artifacts of build '${build.artifactKey}' are not stored anymore",
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import org.springframework.stereotype.Component
|
|||||||
class BranchListing(
|
class BranchListing(
|
||||||
private val gitService: GitService,
|
private val gitService: GitService,
|
||||||
) {
|
) {
|
||||||
fun branches(repo: RepoContext): List<BranchDto> {
|
fun branches(
|
||||||
|
repo: RepoContext,
|
||||||
|
base: String = "",
|
||||||
|
): List<BranchDto> {
|
||||||
val repository = repo.results
|
val repository = repo.results
|
||||||
val heads = gitService.originBranchHeads(repo.workingDir)
|
val heads = gitService.originBranchHeads(repo.workingDir)
|
||||||
val namedResults = repository.latestPerName().filter { it.name != it.branch && it.branch in heads }
|
val namedResults = repository.latestPerName().filter { it.name != it.branch && it.branch in heads }
|
||||||
@@ -45,7 +48,7 @@ class BranchListing(
|
|||||||
// the permanent link belongs to the build it resolves to, not to every build of the name
|
// the permanent link belongs to the build it resolves to, not to every build of the name
|
||||||
val isLatestGreen =
|
val isLatestGreen =
|
||||||
row.artifactKey.isNotEmpty() && row.artifactKey == repository.latestGreenFor(row.name)?.artifactKey
|
row.artifactKey.isNotEmpty() && row.artifactKey == repository.latestGreenFor(row.name)?.artifactKey
|
||||||
if (isLatestGreen) row.copy(latestGreenUrl = BranchPermalinks.permanentUrl(row.name)) else row
|
if (isLatestGreen) row.copy(latestGreenUrl = BranchPermalinks.permanentUrl(row.name, base)) else row
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package de.hoennig.werkator.server
|
|||||||
|
|
||||||
import de.hoennig.werkator.build.ArtifactKeys
|
import de.hoennig.werkator.build.ArtifactKeys
|
||||||
import de.hoennig.werkator.build.BuildResult
|
import de.hoennig.werkator.build.BuildResult
|
||||||
import de.hoennig.werkator.build.BuildResultRepository
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
@@ -18,10 +18,12 @@ import org.springframework.web.server.ResponseStatusException
|
|||||||
* artifacts.
|
* artifacts.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
class BranchPermalinks(
|
class BranchPermalinks {
|
||||||
private val repository: BuildResultRepository,
|
fun latestGreenBuild(
|
||||||
) {
|
repo: RepoContext,
|
||||||
fun latestGreenBuild(branchKey: String): BuildResult {
|
branchKey: String,
|
||||||
|
): BuildResult {
|
||||||
|
val repository = repo.results
|
||||||
val names =
|
val names =
|
||||||
repository
|
repository
|
||||||
.latestPerName()
|
.latestPerName()
|
||||||
@@ -41,7 +43,16 @@ class BranchPermalinks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The permanent artifact-index URL of the build name (branch or named slot), shown in the branches view. */
|
/**
|
||||||
fun permanentUrl(name: String): String = "/branches/${ArtifactKeys.permanentBranchKey(name)}"
|
* The permanent artifact-index URL of the build name (branch or named slot), shown
|
||||||
|
* in the branches view. [base] is the repository prefix (`/repos/<name>`, empty with
|
||||||
|
* one served repository): the key is a hash of the name alone, so two repositories
|
||||||
|
* both having `main` would otherwise share one permanent URL — and it would resolve
|
||||||
|
* against whichever repository the instance happens to serve unscoped.
|
||||||
|
*/
|
||||||
|
fun permanentUrl(
|
||||||
|
name: String,
|
||||||
|
base: String = "",
|
||||||
|
): String = "$base/branches/${ArtifactKeys.permanentBranchKey(name)}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
package de.hoennig.werkator.server
|
package de.hoennig.werkator.server
|
||||||
|
|
||||||
import de.hoennig.werkator.build.ArtifactStore
|
|
||||||
import de.hoennig.werkator.build.BuildExecutor
|
import de.hoennig.werkator.build.BuildExecutor
|
||||||
import de.hoennig.werkator.build.BuildResult
|
import de.hoennig.werkator.build.BuildResult
|
||||||
import de.hoennig.werkator.build.BuildResultRepository
|
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
import de.hoennig.werkator.config.BuildDefinition
|
import de.hoennig.werkator.config.BuildDefinition
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
import de.hoennig.werkator.repo.RepoContext
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
@@ -32,34 +33,72 @@ import java.nio.file.StandardOpenOption
|
|||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
class BuildsApiController(
|
class BuildsApiController(
|
||||||
private val repository: BuildResultRepository,
|
|
||||||
private val buildExecutor: BuildExecutor,
|
private val buildExecutor: BuildExecutor,
|
||||||
private val artifactStore: ArtifactStore,
|
|
||||||
private val controlTokens: ControlTokenService,
|
private val controlTokens: ControlTokenService,
|
||||||
private val gitService: GitService,
|
private val gitService: GitService,
|
||||||
private val branchListing: BranchListing,
|
private val branchListing: BranchListing,
|
||||||
private val repo: RepoContext,
|
private val registry: RepoRegistry,
|
||||||
|
private val repoLinks: RepoLinks,
|
||||||
) {
|
) {
|
||||||
private val workingDir: Path
|
/**
|
||||||
get() = repo.workingDir
|
* Every route exists twice: repository-scoped (`/api/repos/<name>/…`) and unscoped.
|
||||||
|
* The unscoped form means the served repository ([RepoRegistry.current]) and stays
|
||||||
|
* for good — bookmarks, the legacy UI, and the links already posted to Gitea were
|
||||||
|
* written without a repository segment, and a CI that breaks its own old links is
|
||||||
|
* a CI nobody trusts.
|
||||||
|
*/
|
||||||
|
private fun repoOf(name: String?): RepoContext =
|
||||||
|
if (name == null) registry.current() else registry.byName(name) ?: throw UnknownRepositoryException(name)
|
||||||
|
|
||||||
@GetMapping("/api/builds/latest")
|
private fun RepoContext.isLatestGreen(result: BuildResult): Boolean =
|
||||||
fun latest(): List<BuildResultDto> = repository.latestPerName().map { BuildResultDto.from(it, it.isLatestGreen()) }
|
results.latestGreenFor(result.name)?.artifactKey == result.artifactKey
|
||||||
|
|
||||||
|
/** The prefix the permanent links in the answers carry; empty with one served repository. */
|
||||||
|
private fun uiBase(repo: RepoContext): String = repoLinks.base(repo)
|
||||||
|
|
||||||
|
/** An unknown repository name answers like every other miss of this API: 404 with `error`. */
|
||||||
|
@ExceptionHandler(UnknownRepositoryException::class)
|
||||||
|
fun unknownRepository(e: UnknownRepositoryException): ResponseEntity<Any> = notFound(e.message ?: "unknown repository")
|
||||||
|
|
||||||
|
@GetMapping("/api/builds/latest", "/api/repos/{repo}/builds/latest")
|
||||||
|
fun latest(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
|
): List<BuildResultDto> {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
return repo.results.latestPerName().map { BuildResultDto.from(it, repo.isLatestGreen(it), uiBase(repo)) }
|
||||||
|
}
|
||||||
|
|
||||||
/** The legacy branches view: every origin branch with its latest build or `unknown`. */
|
/** The legacy branches view: every origin branch with its latest build or `unknown`. */
|
||||||
@GetMapping("/api/branches")
|
@GetMapping("/api/branches", "/api/repos/{repo}/branches")
|
||||||
fun branches(): List<BranchDto> = branchListing.branches(repo)
|
fun branches(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
|
): List<BranchDto> {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
return branchListing.branches(repo, uiBase(repo))
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/api/builds/history")
|
@GetMapping("/api/builds/history", "/api/repos/{repo}/builds/history")
|
||||||
fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it, it.isLatestGreen()) }
|
fun history(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
|
): List<BuildResultDto> {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
return repo.results.history().map { BuildResultDto.from(it, repo.isLatestGreen(it), uiBase(repo)) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun BuildResult.isLatestGreen(): Boolean = repository.latestGreenFor(name)?.artifactKey == artifactKey
|
/**
|
||||||
|
* The currently executing builds of the served repository — several are possible,
|
||||||
/** The currently executing builds — several are possible, up to `executor.maxConcurrent`. */
|
* up to `executor.maxConcurrent`. The executor is instance-global and returns the
|
||||||
@GetMapping("/api/builds/current")
|
* builds of every registered repository, so this view filters: its [repository]
|
||||||
fun current(): List<CurrentBuildDto> {
|
* holds only this repository's results, and a foreign build looked up in them
|
||||||
val results = repository.history()
|
* would fall back to RUNNING and show a status nobody recorded.
|
||||||
return buildExecutor.currentBuilds().map { build ->
|
*/
|
||||||
|
@GetMapping("/api/builds/current", "/api/repos/{repo}/builds/current")
|
||||||
|
fun current(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
|
): List<CurrentBuildDto> {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
val results = repo.results.history()
|
||||||
|
return buildExecutor.currentBuilds().filter { it.repo === repo }.map { build ->
|
||||||
CurrentBuildDto(
|
CurrentBuildDto(
|
||||||
branch = build.branch,
|
branch = build.branch,
|
||||||
name = build.name,
|
name = build.name,
|
||||||
@@ -76,13 +115,15 @@ class BuildsApiController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Incremental live-log fetch of one running build; poll again with `offset = nextOffset`. */
|
/** Incremental live-log fetch of one running build; poll again with `offset = nextOffset`. */
|
||||||
@GetMapping("/api/builds/current/{artifactKey}/log")
|
@GetMapping("/api/builds/current/{artifactKey}/log", "/api/repos/{repo}/builds/current/{artifactKey}/log")
|
||||||
fun currentLog(
|
fun currentLog(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable artifactKey: String,
|
@PathVariable artifactKey: String,
|
||||||
@RequestParam(defaultValue = "0") offset: Long,
|
@RequestParam(defaultValue = "0") offset: Long,
|
||||||
): ResponseEntity<Any> {
|
): ResponseEntity<Any> {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
val build =
|
val build =
|
||||||
buildExecutor.currentBuilds().firstOrNull { it.artifactKey == artifactKey }
|
buildExecutor.currentBuilds().firstOrNull { it.repo === repo && it.artifactKey == artifactKey }
|
||||||
?: return notFound("no running build with artifact key '$artifactKey'")
|
?: return notFound("no running build with artifact key '$artifactKey'")
|
||||||
return ResponseEntity.ok(readLogTail(artifactKey, build.liveLogFile, offset))
|
return ResponseEntity.ok(readLogTail(artifactKey, build.liveLogFile, offset))
|
||||||
}
|
}
|
||||||
@@ -102,14 +143,17 @@ class BuildsApiController(
|
|||||||
* The name is a parameter, not a path variable, because branch names may contain
|
* The name is a parameter, not a path variable, because branch names may contain
|
||||||
* slashes (Tomcat rejects encoded slashes in the path by default).
|
* slashes (Tomcat rejects encoded slashes in the path by default).
|
||||||
*/
|
*/
|
||||||
@PostMapping("/api/builds/restart")
|
@PostMapping("/api/builds/restart", "/api/repos/{repo}/builds/restart")
|
||||||
fun restart(
|
fun restart(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@RequestParam branch: String,
|
@RequestParam branch: String,
|
||||||
@RequestParam(defaultValue = "false") atOriginHead: Boolean,
|
@RequestParam(defaultValue = "false") atOriginHead: Boolean,
|
||||||
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
||||||
): ResponseEntity<Any> {
|
): ResponseEntity<Any> {
|
||||||
rejectBadToken(headerToken)?.let { return it }
|
rejectBadToken(headerToken)?.let { return it }
|
||||||
val latest = repository.latestFor(branch)
|
val repo = repoOf(repoName)
|
||||||
|
val workingDir = repo.workingDir
|
||||||
|
val latest = repo.results.latestFor(branch)
|
||||||
// the name may be a pool like `main@pitest`; the branch to build is the recorded one
|
// the name may be a pool like `main@pitest`; the branch to build is the recorded one
|
||||||
val branchName = latest?.branch ?: branch
|
val branchName = latest?.branch ?: branch
|
||||||
val commit =
|
val commit =
|
||||||
@@ -143,12 +187,20 @@ class BuildsApiController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Cancels by artifact key because multiple builds can run concurrently. */
|
/** Cancels by artifact key because multiple builds can run concurrently. */
|
||||||
@PostMapping("/api/builds/{artifactKey}/cancel")
|
@PostMapping("/api/builds/{artifactKey}/cancel", "/api/repos/{repo}/builds/{artifactKey}/cancel")
|
||||||
fun cancel(
|
fun cancel(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable artifactKey: String,
|
@PathVariable artifactKey: String,
|
||||||
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
||||||
): ResponseEntity<Any> {
|
): ResponseEntity<Any> {
|
||||||
rejectBadToken(headerToken)?.let { return it }
|
rejectBadToken(headerToken)?.let { return it }
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
// the executor cancels by key across all repositories; a route that names a
|
||||||
|
// repository must not reach into another one, and a queued or running build
|
||||||
|
// always has its PENDING/RUNNING result recorded in its own repository
|
||||||
|
if (repo.results.history().none { it.artifactKey == artifactKey }) {
|
||||||
|
return notFound("no queued or running build with artifact key '$artifactKey'")
|
||||||
|
}
|
||||||
if (!buildExecutor.cancel(artifactKey)) {
|
if (!buildExecutor.cancel(artifactKey)) {
|
||||||
return notFound("no queued or running build with artifact key '$artifactKey'")
|
return notFound("no queued or running build with artifact key '$artifactKey'")
|
||||||
}
|
}
|
||||||
@@ -156,16 +208,18 @@ class BuildsApiController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Removes the stored result and its artifact directory, like the legacy `/control/delete`. */
|
/** Removes the stored result and its artifact directory, like the legacy `/control/delete`. */
|
||||||
@DeleteMapping("/api/builds/{artifactKey}")
|
@DeleteMapping("/api/builds/{artifactKey}", "/api/repos/{repo}/builds/{artifactKey}")
|
||||||
fun delete(
|
fun delete(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable artifactKey: String,
|
@PathVariable artifactKey: String,
|
||||||
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
||||||
): ResponseEntity<Any> {
|
): ResponseEntity<Any> {
|
||||||
rejectBadToken(headerToken)?.let { return it }
|
rejectBadToken(headerToken)?.let { return it }
|
||||||
if (!repository.delete(artifactKey)) {
|
val repo = repoOf(repoName)
|
||||||
|
if (!repo.results.delete(artifactKey)) {
|
||||||
return notFound("no build with artifact key '$artifactKey'")
|
return notFound("no build with artifact key '$artifactKey'")
|
||||||
}
|
}
|
||||||
artifactStore.prune(repository.history())
|
repo.artifactStore.prune(repo.results.history())
|
||||||
return ResponseEntity.ok(mapOf("deleted" to artifactKey))
|
return ResponseEntity.ok(mapOf("deleted" to artifactKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
package de.hoennig.werkator.server
|
package de.hoennig.werkator.server
|
||||||
|
|
||||||
import de.hoennig.werkator.build.ArtifactStore
|
|
||||||
import de.hoennig.werkator.build.BuildExecutor
|
import de.hoennig.werkator.build.BuildExecutor
|
||||||
import de.hoennig.werkator.build.BuildResult
|
import de.hoennig.werkator.build.BuildResult
|
||||||
import de.hoennig.werkator.build.BuildResultRepository
|
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
import de.hoennig.werkator.config.ConfigFiles
|
import de.hoennig.werkator.config.ConfigFiles
|
||||||
import de.hoennig.werkator.config.ConfigLoader
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||||
import de.hoennig.werkator.repo.RepoContext
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import org.springframework.beans.factory.ObjectProvider
|
import org.springframework.beans.factory.ObjectProvider
|
||||||
import org.springframework.boot.info.BuildProperties
|
import org.springframework.boot.info.BuildProperties
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.stereotype.Controller
|
import org.springframework.stereotype.Controller
|
||||||
import org.springframework.ui.Model
|
import org.springframework.ui.Model
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
@@ -34,19 +36,32 @@ import kotlin.streams.asSequence
|
|||||||
*/
|
*/
|
||||||
@Controller
|
@Controller
|
||||||
class UiController(
|
class UiController(
|
||||||
private val repository: BuildResultRepository,
|
|
||||||
private val buildExecutor: BuildExecutor,
|
private val buildExecutor: BuildExecutor,
|
||||||
private val artifactStore: ArtifactStore,
|
|
||||||
private val configLoader: ConfigLoader,
|
private val configLoader: ConfigLoader,
|
||||||
private val gitService: GitService,
|
private val gitService: GitService,
|
||||||
private val metricsCollector: SystemMetricsCollector,
|
private val metricsCollector: SystemMetricsCollector,
|
||||||
private val branchListing: BranchListing,
|
private val branchListing: BranchListing,
|
||||||
private val branchPermalinks: BranchPermalinks,
|
private val branchPermalinks: BranchPermalinks,
|
||||||
private val buildProperties: ObjectProvider<BuildProperties>,
|
private val buildProperties: ObjectProvider<BuildProperties>,
|
||||||
private val repo: RepoContext,
|
private val registry: RepoRegistry,
|
||||||
|
private val repoLinks: RepoLinks,
|
||||||
) {
|
) {
|
||||||
private val workingDir: Path
|
/**
|
||||||
get() = repo.workingDir
|
* Every page exists twice, like the API (ADR 0009): repository-scoped under
|
||||||
|
* `/repos/<name>/…` and unscoped, which means the served repository. Pages stay
|
||||||
|
* per repository instead of merging every repository's rows into one table with a
|
||||||
|
* repository column: a row's actions (restart, cancel, delete) need the repository
|
||||||
|
* anyway, branches come from one origin and artifacts from one store — and with
|
||||||
|
* the one repository that most installations have, such a column is pure noise.
|
||||||
|
* What makes the instance one UI is the repository switcher in the navigation.
|
||||||
|
*/
|
||||||
|
private fun repoOf(name: String?): RepoContext =
|
||||||
|
if (name == null) registry.current() else registry.byName(name) ?: throw UnknownRepositoryException(name)
|
||||||
|
|
||||||
|
/** An unknown repository name is a 404 page, not a server error. */
|
||||||
|
@ExceptionHandler(UnknownRepositoryException::class)
|
||||||
|
fun unknownRepository(e: UnknownRepositoryException): ResponseEntity<String> =
|
||||||
|
ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.message)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permanent redirects for the legacy script's static page names, so bookmarks
|
* Permanent redirects for the legacy script's static page names, so bookmarks
|
||||||
@@ -58,11 +73,15 @@ class UiController(
|
|||||||
setStatusCode(HttpStatus.MOVED_PERMANENTLY)
|
setStatusCode(HttpStatus.MOVED_PERMANENTLY)
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/")
|
@GetMapping("/", "/repos/{repo}")
|
||||||
fun latest(model: Model): String {
|
fun latest(
|
||||||
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds")
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
model.addAttribute("rows", repository.latestPerName().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
|
model: Model,
|
||||||
model.addAttribute("apiPath", "/api/builds/latest")
|
): String {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds", repo = repo)
|
||||||
|
model.addAttribute("rows", repo.results.latestPerName().map { BuildRowView.from(it, links, permanentUrlOf(repo, it)) })
|
||||||
|
model.addAttribute("apiPath", apiBase(repo) + "/builds/latest")
|
||||||
model.addAttribute("allowRestart", true)
|
model.addAttribute("allowRestart", true)
|
||||||
model.addAttribute("restartAtOriginHead", false)
|
model.addAttribute("restartAtOriginHead", false)
|
||||||
model.addAttribute("emptyMessage", "No builds recorded yet.")
|
model.addAttribute("emptyMessage", "No builds recorded yet.")
|
||||||
@@ -70,11 +89,15 @@ class UiController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The legacy branches view: every origin branch with its latest build or an `unknown` row. */
|
/** The legacy branches view: every origin branch with its latest build or an `unknown` row. */
|
||||||
@GetMapping("/branches")
|
@GetMapping("/branches", "/repos/{repo}/branches")
|
||||||
fun branches(model: Model): String {
|
fun branches(
|
||||||
val links = baseModel(model, view = "branches", pageTitle = "Branches")
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
model.addAttribute("rows", branchListing.branches(repo).map { BuildRowView.from(it, links) })
|
model: Model,
|
||||||
model.addAttribute("apiPath", "/api/branches")
|
): String {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
val links = baseModel(model, view = "branches", pageTitle = "Branches", repo = repo)
|
||||||
|
model.addAttribute("rows", branchListing.branches(repo, uiBase(repo)).map { BuildRowView.from(it, links) })
|
||||||
|
model.addAttribute("apiPath", apiBase(repo) + "/branches")
|
||||||
model.addAttribute("allowRestart", true)
|
model.addAttribute("allowRestart", true)
|
||||||
// a row here stands for a branch, not for a past run
|
// a row here stands for a branch, not for a past run
|
||||||
model.addAttribute("restartAtOriginHead", true)
|
model.addAttribute("restartAtOriginHead", true)
|
||||||
@@ -82,11 +105,15 @@ class UiController(
|
|||||||
return "builds"
|
return "builds"
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/history")
|
@GetMapping("/history", "/repos/{repo}/history")
|
||||||
fun history(model: Model): String {
|
fun history(
|
||||||
val links = baseModel(model, view = "history", pageTitle = "Build History")
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
model.addAttribute("rows", repository.history().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
|
model: Model,
|
||||||
model.addAttribute("apiPath", "/api/builds/history")
|
): String {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
val links = baseModel(model, view = "history", pageTitle = "Build History", repo = repo)
|
||||||
|
model.addAttribute("rows", repo.results.history().map { BuildRowView.from(it, links, permanentUrlOf(repo, it)) })
|
||||||
|
model.addAttribute("apiPath", apiBase(repo) + "/builds/history")
|
||||||
model.addAttribute("allowRestart", false)
|
model.addAttribute("allowRestart", false)
|
||||||
model.addAttribute("restartAtOriginHead", false)
|
model.addAttribute("restartAtOriginHead", false)
|
||||||
model.addAttribute("emptyMessage", "No builds archived yet.")
|
model.addAttribute("emptyMessage", "No builds archived yet.")
|
||||||
@@ -94,19 +121,26 @@ class UiController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The permanent branch URL belongs to the build it resolves to — the name's latest green build. */
|
/** The permanent branch URL belongs to the build it resolves to — the name's latest green build. */
|
||||||
private fun permanentUrlOf(result: BuildResult): String? =
|
private fun permanentUrlOf(
|
||||||
if (repository.latestGreenFor(result.name)?.artifactKey == result.artifactKey) {
|
repo: RepoContext,
|
||||||
BranchPermalinks.permanentUrl(result.name)
|
result: BuildResult,
|
||||||
|
): String? =
|
||||||
|
if (repo.results.latestGreenFor(result.name)?.artifactKey == result.artifactKey) {
|
||||||
|
BranchPermalinks.permanentUrl(result.name, uiBase(repo))
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/current")
|
@GetMapping("/current", "/repos/{repo}/current")
|
||||||
fun current(model: Model): String {
|
fun current(
|
||||||
val links = baseModel(model, view = "current", pageTitle = "Current Builds")
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
val results = repository.history()
|
model: Model,
|
||||||
|
): String {
|
||||||
|
val repo = repoOf(repoName)
|
||||||
|
val links = baseModel(model, view = "current", pageTitle = "Current Builds", repo = repo)
|
||||||
|
val results = repo.results.history()
|
||||||
val currentBuilds =
|
val currentBuilds =
|
||||||
buildExecutor.currentBuilds().map { build ->
|
buildExecutor.currentBuilds().filter { it.repo === repo }.map { build ->
|
||||||
CurrentBuildView(
|
CurrentBuildView(
|
||||||
branch = build.branch,
|
branch = build.branch,
|
||||||
name = build.name,
|
name = build.name,
|
||||||
@@ -130,35 +164,39 @@ class UiController(
|
|||||||
/** Hand-maintained release notes (templates/releases.html); linked from the version in the footer. */
|
/** Hand-maintained release notes (templates/releases.html); linked from the version in the footer. */
|
||||||
@GetMapping("/releases")
|
@GetMapping("/releases")
|
||||||
fun releases(model: Model): String {
|
fun releases(model: Model): String {
|
||||||
baseModel(model, view = "releases", pageTitle = "Release Notes")
|
baseModel(model, view = "releases", pageTitle = "Release Notes", repo = registry.current())
|
||||||
return "releases"
|
return "releases"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One metrics page for the whole instance — the resources are the instance's, not a repository's. */
|
||||||
@GetMapping("/system")
|
@GetMapping("/system")
|
||||||
fun system(model: Model): String {
|
fun system(model: Model): String {
|
||||||
baseModel(model, view = "system", pageTitle = "System Metrics")
|
baseModel(model, view = "system", pageTitle = "System Metrics", repo = registry.current())
|
||||||
model.addAttribute("metrics", SystemMetricsView.from(metricsCollector.snapshot()))
|
model.addAttribute("metrics", SystemMetricsView.from(metricsCollector.snapshot()))
|
||||||
return "system"
|
return "system"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Artifact index rendered from the artifact store — legacy pre-generated this page as static HTML. */
|
/** Artifact index rendered from the artifact store — legacy pre-generated this page as static HTML. */
|
||||||
@GetMapping("/builds/{artifactKey}")
|
@GetMapping("/builds/{artifactKey}", "/repos/{repo}/builds/{artifactKey}")
|
||||||
fun artifactIndex(
|
fun artifactIndex(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable artifactKey: String,
|
@PathVariable artifactKey: String,
|
||||||
model: Model,
|
model: Model,
|
||||||
): String {
|
): String {
|
||||||
val result = repository.history().firstOrNull { it.artifactKey == artifactKey }
|
val repo = repoOf(repoName)
|
||||||
val artifactDir = artifactStore.artifactDir(artifactKey)
|
val result = repo.results.history().firstOrNull { it.artifactKey == artifactKey }
|
||||||
|
val artifactDir = repo.artifactStore.artifactDir(artifactKey)
|
||||||
if (result == null && artifactDir == null) {
|
if (result == null && artifactDir == null) {
|
||||||
throw ResponseStatusException(HttpStatus.NOT_FOUND, "no build with artifact key '$artifactKey'")
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "no build with artifact key '$artifactKey'")
|
||||||
}
|
}
|
||||||
return artifactIndexView(
|
return artifactIndexView(
|
||||||
model,
|
model,
|
||||||
pageTitle = "Build Artifacts",
|
pageTitle = "Build Artifacts",
|
||||||
|
repo = repo,
|
||||||
result = result,
|
result = result,
|
||||||
artifactKey = artifactKey,
|
artifactKey = artifactKey,
|
||||||
artifactDir = artifactDir,
|
artifactDir = artifactDir,
|
||||||
filesBase = "/artifacts/$artifactKey",
|
filesBase = uiBase(repo) + "/artifacts/$artifactKey",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,38 +205,42 @@ class UiController(
|
|||||||
* stay on the permanent `/branches/…` paths, so every link copied from this page
|
* stay on the permanent `/branches/…` paths, so every link copied from this page
|
||||||
* outlives artifact pruning.
|
* outlives artifact pruning.
|
||||||
*/
|
*/
|
||||||
@GetMapping("/branches/{branchKey}")
|
@GetMapping("/branches/{branchKey}", "/repos/{repo}/branches/{branchKey}")
|
||||||
fun latestGreenArtifactIndex(
|
fun latestGreenArtifactIndex(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable branchKey: String,
|
@PathVariable branchKey: String,
|
||||||
model: Model,
|
model: Model,
|
||||||
): String {
|
): String {
|
||||||
val build = branchPermalinks.latestGreenBuild(branchKey)
|
val repo = repoOf(repoName)
|
||||||
|
val build = branchPermalinks.latestGreenBuild(repo, branchKey)
|
||||||
model.addAttribute("permanentBranch", build.branch)
|
model.addAttribute("permanentBranch", build.branch)
|
||||||
model.addAttribute("concreteUrl", "/builds/${build.artifactKey}")
|
model.addAttribute("concreteUrl", uiBase(repo) + "/builds/${build.artifactKey}")
|
||||||
return artifactIndexView(
|
return artifactIndexView(
|
||||||
model,
|
model,
|
||||||
pageTitle = "Latest Green Build",
|
pageTitle = "Latest Green Build",
|
||||||
|
repo = repo,
|
||||||
result = build,
|
result = build,
|
||||||
artifactKey = build.artifactKey,
|
artifactKey = build.artifactKey,
|
||||||
artifactDir = artifactStore.artifactDir(build.artifactKey),
|
artifactDir = repo.artifactStore.artifactDir(build.artifactKey),
|
||||||
filesBase = "/branches/$branchKey",
|
filesBase = uiBase(repo) + "/branches/$branchKey",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun artifactIndexView(
|
private fun artifactIndexView(
|
||||||
model: Model,
|
model: Model,
|
||||||
pageTitle: String,
|
pageTitle: String,
|
||||||
|
repo: RepoContext,
|
||||||
result: BuildResult?,
|
result: BuildResult?,
|
||||||
artifactKey: String,
|
artifactKey: String,
|
||||||
artifactDir: Path?,
|
artifactDir: Path?,
|
||||||
filesBase: String,
|
filesBase: String,
|
||||||
): String {
|
): String {
|
||||||
val links = baseModel(model, view = "artifact", pageTitle = pageTitle)
|
val links = baseModel(model, view = "artifact", pageTitle = pageTitle, repo = repo)
|
||||||
model.addAttribute("artifactKey", artifactKey)
|
model.addAttribute("artifactKey", artifactKey)
|
||||||
model.addAttribute("filesBase", filesBase)
|
model.addAttribute("filesBase", filesBase)
|
||||||
model.addAttribute("result", result?.let { BuildRowView.from(it, links) })
|
model.addAttribute("result", result?.let { BuildRowView.from(it, links) })
|
||||||
model.addAttribute("hasArtifacts", artifactDir != null)
|
model.addAttribute("hasArtifacts", artifactDir != null)
|
||||||
model.addAttribute("buildCommand", result?.let { buildCommandOf(it) })
|
model.addAttribute("buildCommand", result?.let { buildCommandOf(repo, it) })
|
||||||
model.addAttribute(
|
model.addAttribute(
|
||||||
"logs",
|
"logs",
|
||||||
artifactDir?.let { logFiles(it, scanForFailure = result != null && result.status != BuildStatus.SUCCESS) }
|
artifactDir?.let { logFiles(it, scanForFailure = result != null && result.status != BuildStatus.SUCCESS) }
|
||||||
@@ -226,21 +268,41 @@ class UiController(
|
|||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix every in-page link and API path is built from. It follows the number
|
||||||
|
* of served repositories, not the route the page was reached through: with one
|
||||||
|
* repository the installation keeps its existing URLs (the session-D acceptance
|
||||||
|
* criterion), with several every link names its repository.
|
||||||
|
*/
|
||||||
|
private fun uiBase(repo: RepoContext): String = repoLinks.base(repo)
|
||||||
|
|
||||||
|
private fun apiBase(repo: RepoContext): String = repoLinks.apiBase(repo)
|
||||||
|
|
||||||
/** Adds the attributes every page needs and returns the Gitea link helper for row building. */
|
/** Adds the attributes every page needs and returns the Gitea link helper for row building. */
|
||||||
private fun baseModel(
|
private fun baseModel(
|
||||||
model: Model,
|
model: Model,
|
||||||
view: String,
|
view: String,
|
||||||
pageTitle: String,
|
pageTitle: String,
|
||||||
|
repo: RepoContext,
|
||||||
): GiteaWebLinks {
|
): GiteaWebLinks {
|
||||||
val config = configLoader.load(workingDir)
|
val config = configLoader.load(repo.workingDir)
|
||||||
val links = GiteaWebLinks(config.gitea)
|
val links = GiteaWebLinks(config.gitea)
|
||||||
val repoName =
|
val repoName =
|
||||||
listOf(config.gitea.owner.trim(), config.gitea.repo.trim())
|
listOf(config.gitea.owner.trim(), config.gitea.repo.trim())
|
||||||
.filter { it.isNotEmpty() }
|
.filter { it.isNotEmpty() }
|
||||||
.joinToString("/")
|
.joinToString("/")
|
||||||
|
val served = registry.all()
|
||||||
model.addAttribute("view", view)
|
model.addAttribute("view", view)
|
||||||
model.addAttribute("pageTitle", pageTitle)
|
model.addAttribute("pageTitle", pageTitle)
|
||||||
model.addAttribute("repoName", repoName)
|
model.addAttribute("repoName", repoName)
|
||||||
|
// every in-page link is built from this prefix, so a scoped page stays scoped
|
||||||
|
model.addAttribute("repoBase", uiBase(repo))
|
||||||
|
model.addAttribute("homeUrl", uiBase(repo).ifEmpty { "/" })
|
||||||
|
model.addAttribute("apiBase", apiBase(repo))
|
||||||
|
model.addAttribute("repoKey", repo.name)
|
||||||
|
// the switcher is what makes several repositories one UI; with one there is nothing to switch
|
||||||
|
model.addAttribute("multiRepo", served.size > 1)
|
||||||
|
model.addAttribute("repos", served.map { RepoLinkView(name = it.name, url = "/repos/${it.name}", current = it === repo) })
|
||||||
model.addAttribute("version", buildProperties.getIfAvailable()?.version ?: "dev")
|
model.addAttribute("version", buildProperties.getIfAvailable()?.version ?: "dev")
|
||||||
model.addAttribute("impressumUrl", config.server.impressumUrl.trim())
|
model.addAttribute("impressumUrl", config.server.impressumUrl.trim())
|
||||||
model.addAttribute("giteaRepoUrl", links.repoUrl ?: "")
|
model.addAttribute("giteaRepoUrl", links.repoUrl ?: "")
|
||||||
@@ -254,7 +316,11 @@ class UiController(
|
|||||||
* build of this pool ever ran — the branch and its job usually override it.
|
* build of this pool ever ran — the branch and its job usually override it.
|
||||||
* The command used by a past run is not persisted, so this is the current answer.
|
* The command used by a past run is not persisted, so this is the current answer.
|
||||||
*/
|
*/
|
||||||
private fun buildCommandOf(result: BuildResult): String {
|
private fun buildCommandOf(
|
||||||
|
repo: RepoContext,
|
||||||
|
result: BuildResult,
|
||||||
|
): String {
|
||||||
|
val workingDir = repo.workingDir
|
||||||
val config =
|
val config =
|
||||||
try {
|
try {
|
||||||
configLoader.loadWithBranchLayer(
|
configLoader.loadWithBranchLayer(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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'")
|
||||||
@@ -506,7 +506,10 @@ class Watcher(
|
|||||||
}
|
}
|
||||||
val keep = originBranches.map { ArtifactKeys.branchKey(it) }.toMutableSet()
|
val keep = originBranches.map { ArtifactKeys.branchKey(it) }.toMutableSet()
|
||||||
// never delete under a build that is still queued or executing
|
// never delete under a build that is still queued or executing
|
||||||
buildExecutor.currentBuilds().forEach { keep += ArtifactKeys.branchKey(it.branch) }
|
buildExecutor
|
||||||
|
.currentBuilds()
|
||||||
|
.filter { it.repo === repo }
|
||||||
|
.forEach { keep += ArtifactKeys.branchKey(it.branch) }
|
||||||
repo.results
|
repo.results
|
||||||
.latestPerName()
|
.latestPerName()
|
||||||
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
||||||
|
|||||||
@@ -99,6 +99,17 @@ function metaContent(name) {
|
|||||||
|
|
||||||
const giteaRepoUrl = metaContent("werkator-gitea-repo-url");
|
const giteaRepoUrl = metaContent("werkator-gitea-repo-url");
|
||||||
|
|
||||||
|
// Empty with one served repository, `/repos/<name>` with several (ADR 0009). Every
|
||||||
|
// path this script builds itself is prefixed with it, so an action triggered on a
|
||||||
|
// repository's page acts on that repository — the paths rendered into the DOM
|
||||||
|
// (`data-api`, artifact links) already carry it.
|
||||||
|
const repoBase = metaContent("werkator-repo-base") || "";
|
||||||
|
|
||||||
|
/** The API of the repository this page belongs to; `/api` when only one is served. */
|
||||||
|
function apiBase() {
|
||||||
|
return repoBase ? "/api" + repoBase : "/api";
|
||||||
|
}
|
||||||
|
|
||||||
// The control token is deliberately NOT embedded in the pages: reading them is
|
// The control token is deliberately NOT embedded in the pages: reading them is
|
||||||
// unauthenticated, so anyone could have read it out of the HTML. The operator
|
// unauthenticated, so anyone could have read it out of the HTML. The operator
|
||||||
// pastes it once per browser from `.git/werkator/control-token` on the server;
|
// pastes it once per browser from `.git/werkator/control-token` on the server;
|
||||||
@@ -389,7 +400,7 @@ function renderBuildRow(build, allowRestart, restartAtOriginHead) {
|
|||||||
const inProgress = build.status === "running" || build.status === "pending";
|
const inProgress = build.status === "running" || build.status === "pending";
|
||||||
if (build.artifactKey) {
|
if (build.artifactKey) {
|
||||||
const artifactLink = elem("a", "artifact-link", inProgress ? "⏳" : "📄");
|
const artifactLink = elem("a", "artifact-link", inProgress ? "⏳" : "📄");
|
||||||
artifactLink.href = "/builds/" + encodeURIComponent(build.artifactKey);
|
artifactLink.href = repoBase + "/builds/" + encodeURIComponent(build.artifactKey);
|
||||||
artifactLink.title = inProgress ? "Open build log — no artifacts yet" : "Open artifacts";
|
artifactLink.title = inProgress ? "Open build log — no artifacts yet" : "Open artifacts";
|
||||||
artifactsCell.appendChild(artifactLink);
|
artifactsCell.appendChild(artifactLink);
|
||||||
}
|
}
|
||||||
@@ -542,7 +553,7 @@ function initCurrentBuilds() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const offset = logOffsets.get(build.artifactKey) || 0;
|
const offset = logOffsets.get(build.artifactKey) || 0;
|
||||||
const url = `/api/builds/current/${encodeURIComponent(build.artifactKey)}/log?offset=${offset}`;
|
const url = `${apiBase()}/builds/current/${encodeURIComponent(build.artifactKey)}/log?offset=${offset}`;
|
||||||
const tail = await fetchJson(url);
|
const tail = await fetchJson(url);
|
||||||
logOffsets.set(build.artifactKey, tail.nextOffset);
|
logOffsets.set(build.artifactKey, tail.nextOffset);
|
||||||
if (tail.content) {
|
if (tail.content) {
|
||||||
@@ -690,11 +701,11 @@ document.addEventListener("click", async (event) => {
|
|||||||
try {
|
try {
|
||||||
if (action === "restart") {
|
if (action === "restart") {
|
||||||
const atOriginHead = button.dataset.atOriginHead === "true" ? "&atOriginHead=true" : "";
|
const atOriginHead = button.dataset.atOriginHead === "true" ? "&atOriginHead=true" : "";
|
||||||
await sendAction("/api/builds/restart?branch=" + encodeURIComponent(button.dataset.branch) + atOriginHead, "POST");
|
await sendAction(apiBase() + "/builds/restart?branch=" + encodeURIComponent(button.dataset.branch) + atOriginHead, "POST");
|
||||||
} else if (action === "cancel") {
|
} else if (action === "cancel") {
|
||||||
await sendAction(`/api/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST");
|
await sendAction(`${apiBase()}/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST");
|
||||||
} else if (action === "delete") {
|
} else if (action === "delete") {
|
||||||
await sendAction("/api/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE");
|
await sendAction(apiBase() + "/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE");
|
||||||
}
|
}
|
||||||
if (refreshNow) {
|
if (refreshNow) {
|
||||||
refreshNow();
|
refreshNow();
|
||||||
|
|||||||
@@ -52,13 +52,13 @@
|
|||||||
<td class="duration-cell" data-label="Duration" th:text="${row.duration}">1:23</td>
|
<td class="duration-cell" data-label="Duration" th:text="${row.duration}">1:23</td>
|
||||||
<td data-label="Artifacts">
|
<td data-label="Artifacts">
|
||||||
<a th:if="${row.artifactKey != ''}" class="artifact-link"
|
<a th:if="${row.artifactKey != ''}" class="artifact-link"
|
||||||
th:href="'/builds/' + ${row.artifactKey}"
|
th:href="${repoBase} + '/builds/' + ${row.artifactKey}"
|
||||||
th:text="${row.inProgress} ? '⏳' : '📄'"
|
th:text="${row.inProgress} ? '⏳' : '📄'"
|
||||||
th:title="${row.inProgress} ? 'Open build log — no artifacts yet' : 'Open artifacts'">📄</a>
|
th:title="${row.inProgress} ? 'Open build log — no artifacts yet' : 'Open artifacts'">📄</a>
|
||||||
<a th:if="${row.latestGreenUrl != null}" class="artifact-link"
|
<a th:if="${row.latestGreenUrl != null}" class="artifact-link"
|
||||||
th:href="${row.latestGreenUrl}"
|
th:href="${row.latestGreenUrl}"
|
||||||
title="Permanent link: artifacts of the latest green build">🔗</a>
|
title="Permanent link: artifacts of the latest green build">🔗</a>
|
||||||
<a th:if="${row.inProgress}" class="artifact-link" href="/current"
|
<a th:if="${row.inProgress}" class="artifact-link" th:href="${repoBase} + '/current'" href="/current"
|
||||||
title="Watch this build live">📡</a>
|
title="Watch this build live">📡</a>
|
||||||
<span th:if="${row.artifactKey == ''}">n/a</span>
|
<span th:if="${row.artifactKey == ''}">n/a</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<main>
|
<main>
|
||||||
<h1 th:replace="~{fragments :: header(${pageTitle})}"></h1>
|
<h1 th:replace="~{fragments :: header(${pageTitle})}"></h1>
|
||||||
<div th:replace="~{fragments :: nav(${view})}"></div>
|
<div th:replace="~{fragments :: nav(${view})}"></div>
|
||||||
<div id="current-builds" data-api="/api/builds/current">
|
<div id="current-builds" th:attr="data-api=${apiBase} + '/builds/current'" data-api="/api/builds/current">
|
||||||
<p id="no-current" class="empty-panel" th:style="${#lists.isEmpty(currentBuilds)} ? '' : 'display: none'">
|
<p id="no-current" class="empty-panel" th:style="${#lists.isEmpty(currentBuilds)} ? '' : 'display: none'">
|
||||||
No build is currently running.
|
No build is currently running.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -7,11 +7,14 @@
|
|||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/werkator.css">
|
<link rel="stylesheet" href="/werkator.css">
|
||||||
<meta name="werkator-gitea-repo-url" th:content="${giteaRepoUrl}">
|
<meta name="werkator-gitea-repo-url" th:content="${giteaRepoUrl}">
|
||||||
|
<!-- Empty with one served repository, `/repos/<name>` with several: werkator.js
|
||||||
|
builds its action and artifact URLs from it, so a page stays in its repository. -->
|
||||||
|
<meta name="werkator-repo-base" th:content="${repoBase}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<h1 th:fragment="header(title)">
|
<h1 th:fragment="header(title)">
|
||||||
<a class="title-home" href="/" aria-label="Open latest builds"><img src="/favicon.svg" alt=""></a>
|
<a class="title-home" th:href="${homeUrl}" href="/" aria-label="Open latest builds"><img src="/favicon.svg" alt=""></a>
|
||||||
<span class="title-text" th:text="${title}">Latest Builds</span>
|
<span class="title-text" th:text="${title}">Latest Builds</span>
|
||||||
<span class="repo-name" th:unless="${#strings.isEmpty(repoName)}" th:text="${repoName}">owner/repo</span>
|
<span class="repo-name" th:unless="${#strings.isEmpty(repoName)}" th:text="${repoName}">owner/repo</span>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -20,15 +23,23 @@
|
|||||||
<div class="view-row">
|
<div class="view-row">
|
||||||
<nav class="view-toggle">
|
<nav class="view-toggle">
|
||||||
<span th:if="${view == 'latest'}">Latest</span>
|
<span th:if="${view == 'latest'}">Latest</span>
|
||||||
<a th:unless="${view == 'latest'}" href="/">Latest</a>
|
<a th:unless="${view == 'latest'}" th:href="${homeUrl}" href="/">Latest</a>
|
||||||
<span th:if="${view == 'branches'}">Branches</span>
|
<span th:if="${view == 'branches'}">Branches</span>
|
||||||
<a th:unless="${view == 'branches'}" href="/branches">Branches</a>
|
<a th:unless="${view == 'branches'}" th:href="${repoBase} + '/branches'" href="/branches">Branches</a>
|
||||||
<span th:if="${view == 'history'}">History</span>
|
<span th:if="${view == 'history'}">History</span>
|
||||||
<a th:unless="${view == 'history'}" href="/history">History</a>
|
<a th:unless="${view == 'history'}" th:href="${repoBase} + '/history'" href="/history">History</a>
|
||||||
<span th:if="${view == 'current'}">Current</span>
|
<span th:if="${view == 'current'}">Current</span>
|
||||||
<span th:if="${view == 'system'}">System</span>
|
<span th:if="${view == 'system'}">System</span>
|
||||||
<a th:unless="${view == 'system'}" href="/system">System</a>
|
<a th:unless="${view == 'system'}" href="/system">System</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
<!-- The repository switcher (ADR 0009): what makes several repositories one UI.
|
||||||
|
With a single served repository there is nothing to switch, and the block is absent. -->
|
||||||
|
<nav class="repo-switch" th:if="${multiRepo}">
|
||||||
|
<th:block th:each="r : ${repos}">
|
||||||
|
<span th:if="${r.current}" th:text="${r.name}" class="repo-current">werkator</span>
|
||||||
|
<a th:unless="${r.current}" th:href="${r.url}" th:text="${r.name}">other</a>
|
||||||
|
</th:block>
|
||||||
|
</nav>
|
||||||
<span class="view-row-actions">
|
<span class="view-row-actions">
|
||||||
<span id="live-indicator" class="status status-unknown" title="live-update state">static</span>
|
<span id="live-indicator" class="status status-unknown" title="live-update state">static</span>
|
||||||
<button id="reload-button" class="reload-button" type="button" title="Reload view" aria-label="Reload view">⟳</button>
|
<button id="reload-button" class="reload-button" type="button" title="Reload view" aria-label="Reload view">⟳</button>
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import de.hoennig.werkator.build.ProcessBuildRunner
|
|||||||
import de.hoennig.werkator.config.ConfigLoader
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
import de.hoennig.werkator.gitea.GiteaClient
|
import de.hoennig.werkator.gitea.GiteaClient
|
||||||
import de.hoennig.werkator.repo.RepoContext
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
import io.kotest.assertions.nondeterministic.eventually
|
import io.kotest.assertions.nondeterministic.eventually
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.kotest.matchers.string.shouldContain
|
import io.kotest.matchers.string.shouldContain
|
||||||
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
@@ -42,6 +45,7 @@ class BuildExecutorArtifactIntegrationTest : FunSpec() {
|
|||||||
val executor =
|
val executor =
|
||||||
BuildExecutor(
|
BuildExecutor(
|
||||||
configLoader = ConfigLoader(),
|
configLoader = ConfigLoader(),
|
||||||
|
repoLinks = RepoLinks(mockk<RepoRegistry>().also { every { it.all() } returns listOf(repo) }),
|
||||||
giteaClient = mockk<GiteaClient>(relaxed = true),
|
giteaClient = mockk<GiteaClient>(relaxed = true),
|
||||||
buildRunner = ProcessBuildRunner(),
|
buildRunner = ProcessBuildRunner(),
|
||||||
workspaces = BranchWorkspaces { _, _, _ -> workspace },
|
workspaces = BranchWorkspaces { _, _, _ -> workspace },
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package de.hoennig.werkator.build
|
|||||||
import de.hoennig.werkator.config.ConfigLoader
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
import de.hoennig.werkator.gitea.GiteaClient
|
import de.hoennig.werkator.gitea.GiteaClient
|
||||||
import de.hoennig.werkator.repo.RepoContext
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
import io.kotest.assertions.nondeterministic.eventually
|
import io.kotest.assertions.nondeterministic.eventually
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.booleans.shouldBeFalse
|
import io.kotest.matchers.booleans.shouldBeFalse
|
||||||
@@ -53,6 +55,7 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
val executor =
|
val executor =
|
||||||
BuildExecutor(
|
BuildExecutor(
|
||||||
configLoader = ConfigLoader(),
|
configLoader = ConfigLoader(),
|
||||||
|
repoLinks = RepoLinks(mockk<RepoRegistry>().also { every { it.all() } returns listOf(repo) }),
|
||||||
giteaClient = giteaClient,
|
giteaClient = giteaClient,
|
||||||
buildRunner = buildRunner,
|
buildRunner = buildRunner,
|
||||||
workspaces = workspaces,
|
workspaces = workspaces,
|
||||||
@@ -138,6 +141,35 @@ class BuildExecutorTest : FunSpec() {
|
|||||||
verify { h.artifactStore.persist(match { it.status == BuildStatus.SUCCESS }, build.stagingDir, h.workingDir) }
|
verify { h.artifactStore.persist(match { it.status == BuildStatus.SUCCESS }, build.stagingDir, h.workingDir) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("the commit status carries the artifact page when a public base URL is configured") {
|
||||||
|
val h =
|
||||||
|
Harness(
|
||||||
|
"""
|
||||||
|
server:
|
||||||
|
publicBaseUrl: https://ci.example.org/
|
||||||
|
branches:
|
||||||
|
default:
|
||||||
|
buildCommand: "true"
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val build = h.executor.startBuild(h.repo, "main", "abc123")
|
||||||
|
|
||||||
|
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
||||||
|
awaitIdle(h)
|
||||||
|
// one served repository: the installation's existing URLs, no repository segment
|
||||||
|
verify {
|
||||||
|
h.giteaClient.publishStatus(
|
||||||
|
"abc123",
|
||||||
|
BuildStatus.SUCCESS,
|
||||||
|
any(),
|
||||||
|
"https://ci.example.org/builds/" + build.artifactKey,
|
||||||
|
h.workingDir,
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test("build commands run in the workspace prepared for the branch") {
|
test("build commands run in the workspace prepared for the branch") {
|
||||||
val h = harness(buildCommand = "pwd", workspaceSubdir = "branch-workspace")
|
val h = harness(buildCommand = "pwd", workspaceSubdir = "branch-workspace")
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class ConsoleBuildRunnerTest : FunSpec() {
|
|||||||
|
|
||||||
private fun runningBuild(stagingDir: Path) =
|
private fun runningBuild(stagingDir: Path) =
|
||||||
RunningBuild(
|
RunningBuild(
|
||||||
|
repo = repo,
|
||||||
branch = "main",
|
branch = "main",
|
||||||
commit = "0123456789abcdef",
|
commit = "0123456789abcdef",
|
||||||
artifactKey = "main-key",
|
artifactKey = "main-key",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.ninjasquad.springmockk.MockkBean
|
|||||||
import de.hoennig.werkator.build.ArtifactStore
|
import de.hoennig.werkator.build.ArtifactStore
|
||||||
import de.hoennig.werkator.build.BuildResult
|
import de.hoennig.werkator.build.BuildResult
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -32,6 +34,12 @@ class ArtifactFileControllerTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var branchPermalinks: BranchPermalinks
|
lateinit var branchPermalinks: BranchPermalinks
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var repo: RepoContext
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var registry: RepoRegistry
|
||||||
|
|
||||||
private val artifactDir: Path = Files.createTempDirectory("werkator-artifact-serve-test")
|
private val artifactDir: Path = Files.createTempDirectory("werkator-artifact-serve-test")
|
||||||
|
|
||||||
private val greenBuild =
|
private val greenBuild =
|
||||||
@@ -46,12 +54,18 @@ class ArtifactFileControllerTest : FunSpec() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
beforeEach {
|
beforeEach {
|
||||||
clearMocks(artifactStore, branchPermalinks)
|
clearMocks(artifactStore, branchPermalinks, repo, registry)
|
||||||
|
every { repo.name } returns "test"
|
||||||
|
every { repo.artifactStore } returns artifactStore
|
||||||
|
every { registry.current() } returns repo
|
||||||
|
every { registry.all() } returns listOf(repo)
|
||||||
|
every { registry.byName(any()) } returns null
|
||||||
|
every { registry.byName("test") } returns repo
|
||||||
every { artifactStore.artifactDir(any()) } returns null
|
every { artifactStore.artifactDir(any()) } returns null
|
||||||
every { artifactStore.artifactDir("known-key") } returns artifactDir
|
every { artifactStore.artifactDir("known-key") } returns artifactDir
|
||||||
every { branchPermalinks.latestGreenBuild(any()) } throws
|
every { branchPermalinks.latestGreenBuild(any(), any()) } throws
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds")
|
ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds")
|
||||||
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild
|
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild
|
||||||
}
|
}
|
||||||
|
|
||||||
test("serves an html artifact with no-cache headers") {
|
test("serves an html artifact with no-cache headers") {
|
||||||
@@ -188,7 +202,7 @@ class ArtifactFileControllerTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("permanent URL answers 404 when the green build's artifacts are gone") {
|
test("permanent URL answers 404 when the green build's artifacts are gone") {
|
||||||
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild.copy(artifactKey = "pruned-key")
|
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild.copy(artifactKey = "pruned-key")
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
.perform(get("/branches/main/build.log"))
|
.perform(get("/branches/main/build.log"))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import de.hoennig.werkator.build.ArtifactKeys
|
|||||||
import de.hoennig.werkator.build.BuildResult
|
import de.hoennig.werkator.build.BuildResult
|
||||||
import de.hoennig.werkator.build.BuildResultRepository
|
import de.hoennig.werkator.build.BuildResultRepository
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import io.kotest.assertions.throwables.shouldThrow
|
import io.kotest.assertions.throwables.shouldThrow
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
@@ -17,7 +18,8 @@ import java.time.Instant
|
|||||||
|
|
||||||
class BranchPermalinksTest : FunSpec() {
|
class BranchPermalinksTest : FunSpec() {
|
||||||
private val repository = mockk<BuildResultRepository>()
|
private val repository = mockk<BuildResultRepository>()
|
||||||
private val permalinks = BranchPermalinks(repository)
|
private val repo = mockk<RepoContext>().also { every { it.results } returns repository }
|
||||||
|
private val permalinks = BranchPermalinks()
|
||||||
|
|
||||||
private fun result(
|
private fun result(
|
||||||
branch: String,
|
branch: String,
|
||||||
@@ -36,20 +38,20 @@ class BranchPermalinksTest : FunSpec() {
|
|||||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("main"))
|
every { repository.latestPerName() } returns listOf(result("feature/x"), result("main"))
|
||||||
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
||||||
|
|
||||||
permalinks.latestGreenBuild("feature_x") shouldBe result("feature/x")
|
permalinks.latestGreenBuild(repo, "feature_x") shouldBe result("feature/x")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("resolves the full branch key with hash suffix") {
|
test("resolves the full branch key with hash suffix") {
|
||||||
every { repository.latestPerName() } returns listOf(result("feature/x"))
|
every { repository.latestPerName() } returns listOf(result("feature/x"))
|
||||||
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
||||||
|
|
||||||
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
permalinks.latestGreenBuild(repo, ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("an unknown branch key answers 404") {
|
test("an unknown branch key answers 404") {
|
||||||
every { repository.latestPerName() } returns listOf(result("main"))
|
every { repository.latestPerName() } returns listOf(result("main"))
|
||||||
|
|
||||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("gone") }
|
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "gone") }
|
||||||
|
|
||||||
exception.statusCode shouldBe HttpStatus.NOT_FOUND
|
exception.statusCode shouldBe HttpStatus.NOT_FOUND
|
||||||
}
|
}
|
||||||
@@ -58,7 +60,7 @@ class BranchPermalinksTest : FunSpec() {
|
|||||||
every { repository.latestPerName() } returns listOf(result("main", status = BuildStatus.FAILED))
|
every { repository.latestPerName() } returns listOf(result("main", status = BuildStatus.FAILED))
|
||||||
every { repository.latestGreenFor("main") } returns null
|
every { repository.latestGreenFor("main") } returns null
|
||||||
|
|
||||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("main") }
|
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "main") }
|
||||||
|
|
||||||
exception.statusCode shouldBe HttpStatus.NOT_FOUND
|
exception.statusCode shouldBe HttpStatus.NOT_FOUND
|
||||||
}
|
}
|
||||||
@@ -66,7 +68,7 @@ class BranchPermalinksTest : FunSpec() {
|
|||||||
test("a permanent key matching several branches answers 409 and names the candidates") {
|
test("a permanent key matching several branches answers 409 and names the candidates") {
|
||||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
||||||
|
|
||||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("feature_x") }
|
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "feature_x") }
|
||||||
|
|
||||||
exception.statusCode shouldBe HttpStatus.CONFLICT
|
exception.statusCode shouldBe HttpStatus.CONFLICT
|
||||||
exception.reason.orEmpty() shouldContain "feature/x"
|
exception.reason.orEmpty() shouldContain "feature/x"
|
||||||
@@ -76,7 +78,7 @@ class BranchPermalinksTest : FunSpec() {
|
|||||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
||||||
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
||||||
|
|
||||||
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
permalinks.latestGreenBuild(repo, ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("permanentUrl uses the hash-free branch key") {
|
test("permanentUrl uses the hash-free branch key") {
|
||||||
@@ -89,7 +91,7 @@ class BranchPermalinksTest : FunSpec() {
|
|||||||
every { repository.latestGreenFor("main@nightly") } returns nightly
|
every { repository.latestGreenFor("main@nightly") } returns nightly
|
||||||
|
|
||||||
// sanitized like any branch key: the '@' becomes '_' in the URL
|
// sanitized like any branch key: the '@' becomes '_' in the URL
|
||||||
permalinks.latestGreenBuild("main_nightly") shouldBe nightly
|
permalinks.latestGreenBuild(repo, "main_nightly") shouldBe nightly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ import de.hoennig.werkator.build.BuildStatus
|
|||||||
import de.hoennig.werkator.build.RunningBuild
|
import de.hoennig.werkator.build.RunningBuild
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
import de.hoennig.werkator.repo.RepoContext
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
@@ -27,6 +31,7 @@ import java.time.Duration
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@WebMvcTest(BuildsApiController::class, properties = ["spring.main.web-application-type=servlet"])
|
@WebMvcTest(BuildsApiController::class, properties = ["spring.main.web-application-type=servlet"])
|
||||||
|
@Import(RepoLinks::class)
|
||||||
class BuildsApiControllerTest : FunSpec() {
|
class BuildsApiControllerTest : FunSpec() {
|
||||||
private val tempDir: Path = Files.createTempDirectory("werkator-server-test")
|
private val tempDir: Path = Files.createTempDirectory("werkator-server-test")
|
||||||
|
|
||||||
@@ -54,6 +59,9 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var repo: RepoContext
|
lateinit var repo: RepoContext
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var registry: RepoRegistry
|
||||||
|
|
||||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||||
|
|
||||||
private val successResult =
|
private val successResult =
|
||||||
@@ -68,6 +76,7 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
|
|
||||||
private fun runningBuild(liveLogFile: Path) =
|
private fun runningBuild(liveLogFile: Path) =
|
||||||
RunningBuild(
|
RunningBuild(
|
||||||
|
repo = repo,
|
||||||
branch = "main",
|
branch = "main",
|
||||||
commit = successResult.commit,
|
commit = successResult.commit,
|
||||||
artifactKey = "main-abc123-running",
|
artifactKey = "main-abc123-running",
|
||||||
@@ -78,8 +87,16 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
beforeEach {
|
beforeEach {
|
||||||
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing, repo)
|
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing, repo, registry)
|
||||||
|
every { repo.name } returns "test"
|
||||||
every { repo.workingDir } returns tempDir
|
every { repo.workingDir } returns tempDir
|
||||||
|
every { repo.results } returns repository
|
||||||
|
every { repo.artifactStore } returns artifactStore
|
||||||
|
// the unscoped routes mean the served repository; `/api/repos/test/…` names it
|
||||||
|
every { registry.all() } returns listOf(repo)
|
||||||
|
every { registry.current() } returns repo
|
||||||
|
every { registry.byName(any()) } returns null
|
||||||
|
every { registry.byName("test") } returns repo
|
||||||
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
|
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
|
||||||
every { repository.latestGreenFor(any()) } returns null
|
every { repository.latestGreenFor(any()) } returns null
|
||||||
}
|
}
|
||||||
@@ -133,6 +150,35 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
.andExpect(jsonPath("$[0].logSize").value(5))
|
.andExpect(jsonPath("$[0].logSize").value(5))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("current answers only the served repository's builds") {
|
||||||
|
val liveLogFile = Files.writeString(tempDir.resolve("mine.log"), "12345")
|
||||||
|
val mine = runningBuild(liveLogFile)
|
||||||
|
val foreign =
|
||||||
|
runningBuild(liveLogFile).copy(
|
||||||
|
repo = mockk<RepoContext>(),
|
||||||
|
artifactKey = "other-repo-running",
|
||||||
|
)
|
||||||
|
every { buildExecutor.currentBuilds() } returns listOf(mine, foreign)
|
||||||
|
every { repository.history() } returns
|
||||||
|
listOf(successResult.copy(status = BuildStatus.RUNNING, artifactKey = mine.artifactKey))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/builds/current"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$[0].artifactKey").value(mine.artifactKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("current log of a build in another repository answers 404") {
|
||||||
|
val liveLogFile = Files.writeString(tempDir.resolve("foreign.log"), "hello world")
|
||||||
|
val foreign = runningBuild(liveLogFile).copy(repo = mockk<RepoContext>())
|
||||||
|
every { buildExecutor.currentBuilds() } returns listOf(foreign)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/builds/current/${foreign.artifactKey}/log"))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
test("current log answers the tail from the requested offset") {
|
test("current log answers the tail from the requested offset") {
|
||||||
val liveLogFile = Files.writeString(tempDir.resolve("tail.log"), "hello world")
|
val liveLogFile = Files.writeString(tempDir.resolve("tail.log"), "hello world")
|
||||||
val build = runningBuild(liveLogFile)
|
val build = runningBuild(liveLogFile)
|
||||||
@@ -299,6 +345,8 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("cancel answers 202 for a cancellable build and 404 otherwise") {
|
test("cancel answers 202 for a cancellable build and 404 otherwise") {
|
||||||
|
every { repository.history() } returns
|
||||||
|
listOf(successResult.copy(artifactKey = "known-key"), successResult.copy(artifactKey = "unknown-key"))
|
||||||
every { buildExecutor.cancel("known-key") } returns true
|
every { buildExecutor.cancel("known-key") } returns true
|
||||||
every { buildExecutor.cancel("unknown-key") } returns false
|
every { buildExecutor.cancel("unknown-key") } returns false
|
||||||
|
|
||||||
@@ -311,6 +359,32 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
.andExpect(status().isNotFound)
|
.andExpect(status().isNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("cancel does not reach a build of another repository") {
|
||||||
|
// the key exists in the executor, but not in this repository's results
|
||||||
|
every { repository.history() } returns listOf(successResult)
|
||||||
|
every { buildExecutor.cancel(any()) } returns true
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
post("/api/repos/test/builds/other-repo-key/cancel")
|
||||||
|
.header(BuildsApiController.TOKEN_HEADER, "secret"),
|
||||||
|
).andExpect(status().isNotFound)
|
||||||
|
verify(exactly = 0) { buildExecutor.cancel(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the repository-scoped routes answer for the named repository and 404 for an unknown name") {
|
||||||
|
every { repository.latestPerName() } returns listOf(successResult)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/repos/test/builds/latest"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$[0].artifactKey").value("main-abc123-key"))
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/repos/no-such-repo/builds/latest"))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
.andExpect(jsonPath("$.error").value("no repository named 'no-such-repo'"))
|
||||||
|
}
|
||||||
|
|
||||||
test("a token in the query string is not accepted — the header is the only way") {
|
test("a token in the query string is not accepted — the header is the only way") {
|
||||||
mockMvc
|
mockMvc
|
||||||
.perform(post("/api/builds/restart").param("branch", "main").param("token", "secret"))
|
.perform(post("/api/builds/restart").param("branch", "main").param("token", "secret"))
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ import de.hoennig.werkator.config.WerkatorConfig
|
|||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||||
import de.hoennig.werkator.repo.RepoContext
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import org.hamcrest.Matchers.containsString
|
import org.hamcrest.Matchers.containsString
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
||||||
@@ -36,6 +39,7 @@ import java.time.Instant
|
|||||||
controllers = [UiController::class, ArtifactFileController::class],
|
controllers = [UiController::class, ArtifactFileController::class],
|
||||||
properties = ["spring.main.web-application-type=servlet"],
|
properties = ["spring.main.web-application-type=servlet"],
|
||||||
)
|
)
|
||||||
|
@Import(RepoLinks::class)
|
||||||
class PermanentBranchRoutesTest : FunSpec() {
|
class PermanentBranchRoutesTest : FunSpec() {
|
||||||
@Autowired
|
@Autowired
|
||||||
lateinit var mockMvc: MockMvc
|
lateinit var mockMvc: MockMvc
|
||||||
@@ -70,6 +74,9 @@ class PermanentBranchRoutesTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var repo: RepoContext
|
lateinit var repo: RepoContext
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var registry: RepoRegistry
|
||||||
|
|
||||||
private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test")
|
private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test")
|
||||||
|
|
||||||
private val greenBuild =
|
private val greenBuild =
|
||||||
@@ -95,14 +102,22 @@ class PermanentBranchRoutesTest : FunSpec() {
|
|||||||
branchListing,
|
branchListing,
|
||||||
branchPermalinks,
|
branchPermalinks,
|
||||||
repo,
|
repo,
|
||||||
|
registry,
|
||||||
)
|
)
|
||||||
|
every { repo.name } returns "test"
|
||||||
every { repo.workingDir } returns Paths.get(".")
|
every { repo.workingDir } returns Paths.get(".")
|
||||||
|
every { repo.results } returns repository
|
||||||
|
every { repo.artifactStore } returns artifactStore
|
||||||
|
every { registry.all() } returns listOf(repo)
|
||||||
|
every { registry.current() } returns repo
|
||||||
|
every { registry.byName(any()) } returns null
|
||||||
|
every { registry.byName("test") } returns repo
|
||||||
every { configLoader.load(any()) } returns WerkatorConfig()
|
every { configLoader.load(any()) } returns WerkatorConfig()
|
||||||
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig()
|
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig()
|
||||||
every { gitService.showFileAtCommit(any(), any(), any()) } returns null
|
every { gitService.showFileAtCommit(any(), any(), any()) } returns null
|
||||||
every { controlTokens.token() } returns "test-token"
|
every { controlTokens.token() } returns "test-token"
|
||||||
every { branchListing.branches(any()) } returns emptyList()
|
every { branchListing.branches(any()) } returns emptyList()
|
||||||
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild
|
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild
|
||||||
every { artifactStore.artifactDir("main-key") } returns artifactDir
|
every { artifactStore.artifactDir("main-key") } returns artifactDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,16 +18,20 @@ import de.hoennig.werkator.metrics.MetricAggregate
|
|||||||
import de.hoennig.werkator.metrics.SystemMetrics
|
import de.hoennig.werkator.metrics.SystemMetrics
|
||||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||||
import de.hoennig.werkator.repo.RepoContext
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoLinks
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.kotest.matchers.string.shouldContain
|
import io.kotest.matchers.string.shouldContain
|
||||||
import io.kotest.matchers.string.shouldNotContain
|
import io.kotest.matchers.string.shouldNotContain
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
import org.hamcrest.Matchers.containsString
|
import org.hamcrest.Matchers.containsString
|
||||||
import org.hamcrest.Matchers.not
|
import org.hamcrest.Matchers.not
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
@@ -42,6 +46,7 @@ import java.time.Duration
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@WebMvcTest(UiController::class, properties = ["spring.main.web-application-type=servlet"])
|
@WebMvcTest(UiController::class, properties = ["spring.main.web-application-type=servlet"])
|
||||||
|
@Import(RepoLinks::class)
|
||||||
class UiControllerTest : FunSpec() {
|
class UiControllerTest : FunSpec() {
|
||||||
private val tempDir: Path = Files.createTempDirectory("werkator-ui-test")
|
private val tempDir: Path = Files.createTempDirectory("werkator-ui-test")
|
||||||
|
|
||||||
@@ -78,6 +83,9 @@ class UiControllerTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var repo: RepoContext
|
lateinit var repo: RepoContext
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var registry: RepoRegistry
|
||||||
|
|
||||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||||
|
|
||||||
private val emptySystemMetrics =
|
private val emptySystemMetrics =
|
||||||
@@ -119,8 +127,16 @@ class UiControllerTest : FunSpec() {
|
|||||||
branchListing,
|
branchListing,
|
||||||
branchPermalinks,
|
branchPermalinks,
|
||||||
repo,
|
repo,
|
||||||
|
registry,
|
||||||
)
|
)
|
||||||
|
every { repo.name } returns "test"
|
||||||
every { repo.workingDir } returns Paths.get(".")
|
every { repo.workingDir } returns Paths.get(".")
|
||||||
|
every { repo.results } returns repository
|
||||||
|
every { repo.artifactStore } returns artifactStore
|
||||||
|
every { registry.all() } returns listOf(repo)
|
||||||
|
every { registry.current() } returns repo
|
||||||
|
every { registry.byName(any()) } returns null
|
||||||
|
every { registry.byName("test") } returns repo
|
||||||
every { configLoader.load(any()) } returns
|
every { configLoader.load(any()) } returns
|
||||||
WerkatorConfig(
|
WerkatorConfig(
|
||||||
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
|
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
|
||||||
@@ -144,6 +160,40 @@ class UiControllerTest : FunSpec() {
|
|||||||
.andExpect(content().string(not(containsString("""href="/current""""))))
|
.andExpect(content().string(not(containsString("""href="/current""""))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("with one served repository the pages keep their existing URLs and show no switcher") {
|
||||||
|
every { repository.latestPerName() } returns listOf(successResult)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(content().string(containsString("""href="/branches"""")))
|
||||||
|
// Thymeleaf drops an attribute whose value is empty, and werkator.js falls back to ""
|
||||||
|
.andExpect(content().string(containsString("""<meta name="werkator-repo-base">""")))
|
||||||
|
.andExpect(content().string(not(containsString("""class="repo-switch""""))))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("with several served repositories every link names its repository and the switcher appears") {
|
||||||
|
val other = mockk<RepoContext>()
|
||||||
|
every { other.name } returns "other"
|
||||||
|
every { registry.all() } returns listOf(repo, other)
|
||||||
|
every { repository.latestPerName() } returns listOf(successResult)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/repos/test"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(content().string(containsString("""href="/repos/test/branches"""")))
|
||||||
|
.andExpect(content().string(containsString("""data-api="/api/repos/test/builds/latest"""")))
|
||||||
|
.andExpect(content().string(containsString("""<meta name="werkator-repo-base" content="/repos/test">""")))
|
||||||
|
.andExpect(content().string(containsString("""class="repo-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") {
|
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
|
||||||
every { repository.latestPerName() } returns listOf(successResult)
|
every { repository.latestPerName() } returns listOf(successResult)
|
||||||
|
|
||||||
@@ -246,6 +296,7 @@ class UiControllerTest : FunSpec() {
|
|||||||
test("current view renders a card per running build with cancel button and started-at attribute") {
|
test("current view renders a card per running build with cancel button and started-at attribute") {
|
||||||
val build =
|
val build =
|
||||||
RunningBuild(
|
RunningBuild(
|
||||||
|
repo = repo,
|
||||||
branch = "main",
|
branch = "main",
|
||||||
commit = successResult.commit,
|
commit = successResult.commit,
|
||||||
artifactKey = "main-abc123-running",
|
artifactKey = "main-abc123-running",
|
||||||
@@ -502,7 +553,7 @@ class UiControllerTest : FunSpec() {
|
|||||||
Files.writeString(artifactDir.resolve("build.stdout.log"), "out")
|
Files.writeString(artifactDir.resolve("build.stdout.log"), "out")
|
||||||
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
|
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
|
||||||
Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "<html></html>")
|
Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "<html></html>")
|
||||||
every { branchPermalinks.latestGreenBuild("main") } returns successResult
|
every { branchPermalinks.latestGreenBuild(any(), "main") } returns successResult
|
||||||
every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir
|
every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
@@ -516,7 +567,7 @@ class UiControllerTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("permanent artifact index of a branch without a green build answers 404") {
|
test("permanent artifact index of a branch without a green build answers 404") {
|
||||||
every { branchPermalinks.latestGreenBuild("main") } throws
|
every { branchPermalinks.latestGreenBuild(any(), "main") } throws
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "branch 'main' has no successful build")
|
ResponseStatusException(HttpStatus.NOT_FOUND, "branch 'main' has no successful build")
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ class WatcherTest : FunSpec() {
|
|||||||
val branch = secondArg<String>()
|
val branch = secondArg<String>()
|
||||||
val commit = thirdArg<String>()
|
val commit = thirdArg<String>()
|
||||||
startedBuilds += branch to commit
|
startedBuilds += branch to commit
|
||||||
runningBuild(branch, commit)
|
runningBuild(repo, branch, commit)
|
||||||
}
|
}
|
||||||
every { artifactStore.prune(any()) } returns emptyList()
|
every { artifactStore.prune(any()) } returns emptyList()
|
||||||
}
|
}
|
||||||
@@ -131,11 +131,13 @@ class WatcherTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun runningBuild(
|
private fun runningBuild(
|
||||||
|
repo: RepoContext,
|
||||||
branch: String,
|
branch: String,
|
||||||
commit: String,
|
commit: String,
|
||||||
): RunningBuild {
|
): RunningBuild {
|
||||||
val stagingDir = Files.createTempDirectory("werkator-watcher-staging")
|
val stagingDir = Files.createTempDirectory("werkator-watcher-staging")
|
||||||
return RunningBuild(
|
return RunningBuild(
|
||||||
|
repo = repo,
|
||||||
branch = branch,
|
branch = branch,
|
||||||
commit = commit,
|
commit = commit,
|
||||||
artifactKey = ArtifactKeys.buildKey(branch, Instant.now()),
|
artifactKey = ArtifactKeys.buildKey(branch, Instant.now()),
|
||||||
@@ -272,7 +274,7 @@ class WatcherTest : FunSpec() {
|
|||||||
test("a poll cycle completes while a build is running and still enqueues other branches") {
|
test("a poll cycle completes while a build is running and still enqueues other branches") {
|
||||||
val harness = Harness()
|
val harness = Harness()
|
||||||
harness.seed("main", BuildStatus.RUNNING, commit = "commit-1")
|
harness.seed("main", BuildStatus.RUNNING, commit = "commit-1")
|
||||||
every { harness.buildExecutor.currentBuilds() } returns listOf(runningBuild("main", "commit-1"))
|
every { harness.buildExecutor.currentBuilds() } returns listOf(runningBuild(harness.repo, "main", "commit-1"))
|
||||||
every { harness.gitService.originBranches(any()) } returns listOf("main", "feature/other")
|
every { harness.gitService.originBranches(any()) } returns listOf("main", "feature/other")
|
||||||
every { harness.gitService.localBranches(any()) } returns listOf("main")
|
every { harness.gitService.localBranches(any()) } returns listOf("main")
|
||||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||||
@@ -767,6 +769,28 @@ class WatcherTest : FunSpec() {
|
|||||||
Files.exists(busyWorktree).shouldBeTrue()
|
Files.exists(busyWorktree).shouldBeTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("a running build of another repository does not keep this repository's worktree") {
|
||||||
|
val harness = Harness()
|
||||||
|
harness.seed("gone", BuildStatus.SUCCESS, commit = "commit-1")
|
||||||
|
val goneWorktree = harness.worktreeDir("gone")
|
||||||
|
val otherDir = Files.createTempDirectory("werkator-watcher-other-running")
|
||||||
|
val other =
|
||||||
|
RepoContext(
|
||||||
|
"other",
|
||||||
|
otherDir,
|
||||||
|
FileBuildResultRepository(otherDir.resolve(".git/werkator/build-results.json")),
|
||||||
|
harness.artifactStore,
|
||||||
|
)
|
||||||
|
// the other repository builds a branch of the same name — its build must not
|
||||||
|
// protect this repository's worktree, whose branch is gone from origin
|
||||||
|
every { harness.buildExecutor.currentBuilds() } returns listOf(runningBuild(other, "gone", "commit-other"))
|
||||||
|
every { harness.gitService.originBranches(any()) } returns emptyList()
|
||||||
|
|
||||||
|
harness.watcher.poll(harness.repo)
|
||||||
|
|
||||||
|
Files.exists(goneWorktree).shouldBeFalse()
|
||||||
|
}
|
||||||
|
|
||||||
test("one repository's unreachable origin neither stops nor silences the other") {
|
test("one repository's unreachable origin neither stops nor silences the other") {
|
||||||
val harness = Harness()
|
val harness = Harness()
|
||||||
val otherDir = Files.createTempDirectory("werkator-watcher-other")
|
val otherDir = Files.createTempDirectory("werkator-watcher-other")
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
# tools/remote [--env-file FILE] werkator instance-update redeploy bundle + werkdock, restart the service
|
# tools/remote [--env-file FILE] werkator instance-update redeploy bundle + werkdock, restart the service
|
||||||
# tools/remote [--env-file FILE] werkator instance-start apply fragment, Apache proxy, systemd unit
|
# tools/remote [--env-file FILE] werkator instance-start apply fragment, Apache proxy, systemd unit
|
||||||
# tools/remote [--env-file FILE] werkator repo-init clone the watched repo, init --apply, rootfs
|
# tools/remote [--env-file FILE] werkator repo-init clone the watched repo, init --apply, rootfs
|
||||||
|
# tools/remote [--env-file FILE] werkator repo-add URL [NAME] clone and init ANOTHER repository for the
|
||||||
|
# registry, then print the entry to add to ~/.werkator.yml
|
||||||
# tools/remote [--env-file FILE] werkator control-token
|
# tools/remote [--env-file FILE] werkator control-token
|
||||||
# tools/remote [--env-file FILE] port-forward start background tunnel to the Werkator UI
|
# tools/remote [--env-file FILE] port-forward start background tunnel to the Werkator UI
|
||||||
# tools/remote [--env-file FILE] port-forward stop
|
# tools/remote [--env-file FILE] port-forward stop
|
||||||
@@ -273,6 +275,53 @@ repo_init() {
|
|||||||
echo " then tools/remote werkator instance-start"
|
echo " then tools/remote werkator instance-start"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Prepare a SECOND (third, …) repository for the registry of an installed
|
||||||
|
# instance (ADR 0009): clone it next to the others and run `init` in it, so it
|
||||||
|
# has its own machine config. The registry entry itself is only PRINTED, never
|
||||||
|
# written: `~/.werkator.yml` is the instance's own file — it carries the port,
|
||||||
|
# the global concurrency and possibly shared credentials, and a script that
|
||||||
|
# edits it in place would rewrite the operator's own configuration behind their
|
||||||
|
# back. Cloning and initialising is mechanical, registering is a decision.
|
||||||
|
repo_add() {
|
||||||
|
local url="${1:-}"
|
||||||
|
[ -n "$url" ] || die "usage: tools/remote [--env-file FILE] werkator repo-add <clone-url> [name]"
|
||||||
|
local name="${2:-$(basename "$url" .git)}"
|
||||||
|
case "$name" in
|
||||||
|
*/*|"") die "the repository name is one path segment (it becomes the route segment /repos/<name>)" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
ensure_ssh
|
||||||
|
ssh "$HOST" "test -x '$WERKATOR_BIN'" || die "no instance on $HOST — run instance-install first"
|
||||||
|
|
||||||
|
echo "==> Cloning $url as '$name'"
|
||||||
|
if ssh "$HOST" "test -d '$TARGET_DIR/$name/.git'"; then
|
||||||
|
echo " (already cloned, skipping)"
|
||||||
|
else
|
||||||
|
ssh "$HOST" "git clone '$url' '$TARGET_DIR/$name'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
# Start the server as a systemd user unit behind the managed Apache. All
|
||||||
# configuration comes from the instance fragment (server.port, publicBaseUrl,
|
# configuration comes from the instance fragment (server.port, publicBaseUrl,
|
||||||
# systemd limits); init generates the units AND the .htaccess — this script
|
# systemd limits); init generates the units AND the .htaccess — this script
|
||||||
@@ -384,6 +433,9 @@ case "$REPO" in
|
|||||||
repo-init)
|
repo-init)
|
||||||
repo_init
|
repo_init
|
||||||
;;
|
;;
|
||||||
|
repo-add)
|
||||||
|
repo_add "${3:-}" "${4:-}"
|
||||||
|
;;
|
||||||
control-token)
|
control-token)
|
||||||
control_token
|
control_token
|
||||||
;;
|
;;
|
||||||
|
|||||||
Reference in New Issue
Block a user