Add cutover; drop rollback in favour of operator-provided recovery
Two changes that arrived together: the cutover phase (ARCHITECTURE.md 4.5) is implemented, and the rollback phase is deleted. Recovery from a failed migration is now explicitly the operator's own snapshot or backup, and out of scope for this tool. internal/cutover implements 4.5 as seven checkpointed steps: verify the staged binary's version, install it, preserve and rewrite the service definition, reload, start, wait for a healthy JMAP session, recalculate quotas. The unit is rewritten in place rather than generated from a template. An operator's unit carries hardening options, limits and dependencies this tool has no business having an opinion about, and regenerating it would silently drop them. It repoints ExecStart (preserving systemd's -@:+! prefix characters and every argument after the executable), updates --config, and strips recovery-mode Environment lines - leaving STALWART_RECOVERY_MODE=1 set would recovery-boot the service on every restart, forever. It refuses on a unit with no ExecStart, and on an Environment line mixing a recovery variable with others: a line it only partly understands is one it must not edit. Quota recalculation is the one step allowed to fail without failing the phase. Its wire format is grounded in Stalwart's x:Task schema reference - Task/set creating one AccountMaintenance per account with maintenanceType recalculateQuota - but the upgrade guide only documents the WebUI path, so two details remain inferred and are called out in stalwartapi/task.go: whether the schema's "read-only" annotation on accountId/maintenanceType means "immutable after creation", and whether a finished task simply leaves the queue (TaskStatus documents Pending/Retry/Failed with no success state). Warning rather than failing is the honest response to that uncertainty, and stale counters are an accounting problem next to calling for a restore of a machine that is otherwise migrated and serving mail. Docker deployments are refused outright: cutting a container over means pulling an image and recreating it, not swapping a binary. On removing rollback. The implementation worked and was tested, and it was removed because restoring bytes correctly is not the hard part. It copied file contents and permissions and verified every restored file against a manifest - and did not preserve ownership. Run as root, as this tool requires, it would have produced a byte-perfect, checksum-verified, root-owned data directory that Stalwart, running as its own user, could not open, and it would have reported success. The PostgreSQL path was worse: pg_dump without --clean emits CREATE TABLE + COPY, which fails replaying into a database whose tables still exist, and the ON_ERROR_STOP=1 added so a half-applied restore couldn't be reported as success turned that into a hard failure. None of it had ever run against a real server. A filesystem snapshot has none of these failure modes, because it never lost the metadata to begin with. So cutover's gate is no longer rollback.CanRollBack but an explicit RecoveryPointConfirmed acknowledgement. That is an assertion, not a check - this tool cannot verify someone else's snapshot - and its only value is that nobody migrates a production mail server having never been asked the question. Two consequences are accepted deliberately: restoring any pre-migration recovery point discards mail delivered since, and a failed migration now stops and reports rather than undoing itself. What the tool still does to make a manual restore easier: the old binary is preserved and never deleted, the original service definition is preserved before the rewrite, the settings and principals dumps stay on disk, and every artifact path and checksum stays in the checkpoint where `status <run-id>` can print it. Also removed: the `confirm` command stub and RollbackWindowClosed, whose only purpose was closing a rollback window that no longer exists, and checkpoint.PhaseRollback. Old state.json files still load - JSON ignores the now-unknown field. Still open, and recorded in 8: cutover ignores systemd drop-ins, so an ExecStart or Environment override in stalwart.service.d/*.conf is invisible to the rewrite - including the recovery variable it exists to strip; nothing prevents concurrent runs on the same run-id; and nothing in this repo has ever run against a real Stalwart, real systemd, or a real store.
This commit is contained in:
+176
-103
@@ -3,7 +3,8 @@
|
||||
Status: design, no implementation yet.
|
||||
Scope: upgrade a Stalwart Mail Server in place from **0.15.5** to the current
|
||||
latest release (**0.16.14** as of 2026-08-19) with no data loss, a working
|
||||
rollback at every step, and an automated post-migration validation pass.
|
||||
a recovery point the operator provides, and an automated post-migration
|
||||
validation pass.
|
||||
|
||||
## 1. Why this isn't a thin wrapper
|
||||
|
||||
@@ -50,8 +51,9 @@ smoke test, not a full migration.
|
||||
**Goals**
|
||||
- Zero data loss for mail, calendar, and contact content (the one thing
|
||||
Stalwart itself guarantees is untouched — everything else is on us).
|
||||
- Every phase has a defined, tested undo. Nothing destructive happens until
|
||||
a verified backup exists.
|
||||
- Nothing destructive happens until the operator has confirmed a recovery
|
||||
point exists. This tool does not implement the undo (see the non-goals
|
||||
and §4.8); it refuses to start without being told one is in place.
|
||||
- Fully automated happy path; the operator answers a preflight confirmation
|
||||
once, then watches (or walks away and checks the report).
|
||||
- Resumable: if the process dies mid-migration (crash, SSH drop, OOM), a
|
||||
@@ -65,6 +67,11 @@ smoke test, not a full migration.
|
||||
engine.
|
||||
|
||||
**Non-goals**
|
||||
- **Not a recovery tool.** Restoring a failed migration is the operator's
|
||||
own snapshot or backup, by whatever method they already trust — ZFS/LVM/
|
||||
btrfs snapshots, VM or volume snapshots, or a restorable backup. This
|
||||
tool does not take one, verify one, or restore from one. §4.8 explains
|
||||
why that turned out to be the right split.
|
||||
- Not a general Stalwart config management tool (no drift detection,
|
||||
no day-2 ops beyond the migration window).
|
||||
- Not a replacement for routine backups — it *produces* a migration-time
|
||||
@@ -86,7 +93,9 @@ smoke test, not a full migration.
|
||||
│ dry-run) │ │ in depth) │ │ config │ │ (apply plan) │ │ smoke) │ │ + counts) │
|
||||
└─────────────┘ └───────────┘ └────────────┘ └───────────────┘ └────────────┘ └────────────┘
|
||||
│ │ │ │ │ │
|
||||
└─────────────────┴────────────────┴─── on failure ──┴─────────────────┴──▶ ROLLBACK
|
||||
└─────────────────┴────────────────┴─── on failure ──┴─────────────────┴──▶ STOP + REPORT
|
||||
(operator restores
|
||||
their own snapshot)
|
||||
```
|
||||
|
||||
Each box is a **phase**; each phase is a sequence of idempotent, checkpointed
|
||||
@@ -167,14 +176,15 @@ different at each layer:
|
||||
above the threshold by default (time cost), but available as
|
||||
`--full-content-backup` regardless of size.
|
||||
4. **Binary preservation**: old binary is moved aside (`stalwart.v0155`),
|
||||
never deleted, so rollback doesn't depend on re-downloading anything.
|
||||
never deleted, so putting the machine back by hand doesn't depend on
|
||||
re-downloading a specific old release under pressure.
|
||||
|
||||
Every backup artifact is checksummed and the checksum recorded in the
|
||||
checkpoint file. Before moving past this phase, the tool **verifies** the
|
||||
filesystem backup by opening it read-only with the *old* binary in a
|
||||
throwaway temp directory and confirming it reports the expected version and
|
||||
a sane account count — catching a corrupt or partial copy before it's relied
|
||||
on, not after a failed rollback.
|
||||
a sane account count — catching a corrupt or partial copy while the
|
||||
pre-migration instance is still up, rather than after it isn't.
|
||||
|
||||
### 4.3 Stage
|
||||
|
||||
@@ -206,8 +216,7 @@ fire-and-forget:
|
||||
`STALWART_RECOVERY_ADMIN` credential (random, never the operator's real
|
||||
password, never logged).
|
||||
3. Poll the recovery HTTP endpoint until healthy or a timeout elapses; on
|
||||
timeout, capture logs and fail into the rollback path rather than
|
||||
hanging indefinitely.
|
||||
timeout, capture logs and stop rather than hanging indefinitely.
|
||||
4. Run `stalwart-cli apply --file export.json`, then the generated
|
||||
best-effort settings plan from §4.3, capturing full output.
|
||||
5. Verify the apply reported success for every object (the tool parses the
|
||||
@@ -232,6 +241,48 @@ against an already-migrated store.
|
||||
the management API, and poll the task queue until it completes rather
|
||||
than firing and moving on.
|
||||
|
||||
**Status: implemented** (`internal/cutover`), but nothing calls it yet — see
|
||||
§8. Notes on how it turned out:
|
||||
|
||||
- It refuses to run at all unless the operator has confirmed a recovery
|
||||
point exists (§4.8). That's an acknowledgement, not a check — this tool
|
||||
can't verify someone else's snapshot — but it makes the irreversibility
|
||||
of this phase impossible to walk into unasked.
|
||||
- The unit is rewritten in place, not generated from a template: an
|
||||
operator's unit carries hardening options, limits and dependencies this
|
||||
tool has no business having an opinion about, and regenerating it would
|
||||
silently drop them. It repoints `ExecStart` (preserving systemd's `-@:+!`
|
||||
prefix characters and every argument after the executable), updates
|
||||
`--config` if asked, and strips recovery-mode `Environment=` lines. It
|
||||
refuses on a unit with no `ExecStart`, and on an `Environment=` line that
|
||||
mixes a recovery variable with others — a line it only partly understands
|
||||
is one it must not edit.
|
||||
- The original unit is preserved and recorded as the `service-unit`
|
||||
artifact *before* the rewrite, so an operator restoring by hand isn't
|
||||
reconstructing a unit file from memory.
|
||||
- Docker deployments are refused: cutting over a container means pulling a
|
||||
new image and recreating the container, not swapping a binary and
|
||||
rewriting a unit.
|
||||
- Quota recalculation is the one step allowed to fail without failing the
|
||||
phase. Stale counters are an accounting problem; a failed cutover is one
|
||||
an operator has to respond to by restoring a machine that is otherwise
|
||||
migrated and serving mail correctly. Calling for that over a counter
|
||||
would be the worse outcome, so it warns and points at the WebUI's Tasks
|
||||
panel.
|
||||
|
||||
The quota call itself is grounded in Stalwart's `x:Task` schema reference
|
||||
(`docs/ref/object/task/`), not guessed: `x:Task/set` creating one
|
||||
`AccountMaintenance` variant per account with `maintenanceType:
|
||||
"recalculateQuota"`, exactly as the WebUI's own "Recalculate disk quotas"
|
||||
fans out. The upgrade guide only documents the WebUI path, so two details
|
||||
remain unconfirmed against a live server and are called out in
|
||||
`internal/stalwartapi/task.go`: whether the schema's "read-only" annotation
|
||||
on `accountId`/`maintenanceType` means "immutable after creation" (it has
|
||||
to, or the variant couldn't be created), and whether a finished task simply
|
||||
leaves the queue (`TaskStatus` documents Pending/Retry/Failed with no
|
||||
success state). That uncertainty is the reason this step warns rather than
|
||||
fails.
|
||||
|
||||
### 4.6 Patch-bump fast path
|
||||
|
||||
For an already-0.16.x install moving to a newer 0.16.x patch (the common
|
||||
@@ -244,9 +295,9 @@ code path, so it doesn't rot independently.
|
||||
|
||||
### 4.7 Post-migration validation
|
||||
|
||||
Runs automatically after cutover; failure here triggers rollback (§4.8)
|
||||
unless `--no-auto-rollback` was passed, in which case it just reports and
|
||||
exits non-zero.
|
||||
Runs automatically after cutover; failure here stops the run, reports
|
||||
loudly, and exits non-zero, leaving the operator to decide what to restore
|
||||
(§4.8).
|
||||
|
||||
- **Version check**: reported server version matches the target exactly.
|
||||
- **Auth check**: WebUI login succeeds over the *configured hostname* via
|
||||
@@ -279,61 +330,64 @@ exits non-zero.
|
||||
numbers are non-zero/sane where preflight showed non-zero usage.
|
||||
|
||||
Output is a single structured report (JSON + human summary): pass/fail per
|
||||
check, with enough detail to hand to the operator or to a rollback decision.
|
||||
check, with enough detail to hand to the operator deciding whether to
|
||||
restore.
|
||||
|
||||
### 4.8 Rollback
|
||||
### 4.8 Recovery from a failed migration — out of scope
|
||||
|
||||
Two triggers: automatic (validation failure, unless disabled) or manual
|
||||
(`stalwart-migrate rollback <run-id>`, usable any time up to a
|
||||
"rollback window closed" checkpoint the operator explicitly confirms once
|
||||
they're satisfied — see §6).
|
||||
**This tool does not undo a migration.** Recovery is the operator's own
|
||||
snapshot or backup, taken by whatever method they already trust and know
|
||||
how to restore: ZFS/LVM/btrfs snapshots, a VM or volume snapshot, or a
|
||||
restorable backup. This tool does not take one, verify one, or restore from
|
||||
one. Cutover refuses to start until the operator confirms one exists (§4.5).
|
||||
|
||||
Procedure, checkpoint-resumable like everything else:
|
||||
1. Stop the new service (or recovery-mode process, if failure happened
|
||||
there).
|
||||
2. Restore the filesystem/DB backup from §4.2 to the original path
|
||||
(`<datadir>.v0155-backup` → `<datadir>`), or restore the targeted SQL
|
||||
dump for external databases.
|
||||
3. Restore the old systemd unit / Compose config.
|
||||
4. Restart the preserved old binary.
|
||||
5. Re-run a reduced version of the §4.7 validation suite against the
|
||||
*restored* instance (version check, protocol reachability, directory
|
||||
counts) to confirm rollback actually worked rather than assuming it did.
|
||||
6. Report clearly that the instance is back on 0.15.5 and the new-version
|
||||
artifacts (staged binary, export.json, apply-plan) are preserved
|
||||
untouched for a retry after the underlying issue is fixed.
|
||||
This replaced a working, tested rollback implementation, and the reasoning
|
||||
is worth recording because the deleted code looked good:
|
||||
|
||||
Rollback never deletes anything from the failed attempt — a second forward
|
||||
attempt reuses the existing backup and dumps rather than re-capturing
|
||||
(faster retry, and one fewer chance for the retry's own backup step to
|
||||
fail).
|
||||
- **Restoring bytes correctly is not the hard part; restoring everything
|
||||
else is.** The implementation copied file contents and permissions and
|
||||
verified every restored file against a manifest — and did not preserve
|
||||
ownership. Run as root, it produced a byte-perfect, checksum-verified,
|
||||
root-owned data directory that Stalwart, running as its own user, could
|
||||
not open. It would have reported success. A filesystem snapshot has no
|
||||
such failure mode, because it never lost the metadata in the first place.
|
||||
- **The external-database path was worse.** `pg_dump` without `--clean`
|
||||
emits `CREATE TABLE` + `COPY`; replaying that into a database whose
|
||||
tables still exist fails outright, and `ON_ERROR_STOP=1` — added so a
|
||||
half-applied restore couldn't be reported as success — turned that into
|
||||
a hard failure. The two SQL paths were asymmetric and only one was
|
||||
plausibly correct.
|
||||
- **It was never exercised against anything real.** Every test drove fake
|
||||
`systemctl`, `psql` and `stalwart` binaries. That's sound for logic and
|
||||
ordering, and it is not evidence about production.
|
||||
- **Snapshots are already in the operator's runbook.** They are atomic,
|
||||
metadata-preserving, cheap with copy-on-write, and cover the whole system
|
||||
— binary, unit file, config, data — rather than the subset one tool
|
||||
thought to capture.
|
||||
|
||||
**Status: implemented** (`internal/rollback`, plus `internal/service` for
|
||||
the systemd/Docker control it needs) for the manual trigger. Departures from
|
||||
the procedure above, and what it still doesn't cover:
|
||||
What this tool keeps doing, so a manual restore is as easy as possible:
|
||||
|
||||
- Only the manual trigger exists. The automatic one fires on validation
|
||||
failure during a real cutover, and there is no real cutover to fail yet.
|
||||
- The procedure gains a step 0 this design didn't call out: the
|
||||
backup is re-verified against its manifest *before* the service is
|
||||
stopped. Finding a corrupt backup is survivable while the failed
|
||||
instance is still up, and unsurvivable once the data directory has been
|
||||
moved aside.
|
||||
- FoundationDB is refused rather than attempted: §4.2's backup step only
|
||||
*starts* an `fdbbackup` job, and restoring one means `fdbrestore`
|
||||
against a quiesced cluster. Refusing up front beats a rollback that
|
||||
reports success without restoring anything.
|
||||
- Step 3 (restore the old unit/Compose config) is wired but inert: it
|
||||
restores a preserved service definition if the run recorded one, and
|
||||
nothing records one yet because cutover — the phase that would rewrite
|
||||
it — doesn't exist. It reports as an explicit skip, not a silent pass.
|
||||
- For an external SQL store, step 2 replays the critical-table dump in
|
||||
place. Unlike the filesystem path, the current contents are *not*
|
||||
preserved first; the plan the command prints says so before it acts.
|
||||
- The old binary is preserved next to the new one (§4.2), never deleted.
|
||||
- The original service definition is preserved as a `service-unit` artifact
|
||||
before cutover rewrites it, so the operator doesn't reconstruct a unit
|
||||
file from memory.
|
||||
- Every artifact path and checksum stays in the checkpoint, and `status
|
||||
<run-id>` prints exactly which steps completed and which failed.
|
||||
|
||||
`stalwart-migrate run` without `--dry-run` still refuses, but the reason
|
||||
has narrowed: what's missing now is §4.5 cutover itself, not the ability to
|
||||
undo it.
|
||||
**The mail-delivery gap is accepted.** Restoring any pre-migration recovery
|
||||
point discards mail delivered since it was taken. This was equally true of
|
||||
the rollback implementation, is inherent to restoring a point in time, and
|
||||
is not something this tool can solve. Plan the migration window
|
||||
accordingly.
|
||||
|
||||
**Two consequences worth being explicit about.** First, the confirmation
|
||||
cutover requires is an assertion, not a check — an unverifiable promise is
|
||||
weaker than a guarantee, and the value is only that nobody migrates a
|
||||
production mail server having never been asked the question. Second, there
|
||||
is no longer an automatic response to a failed migration: a failure stops
|
||||
the run and reports, and a human decides what to restore. Both are
|
||||
deliberate trades for not shipping a recovery path that has never been
|
||||
tested against a real server.
|
||||
|
||||
### 4.9 Dry run
|
||||
|
||||
@@ -363,7 +417,7 @@ post-migration boot check, just pointed somewhere disposable:
|
||||
alternative exists.
|
||||
4. The verified backup copy is cloned again into the sandbox directory
|
||||
(never reusing the same directory recovery mode is about to mutate as the
|
||||
one rollback would restore from).
|
||||
one a manual restore would use).
|
||||
5. **Recovery-mode migration** (§4.4) runs for real against the sandbox:
|
||||
the actual target binary, actual `STALWART_RECOVERY_MODE=1` boot, actual
|
||||
`stalwart-cli apply`.
|
||||
@@ -419,37 +473,39 @@ is the same problem.
|
||||
stalwart-migrate preflight [--config PATH] ... # read-only, prints the report
|
||||
stalwart-migrate run --dry-run [--target-binary PATH] ... # implemented — see §4.9
|
||||
[--keep-artifacts]
|
||||
(without --dry-run: refused today — see §4.8's status note)
|
||||
(without --dry-run: refused today — see §8)
|
||||
stalwart-migrate status [run-id] # implemented
|
||||
stalwart-migrate rollback <run-id> [--yes] # implemented — see §4.8
|
||||
stalwart-migrate confirm <run-id> # not yet implemented
|
||||
stalwart-migrate report <run-id> [--json] # not yet implemented
|
||||
```
|
||||
|
||||
`run` is the only command that mutates anything on a *successful* path, and
|
||||
it always starts with preflight. `rollback` mutates too, by design — it's
|
||||
the one command that stops a running mail server and overwrites a live data
|
||||
directory — so it prints the plan it resolved from the run's checkpoint and
|
||||
refuses to act without `--yes`. Once rollback exists, `confirm` will be a separate, explicit step
|
||||
so backups aren't pruned just because validation passed automatically — the
|
||||
operator gets a beat to actually use the migrated server before disk space
|
||||
is reclaimed. Default retention if never confirmed: configurable TTL, warns
|
||||
loudly, never auto-deletes silently. Flags shown here are the design intent;
|
||||
run `stalwart-migrate <command> -h` for the actual current flag set.
|
||||
`run` is the only command that mutates anything, and it always starts with
|
||||
preflight. Nothing in this tool restores a failed migration (§4.8), so there
|
||||
is no `rollback` command, and no `confirm` step to close a rollback window
|
||||
that no longer exists.
|
||||
|
||||
The migration-time artifacts a run leaves behind — the preserved old binary,
|
||||
the settings and principals dumps, the preserved service definition, and
|
||||
(for the dry-run path) the filesystem copy — are never pruned automatically.
|
||||
They're small next to the data directory, they're what a manual restore
|
||||
reaches for first, and deleting them on a schedule to reclaim disk would be
|
||||
the tool making a call that isn't its to make. Flags shown here are the
|
||||
design intent; run `stalwart-migrate <command> -h` for the actual current
|
||||
flag set.
|
||||
|
||||
## 7. Project layout (Go, matches this workspace's other CLI tools)
|
||||
|
||||
```
|
||||
stalwart-migrator/
|
||||
cmd/stalwart-migrate/ main.go, preflight.go, run.go, status.go, rollback.go — CLI entry + wiring
|
||||
cmd/stalwart-migrate/ main.go, preflight.go, run.go, status.go — CLI entry + wiring
|
||||
internal/plan/ version-boundary → ordered phase list (§4.6) [done]
|
||||
internal/checkpoint/ run-id, state.json read/write, resume logic (§5) [done]
|
||||
internal/preflight/ §4.1 checks [done]
|
||||
internal/backup/ §4.2 — fs/db snapshot, settings dump+convert, Vandelay export [done]
|
||||
internal/recovery/ §4.4 — recovery-mode process supervision + apply [done]
|
||||
internal/cutover/ §4.5 — binary swap, unit rewrite, restart, quota rebuild [done, unwired]
|
||||
internal/validate/ §4.7 — boot-check + content-integrity done; DKIM/TLS + mail-flow not yet [partial]
|
||||
internal/rollback/ §4.8 [not started]
|
||||
internal/stalwartapi/ Ping + AccountSnapshot, incl. per-mailbox counts via impersonation (§8) [done]
|
||||
internal/service/ systemd/Docker start+stop, used by §4.5 [done]
|
||||
internal/stalwartapi/ Ping, AccountSnapshot (per-mailbox counts via impersonation), quota tasks [done]
|
||||
internal/config/ tool's own config (paths, thresholds, credentials handling) [not started]
|
||||
docs/ this file + phase-specific notes as they get built out
|
||||
```
|
||||
@@ -458,7 +514,11 @@ There's no separate `internal/stage` package: the `convert` half of
|
||||
`migrate_v016.py` lives in `internal/backup` next to `dump` (same script,
|
||||
same invocation pattern), and the dry-run sandbox-cloning logic that stands
|
||||
in for the rest of §4.3 currently lives directly in `cmd/run.go` rather than
|
||||
its own package, pending a real cutover phase to generalize it against.
|
||||
its own package. Now that `internal/cutover` exists, that's the code a real
|
||||
staging phase would be generalized out of.
|
||||
|
||||
There's no `internal/rollback` either, and that's a deliberate removal
|
||||
rather than a gap — see §4.8.
|
||||
|
||||
`internal/stalwartapi` is deliberately the only thing that speaks JMAP/HTTP
|
||||
to Stalwart — every other package depends on it, not on `net/http` directly,
|
||||
@@ -466,9 +526,7 @@ so auth handling and retry/backoff live in one place. `internal/service` is
|
||||
the same idea for the other external surface: it is the only thing that
|
||||
shells out to `systemctl` or `docker`, so the commands that can take mail
|
||||
delivery down sit in one auditable file rather than in each phase that
|
||||
happens to need them. Rollback needs it today and cutover will need exactly
|
||||
the same operations, which is why it's its own package rather than living
|
||||
inside `internal/rollback`. `preflight.DeploymentKind` is a type alias for
|
||||
happens to need them. `preflight.DeploymentKind` is a type alias for
|
||||
`service.Kind`, so detection and control can't drift apart.
|
||||
|
||||
## 8. Open questions for the next pass
|
||||
@@ -535,28 +593,43 @@ inside `internal/rollback`. `preflight.DeploymentKind` is a type alias for
|
||||
implemented. With this done, recovery, backup, dry-run, and account/
|
||||
mailbox snapshotting all work end-to-end, and dry-run's comparison is now
|
||||
the closest thing to §4.7's actual no-data-loss guarantee this tool has —
|
||||
the remaining major gap is `internal/rollback` and real cutover (below).
|
||||
- **Rollback: done. Real cutover: still missing.** `internal/rollback` and
|
||||
`internal/service` are implemented and tested (§4.8), so `stalwart-migrate
|
||||
rollback <run-id>` can now undo a run: it re-verifies the backup, stops the
|
||||
service, moves the failed attempt aside without deleting it, restores the
|
||||
data directory (re-verifying every restored file against the manifest) or
|
||||
replays the SQL dump, reinstalls the preserved binary, restarts, and runs a
|
||||
reduced validation suite against the *restored* instance rather than
|
||||
assuming it worked. `run` without `--dry-run` still refuses, but now for
|
||||
the narrower reason that §4.5 cutover itself isn't built - the thing that
|
||||
would swap the binary, rewrite the unit, and switch the service over. Doing
|
||||
rollback first was deliberate: this tool should never be able to commit to
|
||||
a change it can't undo.
|
||||
- **`confirm` still has no implementation**, so nothing can set
|
||||
`RollbackWindowClosed` - rollback honours the flag and refuses when it's
|
||||
set, but only a hand-edited state.json can currently set it. Closing the
|
||||
window is the point of no return for the backups this restores from, so it
|
||||
should land together with the retention/TTL policy §6 describes, not
|
||||
before it.
|
||||
- **Cutover must preserve the service definition it rewrites**, recording it
|
||||
as a `service-unit` artifact; rollback's restore step already reads that
|
||||
contract and reports an explicit skip until something writes one.
|
||||
the remaining major gap is §4.3 staging and the production pipeline
|
||||
(below).
|
||||
- **Cutover is built; nothing wires it into a production run yet.**
|
||||
`internal/cutover` (§4.5) and `internal/service` are implemented and
|
||||
tested. `run` without `--dry-run` still refuses, for one remaining
|
||||
reason: **§4.3 stage doesn't exist**, and neither does the production
|
||||
pipeline that would run preflight → backup → stage → recovery-mode →
|
||||
cutover → validate against real paths instead of a sandbox. What stage
|
||||
still needs: downloading and verifying the target binary into a staging
|
||||
path (`preflight.ResolveRelease` and `backup.DownloadFile` between them
|
||||
already have the pieces), running the convert step against real paths
|
||||
rather than the dry-run's patched sandbox ones, and the best-effort
|
||||
settings apply-plan, which is its own open question below.
|
||||
- **Nothing has ever run against a real Stalwart.** Every test in this
|
||||
repo drives fake `systemctl`, `psql` and `stalwart` binaries and
|
||||
httptest servers. That's sound for logic and ordering and is not
|
||||
evidence about production. One smoke test on a throwaway VM - real
|
||||
0.15.5, real systemd unit, a few accounts with mail - would settle the
|
||||
quota wire format, systemd drop-in handling, and cutover's unit rewrite
|
||||
at once. It should happen before §4.3 is wired, not after.
|
||||
- **Quota recalculation is grounded but unproven.** The `x:Task` wire
|
||||
format comes from Stalwart's schema reference rather than a live server;
|
||||
§4.5 lists exactly which two details are inferred. A smoke test against a
|
||||
real 0.16 instance would settle both, and would let this step be promoted
|
||||
from "warns on failure" to a hard check.
|
||||
- **Cutover doesn't handle Docker.** It refuses container deployments
|
||||
outright, since cutting one over means pulling an image and recreating
|
||||
the container rather than swapping a binary and rewriting a unit.
|
||||
- **Cutover ignores systemd drop-ins.** It rewrites only the main unit
|
||||
file, so an `ExecStart` or `Environment` override in
|
||||
`/etc/systemd/system/stalwart.service.d/*.conf` is invisible to it -
|
||||
including a recovery-mode variable set there, which is exactly the
|
||||
footgun the rewrite exists to prevent. Drop-ins are common enough that
|
||||
this needs handling before a production run, at minimum by detecting
|
||||
them and refusing.
|
||||
- **Nothing prevents concurrent runs.** Two invocations against the same
|
||||
run-id would both proceed; there's no lock file or equivalent.
|
||||
- **Dry-run's un-stopped backup snapshot** (§4.9 step 2): a dry-run still
|
||||
backs up a live, in-use store unless the operator stops it manually first.
|
||||
`internal/service` now makes doing this properly possible - dry-run just
|
||||
|
||||
Reference in New Issue
Block a user