Initial commit: stalwart-migrator design and scaffolding

In-place upgrade tool for Stalwart Mail Server (0.15.5 -> latest) with
checkpointed rollback and post-migration validation. Design stage; see
ARCHITECTURE.md.
This commit is contained in:
2026-08-22 18:17:17 -07:00
commit 719a945d64
71 changed files with 6677 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Binaries
/bin/
/dist/
stalwart-migrate
*.exe
# Test / coverage
*.out
*.test
# Local state & logs
*.log
*.db
*.db-journal
*.db-wal
*.db-shm
# Credentials / local config
.env
*.pem
*.key
# Editor / OS
.DS_Store
.idea/
.vscode/
+530
View File
@@ -0,0 +1,530 @@
# stalwart-migrator — Architecture
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.
## 1. Why this isn't a thin wrapper
Stalwart does not ship an automated upgrade tool today (planned for 1.0,
targeted H1 2026, not yet released). 0.15.5 → 0.16.x is a **major** boundary,
not a patch bump, and it is unusually dangerous to automate naively:
- The v0.15 → v0.16 config model changes completely: multiple TOML files plus
DB-resident settings collapse into one `config.json` that describes only the
datastore connection, with everything else moved into JMAP-managed objects.
- Account names change from bare usernames to full email addresses; DAV URLs
change (`/dav/cal/alice``/dav/cal/alice%40example.com`).
- **On first v0.16 start, the server irreversibly deletes** all directory
records (users/groups/domains/tenants/OAuth clients), all settings, DMARC/
TLS/ARF reports, pending tasks, telemetry, spam training samples, and quota
counters. Mail/calendar/contact data is untouched, but everything else is
gone unless captured first.
- Migration requires a manual "recovery mode" boot of the new binary, then an
external tool (`stalwart-cli apply`) replays a converted settings snapshot
into it over HTTP while it's up in that special mode — a multi-process,
multi-terminal, stateful procedure with no built-in resumability.
- In a cluster, every node must be stopped before migration starts; one node
left on v0.15 corrupts the shared store.
- Real-world failure mode already reported in the wild: post-migration WebUI
login breaks because the UI now requires HTTPS via `defaultHostname`, not
plain IP access — a config/DNS issue, not a data issue, but it reads as
"the migration broke everything" to an operator.
- Not all settings migrate automatically: SMTP listeners, routing, rate
limits, spam rules, and auth backends are explicitly **not** carried over
by Stalwart's own conversion script and must be recreated or replayed from
a separately captured snapshot.
None of this is exotic — it's exactly what Stalwart's own
[`UPGRADING/v0_16.md`](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md)
and `resources/scripts/migrate_v016.py` already do. This project's job is to
turn that fragile, manual, two-terminal runbook into a single supervised,
checkpointed, reversible operation — and to keep working as new releases land
on top of 0.16.x, most of which (0.16.10.16.14, per changelog) are pure
patch/feature releases with **no** schema migration, i.e. a binary swap +
smoke test, not a full migration.
## 2. Design goals / non-goals
**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.
- 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
re-run picks up from the last completed checkpoint instead of redoing or,
worse, double-applying destructive steps.
- Works across the deployment shapes Stalwart actually supports: systemd +
bare binary, Docker/Compose, and single-node vs. cluster — with embedded
(RocksDB/SQLite) or external (PostgreSQL/MySQL/FoundationDB) stores.
- Extensible to future major boundaries (0.16 → 1.0 and beyond) without a
rewrite: version-boundary logic is pluggable, not hardcoded into the core
engine.
**Non-goals**
- 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
backup as a side effect, but ongoing backup policy is the operator's job
(Stalwart's own guidance: import/export is explicitly not a backup
substitute; `Vandelay` per-account export is the documented backup tool).
- Not a cross-major-version skip tool. If the source is older than 0.15.x,
the tool requires stepping to 0.15.x first (this matches Stalwart's own
stated constraint — see UPGRADING notes).
- No support for editing mail content during migration (no format
conversion beyond what Stalwart's own store migration does).
## 3. High-level flow
```
┌─────────────┐ ┌───────────┐ ┌────────────┐ ┌───────────────┐ ┌────────────┐ ┌────────────┐
│ PREFLIGHT │──▶│ BACKUP │──▶│ STAGE NEW │──▶│ RECOVERY-MODE │──▶│ CUTOVER │──▶│ VALIDATE │
│ (checks, │ │ (defense │ │ BINARY + │ │ MIGRATE │ │ (swap, up, │ │ (functional│
│ dry-run) │ │ in depth) │ │ config │ │ (apply plan) │ │ smoke) │ │ + counts) │
└─────────────┘ └───────────┘ └────────────┘ └───────────────┘ └────────────┘ └────────────┘
│ │ │ │ │ │
└─────────────────┴────────────────┴─── on failure ──┴─────────────────┴──▶ ROLLBACK
```
Each box is a **phase**; each phase is a sequence of idempotent, checkpointed
**steps**. State is persisted to disk after every step (§5), so the whole
pipeline can be killed and re-invoked safely.
For a pure patch bump within 0.16.x (no schema change per Stalwart's
changelog through 0.16.14), the plan collapses to: PREFLIGHT → BACKUP →
STAGE → CUTOVER → VALIDATE, skipping the recovery-mode phase entirely (see
§4.6).
## 4. Phases
### 4.1 Preflight
Read-only. Aborts before touching anything if a hard blocker is found;
warns and asks for confirmation on soft blockers.
- Detect current Stalwart version (`stalwart --version`, or JMAP
`Core/echo`/session endpoint if remote).
- Refuse to run if current version is outside the tool's supported starting
range (must be ≥0.15.0; older installs are told to upgrade to 0.15.x
first, per Stalwart's own guidance).
- Detect topology: systemd unit vs. Docker container vs. Compose, single
node vs. cluster member count (via config/cluster settings), reachable
peer nodes.
- **Cluster gate**: refuse to proceed unless every node in the cluster is
confirmed stopped (mirrors the documented hard requirement — one live
v0.15 node during migration corrupts the store).
- Detect store backend(s): RocksDB, SQLite, FoundationDB, PostgreSQL,
MySQL, plus configured blob store (local FS / S3-compatible) and FTS
backend (native / Elasticsearch).
- Disk space check: require free space ≥ N× current data directory size
(embedded stores need a full copy for the backup step; default threshold
configurable, hard-fail below a safety floor).
- Resolve and download the target binary/image, verify checksum/signature
against the published release.
- Fetch and pin the exact upstream `migrate_v016.py` (or its 0.16-successor
equivalent) revision, hash it, vendor the hash into the run's checkpoint
record — we depend on it as an external, versioned dependency, not a
static local copy that can silently drift from upstream.
- Dry-run the settings dump against the live server (read-only JMAP calls)
to confirm admin credentials and API reachability before anything
destructive is scheduled.
- Snapshot pre-migration *facts* used later for validation: account count,
per-account mailbox message counts (IMAP `STATUS`), domain list, DKIM key
fingerprints, TLS cert fingerprints, listener port list. Stored alongside
the checkpoint, not derived after the fact.
- Emit a plain-language plan summary and require explicit confirmation
(`--yes` to skip interactively, but never by default).
### 4.2 Backup — defense in depth
No single backup mechanism is trusted alone, because the risk profile is
different at each layer:
1. **Filesystem/DB snapshot** (infra-level, fast, whole-store):
- Embedded (RocksDB/SQLite): stop-the-world `cp -a` of the data
directory (or LVM/ZFS snapshot if available — preferred, since it
doesn't require the copy to finish before the next step) to a sibling
path (`<datadir>.v0155-backup`), never overwriting source.
- External SQL (Postgres/MySQL): targeted dump of the critical table
set Stalwart's own guide calls out (`s d r h b g j f u` for Postgres;
equivalent for MySQL), not a full-instance dump — matches the
documented, tested restore path and stays fast on large installs.
- FoundationDB: `fdbbackup` against the configured cluster.
2. **Settings/principals export** (the `migrate_v016.py dump` step): captured
during preflight *and* re-captured immediately before cutover, so the
export used for the apply reflects the last-known-good state, not a
stale preflight snapshot if time has passed.
3. **Per-account content export (Vandelay/JMAP)**: for installations under
an operator-configurable account-count threshold, take a belt-and-suspenders
full `vandelay import` (i.e. export-to-file) of every account into
self-contained per-account SQLite archives. This is independent of
storage backend and of the in-place migration path entirely — if
everything else somehow goes wrong, mail content is recoverable via
Stalwart's own documented import path into a clean instance. Skipped
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.
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.
### 4.3 Stage
- Install target binary alongside the old one (never overwrite in place).
- Run `migrate_v016.py convert` against the fresh dump to produce
`config.json` + `export.json`, applying path rewrites for Docker/volume
layouts detected in preflight.
- Additionally generate an **apply-plan for the settings Stalwart's script
does not carry over** — SMTP listeners, routing rules, rate limits, spam
rules, auth backend config — by diffing the old effective config against
the new schema and emitting a best-effort JMAP object set for
`stalwart-cli apply`. This is flagged clearly as best-effort and included
in the final report for manual review; it's the one part of the
documented procedure that's explicitly manual today, and silently getting
it wrong (rather than flagging it) would be worse than not attempting it.
- Stage new systemd unit / Compose file changes without activating them.
### 4.4 Recovery-mode migration
This is the phase most exposed to partial-failure — it drives an external
process (the new Stalwart binary) through an undocumented-duration startup,
then drives a second external process (`stalwart-cli apply`) against it over
HTTP. Both are supervised with explicit timeouts and health polling, not
fire-and-forget:
1. Stop the old service.
2. Start the new binary in the foreground with
`STALWART_RECOVERY_MODE=1` and a freshly generated one-time
`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.
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
apply-tool's structured output rather than trusting exit code alone —
partial application with a zero exit code is exactly the kind of silent
failure this tool exists to catch).
6. Stop recovery mode cleanly (SIGTERM, not SIGKILL, to let it flush).
Checkpointed after each numbered step, so a crash between "apply succeeded"
and "recovery mode stopped" resumes at step 6 instead of re-running apply
against an already-migrated store.
### 4.5 Cutover
- Update the real systemd unit / Compose config to point at the new binary
and config, without the recovery env vars (leaving
`STALWART_RECOVERY_MODE=1` set is a documented footgun — it would recovery-
boot on every restart).
- Start the service normally.
- Wait for healthy JMAP session response.
- Trigger disk-quota (and tenant-quota, if multi-tenant) recalculation via
the management API, and poll the task queue until it completes rather
than firing and moving on.
### 4.6 Patch-bump fast path
For an already-0.16.x install moving to a newer 0.16.x patch (the common
case after the initial major migration, and per the changelog the case for
every release from 0.16.1 through 0.16.14 so far): preflight confirms no
schema-migration flag is set for the target version, and the plan skips
§4.4 entirely — binary swap, restart, same validation suite as §4.7. This
is intentionally the same engine with a shorter plan, not a separate
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.
- **Version check**: reported server version matches the target exactly.
- **Auth check**: WebUI login succeeds over the *configured hostname* via
HTTPS (not bare IP) — this directly targets the real-world post-0.16
login failure mode found in the field.
- **Protocol reachability**: JMAP session, IMAP, SMTP (submission + MTA),
POP3, ManageSieve, CalDAV/CardDAV endpoints all accept a handshake on
their configured ports.
- **Directory integrity**: account/domain/group counts match the preflight
snapshot exactly (accounting for the bare-username → email-address
rewrite, which the tool resolves by comparing normalized identities, not
raw strings).
- **Content integrity — the core no-data-loss check**: per-account IMAP
`STATUS (MESSAGES)` compared against the preflight snapshot for every
mailbox of every account (or a statistically sampled subset above a
configurable account-count threshold, with the full sweep always
available via `--full-validation`). Any mismatch is a hard failure.
- **DKIM/TLS check**: key fingerprints and cert validity match or are
intentionally rotated (new-key generation is an expected v0.16 behavior,
not a bug — the check distinguishes "changed as documented" from
"missing").
- **DNS check**: for domains under Stalwart's automatic DNS management
(new in 0.16), diff expected vs. actual published records and flag
drift rather than assume the automation ran correctly.
- **Mail-flow smoke test**: send one real message through SMTP submission
to a dedicated canary mailbox and confirm it's retrievable via IMAP
within a timeout — the one end-to-end check that nothing upstream can
fake.
- **Quota check**: recalculation task (§4.5) completed and reported
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.
### 4.8 Rollback
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).
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.
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).
**Status: not yet implemented.** `stalwart-migrate run` without `--dry-run`
currently refuses to proceed, precisely because this phase doesn't exist yet
— committing to a real cutover without a working rollback would violate the
one guarantee this tool exists to provide. §4.9 covers what does work today.
### 4.9 Dry run
`stalwart-migrate run --dry-run` runs the real migration mechanics against a
disposable sandbox clone of the data, so an operator can get genuine
confidence *before* committing to a real cutover — not a simulation that
skips the fragile parts, the actual recovery-mode migration (§4.4) and a
post-migration boot check, just pointed somewhere disposable:
1. **Preflight** (§4.1) runs for real, read-only, against the live instance.
2. **Backup** (§4.2) runs for real too, with one exception:
`SkipBinaryPreservation` is set, so the production binary at the real
install path is never moved aside. Taking a *consistent* filesystem
snapshot of an embedded store still means the live service should be
stopped first (the same requirement Stalwart's own export tooling has) —
this tool doesn't automate that stop/start today (no systemd/Docker
control exists yet), so a dry-run without a manual stop first is a
best-effort snapshot of a live, in-use store, and the CLI says so.
3. **Convert**: `migrate_v016.py convert` turns the settings/principals dump
into `config.json` + `export.json`, using the script's own documented
`--patch-paths <old>=<new>` flag to point the generated config at the
sandbox data directory instead of the real one. This is the officially
documented mechanism for exactly this kind of path redirection — the tool
deliberately does not try to rewrite `config.json`'s contents itself,
since depending on its exact schema (which has already changed once,
0.15 → 0.16) is a correctness risk this tool avoids wherever an official
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).
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`.
6. **Boot check + content integrity**: the migrated sandbox is started once
more as an ordinary boot (no recovery-mode env vars) and polled until its
HTTP listener answers, confirming the migrated store doesn't just accept
a settings apply but actually comes up cleanly afterward. If preflight
captured a pre-migration snapshot (§4.1, requires `--admin-url`), the
same boot is then used to capture a fresh post-migration snapshot and
compare the two — this is the actual no-data-loss guarantee, not just
"the mechanics ran": every account and mailbox from before must still be
found afterward (matching by exact name, falling back to the part before
`@` since v0.16's own migration rewrites bare usernames to full email
addresses) with an identical message count. A mismatch or a missing
account fails the check. This covers the message-count half of §4.7's
full suite; DKIM/TLS fingerprint checks and a live mail-flow SMTP→IMAP
smoke test are still open.
7. Every byte written by steps 26 (the fs-backup copy, settings/principals
dumps, downloaded `migrate_v016.py`, sandbox clone, and generated
`config.json`/`export.json`) lives under one per-run directory
(`work-dir/<run-id>`), which is removed on *every* exit path - success,
a failed check partway through, or an early refusal - via a deferred
cleanup, not just the happy path. The only thing left behind afterward
is the checkpoint's `state.json` under `--state-dir`: a small structured
success/failure log (which check failed and why), not bulk data.
`--keep-artifacts` opts out for inspecting a failure. Nothing at the real
binary path, the real service, or the real data directory's *contents*
is ever mutated by steps 26 in the first place.
A same-boundary patch bump (§4.6) has no recovery phase to simulate — dry
run for that plan is just preflight + backup.
## 5. State machine / checkpointing
Every run gets a `run-id` and a checkpoint file
(`/var/lib/stalwart-migrator/runs/<run-id>/state.json`) written after each
step completes, containing: run-id, source/target version, current
phase/step, timestamps, artifact paths + checksums, and the preflight
snapshot facts used by validation. Steps are pure functions of
(checkpoint-state → new-state); re-invoking `stalwart-migrate run` with an
in-progress run-id resumes at the first incomplete step. Steps are written
to be safe to re-run if they were interrupted mid-execution (e.g. the
filesystem copy step checks for and resumes/redoes a partial copy rather
than trusting a checkpoint that says "started" as if it meant "done").
This is the same shape as a deployment pipeline's state file, deliberately —
the risk profile (long-running, multi-process, must survive being killed)
is the same problem.
## 6. CLI surface
```
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)
stalwart-migrate status [run-id] # implemented
stalwart-migrate rollback <run-id> # not yet implemented (§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 and it always starts with
preflight. 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.
## 7. Project layout (Go, matches this workspace's other CLI tools)
```
stalwart-migrator/
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/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/config/ tool's own config (paths, thresholds, credentials handling) [not started]
docs/ this file + phase-specific notes as they get built out
```
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.
`internal/stalwartapi` is deliberately the only thing that speaks JMAP/HTTP
to Stalwart — every other package depends on it, not on `net/http` directly,
so auth handling and retry/backoff live in one place.
## 8. Open questions for the next pass
- **Credential handling**: recovery-mode admin password and any stored JMAP
credentials need a real secrets story (env var pass-through is fine for
v1, but the checkpoint file must never contain them in plaintext).
- **Cluster orchestration**: §4.1's cluster gate assumes the operator stops
other nodes manually; a v2 could SSH-coordinate that instead. Out of scope
for v1.
- **`migrate_v016.py` dependency**: pinning by hash is a start, but the
script is Stalwart's, not ours — need a policy for what happens when it
changes upstream (re-vendor + re-test before bumping the pin, never
silently float to `main`).
- **Best-effort settings apply-plan (§4.3)**: needs real-world testing
against a variety of existing SMTP/routing/spam configs before it's
trusted un-reviewed; v1 should probably always require operator sign-off
on that specific generated plan even with `--yes` set for everything else.
Not started — dry-run currently only replays what `migrate_v016.py`
itself converts.
- **Account/mailbox enumeration** (`stalwartapi.Client.AccountSnapshot`):
**implemented**, including per-mailbox message counts. Account count and
domains come from `x:Account/query` + `x:Account/get` against Stalwart's
management API (`/api`, capability `urn:stalwart:jmap`), confirmed
against `crates/jmap/src/principal/{get,query}.rs` and
`docs/ref/object/account.md`.
Per-mailbox counts needed a second research pass, because a superuser's
own JMAP session does **not** implicitly grant cross-account access —
confirmed by reading `crates/jmap/src/api/session.rs`: the session's
`accounts` map is built solely from the authenticated identity's own
membership/sharing grants, unaffected by any admin flag. The real,
documented mechanism is Stalwart's `impersonate` permission
(`docs/auth/authorization/administrator.md`): an account holding it can
log in *as* another account via the composite Basic-auth username
`<target>%<impersonator>`, after which standard RFC 8621 `Mailbox/get`
(property `totalEmails`) works normally against that impersonated
session's own `apiUrl` (session-discovered per RFC 8620, not the `/api`
management endpoint — confirmed as a distinct endpoint in
`docs/ref/object/account.md`). `AccountSnapshot` now does this per
account it finds; a single account's failure (most likely: `impersonate`
not granted) is recorded in `Snapshot.MailboxErrors` rather than failing
the whole snapshot, so one misconfigured account doesn't hide a working
result for every other one.
One resolved false alarm worth recording: an initial pass of this same
research, reading Stalwart's `main` branch source directly, reported
`x:Account` apparently replaced by `x:Principal`/`x:Quota`. Checking the
*published* docs site directly (which has no `principal.md`/`quota.md`
page, and still documents `x:Account` with a working example) showed that
was an unreleased/in-development refactor in `main`, not the interface
the current released version actually exposes — a reminder that "read the
source" and "read what's actually shipped" can disagree, and it's worth
checking both before changing already-working code on the strength of one.
Preflight now populates `RunState.PreflightSnapshot.MailboxCounts` when
`--admin-url` is set, and `validate.BootCheck` now compares it against a
fresh post-migration snapshot as part of the same boot (§4.9 step 6) —
proven end-to-end with a live smoke test that deliberately made the
"after" instance report fewer messages than the "before" snapshot and
confirmed the dry run failed loudly with the exact before/after counts,
rather than just trusting that. **Still open**: preflight/validate always
attempt every account serially with no sampling/threshold, which could be
slow on a large install — `--full-validation`'s sampling idea from §4.7
hasn't been built yet for this; and DKIM/TLS fingerprint checks plus a
live mail-flow SMTP→IMAP smoke test (the rest of §4.7's suite) aren't
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).
- **No real cutover or rollback yet**: `internal/rollback` doesn't exist,
and neither does any systemd/Docker service control. `run` without
`--dry-run` refuses for exactly this reason. Building rollback first -
before wiring a real cutover - is deliberate: this tool should never be
able to commit to a change it can't undo.
- **Dry-run's un-stopped backup snapshot** (§4.9 step 2): without service
control, a dry-run backs up a live, in-use store unless the operator stops
it manually first. Worth revisiting once service control exists, so
dry-run can offer to do this safely itself.
## Sources
Grounded in Stalwart's own documentation and community reports as of
2026-08-19:
- [UPGRADING/v0_16.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) — exact migration procedure this tool automates
- [UPGRADING/v0_15.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_15.md) — prior breaking-change boundary
- [Database Migration docs](https://stalw.art/docs/management/maintenance/migration/)
- [Backup docs](https://stalw.art/docs/migration/import-export/backup/) (Vandelay)
- [Upgrading guide](https://stalw.art/docs/install/upgrade/)
- [v0.16 blog post](https://stalw.art/blog/stalwart-0-16/)
- [Discussion #2892](https://github.com/stalwartlabs/stalwart/discussions/2892) — breaking changes overview
- [Discussion #3004](https://github.com/stalwartlabs/stalwart/discussions/3004) — upgrading Q&A
- [Discussion #3025](https://github.com/stalwartlabs/stalwart/discussions/3025) — real post-migration WebUI login failure
- [CHANGELOG.md](https://github.com/stalwartlabs/stalwart/blob/main/CHANGELOG.md) — confirms 0.16.10.16.14 carry no further schema migrations
+18
View File
@@ -0,0 +1,18 @@
# stalwart-migrator
In-place upgrade tool for Stalwart Mail Server: 0.15.5 → latest, with no
data loss, checkpointed rollback at every step, and automated post-migration
validation.
Design status: architected, not yet implemented — see
[ARCHITECTURE.md](ARCHITECTURE.md) for the full design, the phase-by-phase
plan, and the research it's grounded in.
```
stalwart-migrate preflight # read-only checks + plan
stalwart-migrate run # execute the migration
stalwart-migrate status <id>
stalwart-migrate rollback <id>
stalwart-migrate confirm <id>
stalwart-migrate report <id>
```
+3
View File
@@ -0,0 +1,3 @@
module github.com/johnellis/stalwart-migrator
go 1.26.5
+260
View File
@@ -0,0 +1,260 @@
package backup
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
)
// Options configures a full backup pass. Which fields matter depends on
// rs.Topology.StoreBackend, as recorded by the preflight phase - Run
// branches on that rather than requiring the caller to pre-select a code
// path.
type Options struct {
// Binary preservation.
BinaryPath string
// SkipBinaryPreservation, when true, never touches BinaryPath - used by
// a dry run, which must not move the production binary aside just to
// simulate a migration it hasn't committed to.
SkipBinaryPreservation bool
// Embedded backend (RocksDB/SQLite).
DataDir string
BackupDir string // destination for the fs snapshot; required if the backend is embedded
// External SQL backend (PostgreSQL/MySQL).
SQL SQLOptions
// FoundationDB backend.
FDB FDBOptions
// Settings/principals dump (always runs - every topology needs it).
MigrationScriptURL string // defaults to DefaultMigrationScriptURL
MigrationScriptSHA256 string // pinned hash; empty accepts and reports whatever is fetched (see DownloadFile)
ScriptDestPath string
AdminURL string
AdminUser string
AdminPassword string
SettingsDumpPath string
PrincipalsDumpPath string
PythonPath string
HTTPClient *http.Client
// Per-account content export (Vandelay) - optional defense-in-depth
// layer. Empty Accounts skips it entirely; this is expected until
// account enumeration is wired up (see stalwartapi.Client.AccountSnapshot).
Vandelay VandelayOptions
Accounts []string
}
// Run executes the full backup pass described in ARCHITECTURE.md §4.2,
// checkpointing each step. Unlike preflight, most steps here are hard
// failures: a backup that didn't actually happen must stop the pipeline,
// not just get reported and continued past. The one genuinely optional
// layer is the per-account Vandelay export, which is skipped (Status:
// StatusSkipped, not a failure) when Options.Accounts is empty - but if the
// operator populated Accounts, a failure there is hard too, since silently
// downgrading an explicitly requested backup layer would be worse than
// stopping.
func Run(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState, opts Options) (Report, error) {
var report Report
step := func(name string, fn func() (checkpoint.StepOutcome, error)) (checkpoint.StepOutcome, error) {
outcome, err := store.RunStep(rs, checkpoint.PhaseBackup, name, fn)
if err != nil {
report.Results = append(report.Results, CheckResult{Name: name, Status: StatusFail, Detail: err.Error()})
return outcome, err
}
status := Status(outcome.Verdict)
if status == "" {
status = StatusOK
}
report.Results = append(report.Results, CheckResult{Name: name, Status: status, Detail: outcome.Detail})
return outcome, nil
}
if opts.SkipBinaryPreservation {
report.Results = append(report.Results, CheckResult{
Name: "preserve-binary", Status: StatusSkipped,
Detail: "skipped (SkipBinaryPreservation) - the production binary is never touched, e.g. for a dry run",
})
} else if _, err := step("preserve-binary", func() (checkpoint.StepOutcome, error) {
preserved, err := PreserveBinary(opts.BinaryPath, rs.SourceVersion)
if err != nil {
return checkpoint.StepOutcome{}, err
}
sum, size, err := hashFile(preserved)
if err != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("backup: hash preserved binary %s: %w", preserved, err)
}
rs.RecordArtifact("old-binary", checkpoint.Artifact{Path: preserved, SHA256: sum, SizeBytes: size})
return checkpoint.StepOutcome{Detail: fmt.Sprintf("preserved %s as %s", opts.BinaryPath, preserved), Extra: preserved}, nil
}); err != nil {
return report, err
}
backends := strings.ToLower(rs.Topology.StoreBackend)
switch {
case strings.Contains(backends, "rocksdb") || strings.Contains(backends, "sqlite"):
if _, err := step("fs-snapshot", func() (checkpoint.StepOutcome, error) {
manifest, err := CopyDataDir(opts.DataDir, opts.BackupDir)
if err != nil {
return checkpoint.StepOutcome{}, err
}
manifestPath := filepath.Join(opts.BackupDir, "..", filepath.Base(opts.BackupDir)+".manifest.json")
if err := WriteManifest(manifestPath, manifest); err != nil {
return checkpoint.StepOutcome{}, err
}
sum, err := manifest.Checksum()
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact("fs-backup", checkpoint.Artifact{Path: opts.BackupDir, SHA256: sum, SizeBytes: manifest.TotalBytes})
return checkpoint.StepOutcome{
Detail: fmt.Sprintf("copied %d file(s), %d bytes, from %s to %s", len(manifest.Files), manifest.TotalBytes, opts.DataDir, opts.BackupDir),
Extra: manifestPath,
}, nil
}); err != nil {
return report, err
}
if _, err := step("fs-verify", func() (checkpoint.StepOutcome, error) {
manifestOutcome := rs.Outcome(checkpoint.PhaseBackup, "fs-snapshot")
manifest, err := ReadManifest(manifestOutcome.Extra)
if err != nil {
return checkpoint.StepOutcome{}, err
}
if err := VerifyDataDirBackup(opts.BackupDir, manifest); err != nil {
return checkpoint.StepOutcome{}, err
}
return checkpoint.StepOutcome{Detail: fmt.Sprintf("re-hashed %d file(s), all match the manifest recorded at copy time", len(manifest.Files))}, nil
}); err != nil {
return report, err
}
case strings.Contains(backends, "postgresql"):
if _, err := step("sql-dump", func() (checkpoint.StepOutcome, error) {
if err := RunPgDump(ctx, opts.SQL); err != nil {
return checkpoint.StepOutcome{}, err
}
sum, size, err := hashFile(opts.SQL.OutPath)
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact("sql-dump", checkpoint.Artifact{Path: opts.SQL.OutPath, SHA256: sum, SizeBytes: size})
return checkpoint.StepOutcome{Detail: fmt.Sprintf("pg_dump of critical tables (%s) to %s, %d bytes", strings.Join(criticalTables, " "), opts.SQL.OutPath, size)}, nil
}); err != nil {
return report, err
}
case strings.Contains(backends, "mysql"):
if _, err := step("sql-dump", func() (checkpoint.StepOutcome, error) {
if err := RunMySQLDump(ctx, opts.SQL); err != nil {
return checkpoint.StepOutcome{}, err
}
sum, size, err := hashFile(opts.SQL.OutPath)
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact("sql-dump", checkpoint.Artifact{Path: opts.SQL.OutPath, SHA256: sum, SizeBytes: size})
return checkpoint.StepOutcome{Detail: fmt.Sprintf("mysqldump of critical tables (%s) to %s, %d bytes", strings.Join(criticalTables, " "), opts.SQL.OutPath, size)}, nil
}); err != nil {
return report, err
}
case strings.Contains(backends, "foundationdb"):
if _, err := step("fdb-backup", func() (checkpoint.StepOutcome, error) {
if err := StartFDBBackup(ctx, opts.FDB); err != nil {
return checkpoint.StepOutcome{}, err
}
return checkpoint.StepOutcome{
Detail: fmt.Sprintf("fdbbackup start issued for destination %s (tag %s) - this only confirms the job was accepted, not that it finished; check `fdbbackup status` before relying on it", opts.FDB.Destination, opts.FDB.Tag),
}, nil
}); err != nil {
return report, err
}
default:
report.Results = append(report.Results, CheckResult{
Name: "backend-backup", Status: StatusSkipped,
Detail: fmt.Sprintf("no known store backend recorded for this run (topology.store_backend=%q) - preflight must run first, or the backend wasn't recognized; no filesystem/DB backup was taken", rs.Topology.StoreBackend),
})
}
if _, err := step("settings-dump", func() (checkpoint.StepOutcome, error) {
scriptURL := opts.MigrationScriptURL
if scriptURL == "" {
scriptURL = DefaultMigrationScriptURL
}
sum, err := DownloadFile(ctx, opts.HTTPClient, scriptURL, opts.ScriptDestPath, opts.MigrationScriptSHA256)
if err != nil {
return checkpoint.StepOutcome{}, err
}
pinNote := ""
if opts.MigrationScriptSHA256 == "" {
pinNote = fmt.Sprintf(" (no pin was configured - record sha256 %s as MigrationScriptSHA256 to pin it for future runs)", sum)
}
if err := RunSettingsDump(ctx, SettingsDumpOptions{
PythonPath: opts.PythonPath,
ScriptPath: opts.ScriptDestPath,
URL: opts.AdminURL,
Username: opts.AdminUser,
Password: opts.AdminPassword,
SettingsPath: opts.SettingsDumpPath,
PrincipalsPath: opts.PrincipalsDumpPath,
}); err != nil {
return checkpoint.StepOutcome{}, err
}
settingsSum, settingsSize, err := hashFile(opts.SettingsDumpPath)
if err != nil {
return checkpoint.StepOutcome{}, err
}
principalsSum, principalsSize, err := hashFile(opts.PrincipalsDumpPath)
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact("settings-dump", checkpoint.Artifact{Path: opts.SettingsDumpPath, SHA256: settingsSum, SizeBytes: settingsSize})
rs.RecordArtifact("principals-dump", checkpoint.Artifact{Path: opts.PrincipalsDumpPath, SHA256: principalsSum, SizeBytes: principalsSize})
return checkpoint.StepOutcome{
Detail: fmt.Sprintf("dumped settings (%d bytes) and principals (%d bytes) from %s%s", settingsSize, principalsSize, opts.AdminURL, pinNote),
Extra: sum,
}, nil
}); err != nil {
return report, err
}
if len(opts.Accounts) == 0 {
report.Results = append(report.Results, CheckResult{
Name: "vandelay-export", Status: StatusSkipped,
Detail: "no account list supplied - skipped; pass Options.Accounts (or --full-content-backup with an account source once account enumeration is wired up) to enable this belt-and-suspenders layer",
})
} else if _, err := step("vandelay-export", func() (checkpoint.StepOutcome, error) {
if err := os.MkdirAll(opts.Vandelay.OutDir, 0o750); err != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("backup: create vandelay output dir %s: %w", opts.Vandelay.OutDir, err)
}
files, err := ExportAccounts(ctx, opts.Vandelay, opts.Accounts)
if err != nil {
return checkpoint.StepOutcome{}, err
}
var totalSize int64
for i, f := range files {
sum, size, err := hashFile(f)
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact(fmt.Sprintf("vandelay-%s", opts.Accounts[i]), checkpoint.Artifact{Path: f, SHA256: sum, SizeBytes: size})
totalSize += size
}
return checkpoint.StepOutcome{Detail: fmt.Sprintf("exported %d account(s), %d bytes total, to %s", len(files), totalSize, opts.Vandelay.OutDir)}, nil
}); err != nil {
return report, err
}
return report, nil
}
+217
View File
@@ -0,0 +1,217 @@
package backup
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
)
func TestBackupRunEndToEndAndResume(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
if err := os.WriteFile(binaryPath, []byte("fake binary"), 0o755); err != nil {
t.Fatal(err)
}
dataDir := filepath.Join(dir, "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dataDir, "db.bin"), []byte("some rocksdb-shaped data"), 0o644); err != nil {
t.Fatal(err)
}
backupDir := filepath.Join(dir, "data-backup")
settingsPath := filepath.Join(dir, "settings.json")
principalsPath := filepath.Join(dir, "principals.json")
pythonScript := fmt.Sprintf("#!/bin/sh\necho fake-settings > %q\necho fake-principals > %q\n", settingsPath, principalsPath)
pythonDir := withFakeExecutable(t, "python3", pythonScript)
pythonScriptPath := filepath.Join(pythonDir, "python3")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("print('fake migrate_v016.py')\n"))
}))
defer srv.Close()
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("store.Create: %v", err)
}
rs.Topology = checkpoint.Topology{StoreBackend: "rocksdb"}
opts := Options{
BinaryPath: binaryPath,
DataDir: dataDir,
BackupDir: backupDir,
MigrationScriptURL: srv.URL,
ScriptDestPath: filepath.Join(dir, "migrate_v016.py"),
AdminURL: "https://mail.example.com",
AdminUser: "admin",
AdminPassword: "hunter2",
SettingsDumpPath: settingsPath,
PrincipalsDumpPath: principalsPath,
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run #1: %v", err)
}
for _, r := range report.Results {
if r.Status == StatusFail {
t.Errorf("Run #1: step %s failed: %s", r.Name, r.Detail)
}
}
for _, name := range []string{"old-binary", "fs-backup", "settings-dump", "principals-dump"} {
if _, ok := rs.Artifacts[name]; !ok {
t.Errorf("Run #1: expected artifact %q to be recorded, got %v", name, rs.Artifacts)
}
}
if _, err := os.Stat(binaryPath + ".v0.15.5"); err != nil {
t.Errorf("preserved binary missing: %v", err)
}
// -- simulate a crash and resume, making every non-idempotent step's
// -- inputs unusable so a re-execution (rather than a skip) fails loudly.
if err := os.RemoveAll(dataDir); err != nil {
t.Fatal(err)
}
srv.Close()
if err := os.WriteFile(pythonScriptPath, []byte("#!/bin/sh\necho should-not-run-again >&2\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatalf("store.Load (resume): %v", err)
}
report2, err := Run(context.Background(), store, resumed, opts)
if err != nil {
t.Fatalf("Run #2 (resume) should succeed without redoing completed steps: %v", err)
}
if len(report2.Results) != len(report.Results) {
t.Errorf("resumed report has %d results, want %d (same as first run)", len(report2.Results), len(report.Results))
}
for _, r := range report2.Results {
if r.Status == StatusFail {
t.Errorf("Run #2 (resume): step %s unexpectedly failed: %s", r.Name, r.Detail)
}
}
}
func TestBackupRunSkipsVandelayWhenNoAccountsGiven(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
os.WriteFile(binaryPath, []byte("fake binary"), 0o755)
dataDir := filepath.Join(dir, "data")
os.MkdirAll(dataDir, 0o755)
os.WriteFile(filepath.Join(dataDir, "db.bin"), []byte("x"), 0o644)
withFakeExecutable(t, "python3", fmt.Sprintf(
"#!/bin/sh\necho x > %q\necho x > %q\n",
filepath.Join(dir, "settings.json"), filepath.Join(dir, "principals.json"),
))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("script"))
}))
defer srv.Close()
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatal(err)
}
rs.Topology = checkpoint.Topology{StoreBackend: "rocksdb"}
opts := Options{
BinaryPath: binaryPath,
DataDir: dataDir,
BackupDir: filepath.Join(dir, "data-backup"),
MigrationScriptURL: srv.URL,
ScriptDestPath: filepath.Join(dir, "migrate_v016.py"),
AdminURL: "https://mail.example.com",
SettingsDumpPath: filepath.Join(dir, "settings.json"),
PrincipalsDumpPath: filepath.Join(dir, "principals.json"),
// Accounts intentionally left empty.
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run: %v", err)
}
found := false
for _, r := range report.Results {
if r.Name == "vandelay-export" {
found = true
if r.Status != StatusSkipped {
t.Errorf("vandelay-export status = %s, want skipped", r.Status)
}
}
}
if !found {
t.Error("report missing a vandelay-export entry")
}
}
func TestBackupRunSkipsBinaryPreservationForDryRun(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
if err := os.WriteFile(binaryPath, []byte("fake binary"), 0o755); err != nil {
t.Fatal(err)
}
dataDir := filepath.Join(dir, "data")
os.MkdirAll(dataDir, 0o755)
os.WriteFile(filepath.Join(dataDir, "db.bin"), []byte("x"), 0o644)
withFakeExecutable(t, "python3", fmt.Sprintf(
"#!/bin/sh\necho x > %q\necho x > %q\n",
filepath.Join(dir, "settings.json"), filepath.Join(dir, "principals.json"),
))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("script"))
}))
defer srv.Close()
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatal(err)
}
rs.Topology = checkpoint.Topology{StoreBackend: "rocksdb"}
opts := Options{
BinaryPath: binaryPath,
SkipBinaryPreservation: true,
DataDir: dataDir,
BackupDir: filepath.Join(dir, "data-backup"),
MigrationScriptURL: srv.URL,
ScriptDestPath: filepath.Join(dir, "migrate_v016.py"),
AdminURL: "https://mail.example.com",
SettingsDumpPath: filepath.Join(dir, "settings.json"),
PrincipalsDumpPath: filepath.Join(dir, "principals.json"),
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run: %v", err)
}
for _, r := range report.Results {
if r.Name == "preserve-binary" && r.Status != StatusSkipped {
t.Errorf("preserve-binary status = %s, want skipped", r.Status)
}
}
if _, err := os.Stat(binaryPath); err != nil {
t.Errorf("production binary at %s should be untouched, but stat failed: %v", binaryPath, err)
}
if _, ok := rs.Artifacts["old-binary"]; ok {
t.Error("no old-binary artifact should be recorded when preservation is skipped")
}
}
+31
View File
@@ -0,0 +1,31 @@
package backup
import (
"fmt"
"os"
)
// PreserveBinary moves the currently-installed binary aside to
// "<binaryPath>.v<sourceVersion>" so rollback can restart the exact old
// binary without re-downloading anything, and cutover can install the new
// one at the original path. It never deletes the old binary, and it's
// idempotent: if a prior attempt at this run already preserved it, calling
// this again just returns the existing preserved path rather than erroring
// on a missing source file.
func PreserveBinary(binaryPath, sourceVersion string) (preservedPath string, err error) {
if sourceVersion == "" {
return "", fmt.Errorf("backup: cannot preserve %s without a source version to suffix it with", binaryPath)
}
preservedPath = binaryPath + ".v" + sourceVersion
if _, statErr := os.Stat(preservedPath); statErr == nil {
return preservedPath, nil
} else if !os.IsNotExist(statErr) {
return "", fmt.Errorf("backup: stat %s: %w", preservedPath, statErr)
}
if err := os.Rename(binaryPath, preservedPath); err != nil {
return "", fmt.Errorf("backup: preserve %s as %s: %w", binaryPath, preservedPath, err)
}
return preservedPath, nil
}
+63
View File
@@ -0,0 +1,63 @@
package backup
import (
"os"
"path/filepath"
"testing"
)
func TestPreserveBinary(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
if err := os.WriteFile(binaryPath, []byte("fake binary"), 0o755); err != nil {
t.Fatal(err)
}
preserved, err := PreserveBinary(binaryPath, "0.15.5")
if err != nil {
t.Fatalf("PreserveBinary: %v", err)
}
wantPath := binaryPath + ".v0.15.5"
if preserved != wantPath {
t.Errorf("preserved = %s, want %s", preserved, wantPath)
}
if _, err := os.Stat(binaryPath); !os.IsNotExist(err) {
t.Error("original binary path should no longer exist after preservation")
}
if _, err := os.Stat(preserved); err != nil {
t.Errorf("preserved binary missing: %v", err)
}
}
func TestPreserveBinaryIsIdempotent(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
if err := os.WriteFile(binaryPath, []byte("fake binary"), 0o755); err != nil {
t.Fatal(err)
}
first, err := PreserveBinary(binaryPath, "0.15.5")
if err != nil {
t.Fatalf("PreserveBinary #1: %v", err)
}
// Simulate a resumed run: the binary is already gone from binaryPath
// (moved on the prior attempt). Calling again must not error just
// because the source no longer exists.
second, err := PreserveBinary(binaryPath, "0.15.5")
if err != nil {
t.Fatalf("PreserveBinary #2 (resume): %v", err)
}
if first != second {
t.Errorf("preserved paths differ across calls: %s vs %s", first, second)
}
}
func TestPreserveBinaryRequiresSourceVersion(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
os.WriteFile(binaryPath, []byte("x"), 0o755)
if _, err := PreserveBinary(binaryPath, ""); err == nil {
t.Fatal("PreserveBinary with an empty source version should error")
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package backup implements the defense-in-depth backup: filesystem/DB snapshot, settings dump, and Vandelay content export.
// See ARCHITECTURE.md §4.2 for the design.
package backup
+48
View File
@@ -0,0 +1,48 @@
package backup
import (
"fmt"
"os"
"path/filepath"
"testing"
)
// withFakeExecutable puts a fake executable named `name` at the front of
// PATH for the duration of the test, so code that shells out to a
// real-world tool (pg_dump, mysqldump, fdbbackup, vandelay, python3) can be
// exercised without that tool actually being installed. t.Setenv restores
// PATH automatically and marks the test non-parallel.
func withFakeExecutable(t *testing.T, name, script string) (dir string) {
t.Helper()
dir = t.TempDir()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return dir
}
// argsFile is a shared convention the fake scripts below use: they append
// their own argv (space-joined, one invocation per line) to a file so the
// test can assert on exactly what was passed.
func argsFile(t *testing.T, dir string) string {
t.Helper()
return filepath.Join(dir, "invoked-args.log")
}
func readArgsFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ""
}
if err != nil {
t.Fatal(err)
}
return string(data)
}
func fakeScriptLoggingArgs(logPath string, extraBody string) string {
return fmt.Sprintf("#!/bin/sh\necho \"$@\" >> %q\n%s\n", logPath, extraBody)
}
+63
View File
@@ -0,0 +1,63 @@
package backup
import (
"context"
"fmt"
"os/exec"
)
// FDBOptions configures a FoundationDB backup via fdbbackup, FDB's own
// backup CLI (not something Stalwart-specific) - see ARCHITECTURE.md §4.2.
type FDBOptions struct {
ClusterFile string // -C; empty uses fdbbackup's own default cluster file
Destination string // -d, a backup URL e.g. "file:///var/backups/stalwart-fdb"
Tag string // -t; defaults to "default" if empty
}
func (o FDBOptions) tag() string {
if o.Tag == "" {
return "default"
}
return o.Tag
}
// BuildFDBBackupStartArgs returns the fdbbackup argv for starting a backup,
// without the leading "fdbbackup".
func BuildFDBBackupStartArgs(o FDBOptions) []string {
args := []string{"start"}
if o.ClusterFile != "" {
args = append(args, "-C", o.ClusterFile)
}
args = append(args, "-d", o.Destination, "-t", o.tag())
return args
}
// StartFDBBackup kicks off an fdbbackup run. fdbbackup start returns as soon
// as the backup job is registered, not when it finishes - callers that need
// to know it's done should poll FDBBackupStatus and look for their own
// definition of "complete" in its output, since this wrapper doesn't parse
// fdbbackup's status text (that format isn't stable enough here to depend
// on without verifying it against the fdbbackup version actually in use).
func StartFDBBackup(ctx context.Context, o FDBOptions) error {
cmd := exec.CommandContext(ctx, "fdbbackup", BuildFDBBackupStartArgs(o)...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("backup: fdbbackup start failed: %w (output: %s)", err, out)
}
return nil
}
// FDBBackupStatus returns fdbbackup's raw status output for the given tag,
// for a human or a caller-supplied parser to interpret.
func FDBBackupStatus(ctx context.Context, o FDBOptions) (string, error) {
args := []string{"status", "-t", o.tag()}
if o.ClusterFile != "" {
args = append(args, "-C", o.ClusterFile)
}
cmd := exec.CommandContext(ctx, "fdbbackup", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("backup: fdbbackup status failed: %w (output: %s)", err, out)
}
return string(out), nil
}
+48
View File
@@ -0,0 +1,48 @@
package backup
import (
"context"
"strings"
"testing"
)
func TestBuildFDBBackupStartArgs(t *testing.T) {
args := BuildFDBBackupStartArgs(FDBOptions{ClusterFile: "/etc/foundationdb/fdb.cluster", Destination: "file:///var/backups/stalwart-fdb", Tag: "stalwart-migrate"})
joined := strings.Join(args, " ")
for _, want := range []string{"start", "-C /etc/foundationdb/fdb.cluster", "-d file:///var/backups/stalwart-fdb", "-t stalwart-migrate"} {
if !strings.Contains(joined, want) {
t.Errorf("BuildFDBBackupStartArgs = %q, missing %q", joined, want)
}
}
}
func TestBuildFDBBackupStartArgsDefaultTag(t *testing.T) {
args := BuildFDBBackupStartArgs(FDBOptions{Destination: "file:///var/backups/stalwart-fdb"})
joined := strings.Join(args, " ")
if !strings.Contains(joined, "-t default") {
t.Errorf("BuildFDBBackupStartArgs = %q, want default tag when none given", joined)
}
}
func TestStartFDBBackupInvokesFdbbackup(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
withFakeExecutable(t, "fdbbackup", fakeScriptLoggingArgs(log, "exit 0"))
err := StartFDBBackup(context.Background(), FDBOptions{Destination: "file:///var/backups/stalwart-fdb"})
if err != nil {
t.Fatalf("StartFDBBackup: %v", err)
}
got := readArgsFile(t, log)
if !strings.Contains(got, "start") || !strings.Contains(got, "-d file:///var/backups/stalwart-fdb") {
t.Errorf("fdbbackup was invoked with %q", got)
}
}
func TestStartFDBBackupPropagatesFailure(t *testing.T) {
withFakeExecutable(t, "fdbbackup", "#!/bin/sh\necho 'cluster unreachable' >&2\nexit 1\n")
err := StartFDBBackup(context.Background(), FDBOptions{Destination: "file:///var/backups/stalwart-fdb"})
if err == nil {
t.Fatal("StartFDBBackup should error when fdbbackup exits non-zero")
}
}
+205
View File
@@ -0,0 +1,205 @@
package backup
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
// Manifest records the per-file checksums produced by CopyDataDir, so a
// later Verify pass can detect corruption or truncation introduced by the
// copy itself. It is NOT authentication that the copied store is a valid,
// openable Stalwart database - confirming that would mean booting the old
// binary read-only against the backup, which needs config/CLI details this
// tool doesn't verify yet (see the parallel caveat on
// stalwartapi.Client.AccountSnapshot). Treat a clean Verify as "the bytes we
// wrote match the bytes we copied", not "Stalwart can open this".
type Manifest struct {
SourceDir string `json:"source_dir"`
Files []ManifestEntry `json:"files"`
TotalBytes int64 `json:"total_bytes"`
}
type ManifestEntry struct {
RelPath string `json:"rel_path"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
// Checksum returns a single content hash identifying this manifest (and
// transitively, the exact set of files and bytes it describes), suitable
// for recording as a checkpoint.Artifact's SHA256 - a directory doesn't
// have one natural hash, so this stands in for it.
func (m *Manifest) Checksum() (string, error) {
data, err := json.Marshal(m)
if err != nil {
return "", fmt.Errorf("backup: marshal manifest: %w", err)
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]), nil
}
// WriteManifest persists a manifest as JSON next to the backup it describes.
func WriteManifest(path string, m *Manifest) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return fmt.Errorf("backup: marshal manifest: %w", err)
}
if err := os.WriteFile(path, data, 0o640); err != nil {
return fmt.Errorf("backup: write manifest %s: %w", path, err)
}
return nil
}
// ReadManifest loads a manifest previously written by WriteManifest.
func ReadManifest(path string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("backup: read manifest %s: %w", path, err)
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("backup: parse manifest %s: %w", path, err)
}
return &m, nil
}
// CopyDataDir copies srcDir to dstDir the way `cp -a` would (directories,
// regular files, and symlinks, preserving regular-file permissions),
// hashing every regular file as it's written. dstDir is cleared first if it
// already exists, so a retried step after a partial failure produces a
// clean copy rather than a mix of old and new files - filesystem copies
// aren't resumable at the byte level in any way worth building here, so a
// retry just redoes the whole thing.
func CopyDataDir(srcDir, dstDir string) (*Manifest, error) {
srcAbs, err := filepath.Abs(srcDir)
if err != nil {
return nil, fmt.Errorf("backup: resolve source %s: %w", srcDir, err)
}
dstAbs, err := filepath.Abs(dstDir)
if err != nil {
return nil, fmt.Errorf("backup: resolve destination %s: %w", dstDir, err)
}
if srcAbs == dstAbs {
return nil, fmt.Errorf("backup: source and destination are the same path: %s", srcAbs)
}
if strings.HasPrefix(dstAbs+string(filepath.Separator), srcAbs+string(filepath.Separator)) {
return nil, fmt.Errorf("backup: destination %s is inside source %s - refusing to copy a directory into itself", dstAbs, srcAbs)
}
if err := os.RemoveAll(dstAbs); err != nil {
return nil, fmt.Errorf("backup: clear stale destination %s: %w", dstAbs, err)
}
if err := os.MkdirAll(dstAbs, 0o750); err != nil {
return nil, fmt.Errorf("backup: create destination %s: %w", dstAbs, err)
}
manifest := &Manifest{SourceDir: srcAbs}
walkErr := filepath.WalkDir(srcAbs, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(srcAbs, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
dstPath := filepath.Join(dstAbs, rel)
if d.Type()&fs.ModeSymlink != 0 {
target, err := os.Readlink(path)
if err != nil {
return fmt.Errorf("readlink %s: %w", path, err)
}
return os.Symlink(target, dstPath)
}
if d.IsDir() {
info, err := d.Info()
if err != nil {
return err
}
return os.MkdirAll(dstPath, info.Mode().Perm())
}
info, err := d.Info()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("backup: unsupported file type at %s (mode %s)", path, info.Mode())
}
sum, size, err := copyFileWithChecksum(path, dstPath, info.Mode().Perm())
if err != nil {
return fmt.Errorf("copy %s: %w", path, err)
}
manifest.Files = append(manifest.Files, ManifestEntry{RelPath: rel, SHA256: sum, Size: size})
manifest.TotalBytes += size
return nil
})
if walkErr != nil {
return nil, fmt.Errorf("backup: copy %s to %s: %w", srcAbs, dstAbs, walkErr)
}
return manifest, nil
}
func copyFileWithChecksum(src, dst string, perm fs.FileMode) (sha256Hex string, size int64, err error) {
in, err := os.Open(src)
if err != nil {
return "", 0, err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return "", 0, err
}
defer out.Close()
h := sha256.New()
n, err := io.Copy(io.MultiWriter(out, h), in)
if err != nil {
return "", 0, err
}
if err := out.Sync(); err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
// VerifyDataDirBackup recomputes every file's checksum under dstDir and
// compares it against the manifest CopyDataDir produced, returning a single
// error describing every mismatch found (not just the first) so a human
// sees the full extent of any corruption in one pass.
func VerifyDataDirBackup(dstDir string, m *Manifest) error {
var problems []string
for _, entry := range m.Files {
path := filepath.Join(dstDir, entry.RelPath)
f, err := os.Open(path)
if err != nil {
problems = append(problems, fmt.Sprintf("%s: %v", entry.RelPath, err))
continue
}
h := sha256.New()
size, err := io.Copy(h, f)
f.Close()
if err != nil {
problems = append(problems, fmt.Sprintf("%s: read error: %v", entry.RelPath, err))
continue
}
if size != entry.Size {
problems = append(problems, fmt.Sprintf("%s: size %d, want %d", entry.RelPath, size, entry.Size))
continue
}
if got := hex.EncodeToString(h.Sum(nil)); got != entry.SHA256 {
problems = append(problems, fmt.Sprintf("%s: sha256 %s, want %s", entry.RelPath, got, entry.SHA256))
}
}
if len(problems) > 0 {
return fmt.Errorf("backup: verification failed for %d of %d file(s):\n%s", len(problems), len(m.Files), strings.Join(problems, "\n"))
}
return nil
}
+168
View File
@@ -0,0 +1,168 @@
package backup
import (
"os"
"path/filepath"
"testing"
)
func writeTree(t *testing.T, root string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(root, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "a.db"), []byte("alpha data"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "sub", "b.db"), []byte("bravo data, a bit longer"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink("a.db", filepath.Join(root, "link-to-a")); err != nil {
t.Fatal(err)
}
}
func TestCopyDataDirAndVerify(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
if err := os.MkdirAll(src, 0o755); err != nil {
t.Fatal(err)
}
writeTree(t, src)
dst := filepath.Join(t.TempDir(), "data-backup")
manifest, err := CopyDataDir(src, dst)
if err != nil {
t.Fatalf("CopyDataDir: %v", err)
}
if len(manifest.Files) != 2 {
t.Fatalf("manifest has %d files, want 2 (symlinks aren't hashed): %+v", len(manifest.Files), manifest.Files)
}
if manifest.TotalBytes != int64(len("alpha data")+len("bravo data, a bit longer")) {
t.Errorf("TotalBytes = %d, want %d", manifest.TotalBytes, len("alpha data")+len("bravo data, a bit longer"))
}
// The copy should be byte-identical, including the symlink.
got, err := os.ReadFile(filepath.Join(dst, "sub", "b.db"))
if err != nil || string(got) != "bravo data, a bit longer" {
t.Errorf("copied sub/b.db = %q, %v", got, err)
}
target, err := os.Readlink(filepath.Join(dst, "link-to-a"))
if err != nil || target != "a.db" {
t.Errorf("copied symlink target = %q, %v, want a.db", target, err)
}
if err := VerifyDataDirBackup(dst, manifest); err != nil {
t.Errorf("VerifyDataDirBackup on an untouched copy: %v", err)
}
}
func TestCopyDataDirRefusesSelfCopy(t *testing.T) {
dir := filepath.Join(t.TempDir(), "data")
os.MkdirAll(dir, 0o755)
if _, err := CopyDataDir(dir, dir); err == nil {
t.Fatal("CopyDataDir(dir, dir) should refuse to copy a directory onto itself")
}
}
func TestCopyDataDirRefusesNestedDestination(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
os.MkdirAll(src, 0o755)
nested := filepath.Join(src, "backup")
if _, err := CopyDataDir(src, nested); err == nil {
t.Fatal("CopyDataDir should refuse a destination nested inside the source")
}
}
func TestCopyDataDirRetryClearsStaleFiles(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
os.MkdirAll(src, 0o755)
writeTree(t, src)
dst := filepath.Join(t.TempDir(), "data-backup")
// Simulate a stale partial copy from a previous failed attempt.
os.MkdirAll(dst, 0o755)
if err := os.WriteFile(filepath.Join(dst, "stale-leftover.tmp"), []byte("junk"), 0o644); err != nil {
t.Fatal(err)
}
manifest, err := CopyDataDir(src, dst)
if err != nil {
t.Fatalf("CopyDataDir: %v", err)
}
if _, err := os.Stat(filepath.Join(dst, "stale-leftover.tmp")); !os.IsNotExist(err) {
t.Error("stale-leftover.tmp should have been cleared by a fresh copy, but still exists")
}
if err := VerifyDataDirBackup(dst, manifest); err != nil {
t.Errorf("VerifyDataDirBackup after retry: %v", err)
}
}
func TestVerifyDataDirBackupDetectsTampering(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
os.MkdirAll(src, 0o755)
writeTree(t, src)
dst := filepath.Join(t.TempDir(), "data-backup")
manifest, err := CopyDataDir(src, dst)
if err != nil {
t.Fatalf("CopyDataDir: %v", err)
}
// Corrupt the backup after the fact - Verify must catch it.
if err := os.WriteFile(filepath.Join(dst, "a.db"), []byte("corrupted!"), 0o644); err != nil {
t.Fatal(err)
}
if err := VerifyDataDirBackup(dst, manifest); err == nil {
t.Fatal("VerifyDataDirBackup should have detected the tampered file")
}
}
func TestManifestChecksumIsDeterministic(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
os.MkdirAll(src, 0o755)
writeTree(t, src)
dst := filepath.Join(t.TempDir(), "data-backup")
m1, err := CopyDataDir(src, dst)
if err != nil {
t.Fatal(err)
}
sum1, err := m1.Checksum()
if err != nil {
t.Fatal(err)
}
dst2 := filepath.Join(t.TempDir(), "data-backup-2")
m2, err := CopyDataDir(src, dst2)
if err != nil {
t.Fatal(err)
}
sum2, err := m2.Checksum()
if err != nil {
t.Fatal(err)
}
if sum1 != sum2 {
t.Errorf("two copies of the same source produced different manifest checksums: %s vs %s", sum1, sum2)
}
}
func TestWriteReadManifestRoundtrip(t *testing.T) {
m := &Manifest{
SourceDir: "/var/lib/stalwart",
Files: []ManifestEntry{{RelPath: "a.db", SHA256: "deadbeef", Size: 42}},
TotalBytes: 42,
}
path := filepath.Join(t.TempDir(), "manifest.json")
if err := WriteManifest(path, m); err != nil {
t.Fatalf("WriteManifest: %v", err)
}
got, err := ReadManifest(path)
if err != nil {
t.Fatalf("ReadManifest: %v", err)
}
if got.TotalBytes != 42 || len(got.Files) != 1 || got.Files[0].SHA256 != "deadbeef" {
t.Errorf("roundtrip mismatch: %+v", got)
}
}
+25
View File
@@ -0,0 +1,25 @@
package backup
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
)
// hashFile returns a file's SHA256 and size, for recording as a
// checkpoint.Artifact.
func hashFile(path string) (sha256Hex string, size int64, err error) {
f, err := os.Open(path)
if err != nil {
return "", 0, fmt.Errorf("backup: hash %s: %w", path, err)
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, fmt.Errorf("backup: hash %s: %w", path, err)
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
+36
View File
@@ -0,0 +1,36 @@
package backup
import (
"fmt"
"strings"
)
// Status is a single backup step's verdict. Unlike preflight, most backup
// steps that fail are hard failures (Run aborts) rather than advisory - see
// Run's doc comment - but Status still distinguishes "did the thing" from
// "correctly skipped because it doesn't apply to this deployment".
type Status string
const (
StatusOK Status = "ok"
StatusSkipped Status = "skipped"
StatusFail Status = "fail"
)
type CheckResult struct {
Name string
Status Status
Detail string
}
type Report struct {
Results []CheckResult
}
func (r Report) String() string {
var b strings.Builder
for _, res := range r.Results {
fmt.Fprintf(&b, "[%-7s] %-18s %s\n", strings.ToUpper(string(res.Status)), res.Name, res.Detail)
}
return b.String()
}
+164
View File
@@ -0,0 +1,164 @@
package backup
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"sort"
"strings"
"time"
)
// DefaultMigrationScriptURL is Stalwart's own v0.15->v0.16 settings
// converter, referenced directly from UPGRADING/v0_16.md. It's an external,
// Stalwart-owned dependency this tool doesn't vendor a copy of - see the
// pinning discussion on DownloadFile and ARCHITECTURE.md §8.
const DefaultMigrationScriptURL = "https://raw.githubusercontent.com/stalwartlabs/stalwart/main/resources/scripts/migrate_v016.py"
// DownloadFile fetches url to destPath and returns its SHA256. If
// expectedSHA256 is non-empty, a mismatching download is rejected (and the
// partial file removed) - this is how a pinned migration-script hash is
// enforced, so a run never silently executes a different version of a
// script than the one it was reviewed against. If expectedSHA256 is empty,
// the download is accepted unconditionally and its hash is returned so the
// caller can record it as the pin for next time; first-run trust-on-first-use
// is a known gap, flagged in ARCHITECTURE.md §8.
func DownloadFile(ctx context.Context, httpClient *http.Client, url, destPath, expectedSHA256 string) (sha256Hex string, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 60 * time.Second}
}
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("backup: fetch %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("backup: fetch %s: unexpected status %s", url, resp.Status)
}
f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640)
if err != nil {
return "", fmt.Errorf("backup: create %s: %w", destPath, err)
}
h := sha256.New()
_, copyErr := io.Copy(io.MultiWriter(f, h), resp.Body)
closeErr := f.Close()
if copyErr != nil {
os.Remove(destPath)
return "", fmt.Errorf("backup: download %s: %w", url, copyErr)
}
if closeErr != nil {
os.Remove(destPath)
return "", fmt.Errorf("backup: close %s: %w", destPath, closeErr)
}
sha256Hex = hex.EncodeToString(h.Sum(nil))
if expectedSHA256 != "" && sha256Hex != expectedSHA256 {
os.Remove(destPath)
return "", fmt.Errorf(
"backup: %s checksum mismatch: got %s, want %s (refusing to run an unexpected version of a script that irreversibly wipes settings on first v0.16 start)",
url, sha256Hex, expectedSHA256,
)
}
return sha256Hex, nil
}
// SettingsDumpOptions configures a migrate_v016.py `dump` invocation
// against a live v0.15.x instance - see UPGRADING/v0_16.md.
type SettingsDumpOptions struct {
PythonPath string // defaults to "python3"
ScriptPath string // local path to the already-downloaded, checksum-verified script
URL string // the running v0.15.x instance's base URL
Username string
Password string
SettingsPath string
PrincipalsPath string
}
// RunSettingsDump runs migrate_v016.py's dump subcommand, which reads the
// live v0.15.x server's settings and principals over its admin API and
// writes them to SettingsPath/PrincipalsPath for the later convert step
// (ARCHITECTURE.md §4.3). This step is read-only against the server, so
// it's safe to run well before cutover - ARCHITECTURE.md §4.2 calls for it
// both at preflight time and again immediately before cutover, since the
// live settings may have changed in between.
func RunSettingsDump(ctx context.Context, o SettingsDumpOptions) error {
python := o.PythonPath
if python == "" {
python = "python3"
}
args := []string{
o.ScriptPath, "dump",
"--url", o.URL,
"--username", o.Username,
"--password", o.Password,
"--settings", o.SettingsPath,
"--principals", o.PrincipalsPath,
}
cmd := exec.CommandContext(ctx, python, args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("backup: migrate_v016.py dump failed: %w (output: %s)", err, out)
}
return nil
}
// SettingsConvertOptions configures a migrate_v016.py `convert` invocation,
// which turns the settings/principals dump into the v0.16 config.json and
// export.json that recovery mode consumes - see UPGRADING/v0_16.md.
type SettingsConvertOptions struct {
PythonPath string // defaults to "python3"
ScriptPath string
SettingsPath string
PrincipalsPath string
ConfigPath string // output: config.json for the new binary's --config flag
OutputPath string // output: export.json for `stalwart-cli apply`
// PatchPaths rewrites path prefixes in the generated config (documented
// for Docker deployments as "--patch-paths /opt/stalwart=/var/lib/stalwart",
// e.g. old-path -> new-path). This is the officially documented
// mechanism a dry-run relies on to point the generated config at a
// sandbox data directory instead of the production one - see
// ARCHITECTURE.md's dry-run design - rather than this tool editing
// config.json's contents directly, which would require depending on its
// exact schema.
PatchPaths map[string]string
}
// RunSettingsConvert runs migrate_v016.py's convert subcommand.
func RunSettingsConvert(ctx context.Context, o SettingsConvertOptions) error {
python := o.PythonPath
if python == "" {
python = "python3"
}
args := []string{
o.ScriptPath, "convert",
"--settings", o.SettingsPath,
"--principals", o.PrincipalsPath,
"--config", o.ConfigPath,
"--output", o.OutputPath,
}
if len(o.PatchPaths) > 0 {
pairs := make([]string, 0, len(o.PatchPaths))
for old, new := range o.PatchPaths {
pairs = append(pairs, old+"="+new)
}
sort.Strings(pairs) // deterministic argv, easier to test and to log
args = append(args, "--patch-paths", strings.Join(pairs, ","))
}
cmd := exec.CommandContext(ctx, python, args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("backup: migrate_v016.py convert failed: %w (output: %s)", err, out)
}
return nil
}
+175
View File
@@ -0,0 +1,175 @@
package backup
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDownloadFileAcceptsMatchingChecksum(t *testing.T) {
content := "print('fake migration script')\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(content))
}))
defer srv.Close()
sum := sha256.Sum256([]byte(content))
expected := hex.EncodeToString(sum[:])
dest := filepath.Join(t.TempDir(), "script.py")
got, err := DownloadFile(context.Background(), nil, srv.URL, dest, expected)
if err != nil {
t.Fatalf("DownloadFile: %v", err)
}
if got != expected {
t.Errorf("returned checksum = %s, want %s", got, expected)
}
data, _ := os.ReadFile(dest)
if string(data) != content {
t.Errorf("downloaded content = %q, want %q", data, content)
}
}
func TestDownloadFileRejectsMismatchedChecksum(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("unexpected content"))
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "script.py")
_, err := DownloadFile(context.Background(), nil, srv.URL, dest, "0000000000000000000000000000000000000000000000000000000000000000")
if err == nil {
t.Fatal("DownloadFile should reject a checksum mismatch")
}
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
t.Error("DownloadFile should remove the file it wrote after a checksum mismatch")
}
}
func TestDownloadFileWithoutPinReturnsComputedHash(t *testing.T) {
content := "arbitrary content"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(content))
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "script.py")
got, err := DownloadFile(context.Background(), nil, srv.URL, dest, "")
if err != nil {
t.Fatalf("DownloadFile: %v", err)
}
sum := sha256.Sum256([]byte(content))
want := hex.EncodeToString(sum[:])
if got != want {
t.Errorf("returned checksum = %s, want %s", got, want)
}
}
func TestRunSettingsDumpInvokesScriptWithFlags(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
err := RunSettingsDump(context.Background(), SettingsDumpOptions{
PythonPath: filepath.Join(pythonDir, "python3"),
ScriptPath: "/opt/migrate_v016.py",
URL: "https://mail.example.com",
Username: "admin",
Password: "hunter2",
SettingsPath: filepath.Join(dir, "settings.json"),
PrincipalsPath: filepath.Join(dir, "principals.json"),
})
if err != nil {
t.Fatalf("RunSettingsDump: %v", err)
}
got := readArgsFile(t, log)
for _, want := range []string{"/opt/migrate_v016.py", "dump", "--url https://mail.example.com", "--username admin"} {
if !strings.Contains(got, want) {
t.Errorf("script invoked with %q, missing %q", got, want)
}
}
}
func TestRunSettingsDumpPropagatesFailure(t *testing.T) {
pythonDir := withFakeExecutable(t, "python3", "#!/bin/sh\necho 'auth failed' >&2\nexit 1\n")
err := RunSettingsDump(context.Background(), SettingsDumpOptions{
PythonPath: filepath.Join(pythonDir, "python3"),
ScriptPath: "/opt/migrate_v016.py",
})
if err == nil {
t.Fatal("RunSettingsDump should error when the script exits non-zero")
}
if !strings.Contains(err.Error(), "auth failed") {
t.Errorf("error = %v, want it to include the script's stderr", err)
}
}
func TestRunSettingsConvertInvokesScriptWithFlags(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
PythonPath: filepath.Join(pythonDir, "python3"),
ScriptPath: "/opt/migrate_v016.py",
SettingsPath: filepath.Join(dir, "settings.json"),
PrincipalsPath: filepath.Join(dir, "principals.json"),
ConfigPath: filepath.Join(dir, "config.json"),
OutputPath: filepath.Join(dir, "export.json"),
})
if err != nil {
t.Fatalf("RunSettingsConvert: %v", err)
}
got := readArgsFile(t, log)
for _, want := range []string{"convert", "--settings", "--config", "--output"} {
if !strings.Contains(got, want) {
t.Errorf("script invoked with %q, missing %q", got, want)
}
}
if strings.Contains(got, "--patch-paths") {
t.Errorf("script invoked with %q, should not include --patch-paths when none given", got)
}
}
func TestRunSettingsConvertWithPatchPaths(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
PythonPath: filepath.Join(pythonDir, "python3"),
ScriptPath: "/opt/migrate_v016.py",
SettingsPath: filepath.Join(dir, "settings.json"),
PrincipalsPath: filepath.Join(dir, "principals.json"),
ConfigPath: filepath.Join(dir, "config.json"),
OutputPath: filepath.Join(dir, "export.json"),
PatchPaths: map[string]string{"/var/lib/stalwart": "/tmp/sandbox/stalwart"},
})
if err != nil {
t.Fatalf("RunSettingsConvert: %v", err)
}
got := readArgsFile(t, log)
if !strings.Contains(got, "--patch-paths /var/lib/stalwart=/tmp/sandbox/stalwart") {
t.Errorf("script invoked with %q, missing the expected --patch-paths flag", got)
}
}
func TestRunSettingsConvertPropagatesFailure(t *testing.T) {
pythonDir := withFakeExecutable(t, "python3", "#!/bin/sh\necho 'unsupported settings key' >&2\nexit 1\n")
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
PythonPath: filepath.Join(pythonDir, "python3"),
ScriptPath: "/opt/migrate_v016.py",
})
if err == nil {
t.Fatal("RunSettingsConvert should error when the script exits non-zero")
}
if !strings.Contains(err.Error(), "unsupported settings key") {
t.Errorf("error = %v, want it to include the script's stderr", err)
}
}
+91
View File
@@ -0,0 +1,91 @@
package backup
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
)
// criticalTables is the exact table set Stalwart's own v0.16 upgrade guide
// backs up before migrating (principals/directory, domains, and the other
// tables its migration script and recovery mode depend on) - a targeted
// dump, not a full-instance one, matching the guide's own tested restore
// path and staying fast on large installs. See ARCHITECTURE.md §4.2 and
// UPGRADING/v0_16.md.
var criticalTables = []string{"s", "d", "r", "h", "b", "g", "j", "f", "u"}
// SQLOptions configures a targeted critical-table dump for an external SQL
// store backend.
type SQLOptions struct {
Host string
Port string
Database string
User string
Password string
OutPath string
}
// BuildPgDumpArgs returns pg_dump's argv for the critical-table backup,
// without the leading "pg_dump" itself.
func BuildPgDumpArgs(o SQLOptions) []string {
args := []string{"-U", o.User, "-d", o.Database}
if o.Host != "" {
args = append(args, "-h", o.Host)
}
if o.Port != "" {
args = append(args, "-p", o.Port)
}
for _, t := range criticalTables {
args = append(args, "-t", t)
}
return append(args, "-f", o.OutPath)
}
// BuildMySQLDumpArgs returns mysqldump's argv for the same critical-table
// set. mysqldump writes to stdout, so RunMySQLDump redirects it rather than
// this function taking an output path flag.
func BuildMySQLDumpArgs(o SQLOptions) []string {
args := []string{"-u", o.User, o.Database}
if o.Host != "" {
args = append(args, "-h", o.Host)
}
if o.Port != "" {
args = append(args, "-P", o.Port)
}
return append(args, criticalTables...)
}
// RunPgDump executes pg_dump with the password passed via the standard
// PGPASSWORD environment variable, never on the command line where it would
// be visible to anything reading this process's argv (e.g. `ps`).
func RunPgDump(ctx context.Context, o SQLOptions) error {
cmd := exec.CommandContext(ctx, "pg_dump", BuildPgDumpArgs(o)...)
cmd.Env = append(os.Environ(), "PGPASSWORD="+o.Password)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("backup: pg_dump failed: %w (output: %s)", err, strings.TrimSpace(string(out)))
}
return nil
}
// RunMySQLDump executes mysqldump with the password passed via the standard
// MYSQL_PWD environment variable, redirecting its stdout to o.OutPath.
func RunMySQLDump(ctx context.Context, o SQLOptions) error {
f, err := os.OpenFile(o.OutPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640)
if err != nil {
return fmt.Errorf("backup: create %s: %w", o.OutPath, err)
}
defer f.Close()
cmd := exec.CommandContext(ctx, "mysqldump", BuildMySQLDumpArgs(o)...)
cmd.Env = append(os.Environ(), "MYSQL_PWD="+o.Password)
cmd.Stdout = f
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("backup: mysqldump failed: %w (stderr: %s)", err, strings.TrimSpace(stderr.String()))
}
return nil
}
+85
View File
@@ -0,0 +1,85 @@
package backup
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestBuildPgDumpArgs(t *testing.T) {
args := BuildPgDumpArgs(SQLOptions{
Host: "db.internal", Port: "5432", Database: "stalwart", User: "stalwart", OutPath: "/backups/out.sql",
})
joined := strings.Join(args, " ")
for _, want := range []string{"-U stalwart", "-d stalwart", "-h db.internal", "-p 5432", "-f /backups/out.sql"} {
if !strings.Contains(joined, want) {
t.Errorf("BuildPgDumpArgs = %q, missing %q", joined, want)
}
}
for _, table := range criticalTables {
if !strings.Contains(joined, "-t "+table) {
t.Errorf("BuildPgDumpArgs missing critical table flag for %q: %q", table, joined)
}
}
}
func TestBuildMySQLDumpArgs(t *testing.T) {
args := BuildMySQLDumpArgs(SQLOptions{Host: "db.internal", Port: "3306", Database: "stalwart", User: "stalwart"})
joined := strings.Join(args, " ")
if !strings.Contains(joined, "-u stalwart") || !strings.Contains(joined, "-h db.internal") || !strings.Contains(joined, "-P 3306") {
t.Errorf("BuildMySQLDumpArgs = %q, missing expected flags", joined)
}
if !strings.HasSuffix(joined, strings.Join(criticalTables, " ")) {
t.Errorf("BuildMySQLDumpArgs = %q, want it to end with the critical table list", joined)
}
}
func TestRunPgDumpInvokesPgDumpWithArgs(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
withFakeExecutable(t, "pg_dump", fakeScriptLoggingArgs(log, "exit 0"))
err := RunPgDump(context.Background(), SQLOptions{
User: "stalwart", Database: "stalwart", OutPath: filepath.Join(dir, "out.sql"),
})
if err != nil {
t.Fatalf("RunPgDump: %v", err)
}
got := readArgsFile(t, log)
if !strings.Contains(got, "-U stalwart") {
t.Errorf("pg_dump was invoked with %q, missing -U stalwart", got)
}
}
func TestRunPgDumpPropagatesFailure(t *testing.T) {
dir := t.TempDir()
withFakeExecutable(t, "pg_dump", "#!/bin/sh\necho 'connection refused' >&2\nexit 1\n")
err := RunPgDump(context.Background(), SQLOptions{User: "stalwart", Database: "stalwart", OutPath: filepath.Join(dir, "out.sql")})
if err == nil {
t.Fatal("RunPgDump should have returned an error when pg_dump exits non-zero")
}
if !strings.Contains(err.Error(), "connection refused") {
t.Errorf("error = %v, want it to include pg_dump's stderr", err)
}
}
func TestRunMySQLDumpWritesStdoutToOutPath(t *testing.T) {
dir := t.TempDir()
withFakeExecutable(t, "mysqldump", "#!/bin/sh\necho '-- fake dump output'\n")
outPath := filepath.Join(dir, "out.sql")
err := RunMySQLDump(context.Background(), SQLOptions{User: "stalwart", Database: "stalwart", OutPath: outPath})
if err != nil {
t.Fatalf("RunMySQLDump: %v", err)
}
data, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !strings.Contains(string(data), "fake dump output") {
t.Errorf("out.sql = %q, want fake dump output redirected into it", data)
}
}
+67
View File
@@ -0,0 +1,67 @@
package backup
import (
"context"
"fmt"
"os/exec"
"path/filepath"
)
// VandelayOptions configures a per-account content export via Stalwart's
// own Vandelay import/export tool - the documented, backend-independent
// backup mechanism (ARCHITECTURE.md §4.2's belt-and-suspenders layer): each
// account's mail, calendars, contacts, Sieve scripts, and identities land in
// one self-contained SQLite archive.
type VandelayOptions struct {
BinaryPath string // defaults to "vandelay"
URL string // the source JMAP server
AuthBasic string // "user:app-password", per vandelay's own --auth-basic flag
OutDir string // one <account>.sqlite file per account goes here
}
// BuildVandelayImportArgs returns vandelay's argv for exporting one
// account's content into a self-contained SQLite archive, without the
// leading "vandelay" itself. "import" is vandelay's own verb for this - it
// names the direction relative to the archive file, not the live server;
// see ARCHITECTURE.md §4.2.
func BuildVandelayImportArgs(o VandelayOptions, accountName, outFile string) []string {
return []string{
"import", "jmap",
"--url", o.URL,
"--auth-basic", o.AuthBasic,
"--account-name", accountName,
outFile,
}
}
// ExportAccount runs one account's Vandelay export and returns the archive
// path it wrote.
func ExportAccount(ctx context.Context, o VandelayOptions, accountName string) (outFile string, err error) {
binary := o.BinaryPath
if binary == "" {
binary = "vandelay"
}
outFile = filepath.Join(o.OutDir, accountName+".sqlite")
cmd := exec.CommandContext(ctx, binary, BuildVandelayImportArgs(o, accountName, outFile)...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("backup: vandelay export of %s failed: %w (output: %s)", accountName, err, out)
}
return outFile, nil
}
// ExportAccounts exports every account in accounts, stopping at the first
// failure. A partial per-account backup set is reported precisely (how many
// succeeded, which one failed and why) rather than silently continuing past
// a failure that might indicate a systemic problem - bad credentials, an
// unreachable server - rather than a one-off.
func ExportAccounts(ctx context.Context, o VandelayOptions, accounts []string) (outFiles []string, err error) {
for _, acct := range accounts {
f, err := ExportAccount(ctx, o, acct)
if err != nil {
return outFiles, fmt.Errorf("backup: exported %d/%d accounts before failing: %w", len(outFiles), len(accounts), err)
}
outFiles = append(outFiles, f)
}
return outFiles, nil
}
+56
View File
@@ -0,0 +1,56 @@
package backup
import (
"context"
"path/filepath"
"strings"
"testing"
)
func TestBuildVandelayImportArgs(t *testing.T) {
args := BuildVandelayImportArgs(VandelayOptions{URL: "https://mail.example.com", AuthBasic: "alice:app-pass"}, "[email protected]", "/backups/alice.sqlite")
joined := strings.Join(args, " ")
for _, want := range []string{"import", "jmap", "--url https://mail.example.com", "--auth-basic alice:app-pass", "--account-name [email protected]", "/backups/alice.sqlite"} {
if !strings.Contains(joined, want) {
t.Errorf("BuildVandelayImportArgs = %q, missing %q", joined, want)
}
}
}
func TestExportAccountsRunsEachAccount(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
withFakeExecutable(t, "vandelay", fakeScriptLoggingArgs(log, "exit 0"))
outDir := filepath.Join(dir, "out")
files, err := ExportAccounts(context.Background(), VandelayOptions{URL: "https://mail.example.com", AuthBasic: "a:b", OutDir: outDir}, []string{"[email protected]", "[email protected]"})
if err != nil {
t.Fatalf("ExportAccounts: %v", err)
}
if len(files) != 2 {
t.Fatalf("got %d files, want 2: %v", len(files), files)
}
got := readArgsFile(t, log)
if !strings.Contains(got, "[email protected]") || !strings.Contains(got, "[email protected]") {
t.Errorf("vandelay invocations = %q, want both accounts", got)
}
}
func TestExportAccountsStopsAtFirstFailure(t *testing.T) {
dir := t.TempDir()
// Fails on the second invocation (bob), succeeds on the first (alice).
script := "#!/bin/sh\ncase \"$*\" in\n *bob*) echo 'account not found' >&2; exit 1 ;;\n *) exit 0 ;;\nesac\n"
withFakeExecutable(t, "vandelay", script)
outDir := filepath.Join(dir, "out")
files, err := ExportAccounts(context.Background(), VandelayOptions{URL: "https://mail.example.com", AuthBasic: "a:b", OutDir: outDir}, []string{"[email protected]", "[email protected]", "[email protected]"})
if err == nil {
t.Fatal("ExportAccounts should have failed on [email protected]")
}
if len(files) != 1 {
t.Errorf("got %d successful exports before failure, want 1 (alice only)", len(files))
}
if !strings.Contains(err.Error(), "1/3") {
t.Errorf("error = %v, want it to report 1/3 accounts exported before failing", err)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package checkpoint implements run-id and state.json persistence, resume logic, and rollback-window tracking.
// See ARCHITECTURE.md §5 for the design.
package checkpoint
+88
View File
@@ -0,0 +1,88 @@
package checkpoint
import (
"fmt"
"time"
)
func (rs *RunState) stepIndex(phase Phase, name string) int {
for i := range rs.Steps {
if rs.Steps[i].Phase == phase && rs.Steps[i].Name == name {
return i
}
}
return -1
}
// Status returns the current status of a step, or StepPending if it has
// never been started.
func (rs *RunState) Status(phase Phase, name string) StepStatus {
if i := rs.stepIndex(phase, name); i >= 0 {
return rs.Steps[i].Status
}
return StepPending
}
// Done reports whether a step already completed successfully. Callers use
// this to decide whether to skip work on resume.
func (rs *RunState) Done(phase Phase, name string) bool {
return rs.Status(phase, name) == StepDone
}
// Outcome returns the StepOutcome recorded for a step, zero-valued if none.
// This is what lets a resumed run reconstruct a skipped step's result
// without re-executing it.
func (rs *RunState) Outcome(phase Phase, name string) StepOutcome {
if i := rs.stepIndex(phase, name); i >= 0 {
return rs.Steps[i].StepOutcome
}
return StepOutcome{}
}
// Begin marks a step as running, creating its record on first attempt or
// resetting it on a retry after a prior failure.
func (rs *RunState) Begin(phase Phase, name string) {
now := time.Now().UTC()
if i := rs.stepIndex(phase, name); i >= 0 {
rs.Steps[i].Status = StepRunning
rs.Steps[i].StartedAt = &now
rs.Steps[i].CompletedAt = nil
rs.Steps[i].StepOutcome = StepOutcome{}
rs.Steps[i].Error = ""
} else {
rs.Steps = append(rs.Steps, StepRecord{
Phase: phase, Name: name, Status: StepRunning, StartedAt: &now,
})
}
rs.UpdatedAt = now
}
// Complete marks a step done with its outcome. It panics if Begin was never
// called for this step - that's a bug in the calling phase, not a runtime
// condition callers should need to handle.
func (rs *RunState) Complete(phase Phase, name string, outcome StepOutcome) {
i := rs.stepIndex(phase, name)
if i < 0 {
panic(fmt.Sprintf("checkpoint: Complete(%s/%s) called without Begin", phase, name))
}
now := time.Now().UTC()
rs.Steps[i].Status = StepDone
rs.Steps[i].CompletedAt = &now
rs.Steps[i].StepOutcome = outcome
rs.Steps[i].Error = ""
rs.UpdatedAt = now
}
// Fail marks a step failed, so a later Begin for the same (phase, name)
// knows to retry it rather than treat it as done.
func (rs *RunState) Fail(phase Phase, name string, stepErr error) {
i := rs.stepIndex(phase, name)
if i < 0 {
panic(fmt.Sprintf("checkpoint: Fail(%s/%s) called without Begin", phase, name))
}
now := time.Now().UTC()
rs.Steps[i].Status = StepFailed
rs.Steps[i].CompletedAt = &now
rs.Steps[i].Error = stepErr.Error()
rs.UpdatedAt = now
}
+179
View File
@@ -0,0 +1,179 @@
package checkpoint
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
// DefaultBaseDir is where runs are persisted when the operator doesn't
// override it.
const DefaultBaseDir = "/var/lib/stalwart-migrator/runs"
// Store persists RunState to disk. It's the only thing in this package that
// touches the filesystem - RunState itself is a plain data type.
type Store struct {
baseDir string
}
func NewStore(baseDir string) *Store {
if baseDir == "" {
baseDir = DefaultBaseDir
}
return &Store{baseDir: baseDir}
}
func (s *Store) runDir(runID string) string { return filepath.Join(s.baseDir, runID) }
func (s *Store) statePath(runID string) string { return filepath.Join(s.runDir(runID), "state.json") }
// Create starts a new run, assigns it an ID, and persists its initial
// state before returning it.
func (s *Store) Create(sourceVersion, targetVersion string) (*RunState, error) {
id, err := newRunID()
if err != nil {
return nil, fmt.Errorf("checkpoint: generate run id: %w", err)
}
now := time.Now().UTC()
rs := &RunState{
RunID: id,
SourceVersion: sourceVersion,
TargetVersion: targetVersion,
CreatedAt: now,
UpdatedAt: now,
Artifacts: map[string]Artifact{},
}
if err := s.Save(rs); err != nil {
return nil, err
}
return rs, nil
}
// Load reads an existing run's state from disk.
func (s *Store) Load(runID string) (*RunState, error) {
data, err := os.ReadFile(s.statePath(runID))
if err != nil {
return nil, fmt.Errorf("checkpoint: load run %s: %w", runID, err)
}
var rs RunState
if err := json.Unmarshal(data, &rs); err != nil {
return nil, fmt.Errorf("checkpoint: parse run %s: %w", runID, err)
}
return &rs, nil
}
// Save writes state to disk atomically: write to a temp file in the same
// directory, fsync it, then rename over the real path. A crash mid-write
// leaves the temp file orphaned and state.json untouched, never a
// truncated or corrupt state.json - that file is the one thing every phase
// and a human operator both trust as the source of truth for what's
// already happened, so a half-written version of it would be worse than an
// old one.
func (s *Store) Save(rs *RunState) error {
dir := s.runDir(rs.RunID)
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("checkpoint: create run directory: %w", err)
}
data, err := json.MarshalIndent(rs, "", " ")
if err != nil {
return fmt.Errorf("checkpoint: marshal state: %w", err)
}
tmp, err := os.CreateTemp(dir, "state-*.json.tmp")
if err != nil {
return fmt.Errorf("checkpoint: create temp state file: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("checkpoint: write temp state file: %w", err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("checkpoint: sync temp state file: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("checkpoint: close temp state file: %w", err)
}
if err := os.Rename(tmpPath, s.statePath(rs.RunID)); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("checkpoint: rename temp state file into place: %w", err)
}
return nil
}
// List returns known run IDs, most recently created first.
func (s *Store) List() ([]string, error) {
entries, err := os.ReadDir(s.baseDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("checkpoint: list runs: %w", err)
}
type run struct {
id string
created time.Time
}
var runs []run
for _, e := range entries {
if !e.IsDir() {
continue
}
rs, err := s.Load(e.Name())
if err != nil {
continue // not a run directory (or a corrupt one) - skip it
}
runs = append(runs, run{rs.RunID, rs.CreatedAt})
}
sort.Slice(runs, func(i, j int) bool { return runs[i].created.After(runs[j].created) })
ids := make([]string, len(runs))
for i, r := range runs {
ids[i] = r.id
}
return ids, nil
}
// RunStep executes fn for (phase, name) unless it already completed
// successfully in a prior attempt at this run, in which case fn is skipped
// and the previously recorded StepOutcome is returned instead - this is
// what makes an interrupted run resumable without redoing (or worse,
// double-applying) work that already happened. State is persisted both
// before fn runs (so a crash during fn is visible as "running", not silently
// forgotten) and after (recording success or failure).
func (s *Store) RunStep(rs *RunState, phase Phase, name string, fn func() (StepOutcome, error)) (StepOutcome, error) {
if rs.Done(phase, name) {
return rs.Outcome(phase, name), nil
}
rs.Begin(phase, name)
if err := s.Save(rs); err != nil {
return StepOutcome{}, fmt.Errorf("checkpoint: persist step start for %s/%s: %w", phase, name, err)
}
outcome, stepErr := fn()
if stepErr != nil {
rs.Fail(phase, name, stepErr)
} else {
rs.Complete(phase, name, outcome)
}
if err := s.Save(rs); err != nil {
if stepErr != nil {
return outcome, fmt.Errorf("%w (additionally failed to persist checkpoint: %v)", stepErr, err)
}
return outcome, fmt.Errorf("step %s/%s succeeded but failed to persist checkpoint: %w", phase, name, err)
}
return outcome, stepErr
}
func newRunID() (string, error) {
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
return "", err
}
return fmt.Sprintf("%s-%s", time.Now().UTC().Format("20060102-150405"), hex.EncodeToString(b)), nil
}
+168
View File
@@ -0,0 +1,168 @@
package checkpoint
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func TestCreateSaveLoadRoundtrip(t *testing.T) {
store := NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create: %v", err)
}
if rs.RunID == "" {
t.Fatal("Create: expected a non-empty run id")
}
rs.Topology = Topology{DeploymentKind: "systemd", StoreBackend: "rocksdb"}
rs.RecordArtifact("fs-backup", Artifact{Path: "/var/lib/stalwart.v0155-backup", SHA256: "deadbeef", SizeBytes: 1024})
if err := store.Save(rs); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, err := store.Load(rs.RunID)
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded.SourceVersion != "0.15.5" || loaded.TargetVersion != "0.16.14" {
t.Errorf("Load: versions = %s -> %s, want 0.15.5 -> 0.16.14", loaded.SourceVersion, loaded.TargetVersion)
}
if loaded.Topology.DeploymentKind != "systemd" {
t.Errorf("Load: DeploymentKind = %q, want systemd", loaded.Topology.DeploymentKind)
}
if got := loaded.Artifacts["fs-backup"].SHA256; got != "deadbeef" {
t.Errorf("Load: artifact sha256 = %q, want deadbeef", got)
}
}
func TestSaveIsAtomicNoLeftoverTempFiles(t *testing.T) {
dir := t.TempDir()
store := NewStore(dir)
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create: %v", err)
}
for i := 0; i < 5; i++ {
rs.Begin(PhasePreflight, "version")
rs.Complete(PhasePreflight, "version", StepOutcome{Verdict: "ok", Detail: "ok"})
if err := store.Save(rs); err != nil {
t.Fatalf("Save #%d: %v", i, err)
}
}
entries, err := os.ReadDir(filepath.Join(dir, rs.RunID))
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, e := range entries {
if e.Name() != "state.json" {
t.Errorf("unexpected leftover file in run dir: %s", e.Name())
}
}
}
func TestRunStepSkipsAlreadyDoneStep(t *testing.T) {
store := NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create: %v", err)
}
calls := 0
fn := func() (StepOutcome, error) {
calls++
return StepOutcome{Verdict: "ok", Detail: "did the thing"}, nil
}
outcome1, err := store.RunStep(rs, PhasePreflight, "version", fn)
if err != nil {
t.Fatalf("RunStep #1: %v", err)
}
if outcome1.Detail != "did the thing" {
t.Errorf("RunStep #1 detail = %q, want %q", outcome1.Detail, "did the thing")
}
if calls != 1 {
t.Fatalf("calls after first RunStep = %d, want 1", calls)
}
// Simulate a resumed run: same rs, same step name. fn must not run again,
// and the previously recorded outcome must come back unchanged.
outcome2, err := store.RunStep(rs, PhasePreflight, "version", fn)
if err != nil {
t.Fatalf("RunStep #2 (resume): %v", err)
}
if calls != 1 {
t.Errorf("calls after resumed RunStep = %d, want 1 (fn should be skipped)", calls)
}
if outcome2 != outcome1 {
t.Errorf("RunStep #2 outcome = %+v, want %+v (unchanged from before)", outcome2, outcome1)
}
}
func TestRunStepRetriesAfterFailure(t *testing.T) {
store := NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create: %v", err)
}
calls := 0
failThenSucceed := func() (StepOutcome, error) {
calls++
if calls == 1 {
return StepOutcome{}, errors.New("transient failure")
}
return StepOutcome{Verdict: "ok", Detail: "succeeded on retry"}, nil
}
if _, err := store.RunStep(rs, PhaseBackup, "fs-snapshot", failThenSucceed); err == nil {
t.Fatal("RunStep #1: expected error, got nil")
}
if rs.Status(PhaseBackup, "fs-snapshot") != StepFailed {
t.Errorf("status after failed attempt = %s, want failed", rs.Status(PhaseBackup, "fs-snapshot"))
}
outcome, err := store.RunStep(rs, PhaseBackup, "fs-snapshot", failThenSucceed)
if err != nil {
t.Fatalf("RunStep #2 (retry): %v", err)
}
if calls != 2 {
t.Errorf("calls = %d, want 2 (failed step must retry, not skip)", calls)
}
if outcome.Detail != "succeeded on retry" {
t.Errorf("detail = %q, want %q", outcome.Detail, "succeeded on retry")
}
if !rs.Done(PhaseBackup, "fs-snapshot") {
t.Error("step should be Done after a successful retry")
}
}
func TestListOrdersNewestFirst(t *testing.T) {
store := NewStore(t.TempDir())
first, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create first: %v", err)
}
first.CreatedAt = first.CreatedAt.Add(-time.Hour)
if err := store.Save(first); err != nil {
t.Fatalf("Save first: %v", err)
}
second, err := store.Create("0.16.14", "0.16.15")
if err != nil {
t.Fatalf("Create second: %v", err)
}
ids, err := store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(ids) != 2 || ids[0] != second.RunID || ids[1] != first.RunID {
t.Errorf("List = %v, want [%s %s]", ids, second.RunID, first.RunID)
}
}
+121
View File
@@ -0,0 +1,121 @@
package checkpoint
import "time"
// Phase identifies one of the top-level migration phases from
// ARCHITECTURE.md §4. Step records are scoped to a phase so the same step
// name can be reused across phases without colliding.
type Phase string
const (
PhasePreflight Phase = "preflight"
PhaseBackup Phase = "backup"
PhaseStage Phase = "stage"
PhaseRecovery Phase = "recovery"
PhaseCutover Phase = "cutover"
PhaseValidate Phase = "validate"
PhaseRollback Phase = "rollback"
)
// StepStatus is the lifecycle state of one checkpointed step.
type StepStatus string
const (
StepPending StepStatus = "pending"
StepRunning StepStatus = "running"
StepDone StepStatus = "done"
StepFailed StepStatus = "failed"
)
// StepOutcome is what a step reports back on success: a verdict
// classification the calling phase defines the meaning of (e.g. preflight's
// "ok"/"warn"/"fail"), a human-readable summary, and an optional
// machine-readable value later steps - or a resumed run reconstructing this
// step's result without re-executing it - need. Keeping these three
// separate (rather than one free-text field) is what lets `stalwart-migrate
// status` print a clean human summary while still round-tripping the data a
// resumed run depends on.
type StepOutcome struct {
Verdict string `json:"verdict,omitempty"`
Detail string `json:"detail,omitempty"`
Extra string `json:"extra,omitempty"`
}
// StepRecord captures one step's lifecycle status plus its StepOutcome.
type StepRecord struct {
Phase Phase `json:"phase"`
Name string `json:"name"`
Status StepStatus `json:"status"`
StepOutcome // embedded (untagged) so its fields flatten into this JSON object
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Error string `json:"error,omitempty"`
}
// Artifact is a content-addressed record of a file a run produced (a
// backup, a settings dump, a downloaded release binary) so later phases and
// a human operator can confirm it hasn't changed underfoot.
type Artifact struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
SizeBytes int64 `json:"size_bytes"`
}
// MailboxCount is one mailbox's message count as observed at preflight
// time, for later comparison against the post-migration count.
type MailboxCount struct {
Mailbox string `json:"mailbox"`
Messages int `json:"messages"`
}
// PreflightSnapshot holds the facts captured before anything is touched,
// which the validate phase later compares against the migrated instance.
// See ARCHITECTURE.md §4.1 and §4.7.
type PreflightSnapshot struct {
TakenAt time.Time `json:"taken_at"`
AccountCount int `json:"account_count"`
Domains []string `json:"domains,omitempty"`
MailboxCounts map[string][]MailboxCount `json:"mailbox_counts,omitempty"` // account -> mailboxes
DKIMFingerprints map[string]string `json:"dkim_fingerprints,omitempty"`
TLSFingerprints []string `json:"tls_fingerprints,omitempty"`
ListenerPorts []int `json:"listener_ports,omitempty"`
}
// Topology records how this Stalwart instance is deployed, as detected
// during preflight, so later phases (cutover, rollback) know whether
// they're managing a systemd unit or a container and what backend they're
// dealing with.
type Topology struct {
DeploymentKind string `json:"deployment_kind,omitempty"` // "systemd", "docker", "unknown"
ClusterNodes []string `json:"cluster_nodes,omitempty"`
StoreBackend string `json:"store_backend,omitempty"`
BlobStore string `json:"blob_store,omitempty"`
FTSBackend string `json:"fts_backend,omitempty"`
}
// RunState is the full persisted state of one migration run: everything
// needed to resume it after a crash, decide whether to roll back, or
// report on it later. See ARCHITECTURE.md §5.
type RunState struct {
RunID string `json:"run_id"`
SourceVersion string `json:"source_version"`
TargetVersion string `json:"target_version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Topology Topology `json:"topology,omitempty"`
Steps []StepRecord `json:"steps"`
Artifacts map[string]Artifact `json:"artifacts,omitempty"`
PreflightSnapshot *PreflightSnapshot `json:"preflight_snapshot,omitempty"`
RollbackWindowClosed bool `json:"rollback_window_closed"`
}
// RecordArtifact stores a content-addressed record of a file this run
// produced, keyed by a short logical name (e.g. "fs-backup", "settings-dump",
// "target-binary") rather than its path, since the path alone doesn't prove
// the content is what this run actually wrote.
func (rs *RunState) RecordArtifact(name string, a Artifact) {
if rs.Artifacts == nil {
rs.Artifacts = map[string]Artifact{}
}
rs.Artifacts[name] = a
}
+3
View File
@@ -0,0 +1,3 @@
// Package config implements the tool's own configuration: paths, thresholds, and credential handling.
// See ARCHITECTURE.md §6 for the design.
package config
+3
View File
@@ -0,0 +1,3 @@
// Package plan implements the version-boundary migration plans (ordered step lists) that the engine executes.
// See ARCHITECTURE.md §4 for the design.
package plan
+122
View File
@@ -0,0 +1,122 @@
package plan
import (
"fmt"
"regexp"
"strconv"
)
// semver is a minimal major.minor.patch version - package-local like the
// equivalent in internal/preflight, since this comparison is the only thing
// plan needs from it and duplicating ~20 lines keeps phase packages
// independent per ARCHITECTURE.md §7.
type semver struct{ Major, Minor, Patch int }
var versionPattern = regexp.MustCompile(`v?(\d+)\.(\d+)\.(\d+)`)
func parseSemver(s string) (semver, error) {
m := versionPattern.FindStringSubmatch(s)
if m == nil {
return semver{}, fmt.Errorf("plan: no version number found in %q", s)
}
major, _ := strconv.Atoi(m[1])
minor, _ := strconv.Atoi(m[2])
patch, _ := strconv.Atoi(m[3])
return semver{major, minor, patch}, nil
}
func (v semver) String() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) }
func (v semver) Compare(o semver) int {
if v.Major != o.Major {
return cmp(v.Major, o.Major)
}
if v.Minor != o.Minor {
return cmp(v.Minor, o.Minor)
}
return cmp(v.Patch, o.Patch)
}
func cmp(a, b int) int {
switch {
case a < b:
return -1
case a > b:
return 1
default:
return 0
}
}
// PhaseName identifies one phase in an ordered migration plan - the phase
// packages this names are internal/preflight, internal/backup,
// internal/recovery, internal/rollback (invoked on failure, not part of the
// forward list), and internal/validate.
type PhaseName string
const (
PhasePreflight PhaseName = "preflight"
PhaseBackup PhaseName = "backup"
PhaseRecovery PhaseName = "recovery" // only present when crossing the 0.15/0.16 boundary
PhaseCutover PhaseName = "cutover"
PhaseValidate PhaseName = "validate"
)
// Plan is the ordered list of phases one migration run needs, decided once
// from the source and target versions. See ARCHITECTURE.md §4.6: crossing
// the 0.15/0.16 boundary needs the full recovery-mode migration (§4.4); a
// same-boundary patch bump (every 0.16.1-0.16.14 release so far, per
// Stalwart's own changelog) is the fast path with no recovery phase at all.
type Plan struct {
Phases []PhaseName
CrossesMajorBoundary bool
SourceVersion string
TargetVersion string
Reason string
}
// HasPhase reports whether name appears in the plan.
func (p *Plan) HasPhase(name PhaseName) bool {
for _, ph := range p.Phases {
if ph == name {
return true
}
}
return false
}
// Decide returns the plan for migrating from sourceVersion to
// targetVersion. It refuses (rather than guesses) if the source is already
// at or beyond the target, since that's not a migration this tool should
// attempt to run.
func Decide(sourceVersion, targetVersion string) (*Plan, error) {
src, err := parseSemver(sourceVersion)
if err != nil {
return nil, fmt.Errorf("plan: parse source version %q: %w", sourceVersion, err)
}
tgt, err := parseSemver(targetVersion)
if err != nil {
return nil, fmt.Errorf("plan: parse target version %q: %w", targetVersion, err)
}
if src.Compare(tgt) >= 0 {
return nil, fmt.Errorf("plan: source %s is already at or beyond target %s - nothing to migrate", src, tgt)
}
crosses := src.Major == 0 && src.Minor < 16 && (tgt.Major > 0 || tgt.Minor >= 16)
if crosses {
return &Plan{
Phases: []PhaseName{PhasePreflight, PhaseBackup, PhaseRecovery, PhaseCutover, PhaseValidate},
CrossesMajorBoundary: true,
SourceVersion: src.String(),
TargetVersion: tgt.String(),
Reason: fmt.Sprintf("%s -> %s crosses the 0.15/0.16 major boundary: full recovery-mode migration required", src, tgt),
}, nil
}
return &Plan{
Phases: []PhaseName{PhasePreflight, PhaseBackup, PhaseCutover, PhaseValidate},
CrossesMajorBoundary: false,
SourceVersion: src.String(),
TargetVersion: tgt.String(),
Reason: fmt.Sprintf("%s -> %s is a same-boundary patch upgrade: fast path applies (no recovery-mode phase)", src, tgt),
}, nil
}
+71
View File
@@ -0,0 +1,71 @@
package plan
import "testing"
func TestDecideCrossesMajorBoundary(t *testing.T) {
p, err := Decide("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Decide: %v", err)
}
if !p.CrossesMajorBoundary {
t.Error("CrossesMajorBoundary = false, want true for 0.15.5 -> 0.16.14")
}
if !p.HasPhase(PhaseRecovery) {
t.Errorf("phases %v missing PhaseRecovery", p.Phases)
}
wantOrder := []PhaseName{PhasePreflight, PhaseBackup, PhaseRecovery, PhaseCutover, PhaseValidate}
if len(p.Phases) != len(wantOrder) {
t.Fatalf("phases = %v, want %v", p.Phases, wantOrder)
}
for i, ph := range wantOrder {
if p.Phases[i] != ph {
t.Errorf("phases[%d] = %s, want %s", i, p.Phases[i], ph)
}
}
}
func TestDecidePatchBumpFastPath(t *testing.T) {
p, err := Decide("0.16.5", "0.16.14")
if err != nil {
t.Fatalf("Decide: %v", err)
}
if p.CrossesMajorBoundary {
t.Error("CrossesMajorBoundary = true, want false for a 0.16.x -> 0.16.x bump")
}
if p.HasPhase(PhaseRecovery) {
t.Errorf("phases %v should not include PhaseRecovery on the fast path", p.Phases)
}
}
func TestDecideRefusesNoOp(t *testing.T) {
if _, err := Decide("0.16.14", "0.16.14"); err == nil {
t.Fatal("Decide should refuse when source == target")
}
}
func TestDecideRefusesRegression(t *testing.T) {
if _, err := Decide("0.16.14", "0.15.5"); err == nil {
t.Fatal("Decide should refuse when source is already beyond target")
}
}
func TestDecideFutureMajorCrossesBoundaryToo(t *testing.T) {
// A hypothetical 0.15.x -> 1.0.0 jump should still be treated as
// crossing the boundary this tool knows how to automate.
p, err := Decide("0.15.9", "1.0.0")
if err != nil {
t.Fatalf("Decide: %v", err)
}
if !p.CrossesMajorBoundary {
t.Error("CrossesMajorBoundary = false, want true for 0.15.9 -> 1.0.0")
}
}
func TestDecideRejectsUnparseableVersions(t *testing.T) {
if _, err := Decide("not-a-version", "0.16.14"); err == nil {
t.Fatal("Decide should reject an unparseable source version")
}
if _, err := Decide("0.15.5", "not-a-version"); err == nil {
t.Fatal("Decide should reject an unparseable target version")
}
}
+290
View File
@@ -0,0 +1,290 @@
package preflight
import (
"context"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi"
)
// Options configures a Checker. Every field has a conservative default
// applied by New except the ones that must name a real path on this host.
type Options struct {
BinaryPath string // installed stalwart binary, e.g. /usr/local/bin/stalwart
ConfigPath string // its config file (TOML pre-0.16, JSON 0.16+)
DataDir string // data directory to size/space-check
ContainerName string // docker container name, if applicable
AdminURL string // base URL for the JMAP reachability check; empty skips it
AdminUser string
AdminPassword string
TargetVersion string // e.g. "0.16.14" or "latest"
MinFreeMultiple float64
HTTPClient *http.Client
}
// Checker runs the preflight checks described in ARCHITECTURE.md §4.1.
type Checker struct {
opts Options
}
func New(opts Options) *Checker {
if opts.MinFreeMultiple <= 0 {
opts.MinFreeMultiple = 2.0
}
return &Checker{opts: opts}
}
// Run executes every preflight check, checkpointing each one so a killed
// and re-invoked run skips checks that already completed - see
// checkpoint.Store.RunStep. It never aborts early on a single Fail: the
// point of preflight is to surface every blocking issue in one pass rather
// than fail-stop-fix-retry one at a time. Callers decide what to do with a
// Report whose Blocking() is true. It only returns a non-nil error for a
// genuine execution fault (e.g. the checkpoint store itself can't be
// written to) - a check finding a real problem is reported via
// Status: StatusFail in the Report, not a Go error.
func (c *Checker) Run(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState) (Report, error) {
var report Report
// runCheck wraps fn as a checkpointed step and appends its result to
// report, whether fn actually ran or was skipped because a prior
// attempt already completed it - either way report ends up with the
// same entries, and the returned checkpoint.StepOutcome.Extra carries
// whatever machine-readable value a later check in this same Run needs.
runCheck := func(name string, fn func() (CheckResult, string)) (checkpoint.StepOutcome, error) {
outcome, err := store.RunStep(rs, checkpoint.PhasePreflight, name, func() (checkpoint.StepOutcome, error) {
res, extra := fn()
return checkpoint.StepOutcome{Verdict: string(res.Status), Detail: res.Detail, Extra: extra}, nil
})
if err != nil {
return checkpoint.StepOutcome{}, err
}
report.Results = append(report.Results, CheckResult{Name: name, Status: Status(outcome.Verdict), Detail: outcome.Detail})
return outcome, nil
}
versionOutcome, err := runCheck("version", func() (CheckResult, string) {
cur, err := DetectVersion(ctx, c.opts.BinaryPath)
if err != nil {
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
}
curV, _ := parseSemver(cur)
if curV.Compare(minSupportedSource) < 0 {
return CheckResult{
Status: StatusFail,
Detail: fmt.Sprintf("current version %s is older than the minimum supported %s - upgrade to 0.15.x first", cur, minSupportedSource),
}, cur
}
return CheckResult{Status: StatusOK, Detail: fmt.Sprintf("current version %s", cur)}, cur
})
if err != nil {
return report, err
}
targetOutcome, err := runCheck("target-release", func() (CheckResult, string) {
rel, err := ResolveRelease(ctx, c.opts.HTTPClient, c.opts.TargetVersion)
if err != nil {
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
}
tag := strings.TrimPrefix(rel.TagName, "v")
detail := fmt.Sprintf("resolved target to %s (%d release assets)", rel.TagName, len(rel.Assets))
if asset := ChecksumAsset(rel); asset != nil {
detail += fmt.Sprintf(", checksum manifest available: %s", asset.Name)
} else {
detail += "; no published checksum manifest found - integrity relies on the one-time HTTPS download only"
}
return CheckResult{Status: StatusOK, Detail: detail}, tag
})
if err != nil {
return report, err
}
if _, err := runCheck("upgrade-direction", func() (CheckResult, string) {
curV, errCur := parseSemver(versionOutcome.Extra)
tgtV, errTgt := parseSemver(targetOutcome.Extra)
if errCur != nil || errTgt != nil {
return CheckResult{Status: StatusWarn, Detail: "could not compare current and target versions (one or both unresolved above)"}, ""
}
if curV.Compare(tgtV) >= 0 {
return CheckResult{
Status: StatusFail,
Detail: fmt.Sprintf("current version %s is already at or beyond target %s - nothing to migrate", curV, tgtV),
}, ""
}
if curV.Major == 0 && curV.Minor < 16 && (tgtV.Major > 0 || tgtV.Minor >= 16) {
return CheckResult{
Status: StatusOK,
Detail: fmt.Sprintf("%s -> %s crosses the 0.15/0.16 major boundary: full recovery-mode migration plan required (ARCHITECTURE.md §4.4)", curV, tgtV),
}, ""
}
return CheckResult{
Status: StatusOK,
Detail: fmt.Sprintf("%s -> %s is a same-boundary patch upgrade: fast-path plan applies (ARCHITECTURE.md §4.6)", curV, tgtV),
}, ""
}); err != nil {
return report, err
}
deploymentOutcome, err := runCheck("deployment-kind", func() (CheckResult, string) {
kind := DetectDeploymentKind(ctx, c.opts.ContainerName)
status := StatusOK
if kind == DeploymentUnknown {
status = StatusWarn
}
return CheckResult{Status: status, Detail: fmt.Sprintf("detected deployment kind: %s", kind)}, string(kind)
})
if err != nil {
return report, err
}
storeOutcome, err := runCheck("store-backend", func() (CheckResult, string) {
matches, err := DetectStoreBackends(c.opts.ConfigPath)
if err != nil {
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
}
if len(matches) == 0 {
return CheckResult{Status: StatusWarn, Detail: "no known store backend type found in config - confirm manually before proceeding"}, ""
}
names := make([]string, len(matches))
backends := make([]string, len(matches))
for i, m := range matches {
names[i] = fmt.Sprintf("%s (%s)", m.Backend, m.Path)
backends[i] = m.Backend
}
return CheckResult{Status: StatusOK, Detail: "found: " + strings.Join(names, ", ")}, strings.Join(backends, ",")
})
if err != nil {
return report, err
}
if _, err := runCheck("cluster-config", func() (CheckResult, string) {
clustered, err := LooksClustered(c.opts.ConfigPath)
if err != nil {
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
}
if clustered {
return CheckResult{
Status: StatusWarn,
Detail: "config mentions clustering - confirm every peer node is stopped before this run proceeds; the tool does not verify this for you",
}, ""
}
return CheckResult{Status: StatusOK, Detail: "no cluster configuration detected"}, ""
}); err != nil {
return report, err
}
if _, err := runCheck("disk-space", func() (CheckResult, string) {
size, err := DirSize(c.opts.DataDir)
if err != nil {
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
}
free, err := FreeBytes(c.opts.DataDir)
if err != nil {
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
}
required := uint64(float64(size) * c.opts.MinFreeMultiple)
detail := fmt.Sprintf("data dir %s is %s, %s free, need >= %s (%.1fx, for the fs-snapshot backup)",
c.opts.DataDir, humanBytes(uint64(size)), humanBytes(free), humanBytes(required), c.opts.MinFreeMultiple)
if free < required {
return CheckResult{Status: StatusFail, Detail: detail}, ""
}
return CheckResult{Status: StatusOK, Detail: detail}, ""
}); err != nil {
return report, err
}
if c.opts.AdminURL != "" {
if _, err := runCheck("admin-reachable", func() (CheckResult, string) {
client := &stalwartapi.Client{
BaseURL: c.opts.AdminURL,
Username: c.opts.AdminUser,
Password: c.opts.AdminPassword,
HTTPClient: c.opts.HTTPClient,
}
if err := client.Ping(ctx); err != nil {
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
}
return CheckResult{Status: StatusOK, Detail: fmt.Sprintf("JMAP session reachable at %s with the given credentials", c.opts.AdminURL)}, ""
}); err != nil {
return report, err
}
if _, err := runCheck("account-snapshot", func() (CheckResult, string) {
client := &stalwartapi.Client{
BaseURL: c.opts.AdminURL,
Username: c.opts.AdminUser,
Password: c.opts.AdminPassword,
HTTPClient: c.opts.HTTPClient,
}
snap, err := client.AccountSnapshot(ctx)
if err != nil {
return CheckResult{
Status: StatusWarn,
Detail: fmt.Sprintf("could not capture the account/domain snapshot: %v - the post-migration directory-integrity check won't have anything to compare against", err),
}, ""
}
mailboxCounts := make(map[string][]checkpoint.MailboxCount, len(snap.MailboxCounts))
for account, counts := range snap.MailboxCounts {
converted := make([]checkpoint.MailboxCount, len(counts))
for i, mc := range counts {
converted[i] = checkpoint.MailboxCount{Mailbox: mc.Mailbox, Messages: mc.Messages}
}
mailboxCounts[account] = converted
}
rs.PreflightSnapshot = &checkpoint.PreflightSnapshot{
TakenAt: time.Now().UTC(),
AccountCount: snap.AccountCount,
Domains: snap.Domains,
MailboxCounts: mailboxCounts,
}
detail := fmt.Sprintf("captured snapshot: %d account(s) across %d domain(s), mailbox counts for %d account(s)",
snap.AccountCount, len(snap.Domains), len(mailboxCounts))
status := StatusOK
if len(snap.MailboxErrors) > 0 {
status = StatusWarn
accounts := make([]string, 0, len(snap.MailboxErrors))
for account := range snap.MailboxErrors {
accounts = append(accounts, account)
}
sort.Strings(accounts)
for i, account := range accounts {
if i >= 3 {
detail += fmt.Sprintf(" (and %d more)", len(accounts)-3)
break
}
detail += fmt.Sprintf("; mailbox count failed for %s: %s", account, snap.MailboxErrors[account])
}
}
return CheckResult{Status: status, Detail: detail}, ""
}); err != nil {
return report, err
}
} else {
report.Results = append(report.Results, CheckResult{
Name: "admin-reachable",
Status: StatusWarn,
Detail: "no --admin-url configured - skipped; the account/mailbox snapshot validate needs later can't be captured without it",
})
}
rs.Topology = checkpoint.Topology{
DeploymentKind: deploymentOutcome.Extra,
StoreBackend: storeOutcome.Extra,
}
if versionOutcome.Extra != "" {
rs.SourceVersion = versionOutcome.Extra
}
if targetOutcome.Extra != "" {
rs.TargetVersion = targetOutcome.Extra
}
if err := store.Save(rs); err != nil {
return report, fmt.Errorf("preflight: persist topology: %w", err)
}
return report, nil
}
+304
View File
@@ -0,0 +1,304 @@
package preflight
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
)
// writeFakeBinary creates a shell script that behaves like `stalwart
// --version` and records each invocation to counterPath, so tests can
// assert a checkpointed step was (or wasn't) re-executed on resume.
func writeFakeBinary(t *testing.T, version, counterPath string) string {
t.Helper()
dir := t.TempDir()
scriptPath := filepath.Join(dir, "fake-stalwart.sh")
script := fmt.Sprintf("#!/bin/sh\necho invoked >> %q\necho 'stalwart %s'\n", counterPath, version)
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return scriptPath
}
func countLines(t *testing.T, path string) int {
t.Helper()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return 0
}
if err != nil {
t.Fatal(err)
}
return len(strings.Split(strings.TrimSpace(string(data)), "\n"))
}
func TestCheckerRunEndToEndAndResume(t *testing.T) {
// -- fixtures --------------------------------------------------------
counterPath := filepath.Join(t.TempDir(), "invocations")
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
configPath := filepath.Join(t.TempDir(), "config.toml")
tomlCfg := "[store.\"rocksdb\"]\ntype = \"rocksdb\"\npath = \"/var/lib/stalwart/data\"\n"
if err := os.WriteFile(configPath, []byte(tomlCfg), 0o644); err != nil {
t.Fatal(err)
}
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Release{
TagName: "v0.16.14",
Assets: []ReleaseAsset{{Name: "checksums.txt"}},
})
})
stateDir := t.TempDir()
store := checkpoint.NewStore(stateDir)
rs, err := store.Create("", "latest")
if err != nil {
t.Fatalf("store.Create: %v", err)
}
checker := New(Options{
BinaryPath: binaryPath,
ConfigPath: configPath,
DataDir: dataDir,
TargetVersion: "latest",
})
// -- first run: everything should execute -----------------------------
report, err := checker.Run(context.Background(), store, rs)
if err != nil {
t.Fatalf("Run #1: %v", err)
}
if report.Blocking() {
t.Fatalf("Run #1: unexpected blocking report:\n%s", report.String())
}
if got := countLines(t, counterPath); got != 1 {
t.Fatalf("binary invocations after Run #1 = %d, want 1", got)
}
if rs.SourceVersion != "0.15.5" {
t.Errorf("rs.SourceVersion = %q, want 0.15.5", rs.SourceVersion)
}
if rs.TargetVersion != "0.16.14" {
t.Errorf("rs.TargetVersion = %q, want 0.16.14 (resolved from \"latest\")", rs.TargetVersion)
}
if rs.Topology.StoreBackend != "rocksdb" {
t.Errorf("rs.Topology.StoreBackend = %q, want rocksdb", rs.Topology.StoreBackend)
}
foundDirection := false
for _, res := range report.Results {
if res.Name == "upgrade-direction" {
foundDirection = true
if !strings.Contains(res.Detail, "major boundary") {
t.Errorf("upgrade-direction detail = %q, want mention of the major boundary", res.Detail)
}
}
}
if !foundDirection {
t.Error("report missing upgrade-direction check")
}
// -- simulate a crash and resume: reload state from disk fresh --------
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatalf("store.Load (resume): %v", err)
}
report2, err := checker.Run(context.Background(), store, resumed)
if err != nil {
t.Fatalf("Run #2 (resume): %v", err)
}
if got := countLines(t, counterPath); got != 1 {
t.Errorf("binary invocations after resumed Run = %d, want 1 (already-done steps must not re-execute)", got)
}
if len(report2.Results) != len(report.Results) {
t.Errorf("resumed report has %d results, want %d (same as first run)", len(report2.Results), len(report.Results))
}
}
func TestCheckerRunFlagsTooOldSource(t *testing.T) {
counterPath := filepath.Join(t.TempDir(), "invocations")
binaryPath := writeFakeBinary(t, "0.14.2", counterPath)
configPath := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(configPath, []byte("[server]\nhostname = \"mail.example.com\"\n"), 0o644); err != nil {
t.Fatal(err)
}
dataDir := t.TempDir()
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
})
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("", "latest")
if err != nil {
t.Fatal(err)
}
checker := New(Options{BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir, TargetVersion: "latest"})
report, err := checker.Run(context.Background(), store, rs)
if err != nil {
t.Fatalf("Run: %v", err)
}
if !report.Blocking() {
t.Fatalf("expected a blocking report for a too-old source version, got:\n%s", report.String())
}
}
func TestCheckerRunFlagsInsufficientDiskSpace(t *testing.T) {
counterPath := filepath.Join(t.TempDir(), "invocations")
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
configPath := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(configPath, []byte("[store.\"rocksdb\"]\ntype = \"rocksdb\"\n"), 0o644); err != nil {
t.Fatal(err)
}
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
})
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("", "latest")
if err != nil {
t.Fatal(err)
}
// An absurd multiple guarantees the free-space check fails regardless
// of how much space the test host actually has free.
checker := New(Options{
BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir,
TargetVersion: "latest", MinFreeMultiple: 1e12,
})
report, err := checker.Run(context.Background(), store, rs)
if err != nil {
t.Fatalf("Run: %v", err)
}
if !report.Blocking() {
t.Fatalf("expected a blocking report for insufficient disk space, got:\n%s", report.String())
}
}
func TestCheckerRunCapturesAccountSnapshotWhenAdminURLSet(t *testing.T) {
counterPath := filepath.Join(t.TempDir(), "invocations")
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
configPath := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(configPath, []byte("[store.\"rocksdb\"]\ntype = \"rocksdb\"\n"), 0o644); err != nil {
t.Fatal(err)
}
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
})
// A fake admin server: answers Ping's session-discovery GET, the
// impersonated session-discovery GET MailboxSnapshot makes for
// [email protected], and the POST /api calls for x:Account/query,
// x:Account/get, and Mailbox/get.
var apiURL string
adminSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap":
user, _, _ := r.BasicAuth()
if strings.Contains(user, "%") {
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"primaryAccounts": map[string]string{"urn:ietf:params:jmap:mail": "mail-alice"},
})
return
}
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPost && r.URL.Path == "/api":
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls := body["methodCalls"].([]any)
name := methodCalls[0].([]any)[0].(string)
switch name {
case "x:Account/query":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Account/query", map[string]any{"ids": []string{"a1"}}, "q"},
}})
case "x:Account/get":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Account/get", map[string]any{"list": []map[string]any{
{"id": "a1", "name": "[email protected]", "domainId": "example.com"},
}}, "g"},
}})
case "Mailbox/get":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"Mailbox/get", map[string]any{"list": []map[string]any{
{"name": "Inbox", "totalEmails": 5},
}}, "m"},
}})
}
default:
w.WriteHeader(http.StatusNotFound)
}
}))
apiURL = adminSrv.URL + "/api"
defer adminSrv.Close()
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("", "latest")
if err != nil {
t.Fatal(err)
}
checker := New(Options{
BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir, TargetVersion: "latest",
AdminURL: adminSrv.URL, AdminUser: "admin", AdminPassword: "hunter2",
})
report, err := checker.Run(context.Background(), store, rs)
if err != nil {
t.Fatalf("Run: %v", err)
}
if report.Blocking() {
t.Fatalf("unexpected blocking report:\n%s", report.String())
}
if rs.PreflightSnapshot == nil {
t.Fatal("PreflightSnapshot should be populated when AdminURL is set")
}
if rs.PreflightSnapshot.AccountCount != 1 {
t.Errorf("AccountCount = %d, want 1", rs.PreflightSnapshot.AccountCount)
}
if len(rs.PreflightSnapshot.Domains) != 1 || rs.PreflightSnapshot.Domains[0] != "example.com" {
t.Errorf("Domains = %v, want [example.com]", rs.PreflightSnapshot.Domains)
}
aliceMailboxes := rs.PreflightSnapshot.MailboxCounts["[email protected]"]
if len(aliceMailboxes) != 1 || aliceMailboxes[0].Mailbox != "Inbox" || aliceMailboxes[0].Messages != 5 {
t.Errorf("alice's mailbox counts = %+v, want [{Inbox 5}]", aliceMailboxes)
}
// Resume: the snapshot must survive a reload from disk without
// re-running the check (the admin server would still work, but this
// confirms the persisted value is what's actually being relied on).
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatalf("store.Load: %v", err)
}
if resumed.PreflightSnapshot == nil || resumed.PreflightSnapshot.AccountCount != 1 {
t.Errorf("resumed PreflightSnapshot = %+v, want AccountCount 1", resumed.PreflightSnapshot)
}
}
+22
View File
@@ -0,0 +1,22 @@
package preflight
import (
"fmt"
"os"
"strings"
)
// LooksClustered does a conservative, heuristic scan for cluster-related
// configuration. It exists to force a manual confirmation gate (see
// ARCHITECTURE.md §4.1's cluster gate: one live node on the old version
// during migration corrupts a shared store), not to enumerate peers
// precisely. A false positive just costs an extra confirmation prompt; a
// false negative is the dangerous direction, so this errs toward matching
// broadly rather than requiring an exact schema match.
func LooksClustered(configPath string) (bool, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return false, fmt.Errorf("preflight: read config %s: %w", configPath, err)
}
return strings.Contains(strings.ToLower(string(data)), "cluster"), nil
}
+34
View File
@@ -0,0 +1,34 @@
package preflight
import (
"os"
"path/filepath"
"testing"
)
func TestLooksClustered(t *testing.T) {
cases := []struct {
name string
content string
want bool
}{
{"no-cluster", "[server]\nhostname = \"mail.example.com\"\n", false},
{"has-cluster-section", "[cluster]\nnode-id = 1\npeers = [\"10.0.0.2\"]\n", true},
{"cluster-mentioned-in-key", "[server]\ncluster-coordinator = \"redis://localhost\"\n", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(tc.content), 0o644); err != nil {
t.Fatal(err)
}
got, err := LooksClustered(path)
if err != nil {
t.Fatalf("LooksClustered: %v", err)
}
if got != tc.want {
t.Errorf("LooksClustered(%s) = %v, want %v", tc.name, got, tc.want)
}
})
}
}
+45
View File
@@ -0,0 +1,45 @@
package preflight
import (
"context"
"os"
"os/exec"
)
// DeploymentKind is how a Stalwart instance appears to be run, which
// determines how cutover and rollback restart it.
type DeploymentKind string
const (
DeploymentSystemd DeploymentKind = "systemd"
DeploymentDocker DeploymentKind = "docker"
DeploymentUnknown DeploymentKind = "unknown"
)
var systemdUnitPaths = []string{
"/etc/systemd/system/stalwart.service",
"/lib/systemd/system/stalwart.service",
"/usr/lib/systemd/system/stalwart.service",
}
// DetectDeploymentKind makes a best-effort guess at how Stalwart is run
// here. It's deliberately conservative and cheap (file stats, one docker
// inspect) rather than exhaustive - an operator-supplied override should
// always be able to win over this, since the cost of guessing wrong here is
// cutover targeting the wrong thing.
func DetectDeploymentKind(ctx context.Context, containerName string) DeploymentKind {
for _, p := range systemdUnitPaths {
if _, err := os.Stat(p); err == nil {
return DeploymentSystemd
}
}
if containerName == "" {
containerName = "stalwart"
}
if _, err := exec.LookPath("docker"); err == nil {
if err := exec.CommandContext(ctx, "docker", "inspect", containerName).Run(); err == nil {
return DeploymentDocker
}
}
return DeploymentUnknown
}
+55
View File
@@ -0,0 +1,55 @@
package preflight
import (
"fmt"
"io/fs"
"path/filepath"
"syscall"
)
// DirSize walks dir and sums the size of every regular file in it. Used to
// estimate how much free space a filesystem-level backup copy will need -
// see ARCHITECTURE.md §4.2.
func DirSize(dir string) (int64, error) {
var total int64
err := filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type().IsRegular() {
info, err := d.Info()
if err != nil {
return err
}
total += info.Size()
}
return nil
})
if err != nil {
return 0, fmt.Errorf("preflight: measure size of %s: %w", dir, err)
}
return total, nil
}
// FreeBytes returns the free space available (to an unprivileged process)
// on the filesystem containing path.
func FreeBytes(path string) (uint64, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return 0, fmt.Errorf("preflight: statfs %s: %w", path, err)
}
return stat.Bavail * uint64(stat.Bsize), nil
}
func humanBytes(n uint64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := uint64(unit), 0
for n/div >= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
+55
View File
@@ -0,0 +1,55 @@
package preflight
import (
"os"
"path/filepath"
"testing"
)
func TestDirSize(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.bin"), make([]byte, 100), 0o644); err != nil {
t.Fatal(err)
}
sub := filepath.Join(dir, "sub")
if err := os.Mkdir(sub, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "b.bin"), make([]byte, 250), 0o644); err != nil {
t.Fatal(err)
}
got, err := DirSize(dir)
if err != nil {
t.Fatalf("DirSize: %v", err)
}
if got != 350 {
t.Errorf("DirSize = %d, want 350", got)
}
}
func TestFreeBytes(t *testing.T) {
got, err := FreeBytes(t.TempDir())
if err != nil {
t.Fatalf("FreeBytes: %v", err)
}
if got == 0 {
t.Error("FreeBytes = 0, want > 0 for a live filesystem")
}
}
func TestHumanBytes(t *testing.T) {
cases := []struct {
in uint64
want string
}{
{500, "500 B"},
{1536, "1.5 KiB"},
{5 * 1024 * 1024, "5.0 MiB"},
}
for _, tc := range cases {
if got := humanBytes(tc.in); got != tc.want {
t.Errorf("humanBytes(%d) = %q, want %q", tc.in, got, tc.want)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package preflight implements the read-only preflight checks that gate a run before anything is touched.
// See ARCHITECTURE.md §4.1 for the design.
package preflight
+74
View File
@@ -0,0 +1,74 @@
package preflight
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// githubAPIBase is a var, not a const, so tests can point it at an
// httptest server instead of hitting the real GitHub API.
var githubAPIBase = "https://api.github.com/repos/stalwartlabs/stalwart/releases"
type ReleaseAsset struct {
Name string `json:"name"`
DownloadURL string `json:"browser_download_url"`
SizeBytes int64 `json:"size"`
}
type Release struct {
TagName string `json:"tag_name"`
Assets []ReleaseAsset `json:"assets"`
}
// ResolveRelease looks up a Stalwart release from the public GitHub API.
// version is either an exact tag like "0.16.14" (a "v" prefix is added if
// missing) or "latest".
func ResolveRelease(ctx context.Context, httpClient *http.Client, version string) (*Release, error) {
url := githubAPIBase + "/latest"
if version != "" && version != "latest" {
tag := version
if !strings.HasPrefix(tag, "v") {
tag = "v" + tag
}
url = githubAPIBase + "/tags/" + tag
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("preflight: fetch release %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("preflight: fetch release %s: unexpected status %s", url, resp.Status)
}
var rel Release
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return nil, fmt.Errorf("preflight: parse release response from %s: %w", url, err)
}
return &rel, nil
}
// ChecksumAsset returns the release asset that looks like a published
// checksum manifest, if any. Its presence isn't guaranteed by Stalwart's
// release process, so callers must treat a nil result as "no independently
// published checksum to verify the download against", not an error.
func ChecksumAsset(rel *Release) *ReleaseAsset {
for i := range rel.Assets {
name := strings.ToLower(rel.Assets[i].Name)
if strings.Contains(name, "sha256") || strings.Contains(name, "checksum") {
return &rel.Assets[i]
}
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package preflight
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func withFakeGithub(t *testing.T, handler http.HandlerFunc) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
old := githubAPIBase
githubAPIBase = srv.URL
t.Cleanup(func() { githubAPIBase = old })
}
func TestResolveReleaseLatest(t *testing.T) {
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/latest" {
t.Errorf("path = %s, want /latest", r.URL.Path)
}
json.NewEncoder(w).Encode(Release{
TagName: "v0.16.14",
Assets: []ReleaseAsset{
{Name: "stalwart-x86_64-linux", DownloadURL: "https://example.com/stalwart"},
{Name: "checksums.txt", DownloadURL: "https://example.com/checksums.txt"},
},
})
})
rel, err := ResolveRelease(context.Background(), nil, "latest")
if err != nil {
t.Fatalf("ResolveRelease: %v", err)
}
if rel.TagName != "v0.16.14" {
t.Errorf("TagName = %s, want v0.16.14", rel.TagName)
}
if asset := ChecksumAsset(rel); asset == nil || asset.Name != "checksums.txt" {
t.Errorf("ChecksumAsset = %+v, want checksums.txt", asset)
}
}
func TestResolveReleaseExactTag(t *testing.T) {
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tags/v0.16.5" {
t.Errorf("path = %s, want /tags/v0.16.5", r.URL.Path)
}
json.NewEncoder(w).Encode(Release{TagName: "v0.16.5"})
})
rel, err := ResolveRelease(context.Background(), nil, "0.16.5")
if err != nil {
t.Fatalf("ResolveRelease: %v", err)
}
if rel.TagName != "v0.16.5" {
t.Errorf("TagName = %s, want v0.16.5", rel.TagName)
}
}
func TestChecksumAssetAbsent(t *testing.T) {
rel := &Release{Assets: []ReleaseAsset{{Name: "stalwart-x86_64-linux"}}}
if asset := ChecksumAsset(rel); asset != nil {
t.Errorf("ChecksumAsset = %+v, want nil", asset)
}
}
+46
View File
@@ -0,0 +1,46 @@
package preflight
import (
"fmt"
"strings"
)
// Status is a single check's verdict.
type Status string
const (
StatusOK Status = "ok"
StatusWarn Status = "warn"
StatusFail Status = "fail"
)
// CheckResult is one named check's outcome.
type CheckResult struct {
Name string
Status Status
Detail string
}
// Report is the full set of preflight check outcomes.
type Report struct {
Results []CheckResult
}
// Blocking reports whether any check failed hard enough that a run must
// not proceed.
func (r Report) Blocking() bool {
for _, res := range r.Results {
if res.Status == StatusFail {
return true
}
}
return false
}
func (r Report) String() string {
var b strings.Builder
for _, res := range r.Results {
fmt.Fprintf(&b, "[%-4s] %-20s %s\n", strings.ToUpper(string(res.Status)), res.Name, res.Detail)
}
return b.String()
}
+106
View File
@@ -0,0 +1,106 @@
package preflight
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"os"
"regexp"
"strings"
)
// knownBackends is the set of store/blob/FTS backend identifiers Stalwart's
// documentation names: RocksDB, SQLite, FoundationDB, PostgreSQL, MySQL,
// S3-compatible storage, and Elasticsearch.
var knownBackends = map[string]bool{
"rocksdb": true, "sqlite": true, "foundationdb": true,
"postgresql": true, "mysql": true, "s3": true, "elasticsearch": true,
}
// BackendMatch is one "type = <backend>" assignment found in a config
// file, tagged with where it was found (the enclosing TOML section, or the
// dotted JSON key path).
type BackendMatch struct {
Path string
Backend string
}
// DetectStoreBackends scans a Stalwart config file for store/blob/FTS
// backend declarations. It deliberately does not assume one fixed schema
// path: the exact TOML (pre-0.16) or JSON (0.16+) layout has already
// changed once between those versions and may change again, so this
// searches structurally for `type = "<known backend>"` assignments wherever
// they appear and reports every match with its location. Treat the result
// as "here's what to confirm before an unattended run", not ground truth.
func DetectStoreBackends(configPath string) ([]BackendMatch, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("preflight: read config %s: %w", configPath, err)
}
if json.Valid(data) {
return scanJSONBackends(data)
}
return scanTOMLBackends(data)
}
var (
tomlSectionRe = regexp.MustCompile(`^\[(.+)\]$`)
tomlKVRe = regexp.MustCompile(`^([A-Za-z0-9_.-]+)\s*=\s*"([^"]*)"$`)
)
func scanTOMLBackends(data []byte) ([]BackendMatch, error) {
var matches []BackendMatch
section := ""
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if m := tomlSectionRe.FindStringSubmatch(line); m != nil {
section = m[1]
continue
}
if m := tomlKVRe.FindStringSubmatch(line); m != nil {
key, value := m[1], strings.ToLower(m[2])
if key == "type" && knownBackends[value] {
matches = append(matches, BackendMatch{Path: section, Backend: value})
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("preflight: scan toml config: %w", err)
}
return matches, nil
}
func scanJSONBackends(data []byte) ([]BackendMatch, error) {
var root any
if err := json.Unmarshal(data, &root); err != nil {
return nil, fmt.Errorf("preflight: parse json config: %w", err)
}
var matches []BackendMatch
walkJSONForBackends(root, "", &matches)
return matches, nil
}
func walkJSONForBackends(node any, path string, matches *[]BackendMatch) {
switch v := node.(type) {
case map[string]any:
if t, ok := v["type"].(string); ok && knownBackends[strings.ToLower(t)] {
*matches = append(*matches, BackendMatch{Path: path, Backend: strings.ToLower(t)})
}
for k, child := range v {
childPath := k
if path != "" {
childPath = path + "." + k
}
walkJSONForBackends(child, childPath, matches)
}
case []any:
for i, child := range v {
walkJSONForBackends(child, fmt.Sprintf("%s[%d]", path, i), matches)
}
}
}
+86
View File
@@ -0,0 +1,86 @@
package preflight
import (
"os"
"path/filepath"
"testing"
)
func TestDetectStoreBackendsTOML(t *testing.T) {
toml := `
[server]
hostname = "mail.example.com"
[store."rocksdb"]
type = "rocksdb"
path = "/var/lib/stalwart/data"
[store."blob"]
type = "s3"
bucket = "stalwart-blobs"
`
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(toml), 0o644); err != nil {
t.Fatal(err)
}
matches, err := DetectStoreBackends(path)
if err != nil {
t.Fatalf("DetectStoreBackends: %v", err)
}
if len(matches) != 2 {
t.Fatalf("got %d matches, want 2: %+v", len(matches), matches)
}
got := map[string]string{}
for _, m := range matches {
got[m.Path] = m.Backend
}
if got[`store."rocksdb"`] != "rocksdb" {
t.Errorf(`store."rocksdb" backend = %q, want rocksdb`, got[`store."rocksdb"`])
}
if got[`store."blob"`] != "s3" {
t.Errorf(`store."blob" backend = %q, want s3`, got[`store."blob"`])
}
}
func TestDetectStoreBackendsJSON(t *testing.T) {
jsonCfg := `{
"store": {
"data": {"type": "postgresql", "host": "db.internal"},
"blob": {"type": "s3", "bucket": "stalwart-blobs"}
}
}`
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(jsonCfg), 0o644); err != nil {
t.Fatal(err)
}
matches, err := DetectStoreBackends(path)
if err != nil {
t.Fatalf("DetectStoreBackends: %v", err)
}
if len(matches) != 2 {
t.Fatalf("got %d matches, want 2: %+v", len(matches), matches)
}
backends := map[string]bool{}
for _, m := range matches {
backends[m.Backend] = true
}
if !backends["postgresql"] || !backends["s3"] {
t.Errorf("matches = %+v, want postgresql and s3", matches)
}
}
func TestDetectStoreBackendsNoMatch(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte("[server]\nhostname = \"mail.example.com\"\n"), 0o644); err != nil {
t.Fatal(err)
}
matches, err := DetectStoreBackends(path)
if err != nil {
t.Fatalf("DetectStoreBackends: %v", err)
}
if len(matches) != 0 {
t.Errorf("got %d matches, want 0: %+v", len(matches), matches)
}
}
+78
View File
@@ -0,0 +1,78 @@
package preflight
import (
"bytes"
"context"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
)
// semver is a minimal major.minor.patch version - enough for this tool's
// comparisons since Stalwart's pre-1.0 release tags don't use pre-release
// or build metadata.
type semver struct {
Major, Minor, Patch int
}
var versionPattern = regexp.MustCompile(`v?(\d+)\.(\d+)\.(\d+)`)
func parseSemver(s string) (semver, error) {
m := versionPattern.FindStringSubmatch(s)
if m == nil {
return semver{}, fmt.Errorf("preflight: no version number found in %q", s)
}
major, _ := strconv.Atoi(m[1])
minor, _ := strconv.Atoi(m[2])
patch, _ := strconv.Atoi(m[3])
return semver{major, minor, patch}, nil
}
func (v semver) String() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) }
// Compare returns -1, 0, or 1 as v is less than, equal to, or greater than o.
func (v semver) Compare(o semver) int {
if v.Major != o.Major {
return cmp(v.Major, o.Major)
}
if v.Minor != o.Minor {
return cmp(v.Minor, o.Minor)
}
return cmp(v.Patch, o.Patch)
}
func cmp(a, b int) int {
switch {
case a < b:
return -1
case a > b:
return 1
default:
return 0
}
}
// minSupportedSource is the oldest version this tool will start a migration
// from. Stalwart's own upgrade guidance says older installs need to reach
// 0.15.x first before crossing the 0.15/0.16 schema boundary this tool
// automates - see ARCHITECTURE.md §1/§4.1.
var minSupportedSource = semver{0, 15, 0}
// DetectVersion runs the installed binary's --version flag and extracts a
// semver from its output.
func DetectVersion(ctx context.Context, binaryPath string) (string, error) {
cmd := exec.CommandContext(ctx, binaryPath, "--version")
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("preflight: run %s --version: %w (output: %s)", binaryPath, err, strings.TrimSpace(out.String()))
}
v, err := parseSemver(out.String())
if err != nil {
return "", fmt.Errorf("preflight: parse version from %s --version output %q: %w", binaryPath, strings.TrimSpace(out.String()), err)
}
return v.String(), nil
}
+58
View File
@@ -0,0 +1,58 @@
package preflight
import "testing"
func TestParseSemver(t *testing.T) {
cases := []struct {
in string
want string
wantErr bool
}{
{"stalwart 0.15.5\n", "0.15.5", false},
{"v0.16.14", "0.16.14", false},
{"Stalwart Mail Server v0.16.0 (build abc123)", "0.16.0", false},
{"no version here", "", true},
{"", "", true},
}
for _, tc := range cases {
got, err := parseSemver(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("parseSemver(%q) = %v, want error", tc.in, got)
}
continue
}
if err != nil {
t.Errorf("parseSemver(%q) unexpected error: %v", tc.in, err)
continue
}
if got.String() != tc.want {
t.Errorf("parseSemver(%q) = %s, want %s", tc.in, got, tc.want)
}
}
}
func TestSemverCompare(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"0.15.5", "0.16.0", -1},
{"0.16.14", "0.16.14", 0},
{"0.16.1", "0.15.5", 1},
{"1.0.0", "0.16.14", 1},
}
for _, tc := range cases {
a, err := parseSemver(tc.a)
if err != nil {
t.Fatalf("parseSemver(%q): %v", tc.a, err)
}
b, err := parseSemver(tc.b)
if err != nil {
t.Fatalf("parseSemver(%q): %v", tc.b, err)
}
if got := a.Compare(b); got != tc.want {
t.Errorf("%s.Compare(%s) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
+54
View File
@@ -0,0 +1,54 @@
package recovery
import (
"context"
"fmt"
"os"
"os/exec"
)
// ApplyOptions configures stalwart-cli apply invocations against a
// recovery-mode instance - see UPGRADING/v0_16.md's documented
// STALWART_URL/STALWART_USER/STALWART_PASSWORD + `stalwart-cli apply --file`
// sequence.
type ApplyOptions struct {
CLIBinaryPath string // defaults to "stalwart-cli"
URL string
User string
Password string
}
// Apply runs `stalwart-cli apply --file <file>` once, with credentials
// passed as environment variables exactly as the upgrade guide's own
// example does, rather than on the command line where they'd be visible to
// anything reading this process's argv.
func Apply(ctx context.Context, o ApplyOptions, file string) error {
binary := o.CLIBinaryPath
if binary == "" {
binary = "stalwart-cli"
}
cmd := exec.CommandContext(ctx, binary, "apply", "--file", file)
cmd.Env = append(os.Environ(),
"STALWART_URL="+o.URL,
"STALWART_USER="+o.User,
"STALWART_PASSWORD="+o.Password,
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("recovery: stalwart-cli apply --file %s failed: %w (output: %s)", file, err, out)
}
return nil
}
// ApplyAll runs Apply for each file in order - export.json first, then any
// additional test-deployment snapshots (ARCHITECTURE.md §4.3/§4.4) -
// stopping at the first failure so a partial, silently-incomplete settings
// replay never gets reported as success.
func ApplyAll(ctx context.Context, o ApplyOptions, files []string) error {
for i, f := range files {
if err := Apply(ctx, o, f); err != nil {
return fmt.Errorf("applied %d/%d file(s) before failing: %w", i, len(files), err)
}
}
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package recovery
import (
"context"
"fmt"
"path/filepath"
"strings"
"testing"
)
func TestApplyInvokesCLIWithEnvCredentials(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
withFakeExecutable(t, "stalwart-cli", fmt.Sprintf("#!/bin/sh\necho \"$@ URL=$STALWART_URL USER=$STALWART_USER\" >> %q\nexit 0\n", log))
err := Apply(context.Background(), ApplyOptions{URL: "http://127.0.0.1:8080", User: "admin", Password: "secret"}, "/tmp/export.json")
if err != nil {
t.Fatalf("Apply: %v", err)
}
got := readArgsFile(t, log)
for _, want := range []string{"apply", "--file /tmp/export.json", "URL=http://127.0.0.1:8080", "USER=admin"} {
if !strings.Contains(got, want) {
t.Errorf("stalwart-cli invoked with %q, missing %q", got, want)
}
}
if strings.Contains(got, "secret") {
t.Error("password should not appear in argv")
}
}
func TestApplyPropagatesFailure(t *testing.T) {
withFakeExecutable(t, "stalwart-cli", "#!/bin/sh\necho 'invalid object' >&2\nexit 1\n")
err := Apply(context.Background(), ApplyOptions{URL: "http://127.0.0.1:8080", User: "admin", Password: "x"}, "/tmp/export.json")
if err == nil {
t.Fatal("Apply should error when stalwart-cli exits non-zero")
}
if !strings.Contains(err.Error(), "invalid object") {
t.Errorf("error = %v, want it to include stderr", err)
}
}
func TestApplyAllStopsAtFirstFailure(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
script := fmt.Sprintf("#!/bin/sh\necho \"$@\" >> %q\ncase \"$*\" in\n *bad.json*) exit 1 ;;\n *) exit 0 ;;\nesac\n", log)
withFakeExecutable(t, "stalwart-cli", script)
err := ApplyAll(context.Background(), ApplyOptions{URL: "http://127.0.0.1:8080", User: "admin", Password: "x"},
[]string{filepath.Join(dir, "good1.json"), filepath.Join(dir, "bad.json"), filepath.Join(dir, "good2.json")})
if err == nil {
t.Fatal("ApplyAll should fail on bad.json")
}
got := readArgsFile(t, log)
if strings.Contains(got, "good2.json") {
t.Error("ApplyAll should stop before applying good2.json after bad.json failed")
}
if !strings.Contains(err.Error(), "1/3") {
t.Errorf("error = %v, want it to report 1/3 files applied before failing", err)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package recovery implements supervision of the Stalwart recovery-mode process and the settings apply step.
// See ARCHITECTURE.md §4.4 for the design.
package recovery
+39
View File
@@ -0,0 +1,39 @@
package recovery
import (
"os"
"path/filepath"
"testing"
)
// withFakeExecutable puts a fake executable named `name` at the front of
// PATH for the duration of the test, so code that shells out to a
// real-world tool (stalwart-cli) can be exercised without that tool
// actually being installed. t.Setenv restores PATH automatically.
func withFakeExecutable(t *testing.T, name, script string) (dir string) {
t.Helper()
dir = t.TempDir()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return dir
}
func argsFile(t *testing.T, dir string) string {
t.Helper()
return filepath.Join(dir, "invoked-args.log")
}
func readArgsFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ""
}
if err != nil {
t.Fatal(err)
}
return string(data)
}
+44
View File
@@ -0,0 +1,44 @@
package recovery
import (
"context"
"fmt"
"net/http"
"time"
)
// WaitForHealthy polls url until it responds (any status - even 401 proves
// the HTTP server itself is up and routing requests, which is what this
// check exists to confirm) or timeout elapses, returning a descriptive
// error on timeout rather than hanging indefinitely. This is what makes
// recovery mode's startup supervised rather than fire-and-forget - see
// ARCHITECTURE.md §4.4.
func WaitForHealthy(ctx context.Context, httpClient *http.Client, url string, timeout time.Duration) error {
if httpClient == nil {
httpClient = &http.Client{Timeout: 5 * time.Second}
}
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
if err := ctx.Err(); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
lastErr = err
} else {
resp.Body.Close()
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(250 * time.Millisecond):
}
}
return fmt.Errorf("recovery: %s did not become reachable within %s: %w", url, timeout, lastErr)
}
+51
View File
@@ -0,0 +1,51 @@
package recovery
import (
"fmt"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"testing"
)
// TestMain lets this test binary also act as a fake Stalwart binary for
// subprocess tests - the standard os/exec "helper process" technique (see
// Go's own os/exec_test.go). When STALWART_MIGRATOR_TEST_HELPER=1 is set,
// the binary runs a minimal HTTP server on STALWART_MIGRATOR_TEST_PORT
// until it receives SIGTERM (or, if STALWART_MIGRATOR_TEST_IGNORE_SIGTERM=1,
// ignores SIGTERM to exercise the SIGKILL escalation path) instead of
// running the actual test suite. This avoids needing a real Stalwart binary
// - or a fabricated stand-in for its HTTP behavior - anywhere in these
// tests: it's real net/http and real process signaling, just running under
// this package's own compiled binary.
func TestMain(m *testing.M) {
if os.Getenv("STALWART_MIGRATOR_TEST_HELPER") == "1" {
runFakeStalwartServer()
return
}
os.Exit(m.Run())
}
func runFakeStalwartServer() {
port := os.Getenv("STALWART_MIGRATOR_TEST_PORT")
ln, err := net.Listen("tcp", "127.0.0.1:"+port)
if err != nil {
fmt.Fprintln(os.Stderr, "fake stalwart: listen:", err)
os.Exit(1)
}
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})}
go srv.Serve(ln)
sigCh := make(chan os.Signal, 1)
if os.Getenv("STALWART_MIGRATOR_TEST_IGNORE_SIGTERM") == "1" {
signal.Ignore(syscall.SIGTERM)
select {} // block forever; the test must SIGKILL this process itself
}
signal.Notify(sigCh, syscall.SIGTERM)
<-sigCh
os.Exit(0)
}
+96
View File
@@ -0,0 +1,96 @@
package recovery
import (
"context"
"fmt"
"os"
"os/exec"
"syscall"
"time"
)
// ProcessOptions configures how the target binary is launched.
type ProcessOptions struct {
BinaryPath string
ConfigPath string
// RecoveryMode, when true, sets STALWART_RECOVERY_MODE=1 and
// STALWART_RECOVERY_ADMIN=<AdminUser>:<AdminPassword> - exactly the
// environment variables Stalwart's own upgrade guide uses to bring the
// new binary up in recovery mode against a not-yet-migrated store (see
// UPGRADING/v0_16.md). When false, the binary is started as a normal
// boot against ConfigPath - used after recovery mode to confirm the
// migrated store comes up cleanly under an ordinary start, not just a
// recovery one (see ARCHITECTURE.md's dry-run design).
RecoveryMode bool
AdminUser string
AdminPassword string
// ExtraEnv is appended after any recovery-mode env vars. This is what
// lets a dry-run point the process at a sandbox without RecoveryMode's
// two env vars becoming the only way to parameterize the child process.
ExtraEnv []string
}
// Process supervises one run of the target Stalwart binary as a background
// child process, so a caller can start it, wait for it to become healthy,
// interact with it, and stop it again - without ever touching a real
// systemd unit or Docker container. See ARCHITECTURE.md §4.4.
type Process struct {
cmd *exec.Cmd
}
// Start launches the binary. It returns as soon as the OS has started the
// process - it does not wait for Stalwart itself to become ready; use
// WaitForHealthy for that.
func (p *Process) Start(ctx context.Context, o ProcessOptions) error {
var env []string
if o.RecoveryMode {
env = append(env,
"STALWART_RECOVERY_MODE=1",
fmt.Sprintf("STALWART_RECOVERY_ADMIN=%s:%s", o.AdminUser, o.AdminPassword),
)
}
env = append(env, o.ExtraEnv...)
cmd := exec.CommandContext(ctx, o.BinaryPath, "--config", o.ConfigPath)
cmd.Env = append(os.Environ(), env...)
if err := cmd.Start(); err != nil {
return fmt.Errorf("recovery: start %s: %w", o.BinaryPath, err)
}
p.cmd = cmd
return nil
}
// Stop sends SIGTERM and waits up to gracePeriod for the process to exit -
// mirroring the upgrade guide's own "Ctrl+C in the first terminal" step,
// just automated - escalating to SIGKILL if it doesn't exit in time so a
// stuck child process can never hang a migration run indefinitely. Safe to
// call on a Process that was never successfully started.
func (p *Process) Stop(gracePeriod time.Duration) error {
if p.cmd == nil || p.cmd.Process == nil {
return nil
}
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
return fmt.Errorf("recovery: signal process (pid %d): %w", p.cmd.Process.Pid, err)
}
done := make(chan error, 1)
go func() { done <- p.cmd.Wait() }()
select {
case err := <-done:
// A non-zero exit from a SIGTERM-based shutdown is expected and not
// itself a failure worth reporting - only an unexpected Wait error is.
if err != nil {
if _, ok := err.(*exec.ExitError); !ok {
return fmt.Errorf("recovery: wait for process (pid %d): %w", p.cmd.Process.Pid, err)
}
}
return nil
case <-time.After(gracePeriod):
_ = p.cmd.Process.Kill()
<-done
return fmt.Errorf("recovery: process (pid %d) did not exit within %s of SIGTERM - sent SIGKILL", p.cmd.Process.Pid, gracePeriod)
}
}
+104
View File
@@ -0,0 +1,104 @@
package recovery
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"testing"
"time"
)
// freePort asks the OS for an unused TCP port by binding to :0 and
// immediately releasing it. There's a small window where something else
// could grab it before the fake server binds, but that's an acceptable,
// standard tradeoff for tests.
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
func helperProcessEnv(port int, extra ...string) []string {
env := []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
}
return append(env, extra...)
}
func testBinaryPath(t *testing.T) string {
t.Helper()
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return self
}
func TestProcessStartWaitHealthyStop(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
proc := &Process{}
err := proc.Start(context.Background(), ProcessOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ExtraEnv: helperProcessEnv(port),
})
if err != nil {
t.Fatalf("Start: %v", err)
}
url := fmt.Sprintf("http://127.0.0.1:%d/", port)
if err := WaitForHealthy(context.Background(), nil, url, 5*time.Second); err != nil {
t.Fatalf("WaitForHealthy: %v", err)
}
if err := proc.Stop(5 * time.Second); err != nil {
t.Fatalf("Stop: %v", err)
}
}
func TestProcessStopEscalatesToSIGKILL(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
proc := &Process{}
err := proc.Start(context.Background(), ProcessOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ExtraEnv: helperProcessEnv(port, "STALWART_MIGRATOR_TEST_IGNORE_SIGTERM=1"),
})
if err != nil {
t.Fatalf("Start: %v", err)
}
url := fmt.Sprintf("http://127.0.0.1:%d/", port)
if err := WaitForHealthy(context.Background(), nil, url, 5*time.Second); err != nil {
t.Fatalf("WaitForHealthy: %v", err)
}
err = proc.Stop(500 * time.Millisecond)
if err == nil {
t.Fatal("Stop should report an error when it had to escalate to SIGKILL")
}
}
func TestWaitForHealthyTimesOut(t *testing.T) {
// Nothing listens on this port.
port := freePort(t)
url := fmt.Sprintf("http://127.0.0.1:%d/", port)
err := WaitForHealthy(context.Background(), nil, url, 500*time.Millisecond)
if err == nil {
t.Fatal("WaitForHealthy should time out when nothing is listening")
}
}
+107
View File
@@ -0,0 +1,107 @@
package recovery
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
)
// Options configures one full recovery-mode migration cycle: starting the
// target binary in recovery mode, waiting for it to come up, applying the
// settings snapshot(s), and stopping it again. See ARCHITECTURE.md §4.4.
type Options struct {
BinaryPath string
ConfigPath string
ListenURL string // recovery mode's own HTTP listener, e.g. "http://127.0.0.1:8080"
AdminUser string
ApplyFiles []string
CLIBinaryPath string
ExtraEnv []string // lets a dry-run point ports/paths at a sandbox without touching production config
StartupTimeout time.Duration
StopGrace time.Duration
HTTPClient *http.Client
}
// GenerateRecoveryPassword returns a fresh random one-time password for
// STALWART_RECOVERY_ADMIN - never the operator's real admin password, never
// logged, never reused across runs or persisted to the checkpoint.
func GenerateRecoveryPassword() (string, error) {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("recovery: generate password: %w", err)
}
return hex.EncodeToString(b), nil
}
// Run executes one recovery-mode cycle as a single checkpointed step. It is
// deliberately not decomposed into per-sub-step checkpoints the way
// preflight and backup are: if this tool's own process crashes mid-cycle,
// the child Stalwart process it started may or may not still be running
// independently, and blindly "resuming" by reattaching to a guessed PID or
// killing an unrelated process on the recovery port would be more dangerous
// than just retrying cleanly. A retry that hits "address already in use"
// surfaces the real problem (an orphaned process from the failed attempt)
// for a human to clear, rather than this tool guessing at cleanup.
//
// Whatever happens after Start succeeds, Stop is always attempted on the
// way out (via a deferred call), so a failure partway through this cycle
// doesn't leak the child process within a single invocation.
func Run(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState, opts Options) (Report, error) {
var report Report
outcome, err := store.RunStep(rs, checkpoint.PhaseRecovery, "recovery-cycle", func() (out checkpoint.StepOutcome, err error) {
password, err := GenerateRecoveryPassword()
if err != nil {
return checkpoint.StepOutcome{}, err
}
proc := &Process{}
if startErr := proc.Start(ctx, ProcessOptions{
BinaryPath: opts.BinaryPath, ConfigPath: opts.ConfigPath,
RecoveryMode: true, AdminUser: opts.AdminUser, AdminPassword: password,
ExtraEnv: opts.ExtraEnv,
}); startErr != nil {
return checkpoint.StepOutcome{}, startErr
}
stopGrace := opts.StopGrace
if stopGrace <= 0 {
stopGrace = 10 * time.Second
}
defer func() {
if stopErr := proc.Stop(stopGrace); stopErr != nil && err == nil {
err = stopErr
}
}()
startupTimeout := opts.StartupTimeout
if startupTimeout <= 0 {
startupTimeout = 60 * time.Second
}
if healthErr := WaitForHealthy(ctx, opts.HTTPClient, opts.ListenURL, startupTimeout); healthErr != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("recovery mode did not come up: %w", healthErr)
}
if applyErr := ApplyAll(ctx, ApplyOptions{
CLIBinaryPath: opts.CLIBinaryPath, URL: opts.ListenURL, User: opts.AdminUser, Password: password,
}, opts.ApplyFiles); applyErr != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("settings apply failed: %w", applyErr)
}
return checkpoint.StepOutcome{
Detail: fmt.Sprintf("recovery mode came up at %s, applied %d settings file(s), stopped cleanly", opts.ListenURL, len(opts.ApplyFiles)),
}, nil
})
if err != nil {
report.Results = append(report.Results, CheckResult{Name: "recovery-cycle", Status: StatusFail, Detail: err.Error()})
return report, err
}
report.Results = append(report.Results, CheckResult{Name: "recovery-cycle", Status: StatusOK, Detail: outcome.Detail})
return report, nil
}
+113
View File
@@ -0,0 +1,113 @@
package recovery
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
)
func TestRecoveryRunEndToEndAndResume(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
applyDir := t.TempDir()
applyLog := argsFile(t, applyDir)
withFakeExecutable(t, "stalwart-cli", fmt.Sprintf("#!/bin/sh\necho \"$@\" >> %q\nexit 0\n", applyLog))
exportFile := filepath.Join(t.TempDir(), "export.json")
os.WriteFile(exportFile, []byte("{}"), 0o644)
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("store.Create: %v", err)
}
opts := Options{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
AdminUser: "admin",
ApplyFiles: []string{exportFile},
ExtraEnv: helperProcessEnv(port),
StartupTimeout: 5 * time.Second,
StopGrace: 5 * time.Second,
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run #1: %v", err)
}
if len(report.Results) != 1 || report.Results[0].Status != StatusOK {
t.Fatalf("Run #1 report = %+v, want a single OK result", report.Results)
}
if !rs.Done(checkpoint.PhaseRecovery, "recovery-cycle") {
t.Fatal("recovery-cycle should be marked done after a successful run")
}
if got := readArgsFile(t, applyLog); got == "" {
t.Fatal("stalwart-cli apply was never invoked")
}
// -- resume: break the CLI so a re-invocation would fail loudly, and use
// -- a port nothing listens on, so re-starting the process would time out.
withFakeExecutable(t, "stalwart-cli", "#!/bin/sh\necho should-not-run-again >&2\nexit 1\n")
badPort := freePort(t)
resumedOpts := opts
resumedOpts.ListenURL = fmt.Sprintf("http://127.0.0.1:%d/", badPort)
resumedOpts.ExtraEnv = helperProcessEnv(badPort)
resumedOpts.StartupTimeout = 300 * time.Millisecond
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatalf("store.Load (resume): %v", err)
}
report2, err := Run(context.Background(), store, resumed, resumedOpts)
if err != nil {
t.Fatalf("Run #2 (resume) should succeed without redoing the cycle: %v", err)
}
if len(report2.Results) != 1 || report2.Results[0].Detail != report.Results[0].Detail {
t.Errorf("resumed report = %+v, want the cached outcome from Run #1", report2.Results)
}
}
func TestRecoveryRunFailsWhenProcessNeverBecomesHealthy(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
// Nothing listens on this port - the binary never actually starts an
// HTTP server, since BinaryPath here is a script that just exits.
binDir := t.TempDir()
binPath := filepath.Join(binDir, "stalwart")
os.WriteFile(binPath, []byte("#!/bin/sh\nsleep 5\n"), 0o755)
port := freePort(t)
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatal(err)
}
opts := Options{
BinaryPath: binPath,
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
AdminUser: "admin",
ApplyFiles: []string{},
StartupTimeout: 300 * time.Millisecond,
StopGrace: 2 * time.Second,
}
_, err = Run(context.Background(), store, rs, opts)
if err == nil {
t.Fatal("Run should fail when the process never becomes healthy")
}
if rs.Status(checkpoint.PhaseRecovery, "recovery-cycle") != checkpoint.StepFailed {
t.Errorf("step status = %s, want failed (so a retry is possible)", rs.Status(checkpoint.PhaseRecovery, "recovery-cycle"))
}
}
+31
View File
@@ -0,0 +1,31 @@
package recovery
import (
"fmt"
"strings"
)
type Status string
const (
StatusOK Status = "ok"
StatusFail Status = "fail"
)
type CheckResult struct {
Name string
Status Status
Detail string
}
type Report struct {
Results []CheckResult
}
func (r Report) String() string {
var b strings.Builder
for _, res := range r.Results {
fmt.Fprintf(&b, "[%-4s] %-16s %s\n", strings.ToUpper(string(res.Status)), res.Name, res.Detail)
}
return b.String()
}
+3
View File
@@ -0,0 +1,3 @@
// Package rollback implements restoring the pre-migration backup and old binary on failure.
// See ARCHITECTURE.md §4.8 for the design.
package rollback
+73
View File
@@ -0,0 +1,73 @@
package stalwartapi
import (
"context"
"fmt"
"net/http"
"strings"
"time"
)
// Client is the shared JMAP/management-API client every migration phase
// uses to talk to a Stalwart instance. It stays deliberately thin: phases
// that need Stalwart-specific behavior (recovery-mode control, apply-plan
// replay, account/mailbox introspection) get methods added here only as
// their wire-level details are confirmed against Stalwart's actual source
// and documentation, never guessed - see management.go and mailbox.go for
// what that grounding looked like for account enumeration and mailbox
// counts respectively.
type Client struct {
BaseURL string // e.g. "https://mail.example.com"
Username string
Password string
HTTPClient *http.Client
}
func (c *Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return &http.Client{Timeout: 15 * time.Second}
}
// Ping confirms the instance is reachable and the given credentials are
// accepted, via JMAP session discovery (RFC 8620 §2, the well-known
// /.well-known/jmap endpoint) over HTTP Basic auth. This is preflight's
// "dry-run" reachability check (ARCHITECTURE.md §4.1): it does nothing but
// read the session document, so it's safe to run against a live production
// server before anything else in the migration happens.
func (c *Client) Ping(ctx context.Context) error {
url := strings.TrimRight(c.BaseURL, "/") + "/.well-known/jmap"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.SetBasicAuth(c.Username, c.Password)
resp, err := c.httpClient().Do(req)
if err != nil {
return fmt.Errorf("stalwartapi: reach %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("stalwartapi: session request to %s returned %s", url, resp.Status)
}
return nil
}
// Snapshot mirrors checkpoint.PreflightSnapshot's shape without this
// package depending on the checkpoint package's format.
type Snapshot struct {
AccountCount int
Domains []string
MailboxCounts map[string][]MailboxCount // account email -> its mailboxes
// MailboxErrors records, per account email, why that account's mailbox
// counts couldn't be captured (e.g. impersonation not permitted for
// that account). A non-empty entry here means MailboxCounts has no
// entry for that account - it's not silently treated as zero messages.
MailboxErrors map[string]string
}
type MailboxCount struct {
Mailbox string
Messages int
}
+3
View File
@@ -0,0 +1,3 @@
// Package stalwartapi implements the JMAP and management-API client shared by every other phase.
// See ARCHITECTURE.md §7 for the design.
package stalwartapi
+112
View File
@@ -0,0 +1,112 @@
package stalwartapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// jmapSession is the subset of RFC 8620 §2's Session object this package
// needs: apiUrl (where to POST standard JMAP method calls - a different
// endpoint from Stalwart's /api management API, confirmed in
// docs/ref/object/account.md) and primaryAccounts (which accountId the
// urn:ietf:params:jmap:mail capability maps to for the authenticated
// identity).
type jmapSession struct {
APIURL string `json:"apiUrl"`
PrimaryAccounts map[string]string `json:"primaryAccounts"`
}
const jmapMailCapability = "urn:ietf:params:jmap:mail"
// fetchSession performs JMAP session discovery (RFC 8620 §2,
// /.well-known/jmap) with the given credentials.
func (c *Client) fetchSession(ctx context.Context, username, password string) (*jmapSession, error) {
url := strings.TrimRight(c.BaseURL, "/") + "/.well-known/jmap"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(username, password)
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("session discovery at %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("session discovery at %s returned %s", url, resp.Status)
}
var session jmapSession
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
return nil, fmt.Errorf("parse session document from %s: %w", url, err)
}
if session.APIURL == "" {
return nil, fmt.Errorf("session document from %s has no apiUrl", url)
}
return &session, nil
}
type mailboxGetEntry struct {
Name string `json:"name"`
TotalEmails int `json:"totalEmails"`
}
// MailboxSnapshot captures every mailbox's message count for one account,
// authenticating as that account via Stalwart's documented impersonation
// mechanism rather than assuming this Client's own credentials get direct
// cross-account access. That assumption would be wrong: Stalwart's JMAP
// session `accounts`/`primaryAccounts` map is built only from the
// authenticated identity's own membership and sharing grants - it is NOT
// expanded for a superuser (confirmed against
// crates/jmap/src/api/session.rs), so a plain Mailbox/get call for an
// arbitrary accountId under this Client's own login would be rejected.
//
// Instead, this Client's Username must hold Stalwart's `impersonate`
// permission (see docs/auth/authorization/administrator.md), and this
// method logs in AS the target account using the documented composite
// login format "<target>%<impersonator>" with the impersonator's password,
// then calls standard RFC 8621 Mailbox/get - reading the exact wire
// property name `totalEmails` - against the URL that account's own JMAP
// session document reports as apiUrl (not the /api management endpoint
// x:Account/* uses; confirmed as a distinct endpoint in
// docs/ref/object/account.md).
func (c *Client) MailboxSnapshot(ctx context.Context, targetEmail string) ([]MailboxCount, error) {
impersonatedUser := fmt.Sprintf("%s%%%s", targetEmail, c.Username)
session, err := c.fetchSession(ctx, impersonatedUser, c.Password)
if err != nil {
return nil, fmt.Errorf("stalwartapi: impersonate %s: %w", targetEmail, err)
}
accountID, ok := session.PrimaryAccounts[jmapMailCapability]
if !ok || accountID == "" {
return nil, fmt.Errorf("stalwartapi: impersonated session for %s has no %s account", targetEmail, jmapMailCapability)
}
responses, err := c.callAs(ctx, impersonatedUser, c.Password, session.APIURL,
[]string{"urn:ietf:params:jmap:core", jmapMailCapability},
[]any{[]any{"Mailbox/get", map[string]any{"accountId": accountID, "properties": []string{"name", "totalEmails"}}, "m"}},
)
if err != nil {
return nil, fmt.Errorf("stalwartapi: Mailbox/get for %s: %w", targetEmail, err)
}
if len(responses) == 0 {
return nil, fmt.Errorf("stalwartapi: Mailbox/get for %s returned no method responses", targetEmail)
}
if responses[0].Name == "error" {
return nil, fmt.Errorf("stalwartapi: Mailbox/get for %s error: %s", targetEmail, responses[0].Args)
}
var result struct {
List []mailboxGetEntry `json:"list"`
}
if err := json.Unmarshal(responses[0].Args, &result); err != nil {
return nil, fmt.Errorf("stalwartapi: parse Mailbox/get response for %s: %w", targetEmail, err)
}
counts := make([]MailboxCount, len(result.List))
for i, m := range result.List {
counts[i] = MailboxCount{Mailbox: m.Name, Messages: m.TotalEmails}
}
return counts, nil
}
+112
View File
@@ -0,0 +1,112 @@
package stalwartapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestMailboxSnapshotImpersonatesAndFetchesCounts(t *testing.T) {
var apiURL string
var sessionAuthUser, sessionAuthPass string
var gotAccountID string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap":
sessionAuthUser, sessionAuthPass, _ = r.BasicAuth()
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"primaryAccounts": map[string]string{jmapMailCapability: "mail-acct-1"},
})
case r.Method == http.MethodPost && r.URL.Path == "/jmap-api":
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls := body["methodCalls"].([]any)
args := methodCalls[0].([]any)[1].(map[string]any)
gotAccountID, _ = args["accountId"].(string)
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"Mailbox/get", map[string]any{"list": []map[string]any{
{"name": "Inbox", "totalEmails": 42},
{"name": "Sent", "totalEmails": 7},
}}, "m"},
}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
apiURL = srv.URL + "/jmap-api"
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
counts, err := client.MailboxSnapshot(context.Background(), "[email protected]")
if err != nil {
t.Fatalf("MailboxSnapshot: %v", err)
}
if sessionAuthUser != "[email protected]%admin" || sessionAuthPass != "hunter2" {
t.Errorf("session discovery auth = (%s, %s), want ([email protected]%%admin, hunter2)", sessionAuthUser, sessionAuthPass)
}
if gotAccountID != "mail-acct-1" {
t.Errorf("Mailbox/get accountId = %s, want mail-acct-1", gotAccountID)
}
if len(counts) != 2 || counts[0].Mailbox != "Inbox" || counts[0].Messages != 42 || counts[1].Mailbox != "Sent" || counts[1].Messages != 7 {
t.Errorf("counts = %+v, want [{Inbox 42} {Sent 7}]", counts)
}
}
func TestMailboxSnapshotFailsWhenImpersonationRejected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "notasuperuser", Password: "x"}
_, err := client.MailboxSnapshot(context.Background(), "[email protected]")
if err == nil {
t.Fatal("MailboxSnapshot should error when session discovery (impersonation) is rejected")
}
}
func TestMailboxSnapshotFailsWhenSessionHasNoMailAccount(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": "http://unused/",
"primaryAccounts": map[string]string{},
})
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
_, err := client.MailboxSnapshot(context.Background(), "[email protected]")
if err == nil {
t.Fatal("MailboxSnapshot should error when the session has no jmap:mail primary account")
}
}
func TestMailboxSnapshotPropagatesMailboxGetError(t *testing.T) {
var apiURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/.well-known/jmap":
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"primaryAccounts": map[string]string{jmapMailCapability: "mail-acct-1"},
})
case r.URL.Path == "/jmap-api":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"error", map[string]any{"type": "accountNotFound"}, "m"},
}})
}
}))
defer srv.Close()
apiURL = srv.URL + "/jmap-api"
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
_, err := client.MailboxSnapshot(context.Background(), "[email protected]")
if err == nil {
t.Fatal("MailboxSnapshot should propagate a JMAP-level error from Mailbox/get")
}
}
+215
View File
@@ -0,0 +1,215 @@
package stalwartapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
)
// managementCapabilities are the JMAP capability URNs Stalwart requires for
// its management object calls (x:Account/*): standard JMAP core plus its
// own urn:stalwart:jmap extension. Confirmed against
// docs/ref/object/account.md and crates/jmap-proto/src/request/capability.rs
// in stalwartlabs/stalwart.
var managementCapabilities = []string{"urn:ietf:params:jmap:core", "urn:stalwart:jmap"}
type jmapRequest struct {
Using []string `json:"using"`
MethodCalls []any `json:"methodCalls"`
}
type jmapRawResponse struct {
MethodResponses []json.RawMessage `json:"methodResponses"`
}
// methodResponse is one [name, args, callId] triple from a JMAP response,
// per RFC 8620 §3.2 - Stalwart's management API follows the same envelope
// shape as its regular JMAP methods.
type methodResponse struct {
Name string
Args json.RawMessage
CallID string
}
// call POSTs one JMAP-style request to the management API (Stalwart's /api
// endpoint - see docs/ref/object/account.md, which is distinct from /jmap)
// using this Client's own credentials, and returns its parsed method
// responses in order.
func (c *Client) call(ctx context.Context, using []string, methodCalls []any) ([]methodResponse, error) {
return c.callAs(ctx, c.Username, c.Password, strings.TrimRight(c.BaseURL, "/")+"/api", using, methodCalls)
}
// callAs is call's underlying primitive: it accepts an explicit
// username/password/URL rather than always using this Client's own
// credentials and the management endpoint. MailboxSnapshot uses this to
// call standard JMAP methods (not Stalwart's x: management objects) against
// the URL a JMAP session document says to use, authenticated as an
// impersonated identity rather than this Client's own.
func (c *Client) callAs(ctx context.Context, username, password, url string, using []string, methodCalls []any) ([]methodResponse, error) {
reqBody, err := json.Marshal(jmapRequest{Using: using, MethodCalls: methodCalls})
if err != nil {
return nil, fmt.Errorf("stalwartapi: encode request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(username, password)
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("stalwartapi: call %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("stalwartapi: %s returned %s: %s", url, resp.Status, strings.TrimSpace(string(body)))
}
var raw jmapRawResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("stalwartapi: decode response from %s: %w", url, err)
}
responses := make([]methodResponse, 0, len(raw.MethodResponses))
for _, r := range raw.MethodResponses {
var triple [3]json.RawMessage
if err := json.Unmarshal(r, &triple); err != nil {
return nil, fmt.Errorf("stalwartapi: parse method response envelope: %w", err)
}
var name, callID string
if err := json.Unmarshal(triple[0], &name); err != nil {
return nil, fmt.Errorf("stalwartapi: parse method response name: %w", err)
}
if err := json.Unmarshal(triple[2], &callID); err != nil {
return nil, fmt.Errorf("stalwartapi: parse method response call id: %w", err)
}
responses = append(responses, methodResponse{Name: name, Args: triple[1], CallID: callID})
}
return responses, nil
}
// account is the subset of x:Account/get's response fields this tool needs,
// confirmed against Stalwart's own docs/ref/object/account.md (whose
// stalwart-cli example is `query Account --fields id,name,domainId,usedDiskQuota`).
type account struct {
ID string `json:"id"`
Name string `json:"name"`
DomainID string `json:"domainId"`
}
// AccountSnapshot enumerates every account on the instance via Stalwart's
// management API - x:Account/query to list ids, then x:Account/get to fetch
// their name/domainId - and returns the account count, set of domains in
// use, and (via MailboxSnapshot, per account) every mailbox's message
// count. This is what preflight's snapshot (ARCHITECTURE.md §4.1) and
// validate's directory-integrity and content-integrity checks (§4.7)
// compare before and after migration - the latter being the actual
// no-data-loss guarantee.
//
// A per-account mailbox-count failure (most likely: this Client's Username
// lacks the `impersonate` permission MailboxSnapshot depends on) does not
// fail the whole snapshot - the account/domain enumeration above is already
// useful on its own, and one account's failure shouldn't hide a working
// result for every other account. Instead it's recorded in
// Snapshot.MailboxErrors, keyed by account email, so callers can report
// exactly what's missing rather than silently treating an unreachable
// account's mailboxes as having zero messages.
func (c *Client) AccountSnapshot(ctx context.Context) (*Snapshot, error) {
queryResp, err := c.call(ctx, managementCapabilities, []any{
[]any{"x:Account/query", map[string]any{"filter": map[string]any{}}, "q"},
})
if err != nil {
return nil, fmt.Errorf("stalwartapi: Account/query: %w", err)
}
ids, err := accountQueryIDs(queryResp)
if err != nil {
return nil, err
}
if len(ids) == 0 {
return &Snapshot{}, nil
}
getResp, err := c.call(ctx, managementCapabilities, []any{
[]any{"x:Account/get", map[string]any{"ids": ids, "properties": []string{"id", "name", "domainId"}}, "g"},
})
if err != nil {
return nil, fmt.Errorf("stalwartapi: Account/get: %w", err)
}
accounts, err := accountGetList(getResp)
if err != nil {
return nil, err
}
mailboxCounts := map[string][]MailboxCount{}
mailboxErrors := map[string]string{}
for _, a := range accounts {
if a.Name == "" {
continue // no login/email to impersonate against
}
counts, err := c.MailboxSnapshot(ctx, a.Name)
if err != nil {
mailboxErrors[a.Name] = err.Error()
continue
}
mailboxCounts[a.Name] = counts
}
domainSet := map[string]bool{}
for _, a := range accounts {
if a.DomainID != "" {
domainSet[a.DomainID] = true
}
}
domains := make([]string, 0, len(domainSet))
for d := range domainSet {
domains = append(domains, d)
}
sort.Strings(domains)
return &Snapshot{
AccountCount: len(accounts),
Domains: domains,
MailboxCounts: mailboxCounts,
MailboxErrors: mailboxErrors,
}, nil
}
func accountQueryIDs(responses []methodResponse) ([]string, error) {
if len(responses) == 0 {
return nil, fmt.Errorf("stalwartapi: Account/query returned no method responses")
}
r := responses[0]
if r.Name == "error" {
return nil, fmt.Errorf("stalwartapi: Account/query error: %s", r.Args)
}
var result struct {
IDs []string `json:"ids"`
}
if err := json.Unmarshal(r.Args, &result); err != nil {
return nil, fmt.Errorf("stalwartapi: parse Account/query response: %w", err)
}
return result.IDs, nil
}
func accountGetList(responses []methodResponse) ([]account, error) {
if len(responses) == 0 {
return nil, fmt.Errorf("stalwartapi: Account/get returned no method responses")
}
r := responses[0]
if r.Name == "error" {
return nil, fmt.Errorf("stalwartapi: Account/get error: %s", r.Args)
}
var result struct {
List []account `json:"list"`
}
if err := json.Unmarshal(r.Args, &result); err != nil {
return nil, fmt.Errorf("stalwartapi: parse Account/get response: %w", err)
}
return result.List, nil
}
+201
View File
@@ -0,0 +1,201 @@
package stalwartapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// jmapEnvelope mirrors the wire shape this package's call() parses: a
// top-level {"methodResponses": [...]} object where each entry is a
// [name, args, callId] triple (RFC 8620 §3.2).
type jmapEnvelope struct {
MethodResponses []any `json:"methodResponses"`
}
// accountManagementAndMailboxServer builds a fake server that answers both
// the x:Account/* management calls AccountSnapshot makes directly, and the
// session-discovery + Mailbox/get calls it makes indirectly (per account)
// via MailboxSnapshot. mailboxesFor maps an account email to the mailbox
// list its Mailbox/get should return; an account absent from the map gets a
// 403 on session discovery, simulating a missing `impersonate` grant.
func accountManagementAndMailboxServer(t *testing.T, mailboxesFor map[string][]map[string]any) (*httptest.Server, *[]string) {
t.Helper()
var gotPaths []string
var apiURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPaths = append(gotPaths, r.URL.Path)
if r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap" {
user, _, _ := r.BasicAuth()
target := strings.SplitN(user, "%", 2)[0]
if _, ok := mailboxesFor[target]; !ok {
w.WriteHeader(http.StatusForbidden)
return
}
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"primaryAccounts": map[string]string{jmapMailCapability: "mail-" + target},
})
return
}
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls := body["methodCalls"].([]any)
first := methodCalls[0].([]any)
methodName := first[0].(string)
switch methodName {
case "x:Account/query":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/query", map[string]any{"ids": []string{"a1", "a2"}}, "q"},
}})
case "x:Account/get":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/get", map[string]any{"list": []map[string]any{
{"id": "a1", "name": "[email protected]", "domainId": "example.com"},
{"id": "a2", "name": "[email protected]", "domainId": "example.org"},
}}, "g"},
}})
case "Mailbox/get":
args := first[1].(map[string]any)
accountID := args["accountId"].(string)
target := strings.TrimPrefix(accountID, "mail-")
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"Mailbox/get", map[string]any{"list": mailboxesFor[target]}, "m"},
}})
default:
t.Errorf("unexpected method call: %s", methodName)
}
}))
apiURL = srv.URL + "/api"
return srv, &gotPaths
}
func TestAccountSnapshotQueriesThenGets(t *testing.T) {
srv, _ := accountManagementAndMailboxServer(t, map[string][]map[string]any{
"[email protected]": {{"name": "Inbox", "totalEmails": 10}},
"[email protected]": {{"name": "Inbox", "totalEmails": 3}, {"name": "Archive", "totalEmails": 100}},
})
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
snap, err := client.AccountSnapshot(context.Background())
if err != nil {
t.Fatalf("AccountSnapshot: %v", err)
}
if snap.AccountCount != 2 {
t.Errorf("AccountCount = %d, want 2", snap.AccountCount)
}
if len(snap.Domains) != 2 || snap.Domains[0] != "example.com" || snap.Domains[1] != "example.org" {
t.Errorf("Domains = %v, want [example.com example.org] (sorted)", snap.Domains)
}
if len(snap.MailboxErrors) != 0 {
t.Errorf("MailboxErrors = %v, want none (both accounts should succeed)", snap.MailboxErrors)
}
alice := snap.MailboxCounts["[email protected]"]
if len(alice) != 1 || alice[0].Mailbox != "Inbox" || alice[0].Messages != 10 {
t.Errorf("alice's mailboxes = %+v, want [{Inbox 10}]", alice)
}
bob := snap.MailboxCounts["[email protected]"]
if len(bob) != 2 || bob[1].Mailbox != "Archive" || bob[1].Messages != 100 {
t.Errorf("bob's mailboxes = %+v, want Inbox then Archive(100)", bob)
}
}
func TestAccountSnapshotRecordsPerAccountMailboxFailureWithoutFailingOverall(t *testing.T) {
// [email protected] is deliberately absent from mailboxesFor, simulating
// a missing `impersonate` grant for that one account.
srv, _ := accountManagementAndMailboxServer(t, map[string][]map[string]any{
"[email protected]": {{"name": "Inbox", "totalEmails": 10}},
})
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
snap, err := client.AccountSnapshot(context.Background())
if err != nil {
t.Fatalf("AccountSnapshot should not fail overall just because one account's mailbox capture failed: %v", err)
}
if snap.AccountCount != 2 {
t.Errorf("AccountCount = %d, want 2 (account enumeration is unaffected by the mailbox-capture failure)", snap.AccountCount)
}
if _, ok := snap.MailboxCounts["[email protected]"]; !ok {
t.Error("alice's mailbox counts should still be captured")
}
if _, ok := snap.MailboxCounts["[email protected]"]; ok {
t.Error("bob's mailbox counts should NOT be present - his capture failed")
}
if _, ok := snap.MailboxErrors["[email protected]"]; !ok {
t.Error("bob's failure should be recorded in MailboxErrors, not silently dropped")
}
}
func TestAccountSnapshotEmptyInstance(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/query", map[string]any{"ids": []string{}}, "q"},
}})
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
snap, err := client.AccountSnapshot(context.Background())
if err != nil {
t.Fatalf("AccountSnapshot: %v", err)
}
if snap.AccountCount != 0 {
t.Errorf("AccountCount = %d, want 0", snap.AccountCount)
}
}
func TestAccountSnapshotPropagatesJMAPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"error", map[string]any{"type": "forbidden"}, "q"},
}})
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
_, err := client.AccountSnapshot(context.Background())
if err == nil {
t.Fatal("AccountSnapshot should surface a JMAP-level error response")
}
}
func TestAccountSnapshotPropagatesHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("invalid credentials"))
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "wrong"}
_, err := client.AccountSnapshot(context.Background())
if err == nil {
t.Fatal("AccountSnapshot should error on a non-200 response")
}
}
func TestAccountSnapshotSendsBasicAuth(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "admin" || pass != "hunter2" {
t.Errorf("BasicAuth = (%s, %s, %v), want (admin, hunter2, true)", user, pass, ok)
}
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/query", map[string]any{"ids": []string{}}, "q"},
}})
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
if _, err := client.AccountSnapshot(context.Background()); err != nil {
t.Fatalf("AccountSnapshot: %v", err)
}
}
+119
View File
@@ -0,0 +1,119 @@
package validate
import (
"context"
"fmt"
"net/http"
"time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/recovery"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi"
)
// BootCheckOptions configures a normal (non-recovery-mode) boot of the
// migrated instance, to confirm it comes up cleanly outside recovery mode -
// not just that recovery mode itself could apply settings to it - and,
// optionally, a content-integrity comparison performed against that same
// boot before it's stopped again.
type BootCheckOptions struct {
BinaryPath string
ConfigPath string
ListenURL string
ExtraEnv []string
Timeout time.Duration
StopGrace time.Duration
HTTPClient *http.Client
// ContentIntegrityBefore, if non-nil, is the pre-migration snapshot
// preflight captured (checkpoint.RunState.PreflightSnapshot). When set,
// BootCheck captures a fresh snapshot from the instance it just booted
// - authenticating with AdminUser/AdminPassword, which migrate over
// unchanged with the account (they don't need to differ from the
// pre-migration admin credentials) - and compares the two: this is the
// actual no-data-loss guarantee from ARCHITECTURE.md §4.7, not just
// "the migration mechanics ran". Left nil, only the boot-reachability
// check runs, e.g. when preflight never captured a snapshot because
// --admin-url wasn't set.
ContentIntegrityBefore *checkpoint.PreflightSnapshot
AdminUser string
AdminPassword string
}
// BootCheck starts the target binary the way cutover eventually will (an
// ordinary boot, no STALWART_RECOVERY_MODE), waits for its HTTP listener to
// answer, optionally compares its content against ContentIntegrityBefore
// while it's up, then stops it. It reuses recovery.Process and
// recovery.WaitForHealthy rather than re-implementing process supervision,
// since "start the binary and confirm it's reachable" is exactly what those
// already do.
//
// Like recovery.Run, this is deliberately one atomic operation rather than
// separately checkpointed sub-steps: if this tool's own process crashes
// between the boot succeeding and the content check running, there's no
// safe way to reattach to whatever's left of the child process on resume,
// so a retry just redoes the whole cycle - see recovery.Run's doc comment
// for the full reasoning, which applies identically here.
func BootCheck(ctx context.Context, o BootCheckOptions) (detail string, result *ContentIntegrityResult, err error) {
proc := &recovery.Process{}
if startErr := proc.Start(ctx, recovery.ProcessOptions{
BinaryPath: o.BinaryPath, ConfigPath: o.ConfigPath, RecoveryMode: false, ExtraEnv: o.ExtraEnv,
}); startErr != nil {
return "", nil, fmt.Errorf("validate: start normal boot: %w", startErr)
}
stopGrace := o.StopGrace
if stopGrace <= 0 {
stopGrace = 10 * time.Second
}
defer func() {
if stopErr := proc.Stop(stopGrace); stopErr != nil && err == nil {
err = stopErr
}
}()
timeout := o.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
if healthErr := recovery.WaitForHealthy(ctx, o.HTTPClient, o.ListenURL, timeout); healthErr != nil {
return "", nil, fmt.Errorf("migrated instance did not come up under a normal (non-recovery-mode) boot: %w", healthErr)
}
detail = fmt.Sprintf("migrated instance booted normally (not in recovery mode) and answered at %s", o.ListenURL)
if o.ContentIntegrityBefore == nil {
return detail, nil, nil
}
client := &stalwartapi.Client{BaseURL: o.ListenURL, Username: o.AdminUser, Password: o.AdminPassword, HTTPClient: o.HTTPClient}
result, ciErr := compareContentIntegrity(ctx, client, o.ContentIntegrityBefore)
if ciErr != nil {
return detail, nil, fmt.Errorf("content-integrity comparison failed: %w", ciErr)
}
if !result.OK() {
return detail, result, fmt.Errorf("content integrity check found problems: %s", result.String())
}
return detail, result, nil
}
// Run executes BootCheck as a single checkpointed step, mirroring
// preflight/backup/recovery's pattern.
func Run(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState, opts BootCheckOptions) (Report, error) {
var report Report
outcome, err := store.RunStep(rs, checkpoint.PhaseValidate, "boot-check", func() (checkpoint.StepOutcome, error) {
detail, result, err := BootCheck(ctx, opts)
if err != nil {
return checkpoint.StepOutcome{}, err
}
if result != nil {
detail += " - " + result.String()
}
return checkpoint.StepOutcome{Detail: detail}, nil
})
if err != nil {
report.Results = append(report.Results, CheckResult{Name: "boot-check", Status: StatusFail, Detail: err.Error()})
return report, err
}
report.Results = append(report.Results, CheckResult{Name: "boot-check", Status: StatusOK, Detail: outcome.Detail})
return report, nil
}
+251
View File
@@ -0,0 +1,251 @@
package validate
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
)
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
func testBinaryPath(t *testing.T) string {
t.Helper()
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return self
}
func TestBootCheckSucceedsWhenInstanceComesUp(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
detail, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
})
if err != nil {
t.Fatalf("BootCheck: %v", err)
}
if detail == "" {
t.Error("BootCheck returned an empty detail on success")
}
if result != nil {
t.Errorf("result = %+v, want nil when ContentIntegrityBefore wasn't set", result)
}
}
func TestBootCheckFailsWhenInstanceNeverComesUp(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
binPath := filepath.Join(t.TempDir(), "stalwart")
os.WriteFile(binPath, []byte("#!/bin/sh\nsleep 5\n"), 0o755)
port := freePort(t)
_, _, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: binPath,
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
Timeout: 300 * time.Millisecond,
StopGrace: 2 * time.Second,
})
if err == nil {
t.Fatal("BootCheck should fail when nothing ever answers ListenURL")
}
}
func TestRunEndToEndAndResume(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatal(err)
}
opts := BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run #1: %v", err)
}
if report.Blocking() {
t.Fatalf("Run #1: unexpected failure: %s", report.String())
}
// Resume with a config that would fail if re-executed (nothing listens
// on badPort) - a skip proves the step didn't re-run.
badPort := freePort(t)
resumedOpts := opts
resumedOpts.ListenURL = fmt.Sprintf("http://127.0.0.1:%d/", badPort)
resumedOpts.Timeout = 300 * time.Millisecond
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatal(err)
}
report2, err := Run(context.Background(), store, resumed, resumedOpts)
if err != nil {
t.Fatalf("Run #2 (resume) should succeed without redoing the check: %v", err)
}
if report2.Blocking() {
t.Fatalf("Run #2 (resume): unexpected failure: %s", report2.String())
}
}
// beforeSnapshotWithAliceInbox builds a checkpoint.PreflightSnapshot
// matching the fake server's single hardcoded account ([email protected],
// mailbox "Inbox") with the given pre-migration message count.
func beforeSnapshotWithAliceInbox(messages int) *checkpoint.PreflightSnapshot {
return &checkpoint.PreflightSnapshot{
AccountCount: 1,
Domains: []string{"example.com"},
MailboxCounts: map[string][]checkpoint.MailboxCount{
"[email protected]": {{Mailbox: "Inbox", Messages: messages}},
},
}
}
func TestBootCheckContentIntegrityPassesWhenCountsMatch(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
detail, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
"STALWART_MIGRATOR_TEST_MAILBOX_COUNT=42", // matches beforeSnapshotWithAliceInbox(42)
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
ContentIntegrityBefore: beforeSnapshotWithAliceInbox(42),
AdminUser: "admin",
AdminPassword: "hunter2",
})
if err != nil {
t.Fatalf("BootCheck: %v", err)
}
if result == nil {
t.Fatal("result should be populated when ContentIntegrityBefore was set")
}
if !result.OK() {
t.Errorf("result.OK() = false, want true: %s", result.String())
}
if result.AccountsChecked != 1 || result.MailboxesChecked != 1 {
t.Errorf("AccountsChecked=%d MailboxesChecked=%d, want 1 and 1", result.AccountsChecked, result.MailboxesChecked)
}
if detail == "" {
t.Error("detail should still describe the boot")
}
}
func TestBootCheckContentIntegrityFailsWhenCountsMismatch(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
_, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
"STALWART_MIGRATOR_TEST_MAILBOX_COUNT=40", // the "after" server reports 40
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
ContentIntegrityBefore: beforeSnapshotWithAliceInbox(42), // but "before" said 42 - two messages went missing
AdminUser: "admin",
AdminPassword: "hunter2",
})
if err == nil {
t.Fatal("BootCheck should fail when a post-migration mailbox count doesn't match the pre-migration one")
}
if result == nil || result.OK() {
t.Fatalf("result = %+v, want a non-OK result describing the mismatch", result)
}
if len(result.MessageCountMismatches) != 1 {
t.Fatalf("MessageCountMismatches = %+v, want exactly one entry", result.MessageCountMismatches)
}
mismatch := result.MessageCountMismatches[0]
if mismatch.Account != "[email protected]" || mismatch.Mailbox != "Inbox" || mismatch.Before != 42 || mismatch.After != 40 {
t.Errorf("mismatch = %+v, want [email protected]/Inbox 42->40", mismatch)
}
}
func TestBootCheckContentIntegrityDetectsMissingAccount(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
before := &checkpoint.PreflightSnapshot{
AccountCount: 2,
Domains: []string{"example.com", "example.net"},
MailboxCounts: map[string][]checkpoint.MailboxCount{
"[email protected]": {{Mailbox: "Inbox", Messages: 42}},
"[email protected]": {{Mailbox: "Inbox", Messages: 5}}, // the fake server only ever knows about alice
},
}
_, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
"STALWART_MIGRATOR_TEST_MAILBOX_COUNT=42",
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
ContentIntegrityBefore: before,
AdminUser: "admin",
AdminPassword: "hunter2",
})
if err == nil {
t.Fatal("BootCheck should fail when an account present before migration can't be found afterward")
}
if result == nil || len(result.MissingAccounts) != 1 || result.MissingAccounts[0] != "[email protected]" {
t.Fatalf("result = %+v, want MissingAccounts = [[email protected]]", result)
}
}
+113
View File
@@ -0,0 +1,113 @@
package validate
import (
"context"
"fmt"
"sort"
"strings"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi"
)
// MailboxDelta is one mailbox whose message count didn't match between the
// pre- and post-migration snapshots.
type MailboxDelta struct {
Account string
Mailbox string
Before int
After int
}
// ContentIntegrityResult is the outcome of comparing a pre-migration
// snapshot against a freshly captured post-migration one - the actual
// no-data-loss check described in ARCHITECTURE.md §4.7.
type ContentIntegrityResult struct {
AccountsChecked int
MailboxesChecked int
MissingAccounts []string // present before, not found after (even accounting for the email-address rewrite)
MessageCountMismatches []MailboxDelta // present both before and after, but with a different message count
}
// OK reports whether every account and mailbox the pre-migration snapshot
// knew about was found afterward with an identical message count.
func (r ContentIntegrityResult) OK() bool {
return len(r.MissingAccounts) == 0 && len(r.MessageCountMismatches) == 0
}
func (r ContentIntegrityResult) String() string {
if r.OK() {
return fmt.Sprintf("content integrity: %d account(s), %d mailbox(es) checked, all message counts match", r.AccountsChecked, r.MailboxesChecked)
}
var b strings.Builder
fmt.Fprintf(&b, "content integrity: %d account(s), %d mailbox(es) checked", r.AccountsChecked, r.MailboxesChecked)
for _, a := range r.MissingAccounts {
fmt.Fprintf(&b, "; MISSING ACCOUNT %s", a)
}
for _, d := range r.MessageCountMismatches {
fmt.Fprintf(&b, "; MESSAGE COUNT MISMATCH %s/%s: %d before, %d after", d.Account, d.Mailbox, d.Before, d.After)
}
return b.String()
}
// compareContentIntegrity captures a fresh snapshot via client and compares
// it against before, matching accounts by exact name first and falling
// back to the local part (the text before "@") since Stalwart's v0.16
// migration rewrites bare usernames to full email addresses
// (UPGRADING/v0_16.md: "the migration script automatically assigns the
// default domain to accounts lacking one") - an exact-string comparison
// alone would misreport every rewritten account as missing.
func compareContentIntegrity(ctx context.Context, client *stalwartapi.Client, before *checkpoint.PreflightSnapshot) (*ContentIntegrityResult, error) {
after, err := client.AccountSnapshot(ctx)
if err != nil {
return nil, fmt.Errorf("capture post-migration snapshot: %w", err)
}
result := &ContentIntegrityResult{}
beforeAccounts := make([]string, 0, len(before.MailboxCounts))
for a := range before.MailboxCounts {
beforeAccounts = append(beforeAccounts, a)
}
sort.Strings(beforeAccounts)
for _, beforeAccount := range beforeAccounts {
result.AccountsChecked++
afterMailboxes, found := after.MailboxCounts[beforeAccount]
if !found {
afterMailboxes, found = findByLocalPart(after.MailboxCounts, beforeAccount)
}
if !found {
result.MissingAccounts = append(result.MissingAccounts, beforeAccount)
continue
}
afterByName := make(map[string]int, len(afterMailboxes))
for _, m := range afterMailboxes {
afterByName[m.Mailbox] = m.Messages
}
beforeMailboxes := append([]checkpoint.MailboxCount(nil), before.MailboxCounts[beforeAccount]...)
sort.Slice(beforeMailboxes, func(i, j int) bool { return beforeMailboxes[i].Mailbox < beforeMailboxes[j].Mailbox })
for _, bm := range beforeMailboxes {
result.MailboxesChecked++
afterCount, ok := afterByName[bm.Mailbox]
if !ok || afterCount != bm.Messages {
result.MessageCountMismatches = append(result.MessageCountMismatches, MailboxDelta{
Account: beforeAccount, Mailbox: bm.Mailbox, Before: bm.Messages, After: afterCount,
})
}
}
}
return result, nil
}
func findByLocalPart(mailboxCounts map[string][]stalwartapi.MailboxCount, beforeAccount string) ([]stalwartapi.MailboxCount, bool) {
local := strings.SplitN(beforeAccount, "@", 2)[0]
for afterAccount, mb := range mailboxCounts {
if strings.SplitN(afterAccount, "@", 2)[0] == local {
return mb, true
}
}
return nil, false
}
+165
View File
@@ -0,0 +1,165 @@
package validate
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi"
)
// jmapEnvelope mirrors the wire shape stalwartapi.Client.call() parses.
type jmapEnvelope struct {
MethodResponses []any `json:"methodResponses"`
}
// fakeManagementServer serves x:Account/query + x:Account/get from
// accounts, and, for each of them, session discovery + Mailbox/get from
// mailboxesByEmail (keyed by the account's post-migration email).
func fakeManagementServer(t *testing.T, accounts []map[string]any, mailboxesByEmail map[string][]map[string]any) *httptest.Server {
t.Helper()
var apiURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap" {
user, _, _ := r.BasicAuth()
target := strings.SplitN(user, "%", 2)[0]
if _, ok := mailboxesByEmail[target]; !ok {
w.WriteHeader(http.StatusForbidden)
return
}
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"primaryAccounts": map[string]string{"urn:ietf:params:jmap:mail": "mail-" + target},
})
return
}
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls := body["methodCalls"].([]any)
call := methodCalls[0].([]any)
name := call[0].(string)
switch name {
case "x:Account/query":
ids := make([]string, len(accounts))
for i, a := range accounts {
ids[i] = a["id"].(string)
}
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/query", map[string]any{"ids": ids}, "q"},
}})
case "x:Account/get":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/get", map[string]any{"list": accounts}, "g"},
}})
case "Mailbox/get":
args := call[1].(map[string]any)
accountID := args["accountId"].(string)
target := strings.TrimPrefix(accountID, "mail-")
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"Mailbox/get", map[string]any{"list": mailboxesByEmail[target]}, "m"},
}})
}
}))
apiURL = srv.URL + "/api"
return srv
}
func TestCompareContentIntegrityMatchesRewrittenBareUsernameByLocalPart(t *testing.T) {
// Pre-migration, the account was a bare username "alice" (pre-0.16
// style). Post-migration, v0.16's own conversion rewrote it to a full
// email address - see UPGRADING/v0_16.md. An exact-string match would
// wrongly report "alice" as missing.
srv := fakeManagementServer(t,
[]map[string]any{{"id": "a1", "name": "[email protected]", "domainId": "example.com"}},
map[string][]map[string]any{"[email protected]": {{"name": "Inbox", "totalEmails": 42}}},
)
defer srv.Close()
before := &checkpoint.PreflightSnapshot{
MailboxCounts: map[string][]checkpoint.MailboxCount{
"alice": {{Mailbox: "Inbox", Messages: 42}}, // bare username, pre-migration
},
}
client := &stalwartapi.Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
result, err := compareContentIntegrity(context.Background(), client, before)
if err != nil {
t.Fatalf("compareContentIntegrity: %v", err)
}
if !result.OK() {
t.Errorf("result.OK() = false, want true (local-part match should have found [email protected]): %s", result.String())
}
if len(result.MissingAccounts) != 0 {
t.Errorf("MissingAccounts = %v, want none", result.MissingAccounts)
}
}
func TestCompareContentIntegrityNoFalseMatchAcrossUnrelatedAccounts(t *testing.T) {
// "alice" (before) must not spuriously match "[email protected]"
// (after) just because one contains the other - local-part comparison
// must be an exact match on the part before "@", not a substring check.
srv := fakeManagementServer(t,
[]map[string]any{{"id": "a1", "name": "[email protected]", "domainId": "example.com"}},
map[string][]map[string]any{"[email protected]": {{"name": "Inbox", "totalEmails": 1}}},
)
defer srv.Close()
before := &checkpoint.PreflightSnapshot{
MailboxCounts: map[string][]checkpoint.MailboxCount{
"alice": {{Mailbox: "Inbox", Messages: 42}},
},
}
client := &stalwartapi.Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
result, err := compareContentIntegrity(context.Background(), client, before)
if err != nil {
t.Fatalf("compareContentIntegrity: %v", err)
}
if result.OK() {
t.Fatal("result.OK() = true, want a missing-account failure - [email protected] is a different account than alice")
}
if len(result.MissingAccounts) != 1 || result.MissingAccounts[0] != "alice" {
t.Errorf("MissingAccounts = %v, want [alice]", result.MissingAccounts)
}
}
func TestCompareContentIntegrityMultipleMailboxesPerAccount(t *testing.T) {
srv := fakeManagementServer(t,
[]map[string]any{{"id": "a1", "name": "[email protected]", "domainId": "example.org"}},
map[string][]map[string]any{"[email protected]": {
{"name": "Inbox", "totalEmails": 10},
{"name": "Archive", "totalEmails": 200},
}},
)
defer srv.Close()
before := &checkpoint.PreflightSnapshot{
MailboxCounts: map[string][]checkpoint.MailboxCount{
"[email protected]": {
{Mailbox: "Inbox", Messages: 10},
{Mailbox: "Archive", Messages: 199}, // one message short
},
},
}
client := &stalwartapi.Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
result, err := compareContentIntegrity(context.Background(), client, before)
if err != nil {
t.Fatalf("compareContentIntegrity: %v", err)
}
if result.AccountsChecked != 1 || result.MailboxesChecked != 2 {
t.Errorf("AccountsChecked=%d MailboxesChecked=%d, want 1 and 2", result.AccountsChecked, result.MailboxesChecked)
}
if len(result.MessageCountMismatches) != 1 {
t.Fatalf("MessageCountMismatches = %+v, want exactly one (Archive)", result.MessageCountMismatches)
}
m := result.MessageCountMismatches[0]
if m.Mailbox != "Archive" || m.Before != 199 || m.After != 200 {
t.Errorf("mismatch = %+v, want Archive 199->200", m)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package validate implements the post-migration validation suite and its structured report.
// See ARCHITECTURE.md §4.7 for the design.
package validate
+101
View File
@@ -0,0 +1,101 @@
package validate
import (
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"testing"
)
// TestMain lets this test binary also act as a fake Stalwart binary,
// mirroring internal/recovery's own TestMain - see that package's doc
// comment for why (the standard os/exec "helper process" technique). Beyond
// plain reachability, it also speaks just enough JMAP to serve
// stalwartapi.Client.AccountSnapshot (x:Account/query, x:Account/get,
// session discovery, Mailbox/get) for a single fixed fake account
// "[email protected]", so BootCheck's content-integrity comparison can be
// exercised against a real subprocess rather than mocked in-process. The
// mailbox message count it reports is configurable via
// STALWART_MIGRATOR_TEST_MAILBOX_COUNT (default 42), so tests can produce
// both a matching and a mismatching post-migration snapshot.
func TestMain(m *testing.M) {
if os.Getenv("STALWART_MIGRATOR_TEST_HELPER") == "1" {
runFakeStalwartServer()
return
}
os.Exit(m.Run())
}
func runFakeStalwartServer() {
port := os.Getenv("STALWART_MIGRATOR_TEST_PORT")
messageCount := 42
if v := os.Getenv("STALWART_MIGRATOR_TEST_MAILBOX_COUNT"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
messageCount = n
}
}
ln, err := net.Listen("tcp", "127.0.0.1:"+port)
if err != nil {
fmt.Fprintln(os.Stderr, "fake stalwart: listen:", err)
os.Exit(1)
}
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap":
user, _, _ := r.BasicAuth()
if strings.Contains(user, "%") {
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": "http://127.0.0.1:" + port + "/api",
"primaryAccounts": map[string]string{"urn:ietf:params:jmap:mail": "mail-alice"},
})
return
}
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPost && r.URL.Path == "/api":
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls, _ := body["methodCalls"].([]any)
if len(methodCalls) == 0 {
w.WriteHeader(http.StatusBadRequest)
return
}
call := methodCalls[0].([]any)
name := call[0].(string)
switch name {
case "x:Account/query":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Account/query", map[string]any{"ids": []string{"a1"}}, "q"},
}})
case "x:Account/get":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Account/get", map[string]any{"list": []map[string]any{
{"id": "a1", "name": "[email protected]", "domainId": "example.com"},
}}, "g"},
}})
case "Mailbox/get":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"Mailbox/get", map[string]any{"list": []map[string]any{
{"name": "Inbox", "totalEmails": messageCount},
}}, "m"},
}})
default:
w.WriteHeader(http.StatusBadRequest)
}
default:
w.WriteHeader(http.StatusOK)
}
})}
go srv.Serve(ln)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM)
<-sigCh
os.Exit(0)
}
+40
View File
@@ -0,0 +1,40 @@
package validate
import (
"fmt"
"strings"
)
type Status string
const (
StatusOK Status = "ok"
StatusFail Status = "fail"
)
type CheckResult struct {
Name string
Status Status
Detail string
}
type Report struct {
Results []CheckResult
}
func (r Report) Blocking() bool {
for _, res := range r.Results {
if res.Status == StatusFail {
return true
}
}
return false
}
func (r Report) String() string {
var b strings.Builder
for _, res := range r.Results {
fmt.Fprintf(&b, "[%-4s] %-16s %s\n", strings.ToUpper(string(res.Status)), res.Name, res.Detail)
}
return b.String()
}