Commit Graph
28 Commits
Author SHA1 Message Date
jcoffey-dev f1aff4ba07 Point links at git.coffeylabs.org after the move from GitHub
ci / test (pull_request) Successful in 2m4s
ci / release (pull_request) Skipped
GitHub took the organization's repos and GHCR offline on 2026-09-20. Repo,
release, raw-file and clone links now go to Gitea at git.coffeylabs.org,
container images to registry.coffeylabs.org, and GitLab-style /-/blob paths
to Gitea's /src/branch form. Go module paths are identifiers and stay as
they are; links to GitHub issues and pull requests are left as history.
2026-09-22 09:08:15 -07:00
jcoffey-dev 0543931616 Release binaries for linux amd64 and arm64
Pushing a v* tag runs .github/workflows/release.yml: vet, test,
govulncheck, then scripts/build-release.sh builds reproducible archives
for linux/amd64 and linux/arm64 with a SHA256SUMS file, and attaches
them to the release. workflow_dispatch takes a tag for a run that never
started. Same shape as ihasmail-oneshot's releases.

Adds a version subcommand, set at build time.

go.mod moves to 1.26.8: the workflow builds with the go.mod version,
and govulncheck finds four standard-library vulnerabilities the tool
reaches in 1.26.5 (GO-2026-6218, GO-2026-6090, GO-2026-5972,
GO-2026-5026), all fixed in 1.26.6.

README installs from the latest release, with building from source as
the alternative; CONTRIBUTING describes how releases are cut.
2026-09-15 14:26:05 -07:00
jcoffey-dev 1b4258113e Name the documentation rows the way ihasmail-oneshot's table does 2026-09-15 12:26:17 -07:00
jcoffey-dev bb2f56bfa5 Add license and docs badges, as ihasmail-oneshot has 2026-09-15 12:26:03 -07:00
jcoffey-dev bd117e6fca Put the documentation links first, with the guide marked as the place to start 2026-09-15 12:25:39 -07:00
jcoffey-dev 6608f3a97f Shorten the README; move the technical detail into docs/
The README keeps what the tool is, how to install it and the first commands,
and points to the guide on docs.ihasmail.org. Everything else moves, whole,
into docs/ and CONTRIBUTING.md, where it is organized for readers who want
the detail. Where the old README disagreed with the code, the code wins.
2026-09-15 12:14:36 -07:00
jcoffey-dev 12ec0c3fd4 Rename the module to the Coffey-Labs organisation
The repositories moved off LINUXexpert-org. Here that is not a
documentation change: the old organisation was the module path, so it is
declared in go.mod and repeated in every internal import.

Leaving it would have been worse than a stale link. GitHub redirects the
repository, but a go.mod whose module line disagrees with the path it was
fetched from is an error rather than a redirect, so `go get` on the new
address would have failed against the old declaration.

go.mod, 34 files of imports, and the repository links in README and
ARCHITECTURE. go mod tidy leaves go.sum untouched -- no dependency moved,
only our own path.
2026-08-30 15:24:18 -07:00
LINUXexpert.org 8bf7c8e2ac Merge pull request #15 from LINUXexpert-org/copyright-coffey-labs
Change the copyright holder to Coffey Labs
2026-08-30 01:29:22 -07:00
jcoffey-dev 77e80cb9f2 Change the copyright holder to Coffey Labs
119 SPDX-FileCopyrightText headers and the README's licence line.

The distinction that matters here: LINUXexpert-org appears in this repository
in two completely different roles. As a copyright holder in the SPDX headers,
which is what changes, and as the GitHub organisation in the module path and 64
import statements, which does not -- the repository still lives at
github.com/LINUXexpert-org/stalwart-migrator, and rewriting that would not be a
licence change, it would break the build.

Both replacements are anchored to their copyright forms, so an import path
cannot match either. Import count is 64 before and after, and go.mod is
untouched.

LICENSE untouched: the FSF's copyright on the GPL text and the "<name of
author>" placeholders are not ours to edit.

go vet, go build and go test all clean.
2026-08-30 01:28:23 -07:00
jcoffey-dev d0d2954ad1 Say what the field report changed and what it left open
Three real 0.15.5 -> 0.16.19 migrations by @kaya-eu, at 221 GB and on
arm64, are worth recording — with the qualifications that make them
readable. They ran a commit predating the automated Docker cutover and did
that step by hand, so what those runs exercise is preflight, the dumps,
the conversion and the recovery-mode store migration.

Three new sections for what they found:

The store migration may need one more recovery boot. This is open. Two
failures, never both on one run, each cured by one extra recovery-mode
boot, and Stalwart's own "multi-step process" panic says the store
migration is not finished when the single boot exits. Documented as the
manual step rather than left for the next operator to rediscover, and
documented as unfixed rather than implied away.

A certificate that serves HTTPS may not serve the mail ports.
IMAPS/SMTPS/POP3S keep a self-signed certificate until
SystemSettings.defaultCertificateId is set. Written up beside the ACME
section, which has the same shape: something the converter does not carry
and nothing warns about. This tool should generate it and does not yet.

Do not boot recovery mode again afterwards already landed with the
artifacts it argues for; this links the production paragraph to it.

Two status claims were also stale. §4.5 still said the container path was
not reachable from the CLI, which #9 changed, and both it and §7 said no
real Stalwart image had been met — one has now been inspected, which is
how #11's two bugs were found. What remains untested is everything after
the recreate, and the Docker section now says what cutover carries and
how the config reaches the container.
2026-08-29 17:53:38 -07:00
jcoffey-dev f324d66c5c Keep the four files a run cannot produce again
The settings and principals dumps, the apply plan and its supplement lived
only in --work-dir, which a successful run deletes. All four are
irreplaceable once the store has been migrated: the dumps can only be
taken from a live pre-migration instance, and the plan is what was
actually replayed. `rehearse` already kept the plan and the supplement, so
the read-only command preserved more of its conclusions than the
destructive one did.

They are now copied into the run's state directory before the store is
touched, recorded as artifacts with checksums, and kept whether or not the
run succeeded and whether or not --keep-artifacts was passed. README
claimed the dumps stayed on disk; now they do.

What made this concrete: an operator who booted recovery mode again after
a completed migration, for an unrelated reason, and found Domain and
Account queries coming back empty on the next start — twice, on two
different servers, and verified as genuinely gone rather than a stale
read. Re-applying that run's export.json and supplement.json against a
fresh recovery boot is what got the server back both times, and they had
those files only because they had thought to pass --keep-artifacts.
Nobody should have to guess that in advance.

The README now says not to boot recovery mode after a migration. That is
Stalwart's behaviour rather than this tool's, but this tool is where an
operator learns the technique, and it said nothing about it being a
one-time step.

Reported by @kaya-eu in #1.
2026-08-29 17:50:56 -07:00
jcoffey-dev 272439cf2a 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.
2026-08-28 17:42:31 -07:00
jcoffey-dev 80a76fbb4a Lead with the production result and the two preconditions
Someone deciding whether to trust this needs the first fact immediately: it
has migrated a real mail server, and what that cost in downtime. It was
buried in a Status section under a line count.

The two things an operator has to fix before starting - multi-tenancy, and
not migrating as the built-in admin - now sit above everything else too.
Both are changes to the directory rather than flags, so finding them after
booking a maintenance window is the wrong order, and that is exactly how
both were found on the production run.

Status keeps the line counts and the command table and no longer repeats
the paragraph above it.
2026-08-24 17:18:33 -07:00
jcoffey-dev 7ec1e70109 Say up front what an operator has to fix before migrating
Both blockers found on the production run were changes to the directory, not
flags, and both were discovered by reading a preflight failure rather than
the documentation. They now lead the README.

Multi-tenancy had no section at all: v0.16 requires a tenant-scoped account
to sit on a domain owned by that same tenant, v0.15 did not, and an install
that is valid today can be unrepresentable tomorrow. Where one tenant's
accounts use a tenant-less domain the conversion repairs it; where two
tenants share a domain nothing can, and it has to be resolved in v0.15.

The admin section predated the preflight check that now refuses a
fallback-admin, and it stopped one step short: the account has to keep its
rights *after* the migration, not merely exist. An account whose admin came
only from `tenant-admin` authenticates afterwards and is still refused every
management operation, and preflight cannot predict that - it cannot know
which roles the converter carries across.
2026-08-24 17:17:04 -07:00
jcoffey-dev 3342662b33 Record that this migrated a production server, and what it took
On 2026-08-25 the tool migrated a live mail server - nine domains, six
accounts, a 2.4 GB RocksDB store - from 0.15.5 to 0.16.19 with 8 seconds of
downtime and every phase green, validation included.

The practice worth copying is the dress rehearsal on a clone that preceded
it. `rehearse` is read-only and stops before applying, which is where a real
migration fails; a clone closes that gap for about six seconds of production
downtime. It caught four faults that would each have reached production, and
three of them only appear against a real instance. The README now describes
how to build one, including the parts that are easy to get wrong: a
consistent copy of a single-writer store, and a guest with no route out so a
copy of a live mail server cannot renew real certificates or deliver real
queued mail.

It also documents what Stalwart's own converter drops without saying so.
migrate_v016.py consumes every acme.* setting, emits nothing, and does not
report them as unmigrated - so a migration reports success while removing
certificate renewal, and nothing looks wrong until the certificate expires
about ninety days later. The AcmeProvider that had to be rebuilt by hand is
recorded in ARCHITECTURE.md, shape confirmed against a live 0.16.19, as the
specification for generating it in the supplemental plan.
2026-08-24 17:15:18 -07:00
jcoffey-dev 3adaee3bc6 Stop a clean migration reporting domains it never lost
The post-migration comparison had the two versions counting domains
differently, and yesterday's wiring turned that into a gate: `run` would have
failed a migration that lost nothing.

The 0.15 side added every domain appearing in any account's address on top of
the domain principals - the fallback's own comment says "if the instance has
no explicit domain principals", but the loop ran unconditionally. The 0.16
side did the reverse, listing only domains some account calls its primary,
discarding the full Domain list it had already fetched. An instance with
three declared domains and accounts aliased across nine reported nine before
and three after. INBUXA is exactly that shape, and this was the account/domain
over-count noted as undiagnosed.

Both sides now mean "the domains this server holds". A domain that still goes
missing is reported as a warning rather than failing the run: what the two
versions call a domain differs across this boundary in ways we have now been
caught by once, and a missing account - which is compared with a local-part
fallback and is what actually matters - still fails.

Narrowing OK() also made String() return before printing the domain lines,
so the new warning would have been silent. Caught by its own test.
2026-08-24 13:26:15 -07:00
jcoffey-dev 28128633ef Check the migration actually kept everything, and let report say so
internal/validate was written and tested and then never called: `run` ended
at cutover, so the tool performed a migration and never confirmed it had
carried the data across, and `report` was an error message pointing at the
package that would have answered.

`run` now compares the migrated instance against the snapshot preflight took
and fails if an account or a domain that existed before is missing from it.
The comparison runs against the service cutover has just started, which is
the instance people will actually use - its real config, its real ports,
under its real service manager - and costs no extra downtime; booting a
second copy inside the maintenance window would. BootCheck stays as the
equivalent for an instance the tool boots itself.

The service is left running on a failure. By that point the store has been
migrated in place, so stopping it undoes nothing, and only the operator can
weigh the finding against their recovery point.

A check that could not run is reported as skipped, never as a pass. Preflight
only captures the "before" when it has an admin URL, and a run without one
has to say it compared nothing rather than imply everything survived - which
is the exact failure ARCHITECTURE.md §4.7 warns about. `report <run-id>`
re-reads the recorded verdict rather than re-checking: run again next week
and you would be asking how the instance looks now, not how it looked when
it was migrated.

§4.7 said validation ran after cutover while the only implementation booted
its own copy, and listed a suite far larger than what exists. It now says
which of the two happens, and which checks are real.
2026-08-24 12:27:36 -07:00
jcoffey-dev 558af1005f Stop the docs saying the migration does not work
Two places told an operator that `run` is unimplemented and refuses: the
usage text, and the first line of the README's status. Both predate `run`
being wired up, and the README contradicted itself three lines later, where
the command table says it works. Anyone reading either would conclude the
tool cannot migrate.

The rest of that section had drifted too: staging exists as a package, and
the package table was missing it along with applyplan, while the line counts
had aged. Recounted, and the column now says what it is measuring.

`report` really is unimplemented, so the usage text now says so where it is
advertised.
2026-08-24 12:19:26 -07:00
jcoffey-dev 5a4c175042 Implement run: the migration pipeline, end to end
The phases have all existed for a while; nothing chained them. The order
here is the one arrived at by performing this migration by hand against a
clone of production before writing it down:

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

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

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

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

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

    MIGRATION COMPLETE. Mail was down for 6s.

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

Both refusal gates were tested, as was the failure path: an apply that
fails leaves the run stopped with the store part-migrated, and the error
says to restore the recovery point rather than restart the old version
against it.
2026-08-23 22:49:21 -07:00
jcoffey-dev 479e6d563e README: require a named admin account, and say why
A config-file fallback admin does not survive the migration, and an operator
whose only administrator is one comes out of a successful migration unable
to administer the server. Three verified reasons, all found on real
migrations: v0.16 keeps its config in the store so the old
[authentication.fallback-admin] block stops being read at all;
migrate_v016.py assigns every migrated account the User role regardless of
what it held, so even a directory admin needs its role restored; and the
restoration is refused when an account's local part is ambiguous across
domains, since granting administrator rights to the wrong account is worse
than granting them to none.
2026-08-23 22:35:05 -07:00
jcoffey-dev 0d83283caa Classify the unmigrated settings instead of just counting them
A production rehearsal reported 12,182 settings not carried over by
migrate_v016.py. As a bare number that reads as an impossible amount of
manual reconstruction, and it is misleading. Snapshotting a v0.16.14 store
migrated from a real v0.15.5 showed what those settings actually are:

    8547  regenerates         server.blocked-ip
    3337  shipped with v0.16  lookup.url-redirectors, lookup.trusted-domains,
                              spam-filter.list, spam-filter.rule,
                              spam-filter.dnsbl, lookup.surbl-hashbl
     224  already carried     server.listener, signature.* (DKIM)
     293  NEED YOUR REVIEW    queue.schedule, config.local-keys,
                              server.auto-ban, spam-filter.llm, queue.tls, ...

server.blocked-ip is auto-ban state that repopulates from live traffic. The
stock groups are data v0.16 provides itself - 2,084 MemoryLookupKey, 66
SpamRule and 18 SpamDnsblServer objects were already present in the migrated
store. DKIM came across as DkimSignature objects with private keys intact,
verified on that instance. So the real worklist is ~293 keys, not 12,182.

backup.UnmigratedReport.Classify encodes this and the rehearsal now reports
the categorised view. Rules match longest-prefix-first, because
server.blocked-ip is runtime state while server.auto-ban beside it is
configuration, and an unrecognized prefix defaults to "needs review" -
assuming an unknown setting is safe to ignore is the wrong default.

This also retired the lookup and spam-filter generators that were the
planned next step. v0.15's rules are stwt_rbl_senderscore_ip; v0.16's are
STWT_RBL_SENDERSCORE_IP - the same stock set, already installed. Generating
them from v0.15 would duplicate every rule and revert upstream updates, so
they were deliberately not written. The targets worth generating are the
small site-specific groups instead: queue.schedule, queue.tls,
session.auth, server.auto-ban.

No production data in this commit: the test fixture uses the real group
names and counts with example.com standing in for customer domains.
2026-08-23 22:07:29 -07:00
jcoffey-dev 3bd694114f Generate a v0.16 apply plan for the listeners migrate_v016.py leaves behind
First piece of ARCHITECTURE.md 4.3's apply-plan, and the piece that decides
whether a migrated server answers at all: server.listener is not among the
settings the official converter carries, so a freshly migrated instance
binds nothing. Every other unmigrated setting degrades the server; this one
stops it being a server.

internal/applyplan maps server.listener.* onto x:NetworkListener objects and
reports its own coverage. Against the smoke instance that is 24 of 3,505
unmigrated keys - 0.7% - and the output says 0.7%, listing the largest
groups it did not touch. A plan covering a fraction while implying
completeness would be worse than no plan.

The wire format was confirmed against the binary, not the documentation.
The published schema reference gives NetworkListener.bind as a JSON array;
0.16.14 rejects that outright ("Invalid value for object property.
Properties: bind"). The encoding it accepts is a value-keyed set,
{"[::]:25": true}, found by applying a plan to a live recovery-mode 0.16.14
and reading it back with `stalwart-cli snapshot`. Only mappings confirmed
that way are in DefaultGenerators; managesieve -> manageSieve is the one
protocol whose spelling changes, and an unrecognized protocol is reported
and skipped rather than passed through to fail at apply time.

Operations are upserts matched on name, so a plan can be re-run - an
operator will run it more than once - and the supplement is applied after
export.json rather than merged into it, so a generated mapping can never
override one the official script got right.

Verified end to end: rehearse against a real 0.15.5 generated ten
listeners, `stalwart-cli apply` created all ten on a real 0.16.14 with zero
failures, a snapshot read them back with correct protocols, binds and TLS
flags, and re-applying reported 10 updated / 0 created / 0 failed.
2026-08-23 21:12:45 -07:00
jcoffey-dev a0f846a31b Replace the sandbox dry run with a read-only rehearsal
`run --dry-run` cloned the data directory into a sandbox, migrated the copy,
booted it, and compared content before and after. Running that design
against a real 0.15.5 instance and a real production settings corpus
retired it:

  * The mechanics were never the risk. Backup, dump, convert and the
    recovery-mode store migration all worked essentially first time.
  * Its final comparison cannot work at all. It needs the migrated sandbox
    to answer an API, and server.listener is not among the settings
    migrate_v016.py carries - so a migrated instance has no listeners and
    answers on nothing. That is the true post-migration state, not a
    sandbox artifact to engineer around.
  * The expensive half bought the least: against a 3.6 GB production store
    it copies the data twice, reading a live mail store, to prove RocksDB
    files copy and recovery mode can open them.

Meanwhile the cheap half found every problem that would have derailed a
real migration - an empty defaultHostname v0.16 rejects, passwords v0.16
refuses to create, and a 12,182-key reconstruction worklist - and needs no
data copy at all.

So `stalwart-migrate rehearse`: preflight, dump, convert, report. It copies
nothing, starts no server, and never writes to the store, so it is safe to
run against production repeatedly without a maintenance window. It needs no
target binary either, since convert is pure Python.

The scratch directory is cleaned up as before, with the rehearsal's two
conclusions lifted out first and recorded as artifacts: export.json (what
will carry over) and unmigrated.txt (what will not). Recording an artifact
whose path was about to be deleted was a bug in the first cut of this;
both now resolve.

`run` keeps its refusal and explains where rehearse went. `--dry-run` is
kept as a flag purely to say what replaced it.

Verified against the smoke VM end to end: rehearsal completes read-only in
seconds and reports 3505 unmigrated settings on a default install,
listeners included.
2026-08-23 20:50:40 -07:00
jcoffey-dev 6c838e8b70 Add GPL-3.0 license
Copyright LINUXexpert-org. LICENSE is the canonical FSF text from
gnu.org/licenses/gpl-3.0.txt verbatim, not a reflowed or paraphrased copy,
and README gains the standard notice.

GPLv3 rather than the AGPLv3 used by cairnobs: this is a command-line tool,
so AGPL's network clause - the whole reason to reach for AGPL over GPL - has
nothing to attach to here.

No per-file SPDX headers, matching this workspace's existing convention.
Nothing is vendored: the tool is standard library only, and migrate_v016.py
is fetched from Stalwart at runtime rather than redistributed.
2026-08-23 18:01:12 -07:00
jcoffey-dev 7e04351b0f Add cutover; drop rollback in favour of operator-provided recovery
Two changes that arrived together: the cutover phase (ARCHITECTURE.md 4.5)
is implemented, and the rollback phase is deleted. Recovery from a failed
migration is now explicitly the operator's own snapshot or backup, and out
of scope for this tool.

internal/cutover implements 4.5 as seven checkpointed steps: verify the
staged binary's version, install it, preserve and rewrite the service
definition, reload, start, wait for a healthy JMAP session, recalculate
quotas.

The unit is rewritten in place rather than generated from a template. An
operator's unit carries hardening options, limits and dependencies this
tool has no business having an opinion about, and regenerating it would
silently drop them. It repoints ExecStart (preserving systemd's -@:+!
prefix characters and every argument after the executable), updates
--config, and strips recovery-mode Environment lines - leaving
STALWART_RECOVERY_MODE=1 set would recovery-boot the service on every
restart, forever. It refuses on a unit with no ExecStart, and on an
Environment line mixing a recovery variable with others: a line it only
partly understands is one it must not edit.

Quota recalculation is the one step allowed to fail without failing the
phase. Its wire format is grounded in Stalwart's x:Task schema reference -
Task/set creating one AccountMaintenance per account with maintenanceType
recalculateQuota - but the upgrade guide only documents the WebUI path, so
two details remain inferred and are called out in stalwartapi/task.go:
whether the schema's "read-only" annotation on accountId/maintenanceType
means "immutable after creation", and whether a finished task simply leaves
the queue (TaskStatus documents Pending/Retry/Failed with no success
state). Warning rather than failing is the honest response to that
uncertainty, and stale counters are an accounting problem next to calling
for a restore of a machine that is otherwise migrated and serving mail.

Docker deployments are refused outright: cutting a container over means
pulling an image and recreating it, not swapping a binary.

On removing rollback. The implementation worked and was tested, and it was
removed because restoring bytes correctly is not the hard part. It copied
file contents and permissions and verified every restored file against a
manifest - and did not preserve ownership. Run as root, as this tool
requires, it would have produced a byte-perfect, checksum-verified,
root-owned data directory that Stalwart, running as its own user, could not
open, and it would have reported success. The PostgreSQL path was worse:
pg_dump without --clean emits CREATE TABLE + COPY, which fails replaying
into a database whose tables still exist, and the ON_ERROR_STOP=1 added so
a half-applied restore couldn't be reported as success turned that into a
hard failure. None of it had ever run against a real server. A filesystem
snapshot has none of these failure modes, because it never lost the
metadata to begin with.

So cutover's gate is no longer rollback.CanRollBack but an explicit
RecoveryPointConfirmed acknowledgement. That is an assertion, not a check -
this tool cannot verify someone else's snapshot - and its only value is
that nobody migrates a production mail server having never been asked the
question. Two consequences are accepted deliberately: restoring any
pre-migration recovery point discards mail delivered since, and a failed
migration now stops and reports rather than undoing itself.

What the tool still does to make a manual restore easier: the old binary is
preserved and never deleted, the original service definition is preserved
before the rewrite, the settings and principals dumps stay on disk, and
every artifact path and checksum stays in the checkpoint where `status
<run-id>` can print it.

Also removed: the `confirm` command stub and RollbackWindowClosed, whose
only purpose was closing a rollback window that no longer exists, and
checkpoint.PhaseRollback. Old state.json files still load - JSON ignores
the now-unknown field.

Still open, and recorded in 8: cutover ignores systemd drop-ins, so an
ExecStart or Environment override in stalwart.service.d/*.conf is invisible
to the rewrite - including the recovery variable it exists to strip;
nothing prevents concurrent runs on the same run-id; and nothing in this
repo has ever run against a real Stalwart, real systemd, or a real store.
2026-08-23 17:52:47 -07:00
jcoffey-dev ade9906275 Implement internal/rollback and the service control it needs
Rollback was the one phase gating everything else: `run` without --dry-run
refused because this tool could not undo a cutover it had committed to.
That reason is now gone, and the refusal has narrowed to the fact that
there is no real cutover to undo yet.

internal/rollback implements ARCHITECTURE.md 4.8 as eight checkpointed
steps under PhaseRollback: verify-backup, stop-service,
preserve-failed-state, restore-data, restore-binary,
restore-service-config, start-service, verify-rollback.

Three things depart from what 4.8 specified, each for a reason:

- The backup is re-verified against its manifest *before* the service is
  stopped, which the design didn't call out. Finding a corrupt backup is
  survivable while the failed instance is still up, and unsurvivable once
  its data directory has been moved aside.
- BuildPlan is separate from Run, so every reason to refuse (closed
  rollback window, FoundationDB, no recorded backup, unknown deployment
  kind, missing database credentials) is found before anything is touched.
  The CLI prints that resolved plan and acts only with --yes.
- The restore is re-verified against the same manifest after writing. A
  restore that put back truncated bytes and reported success would be
  worse than one that failed outright.

Nothing from the failed attempt is deleted: the half-migrated data
directory and the displaced binary are moved to .failed-<run-id> names, so
a retry after the underlying issue is fixed still has both the evidence and
the artifacts. Afterwards a reduced validation suite runs against the
*restored* instance (version, reachability, directory counts) rather than
assuming the restore worked.

internal/service is a new package holding the systemd/Docker control this
needs. It's separate rather than living inside internal/rollback because
cutover will need the identical operations, and because the commands that
can take mail delivery down belong in one auditable place - the same
reasoning that makes stalwartapi the only thing speaking JMAP.
preflight.DeploymentKind is now a type alias for service.Kind so detection
and control can't drift apart. Its Active() reads `systemctl is-active`'s
output rather than its exit status: systemctl exits non-zero for every
non-active state, so exit-status logic would make "inactive" - the answer a
rollback most needs - look like a failure to read the state at all.

Also fixes a pre-existing bug in `status`: Go's flag package stops parsing
at the first positional argument, so `status <run-id> --state-dir X` looked
the run up in the default directory and reported it missing. `rollback`
would have inherited the same footgun on a command whose flags decide what
gets overwritten.

Still open: `confirm` cannot set RollbackWindowClosed. Rollback honours the
flag and refuses when it's set, but closing the window is the point of no
return for the backups this restores from, so it should land with the
retention policy 6 describes rather than before it.

Verified end to end against a fake systemd deployment: half-migrated data
restored to its original contents, failed state preserved, old binary
reinstalled and reporting 0.15.5, unit restarted, and a re-run of the
completed rollback inert.
2026-08-23 15:54:48 -07:00
jcoffey-dev 56f465d8a9 docs: rewrite the README around what actually works
The old README was wrong in both directions. It called the project
"architected, not yet implemented" while ~6,000 lines of tested code
exist across backup, preflight, checkpointing, validation and recovery;
and it listed six subcommands as a flat menu when three of them return
"not implemented yet" and a fourth refuses unless given --dry-run.

Replaced with a per-command and per-package status table, each entry
checked against the binary rather than the design doc: rollback, confirm
and report were run to confirm they error out, and the package line
counts come from the tree.

Records why `run` refuses without --dry-run -- internal/rollback is a
doc.go and nothing else, and committing to a migration with no working
rollback would break the one guarantee the tool exists to make. That
refusal is correct behaviour today, not a defect to be filed.

Corrects a claim in my own first draft: preflight is not purely
read-only. Its checks against the Stalwart install are, but it records
the run as a checkpoint first, so it fails without write access to
/var/lib/stalwart-migrator -- a compile-time constant with no flag or
env override. Found by running it.
2026-08-22 21:59:57 -07:00
jcoffey-dev 719a945d64 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.
2026-08-22 18:17:17 -07:00