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:
@@ -209,12 +209,15 @@ func accountKey(p restPrincipal) string {
|
||||
|
||||
// TenantNames returns the tenant principals on a v0.15.x instance.
|
||||
//
|
||||
// Multi-tenancy has to be detected before a migration starts, because
|
||||
// Stalwart's own converter does not survive it: it emits the Tenant and the
|
||||
// Domains correctly and then every Account with `tenantId: null`, so the
|
||||
// account references a tenant-owned domain while belonging to no tenant and
|
||||
// the apply is rejected with `invalidForeignKey`. Observed on a real
|
||||
// migration, at the point where the mail server was already stopped.
|
||||
// Multi-tenancy has to be established before a migration starts. v0.16
|
||||
// requires a tenant-scoped account to sit on a domain owned by that same
|
||||
// tenant; v0.15 did not, and Stalwart's converter carries the two facts
|
||||
// over independently, so an install that is valid today can convert into a
|
||||
// plan the new server rejects with `invalidForeignKey` on the Domain
|
||||
// reference - during the recovery-mode apply, with the mail server already
|
||||
// stopped and the store already at schema v6. See FetchTenantLayout, which
|
||||
// builds on this to predict that outcome, and applyplan.ReconcileDomainTenants,
|
||||
// which repairs the plan.
|
||||
func (c *Client) TenantNames(ctx context.Context) ([]string, error) {
|
||||
tenants, err := c.restPrincipals(ctx, "tenant")
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user