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.
This commit is contained in:
2026-08-24 00:27:26 -07:00
parent c29140b6b3
commit e955a41d58
9 changed files with 1266 additions and 32 deletions
+31 -9
View File
@@ -328,24 +328,46 @@ func (c *Checker) Run(ctx context.Context, store *checkpoint.Store, rs *checkpoi
BaseURL: c.opts.AdminURL, Username: c.opts.AdminUser,
Password: c.opts.AdminPassword, HTTPClient: c.opts.HTTPClient,
}
tenants, err := client.TenantNames(ctx)
layout, err := client.FetchTenantLayout(ctx)
if err != nil {
return CheckResult{
Status: StatusWarn,
Detail: fmt.Sprintf("couldn't determine whether this instance is multi-tenant: %v - if it is, the migration will fail after the service is stopped", err),
Detail: fmt.Sprintf("couldn't map this instance's tenants: %v - if it is multi-tenant, "+
"a domain/tenant mismatch would only surface during the conversion", err),
}, ""
}
if len(tenants) > 0 {
if len(layout.Tenants) == 0 {
return CheckResult{Status: StatusOK, Detail: "single-tenant: no tenant principals, so no account can mismatch its domain"}, ""
}
plan := layout.Analyze()
if len(plan.Problems) > 0 {
details := make([]string, 0, len(plan.Problems))
for _, p := range plan.Problems {
details = append(details, fmt.Sprintf("%s: %s", p.Domain, p.Detail))
}
return CheckResult{
Status: StatusFail,
Detail: fmt.Sprintf("this instance has %d tenant(s) (%s), and Stalwart's migrate_v016.py does not carry tenant "+
"membership onto accounts: it creates the Tenant and Domains, then every Account with a null tenantId, and the "+
"apply is rejected with invalidForeignKey - during recovery-mode migration, with the service already stopped. "+
"Migrate a multi-tenant install by hand, or wait for a converter that handles it",
len(tenants), strings.Join(tenants, ", ")),
Detail: fmt.Sprintf("this instance has %d tenant(s) (%s) in an arrangement v0.16 cannot represent - %s. "+
"Resolve this in v0.15 first: give each tenant its own domains, or move the accounts into one tenant",
len(layout.Tenants), strings.Join(layout.Tenants, ", "), strings.Join(details, "; ")),
}, ""
}
return CheckResult{Status: StatusOK, Detail: "single-tenant: no tenant principals, so the conversion's null tenantId is harmless"}, ""
if len(plan.Adoptions) > 0 {
return CheckResult{
Status: StatusWarn,
Detail: fmt.Sprintf("this instance has %d tenant(s) (%s); %d domain(s) (%s) have no tenant of their own but are "+
"used only by accounts of a single tenant. v0.16 requires them to match, so the conversion will assign each "+
"domain to that tenant - the accounts migrate intact, but those domains become tenant-owned",
len(layout.Tenants), strings.Join(layout.Tenants, ", "),
len(plan.Adoptions), strings.Join(plan.Adoptions, ", ")),
}, ""
}
return CheckResult{
Status: StatusOK,
Detail: fmt.Sprintf("%d tenant(s) (%s), and every account already sits on a domain of its own tenant",
len(layout.Tenants), strings.Join(layout.Tenants, ", ")),
}, ""
}); err != nil {
return report, err
}