Wire the container path up, behind a flag that says what it is

Everything the container migration needs has landed a piece at a time and
nothing called any of it. `run` now does: stage pulls and verifies an image
instead of downloading a binary, the recovery cycle launches a throwaway
container against the live container's own mounts, and cutover recreates it.
Preflight's blanket refusal of docker goes with it -- what still refuses is
specific to a container rather than to containers, which is compose and
data that is not on a volume.

It refuses without --container-path-unproven, and that flag is the honest
part of this change. Every test drives a fake docker. That proves the right
commands are assembled and proves nothing about whether a real image reads
the config it is handed -- which is the exact limit ARCHITECTURE.md section
4.8 records about the rollback code that was deleted for being tested only
against fakes. A doc note seemed too quiet for a tool that stops a mail
server, so it is a flag nobody reaches without being told.

The converted config reaches the container through the data volume. It is
written under the host side of whichever mount covers --data-dir and named
on the container side, because cutover recreates a container with the mounts
it had and cannot invent a new one for a config file. --data-dir therefore
names the path inside the container, which preflight already says when it
matches no mount.

PatchPaths stays unused, deliberately. Its documented purpose is pointing a
rehearsal at a sandbox; a real container's dumped settings already carry
container-side paths, because they come from the live server rather than
from a file on this host.

The preflight test that asserted docker was refused outright now asserts
the replacement rather than being deleted -- "docker is allowed through
here" is the thing that would be wrong to regress. Its fixture had to make
--data-dir both a real host directory and one the fake container mounts,
since disk-space stats it and container-data-volume wants it covered.

README gains the container section and, at the top, the note that this is
ihasmail's companion.
This commit is contained in:
2026-08-28 17:42:31 -07:00
parent a23a00dfba
commit 272439cf2a
6 changed files with 207 additions and 39 deletions
+49 -1
View File
@@ -10,6 +10,11 @@ job](#recovery-is-your-job) before using it on anything you care about.
Go, standard library only — no external dependencies.
A companion to [**ihasmail**](https://github.com/LINUXexpert-org/ihasmail),
a JMAP-first webmail client for Stalwart. That one is what you read your
mail in; this one is what gets the server underneath it onto a version that
speaks the protocol it needs.
**This has been used to migrate a production mail server.** On 2026-08-25 it
took a live server — nine domains, six accounts, a 2.4 GB RocksDB store —
from 0.15.5 to 0.16.19 with **8 seconds** of downtime, every phase green
@@ -55,7 +60,7 @@ described above.
|---|---|
| `stalwart-migrate preflight` | **Works** — read-only checks and a migration plan |
| `stalwart-migrate rehearse` | **Works** — read-only; converts your settings and reports what won't carry over |
| `stalwart-migrate run` | **Works** — performs the migration; `--recovery-point-confirmed --yes` |
| `stalwart-migrate run` | **Works** — performs the migration; `--recovery-point-confirmed --yes`. Container deployments additionally need `--container-path-unproven` (see [Docker deployments](#docker-deployments)) |
| `stalwart-migrate tenants` | **Works** — read-only; who owns which domain, and what would block a migration |
| `stalwart-migrate status <id>` | **Works** |
| `stalwart-migrate report <id>` | **Works** — prints what validation found for a run |
@@ -70,6 +75,49 @@ tool cannot undo a migration and will not start without it).
**Start with `rehearse` first.** It is read-only, needs no maintenance
window, and tells you what `run` will and won't carry over.
### Docker deployments
A containerised Stalwart can be migrated, with two things to know first.
**The container path has never been run against a real Stalwart image.**
Its logic is tested and its refusals are real, but a fake `docker` proves
only that the right commands are assembled — not that the image reads the
config it is handed. `run` refuses a container deployment unless you pass
`--container-path-unproven`, which is there so nobody reaches it without
being told. Rehearse on a clone first; that advice goes double here.
**Two flags and one convention:**
- `--target-image` names the image in full, e.g.
`stalwartlabs/stalwart:v0.16.14`. It is never derived from the running
container by swapping the tag — that is wrong for a digest-pinned image,
a mirror or a fork, and being wrong means pulling the wrong software into
a mail server.
- `--container` names the container (default `stalwart`).
- `--data-dir` must name the path **inside** the container, since that is
where its data actually lives. `preflight` says so if it matches none of
the container's mounts.
**What it refuses, and why.** A container cannot be edited in place the way
a unit file can, so cutting one over means rebuilding it — and a container
rebuilt without its capabilities, its custom network or its device mappings
starts cleanly and is quietly not the server it was. So cutover carries
across what it understands (mounts, ports, environment, restart policy,
labels) and refuses outright when it finds anything else, naming what it
found. It also refuses a container whose data is not on a volume — an
upgrade replaces the container, and the writable layer goes with it — and
one managed by Docker Compose, because recreating it out from under compose
leaves the container and the compose file disagreeing about what is
deployed, and the next `compose up` reverts the migration. Compose
deployments are migrated by editing the image tag in the compose file and
running `compose up -d`.
**What it keeps.** The old container is renamed rather than removed, the old
image is never pruned, and the container's `docker inspect` is preserved as
an artifact before anything is replaced. Together those are the manual
restore path — see [Recovery is your job](#recovery-is-your-job), which
applies here exactly as it does to a binary install.
Measured on a full migration: the store converts in seconds, and the service
was down for **6 seconds** end to end. Plan the window around verification,
not data volume.