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:
@@ -261,10 +261,26 @@ func runRun(args []string) (err error) {
|
||||
}); err != nil {
|
||||
return checkpoint.StepOutcome{}, err
|
||||
}
|
||||
return checkpoint.StepOutcome{Detail: "converted settings into a v0.16 apply plan"}, nil
|
||||
// Repair migrate_v016.py's domain/tenant mismatch before the
|
||||
// plan is ever applied. Left alone it surfaces as
|
||||
// "invalidForeignKey | Object id: Domain#..." partway through
|
||||
// apply - with the old service already stopped and the store
|
||||
// already at schema v6, i.e. at the one point in the run where
|
||||
// there is no way forward and no way back. See
|
||||
// applyplan/tenants.go.
|
||||
tenantFix, err := applyplan.ReconcileDomainTenantsFile(convertedExport)
|
||||
if err != nil {
|
||||
return checkpoint.StepOutcome{}, err
|
||||
}
|
||||
detail := "converted settings into a v0.16 apply plan"
|
||||
if len(tenantFix.Adoptions) > 0 {
|
||||
detail += " - " + tenantFix.String()
|
||||
}
|
||||
return checkpoint.StepOutcome{Detail: detail}, nil
|
||||
}); err != nil {
|
||||
return fmt.Errorf("convert settings: %w", err)
|
||||
}
|
||||
fmt.Println(rs.Outcome(checkpoint.PhaseStage, "convert-settings").Detail)
|
||||
unmigrated, readErr := backup.ReadUnmigratedReport(unmigratedPath)
|
||||
if readErr == nil && unmigrated != nil && unmigrated.TotalKeys > 0 {
|
||||
keptWorklist := filepath.Join(runStateDir, "unmigrated.txt")
|
||||
|
||||
Reference in New Issue
Block a user