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.
This commit is contained in:
2026-08-23 23:51:43 -07:00
parent 9faa21f4f1
commit c29140b6b3
6 changed files with 141 additions and 2 deletions
+24
View File
@@ -332,3 +332,27 @@ func TestAccountSnapshotResolvesDomainIdsToNames(t *testing.T) {
t.Errorf("Domains = %v, want [smoke.test] - ids must be resolved or every domain reads as missing", snap.Domains)
}
}
// Multi-tenancy has to be detectable before a migration starts. Stalwart's
// converter emits the Tenant and Domains correctly and then every Account
// with a null tenantId, so the apply is rejected with invalidForeignKey -
// observed on a real migration, at the point where the mail server was
// already stopped.
func TestTenantNamesReportsTenantPrincipals(t *testing.T) {
srv, _ := stalwart015Server(t,
[]map[string]any{{"id": 1, "type": "individual", "name": "[email protected]"}},
nil,
)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
// The fake serves the "domain" set for types=domain and individuals
// otherwise; a tenant query returns the individuals set, so assert on
// the call succeeding and the names being read, not on a fixed count.
names, err := client.TenantNames(context.Background())
if err != nil {
t.Fatalf("TenantNames: %v", err)
}
if names == nil {
t.Error("TenantNames returned nil without an error; want a (possibly empty) list")
}
}