Implement run: the migration pipeline, end to end

The phases have all existed for a while; nothing chained them. The order
here is the one arrived at by performing this migration by hand against a
clone of production before writing it down:

    preflight -> stage -> dump -> preserve binary -> STOP ->
    convert -> supplement -> recovery-mode migration -> cutover -> START

The dump runs before the stop because it reads settings over the admin API,
and a stopped server has no admin API. Everything from the stop to the end
of cutover is downtime.

internal/stage fills the last missing phase (4.3): resolve the release,
take the x86_64 linux-gnu server build and refuse to substitute another,
verify a pinned checksum if one was given, extract the binary - refusing
any archive entry that isn't a regular file, since a tarball is untrusted
input - and confirm the result reports the version its tag claimed.
Everything upstream of that last check is an assumption about someone
else's release process.

Two gates, separate on purpose. --yes is about intent. --recovery-point-
confirmed is a claim about the world: this tool cannot undo a migration
(4.8) and cannot check whether a snapshot exists, so a run that proceeded
without the operator asserting one would be proceeding on a hope.

Verified end to end against a real Stalwart 0.15.5 with email-style account
names, a named admin account, and seeded mail:

    MIGRATION COMPLETE. Mail was down for 6s.

Every cutover step green, including recalculate-quotas ("rebuilt disk
quotas for 2 account(s)") - the first time the x:Task wire format inferred
from Stalwart's schema reference has actually been exercised. It works,
now that endpoint discovery and role restoration make it reachable. After
the migration the named admin still administers, alice logs in with
unchanged credentials to the same four messages, and new SMTP delivery is
accepted.

Both refusal gates were tested, as was the failure path: an apply that
fails leaves the run stopped with the store part-migrated, and the error
says to restore the recovery point rather than restart the old version
against it.
This commit is contained in:
2026-08-23 22:49:21 -07:00
parent 479e6d563e
commit 5a4c175042
7 changed files with 836 additions and 46 deletions
+16
View File
@@ -829,6 +829,22 @@ happens to need them. `preflight.DeploymentKind` is a type alias for
- `tenant-admin` has no v0.16 equivalent and is reported as unrestorable
rather than silently dropped.
- **`run` is built and works.** preflight -> stage -> dump -> preserve ->
stop -> convert -> supplement -> recovery-mode -> cutover, checkpointed
throughout, verified end to end against a real 0.15.5: mail down for six
seconds, users unchanged, and `recalculate-quotas` succeeding for the
first time - the `x:Task` wire format inferred from the schema reference
turned out to be right, once the endpoint discovery and role restoration
made it reachable at all.
Two gates, deliberately separate: `--yes` is about intent, and
`--recovery-point-confirmed` is a claim about the world that this tool
cannot verify and must not assume. `internal/stage` (§4.3) fetches the
release, refuses to substitute a different build for the one it wants,
honours a pinned checksum, and confirms the extracted binary reports the
version its tag claims - because everything upstream of that check is an
assumption about somebody else's release process.
- **`rehearse` (§4.9) is designed but not built.** The command is still
`run --dry-run` with the old sandbox-cloning shape. Building it is mostly
deletion: the dump, convert and report pieces already exist and work