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
+12 -9
View File
@@ -20,19 +20,22 @@ them into a production run yet, so `run` still refuses.
|---|---|
| `stalwart-migrate preflight` | **Works** — read-only checks and a migration plan |
| `stalwart-migrate rehearse` | **Works** — read-only; converts your settings and reports what won't carry over |
| `stalwart-migrate run` | **Refuses on purpose** — see below |
| `stalwart-migrate run` | **Works** — performs the migration; `--recovery-point-confirmed --yes` |
| `stalwart-migrate status <id>` | **Works** |
| `stalwart-migrate report <id>` | Not implemented |
**`run` deliberately refuses to proceed.** Cutover (ARCHITECTURE.md §4.5) is
implemented, but nothing calls it: the staging phase (§4.3) and the pipeline
that would run preflight → backup → stage → recovery-mode → cutover →
validate against real paths don't exist yet. `run` stops rather than going
partway. That refusal is the correct behaviour today, not a bug.
**`run` performs the migration**, in the order
preflight → stage → dump → stop → convert → recovery-mode → cutover. It
needs two flags: `--yes` (intent) and `--recovery-point-confirmed` (a claim
that you have a snapshot or backup you have verified you can restore — this
tool cannot undo a migration and will not start without it).
**Start with `rehearse` instead.** It is read-only, needs no maintenance
window, and answers the question that actually shapes a migration plan —
see below.
**Start with `rehearse` first.** It is read-only, needs no maintenance
window, and tells you what `run` will and won't carry over.
Measured on a full migration: the store converts in seconds, and the service
was down for **6 seconds** end to end. Plan the window around verification,
not data volume.
Package state: