Files
werkator/docs/rfcs/0005-werkator-deploys-itself.md
2944db7616
werkator build failed after 01:46
feat(deploy): the instance ends itself after deploying (#65) (#65)
RFC 0005 step 2. `restartsInstance` marks the build that installs the
version the instance is to run next. On SUCCESS the executor drains —
nothing new starts, everything executing is finished and recorded — and
then closes the server context, so Restart=always brings the new version
up. Until now the restart was scheduled from outside and asked whether
the instance happened to be idle, so a build starting between the answer
and the stop was cut short and re-run by the recovery.

The key is pinned like the sandbox policy, and refused on builds.default
and on a definition without trigger.branches: a restart must never come
from whatever branch went green last.

tools/self-deploy reads WERKATOR_RESTARTS_INSTANCE from its environment
and hands the running MainPID to the transient unit, which waits for that
pid to go instead of polling for idleness. The stop afterwards keeps
Restart=always from bringing the old runtime back mid-swap; the swap and
the rollback stay outside, where PR#64 put them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #65
2026-09-15 11:17:15 +02:00

219 lines
19 KiB
Markdown

# RFC 0005: Werkator Deploys Itself
**Status:**
- proposed: 2026-09-13
- accepted: 2026-09-13 (A first, then B on top; see Decisions)
- rejected: -
- implemented: step 1 on 2026-09-14 (PR#61); step 2 on 2026-09-15 (PR#65)
**Proposal:** Werkator's own repository gets a deployment like every other repository it builds: a follow-up build on `main` that installs the freshly built runtime bundle on the host and restarts the instance.
The one thing that sets this repository apart — the build that deploys runs *inside* the process it replaces — is solved in two steps that can be executed independently, each as one pull request.
Step 1 needs no Werkator code: the build ends green first and asks systemd for the restart afterwards.
Step 2 makes the restart a feature of the build definition, so Werkator drains its queue and ends itself; the deployment no longer decides the moment, it only stages the runtime and leaves the swap and the rollback to the transient unit.
Each step is written so that it can be handed to an agent as a `/goal` on its own: [Step 1](#step-1--restart-from-outside-the-build-option-a) and [Step 2](#step-2--restartsinstance-as-a-build-setting-option-b) each list their deliverables and their completion conditions.
## Context and Problem Statement
Every repository the production instance on mih09 watches can deploy itself with a follow-up build (`afterSuccessOf`, PR#23): a definition on the host that runs after a green build and ships what it built.
Werkator's own repository is the exception.
It is built on mih09 like the others, but its rollout is still `tools/remote werkator instance-update` from a developer machine: upload the bundle, stop the unit, swap the runtime, start the unit.
Merged main commits therefore run on the instance only when somebody remembers — v1.13.1 was on main for a while before it replaced v1.12.0, and every merge in between paid the price of the bug it fixed.
A deployment definition would not work for this repository today, and not because of the restart itself.
The deploy build is a child process of the running Werkator, and a restart of the instance has three effects on it:
- **The build dies with the service.** `systemctl --user stop` ends the whole control group, and `BuildExecutor.shutdown()` destroys every executing process tree anyway and records the build as `INTERRUPTED`.
- **It runs again after the restart.** `Watcher.recoverOnStartup` re-enqueues every `INTERRUPTED` build whose branch still exists.
The re-run deploys again, restarts again, and so on: an endless loop, not a one-time glitch.
- **The green status never lands.** Result persistence, the Gitea status and the artifacts happen after the process ends, which never happens regularly.
So the restart must be *caused* by the build but must not *happen* inside it.
The build ends `SUCCESS`, and only then does something else restart the service.
Two facts of the mih09 installation shape the solution:
- The instance is a systemd user unit (`werkator-werkator.service`) with `Restart=always`, running the jlink bundle from `~/.werkator/werkator/`; `tools/remote` swaps that directory as a whole (`swap_instance_runtime`: `werkator` → `werkator.prev`, untar, `werkator --version`).
A build of this repository runs on the same host as the instance, so no ssh is involved in a self-deployment — the swap is a local `mv`.
- Builds run in the werkdock sandbox, which has a JDK 21 and therefore `jlink`; the host itself has no JDK.
The bundle must be built inside the sandbox, but the swap and the restart must happen natively, on the host's filesystem and with the user's systemd instance.
## The Options
- **A — a transient systemd job restarts the service.**
The deploy definition runs natively, stages and swaps the bundle, and as its last step schedules the restart with `systemd-run --user --on-active=…`.
The transient timer lives outside the service's control group and survives the stop; the build is already `SUCCESS` when the restart happens, so the recovery leaves it alone.
No Werkator code changes; the deployment is a script in the repository plus the host's part of the definition.
- **B — `restartsInstance: true` on the build definition.**
Werkator knows that this build replaces the service: after the result is persisted and published it stops taking builds, waits until nothing executes, and closes the server context; `Restart=always` brings the new version up.
Works the same on werkdock and Docker hosts, and the deployment script no longer talks to systemd.
Costs a new setting in the executor and the config, in the three places that must stay in sync.
- **C — a systemd path unit as the deployer.**
`init --systemd` generates `werkator-deploy.path` and `.service`; the build only drops the bundle and a marker file; the path unit swaps and restarts.
Robust and extensible towards rollback, but two more units on every host, including those that never deploy themselves.
### Recommendation
A first, because it works today without touching the executor and cleanly avoids the loop; B on top once A has run on mih09, because a restart is then a property of the build and not a trick of one script.
C is not needed once B exists.
## Why the key is pinned
The branch layer wins over the whole `builds` section.
Without pinning, a branch could write `restartsInstance: true` into any definition of its committed `.werkator.yml`, say into `test`, and every push would end the instance and abort every other running build.
That is the reason `afterSuccessOf` and the trigger block of a follow-up are pinned: a branch describes its own build, but it never decides what happens to the host.
A restart is a host decision, like the sandbox, the network and the socket.
Pinning also means a feature branch cannot deploy itself through a pinned key that "also applies" there:
- Whether the deploy build runs at all is decided by its trigger, and the trigger of a follow-up is pinned as a whole: `afterSuccessOf: test` and `branches: ["main"]` come from the host, a branch can neither change nor remove them.
On a feature branch `deploy` is never enqueued, and a key of a build that does not run has no effect.
- A branch cannot equip another build with the key: pinning writes the host's value back.
For `test` that is `false`, because the host does not set it there; for a build the branch invents the value comes from the host's `builds.default`, also `false` — exactly how `docker.enabled` behaves today.
The condition is that the host restricts its deploy definition to `main` itself.
A definition with `restartsInstance` and no `branches` selector would replace the instance with whatever branch went green last.
That is a host misconfiguration, not a hole a branch can open, and Werkator refuses it at start (Step 2).
## Where the configuration lives
The split is the one `docs/configuration.md` already prescribes for follow-up builds: what the deployment *does* comes with the repository, *when, where and with what* belongs to the host.
**In the repository, `.werkator.yml`:** a definition `deploy` that has no trigger without the host's part and therefore never runs anywhere else.
`test` builds the runtime bundle alongside, since only the sandbox has a JDK; `deploy` takes it from the same worktree, which is why it sets `cleanCommand: ""`.
```yaml
builds:
default:
buildCommand: ./gradlew --console=plain --no-daemon test runtimeBundle
deploy:
cleanCommand: "" # keeps the bundle test just built in this worktree
buildCommand: tools/self-deploy # stage, verify, swap, schedule the restart
artifactDirs: []
```
**On the host, the fragment applied with `init --apply`:** the trigger, the branch restriction and the decision that this build runs natively, since it has to reach the installation directory.
All three are pinned; a branch reaches none of them.
```yaml
builds:
deploy:
trigger:
afterSuccessOf: test
branches: ["main"]
statusContext: werkator/deploy
werkdock:
enabled: false
```
With Step 2, `restartsInstance: true` joins this host block.
The bundle is not an artifact: it is around 60 MB per build and would be kept `retentionPerBranch` times per branch on the SSD for no reader.
It stays in the worktree, where the follow-up finds it and the next `cleanCommand` of `test` removes it.
## Step 1 — Restart from outside the build (Option A)
One pull request; minor version bump, since the repository gains a deployment.
### Deliverables
1. **`tools/self-deploy`**, a bash script run by the deploy build natively in the branch worktree of `main`.
It takes no parameters and reads no env file; everything it needs it derives from where it runs:
- the primary checkout from `git rev-parse --git-common-dir`, and from its basename the unit name exactly as `SystemdServiceFiles.unitName` derives it (`werkator-<dir>.service`, characters outside `[A-Za-z0-9_.-]` replaced by `-`);
- the installation directory from the unit's `ExecStart` (`systemctl --user show -p ExecStart`), whose jar path lies inside the bundle directory `<install>/werkator/`;
- the instance's port from `<install>/werkator/bin/werkator config:print`, the way `tools/remote require_idle` does.
It fails clearly, without touching anything, when `build/distributions/werkator-runtime-linux-x64.tar.gz` is missing — a restart of `deploy` alone runs without its predecessor and must not install an old bundle — and when the running unit is not the one the checkout belongs to.
2. **The staged swap.** Unpack into `<install>/werkator.new`, run `werkator.new/bin/werkator --version` natively — this is what catches a bundle whose jlinked JRE does not run on the host — and only then `rm -rf werkator.prev; mv werkator werkator.prev; mv werkator.new werkator`.
The unit's `ExecStart` path stays valid because the directory name does not change.
3. **The restart and the watchdog**, one transient unit scheduled with `systemd-run --user --collect --on-active=10 --unit=werkator-self-restart`:
restart the service, wait, probe `http://127.0.0.1:<port>/api/watcher`; on failure swap `werkator.prev` back and restart once more, and leave that fact in the journal.
The transient unit is not part of the service's control group, so the stop does not end it.
`XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS` are set in the build's environment because the build is a child of the service, which the user manager started; the script must not assume a login shell.
4. **`tools/remote` reuses the swap.** `swap_instance_runtime` and `instance-update` do the same staged swap with the same verification, by uploading and running the same script with a flag that skips the restart scheduling (the remote script does the stop/start itself, as today).
Two implementations of the swap would drift apart at the first change of the layout.
5. **`.werkator.yml`** as shown above: `runtimeBundle` in the default build command, the `deploy` definition with `cleanCommand: ""` and an empty `artifactDirs`.
6. **`docs/deployment.md`** gets a section "Self-Deployment" under "Updating an Existing Installation": the two parts of the configuration, the staged swap, the watchdog and its rollback, and that `werkator.prev` is the last known good version.
`docs/configuration.md` gets one sentence in "Follow-up builds" naming this RFC as the example of a deployment that restarts the very instance running it.
7. **The PR-doc** with the scenarios below, the version bump and the release-notes entry.
### Scenarios
- Given `test` went green on `main`, when `deploy` runs, then the new bundle is installed, the build is recorded as `SUCCESS` before the service restarts, and the restarted instance reports the new version in its footer.
- Given `deploy` is restarted from the UI without a preceding `test`, when the script finds no bundle in the worktree, then it fails with a message saying so and the installation is untouched.
- Given the new bundle's `werkator --version` fails natively, when the script verifies it, then nothing is swapped and the build fails.
- Given the restarted service does not answer within the watchdog's wait, when the watchdog probes it, then `werkator.prev` is swapped back, the service is restarted again, and the journal says so.
- Given a feature branch commits `builds.deploy.trigger.branches: ["*"]`, when the watcher evaluates the branch, then `deploy` is not enqueued for it (the trigger of a follow-up is pinned).
The first scenario is verified on mih09, not by a test: the PR-doc records the run.
The last is a `ConfigLoaderTest` case if one does not cover it yet.
### Completion conditions
- `./gradlew build` is green and the CI build of the branch on werkator.javagil.de is green.
- `tools/self-deploy` passes `shellcheck` and refuses to run in the primary checkout (it must be in a worktree of the repository it deploys).
- `tools/remote --env-file .env.mih09 werkator instance-update` still works and now goes through the staged swap; this is the rollout of the PR itself and the last manual one.
- After the merge, the host fragment on mih09 carries the `deploy` block, applied with `init --apply`, and the next merge to `main` deploys itself: `test` green, `deploy` green, footer shows the new version, History shows both builds.
- The PR-doc's first scenario records that run with its commit and the two build durations.
### What Step 1 leaves open
- The restart happens at a fixed delay after the build, whether or not another build has started in the meantime.
With `executor.maxConcurrent: 1` on mih09 nothing else runs while `deploy` runs, but a build enqueued right after it can be interrupted by the restart and is then re-run by the recovery.
Acceptable once, not as a rule; Step 2 closes it.
- The script knows systemd.
On a host without a user manager (a Docker host with a system unit, or none) Step 1 does not apply; the script says so and fails.
## Step 2 — `restartsInstance` as a build setting (Option B)
One pull request on top of Step 1; minor version bump.
### Deliverables
1. **`restartsInstance: Boolean = false`** as a build setting in `WerkatorConfig`, inherited like every setting, pinned like `docker.enabled`: stripped from the branch layer, written back from the host's definition of the same name or from the host's `builds.default`.
Refused at start, naming the file, when set on `builds.default` or on a definition without a `trigger.branches` selector — a restart must never come from "whatever branch went green".
The three places stay in sync: the data class, the `InitCommand` template (commented out, with the warning), `docs/configuration.md`.
2. **The executor's part.** When a build with the setting ends `SUCCESS` — and only then; a failed or interrupted deploy restarts nothing — the executor, after persisting the result and publishing the status, enters a *draining* state: it starts no further build (queued builds stay `PENDING`, which the recovery re-enqueues), waits until nothing executes, and then asks the server to close its context.
`ServerCommand` already blocks on `ContextClosedEvent`; the programmatic close falls through `awaitShutdown`, and `main()` exits — `exitProcess` stays in `main()`, the invariant holds.
`Restart=always` starts the new version.
The watcher's `state` and `/api/watcher` say "restarting" during the drain, so the UI's banner explains why nothing starts.
3. **`tools/self-deploy` stops scheduling the restart** when it runs under a Werkator that restarts itself; it keeps scheduling the watchdog.
How it knows: the executor exports `WERKATOR_RESTARTS_INSTANCE=true` into the environment of a build with the setting, so the script needs no version check.
The rollback stays with the watchdog: an instance that does not start cannot swap itself back.
4. **Docs and PR-doc.** `docs/deployment.md` "Self-Deployment" describes the drain; `docs/configuration.md` documents the key with its two refusals; AGENTS.md's invariant on pinned keys names it; the PR-doc carries the scenarios.
### Scenarios
- Given a host definition `deploy` with `restartsInstance: true` and `branches: ["main"]`, when its run ends `SUCCESS`, then the result is persisted and published before the context closes, and after the restart the recovery re-runs nothing for it.
- Given the same and a second build is executing when `deploy` ends, when the executor drains, then the second build finishes and is recorded before the context closes, and the restart happens afterwards.
- Given the same and a build is queued when `deploy` ends, when the executor drains, then the queued build stays `PENDING`, starts nothing, and is re-enqueued by the recovery after the restart.
- Given `deploy` ends `FAILURE`, `TIMED_OUT` or `INTERRUPTED`, when the executor looks at the setting, then nothing is drained and the instance keeps running.
- Given a feature branch commits `builds.test.restartsInstance: true`, when the config is loaded for that branch, then the value is `false` (written back from the host).
- Given a host writes `restartsInstance: true` on `builds.default`, or on a definition without `trigger.branches`, when the instance starts, then the start is refused naming the file and the key.
All but the first are `BuildExecutorTest` and `ConfigLoaderTest` cases; the first is recorded from mih09 in the PR-doc.
### Completion conditions
- `./gradlew build` green, CI green.
- The host fragment on mih09 adds `restartsInstance: true` to the `deploy` block; the merge of this PR is deployed by Step 1's mechanism and the next merge after it by Step 2's — the PR-doc records both.
- `tools/self-deploy` no longer schedules a restart under the new instance, verified by the journal: only the watchdog unit appears.
## Decisions
Taken on 2026-09-13:
1. **A, then B** — Step 1 ships the deployment without executor changes; Step 2 makes the restart a build setting.
C is not pursued.
2. **The bundle is built by `test` inside the sandbox** and taken from the worktree by `deploy` (`cleanCommand: ""`); it is not an artifact.
3. **The swap is staged and verified natively** before it replaces anything; `werkator.prev` is the last known good version, and the watchdog outside the service swaps it back when the new version does not answer.
4. **`restartsInstance` is pinned** like the sandbox policy and refused on `builds.default` and on a definition without a `branches` selector.
5. **The rollback stays outside Werkator** even in Step 2: a transient systemd unit, since an instance that does not start cannot help itself.
## Consequences
- Merges to `main` reach mih09 within one poll cycle plus two builds; the manual `instance-update` remains for the first installation and for emergencies.
- `test` takes longer on every branch by the `runtimeBundle` task; the bundle is not kept.
- `BuildExecutor` gains a draining state (Step 2), and the invariant "never assume a single running build" gains a sibling: never assume the instance keeps running after a build.
- Not touched: the trigger semantics of follow-ups, the pull-request gate, the Docker runner, and the werkdock sandbox.
- Open, for a later RFC: a self-deployment on a Docker host with a system unit, and whether `init --systemd` should generate the watchdog as a permanent unit (Option C's half) instead of `systemd-run`.