Commit Graph
5 Commits
Author SHA1 Message Date
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 e955a41d58 Fix the domain/tenant mismatch that failed the second live migration
The second production attempt failed during recovery-mode apply, with the
mail server already stopped and the store already at schema v6:

    create Account restore-13: invalidForeignKey | Object id: Domain#d

v0.16 requires a tenant-scoped Account to sit on a Domain owned by that
same tenant, for its primary domain and for every alias. v0.15 imposed no
such rule, and migrate_v016.py carries the two facts over independently:
_build_domains sets a domain's memberTenantId only for domains declared as
their own `domain` principal with a `tenant`, while _build_user sets the
account's from the account's own record. A domain that exists only inside
an email address is inferred, gets no tenant, and every tenant-scoped
account using it is then rejected.

Established by reproduction rather than inference: a synthetic v0.15
principal dump, run through the unpatched upstream converter and applied to
a real 0.16.14 in recovery mode, reproduces the error character for
character - the `#d` is the server's own object id for the offending
domain, not a plan client-id. The same harness establishes which directions
are constrained: a tenant-scoped account on a tenant-less domain or on
another tenant's domain is rejected; a global account on a tenant-owned
domain is accepted.

  - applyplan.ReconcileDomainTenants repairs the plan between convert and
    apply. Where a tenant-less domain is used only by accounts of one
    tenant, the domain adopts that tenant - the sole assignment that both
    applies and keeps every account. Where accounts genuinely disagree it
    changes nothing and reports why, because forcing such a plan through
    would mean dropping mailboxes.
  - stalwartapi.FetchTenantLayout maps tenant membership over the 0.15 REST
    API and predicts the outcome with the same rule the server enforces, so
    preflight either warns about the domains that will adopt a tenant or
    fails - while the service is still running.
  - The plan is parsed generically rather than through the typed Operation.
    A real export.json mixes shapes: `create` maps a client-id to an object,
    `update` carries a flat one. The typed form failed on the first `update`
    line, found by running against actual converter output. Numbers decode
    as json.Number so a 10 GiB quota is not rewritten as 1.073741824e+10.

Corrects the record: the previous commit claimed the converter emits every
Account with `tenantId: null` and made preflight refuse every multi-tenant
install on that basis. The field is memberTenantId, the converter does
populate it, and the export had been inspected for a key no version of the
script ever writes. The refusal is now narrowed to what v0.16 genuinely
cannot represent.

The same fix has been prepared for migrate_v016.py upstream. The tool
downloads that script rather than vendoring it, so the repair stays here
until a released version carries it, and is a no-op on a consistent plan.
2026-08-24 00:27:26 -07:00
jcoffey-dev c29140b6b3 Refuse multi-tenant installs in preflight, not after the service is stopped
A second live attempt failed in the same shape as the first: preflight
clean, settings dumped and converted, then a failure during recovery-mode
migration with the mail server already stopped.

    created Tenant (1)
    created Domain (9)
    create Account restore-13: invalidForeignKey | Object id: Domain#d

migrate_v016.py carries the Tenant and the Domains but emits every Account
with a null tenantId, so the account references a tenant-owned domain while
belonging to no tenant and the foreign key is rejected. That is Stalwart's
converter and there is nothing this tool can do about it: a multi-tenant
install has to be migrated by hand until the converter handles tenants.

What this tool got wrong was the timing. Tenant principals are one API call
away and were readable the entire time the server was running. Preflight now
queries them and fails before anything is touched, with an explanation of
exactly what would otherwise fail and when.

This is the same lesson as the stalwart-cli check: knowable in advance,
discovered after a production mail server had been stopped, twice. Any
dependency of the conversion belongs in preflight, not in the phase that
consumes it.

Also makes the external-tool checks advisory during `rehearse`, which never
invokes stalwart-cli - refusing to run read-only reconnaissance because the
operator lacks a tool that reconnaissance would tell them to get was
backwards.
2026-08-23 23:51:43 -07:00
jcoffey-dev 3e155fa42c Fix three defects a full VM migration exposed
Ran a complete 0.15.5 -> 0.16.14 migration of the smoke VM, driving the
phases in the order the real pipeline will. It worked - all mail intact and
readable afterwards, all ten listeners up, cutover executed for the first
time ever and checkpoint resume exercised - and it exposed three defects.

1. The converted config was installed root-owned while the service runs as
   its own user. Stalwart crash-looped 28 times on "Failed to read data
   store settings: Permission denied", minutes after the mistake and
   nowhere near it. This is the same ownership trap that retired the
   rollback implementation, in a new place: writing files as root is the
   natural thing for a tool running as root to do, and it is wrong every
   time the service is not root.

   Cutover now installs the config itself, copying ownership and mode from
   the config being replaced.

2. v0.16.14 does not serve /api - the endpoint stalwartapi assumed.
   Confirmed against a fully migrated, fully configured, serving instance
   rather than a sandbox: /api, /api/principal and /jmap/ all 404. The JMAP
   endpoint is the one the session document advertises, which is what RFC
   8620 discovery is for.

   The client now discovers it, re-basing the advertised path onto the
   operator's host: a real instance advertises its canonical public URL
   ("https://mail.smoke.test/jmap/") which frequently isn't reachable from
   where this tool runs. The session is authoritative about the path; the
   operator is authoritative about the host.

3. Dispatching on the urn:stalwart:jmap capability was wrong, because
   NEITHER version advertises it - not 0.15.5, and not a fully migrated
   0.16.14. That sent 0.16 instances down the 0.15 REST path where every
   call 404s. The client probes what the instance actually serves instead.
   Less elegant than a declared capability, with the advantage of being
   true.

Also: a JMAP "forbidden" now explains itself. An account holding the admin
role before the migration was refused x:Account/query afterwards, and a
bare "forbidden" gives an operator nowhere to start. Whether the role
failed to carry or v0.16 wants different permissions was not isolated, and
that question is recorded as open - it gates quota recalculation and any
post-migration validation.

Verified against both live instances: the 0.15.5 reports 3 accounts and its
domain over REST, and the migrated 0.16.14 routes to JMAP, finds the right
endpoint, and returns the explained refusal.
2026-08-23 21:32:30 -07:00
jcoffey-dev 4568f9abbf Capture the pre-migration snapshot from 0.15.x, and stop claiming counts match when none were compared
Found by running preflight against a real Stalwart 0.15.5 in a VM. Two
defects, the second worse than the first.

1. AccountSnapshot could not read the version this tool migrates FROM.
   0.15.5 advertises no urn:stalwart:jmap capability and POST /api returns
   404 - the JMAP management API and x:Account are 0.16 features. 0.15.x
   exposes a REST API at GET /api/principal instead. So preflight's
   account-snapshot check warned and moved on, and every run against a real
   source instance had no "before" data at all.

   AccountSnapshot now dispatches on the capability the session document
   advertises - a positive signal, not an inference from a failed call -
   and internal/stalwartapi/principal.go implements the 0.15.x REST path,
   including its 1-based page/limit pagination so an install larger than
   one page isn't silently truncated.

2. With no "before" counts, the content-integrity comparison iterated an
   empty map, checked nothing, and reported "all message counts match".
   That is the strongest claim this tool makes - ARCHITECTURE 4.7 calls it
   the actual no-data-loss guarantee - made vacuously, and it would have
   passed on a migration that lost every message.

   The comparison now derives its account set from whatever the source
   could report, verifies every account and domain survived either way, and
   carries MessageCountsCompared so the report says plainly "MESSAGE COUNTS
   NOT COMPARED ... no-data-loss is NOT verified here" rather than implying
   otherwise.

What can and cannot be checked across the 0.15/0.16 boundary, now that a
real server has answered: 0.15.x has no per-mailbox message count at any
endpoint, and the impersonation login 0.16 offers returns 401 there, so
before/after message counts are impossible for the boundary migration this
tool exists for. Both versions do report per-account used quota (usedQuota
in 0.15's REST list, usedDiskQuota on 0.16's x:Account), so that is
captured on both sides. It is recorded and reported, not asserted on:
4.5 notes the 0.16 migration resets quotas to zero pending recalculation,
so comparing those bytes across the boundary would be a false alarm
generator.

Test servers across preflight, validate and stalwartapi now advertise
urn:stalwart:jmap, since they stand in for 0.16 instances and that
capability is what says so.

Verified end to end against the smoke VM: all nine preflight checks pass,
and the checkpoint records 2 accounts, 1 domain and per-account used quota
where it previously recorded nothing.
2026-08-23 18:51:19 -07:00