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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user