Files
stalwart-migrator/cmd/stalwart-migrate/tenants.go
T
jcoffey-dev 0332f40b25 Add tenants: show who owns what before deciding how to unblock a migration
preflight refuses a layout v0.16 cannot represent, but it reports the problem
rather than the shape of the instance around it. Choosing between giving a
tenant its own domains, moving accounts, or collapsing everything into one
tenant needs the whole picture: which domains have an owner, which accounts
reach across to somebody else's, and which of those the converter would have
repaired by itself.

Read-only, and it names the offending domain against each account that
touches it rather than making the reader join the two lists by eye.
2026-08-24 15:33:57 -07:00

122 lines
4.1 KiB
Go

// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"context"
"flag"
"fmt"
"net/http"
"os"
"sort"
"github.com/LINUXexpert-org/stalwart-migrator/internal/stalwartapi"
)
// runTenants prints who owns what, read-only.
//
// preflight already refuses a layout v0.16 cannot represent, but it reports
// the problem, not the shape of the instance around it. Deciding how to
// resolve one — give a tenant its own domains, or collapse everything into
// one — needs the whole picture: which domains have an owner, which accounts
// reach across to a domain owned by somebody else, and which of those the
// converter would have repaired by itself.
func runTenants(args []string) error {
fs := flag.NewFlagSet("tenants", flag.ExitOnError)
adminURL := fs.String("admin-url", "", "base URL for the instance's admin API (required)")
adminUser := fs.String("admin-user", "", "admin username")
adminPassword := fs.String("admin-password", os.Getenv("STALWART_MIGRATE_ADMIN_PASSWORD"),
"admin password (or set STALWART_MIGRATE_ADMIN_PASSWORD)")
if err := fs.Parse(args); err != nil {
return err
}
if *adminURL == "" {
return fmt.Errorf("--admin-url is required")
}
client := &stalwartapi.Client{
BaseURL: *adminURL, Username: *adminUser, Password: *adminPassword, HTTPClient: &http.Client{},
}
layout, err := client.FetchTenantLayout(context.Background())
if err != nil {
return fmt.Errorf("read the tenant layout: %w", err)
}
if len(layout.Tenants) == 0 {
fmt.Println("single-tenant: this instance declares no tenant principals, so no account can mismatch its domain.")
return nil
}
tenants := append([]string(nil), layout.Tenants...)
sort.Strings(tenants)
fmt.Printf("tenants (%d): %v\n", len(tenants), tenants)
domains := make([]string, 0, len(layout.DomainTenant))
for d := range layout.DomainTenant {
domains = append(domains, d)
}
// Domains that exist only inside an address have no entry of their own.
seen := map[string]bool{}
for _, p := range layout.Principals {
for _, d := range p.Domains {
if _, declared := layout.DomainTenant[d]; !declared && !seen[d] {
seen[d] = true
domains = append(domains, d)
}
}
}
sort.Strings(domains)
fmt.Println("\ndomains:")
for _, d := range domains {
owner, declared := layout.DomainTenant[d]
switch {
case !declared:
fmt.Printf(" %-28s (not declared as a domain principal - the converter infers it, with no tenant)\n", d)
case owner == "":
fmt.Printf(" %-28s no tenant\n", d)
default:
fmt.Printf(" %-28s tenant %s\n", d, owner)
}
}
fmt.Println("\naccounts:")
principals := append([]stalwartapi.PrincipalTenancy(nil), layout.Principals...)
sort.Slice(principals, func(i, j int) bool { return principals[i].Name < principals[j].Name })
for _, p := range principals {
tenant := p.Tenant
if tenant == "" {
tenant = "(global)"
}
fmt.Printf(" %-32s %-10s tenant %-14s uses %v\n", p.Name, p.Type, tenant, p.Domains)
// The rule v0.16 enforces, spelled out per account.
for _, d := range p.Domains {
owner, declared := layout.DomainTenant[d]
if p.Tenant != "" && declared && owner != "" && owner != p.Tenant {
fmt.Printf(" ^ %s is owned by tenant %s - v0.16 rejects this\n", d, owner)
}
}
}
plan := layout.Analyze()
fmt.Println("\nwhat happens on migration:")
if len(plan.Adoptions) == 0 && len(plan.Problems) == 0 {
fmt.Println(" nothing to repair - this layout converts as it stands.")
return nil
}
for _, a := range plan.Adoptions {
fmt.Printf(" REPAIRED %s has no tenant of its own and only one tenant's accounts use it; it adopts that tenant\n", a)
}
for _, p := range plan.Problems {
fmt.Printf(" BLOCKED %s: %s\n", p.Domain, p.Detail)
}
if len(plan.Problems) > 0 {
fmt.Println("\nResolve a BLOCKED domain in v0.15 before migrating: give the tenant its own")
fmt.Println("domains, or move the accounts involved into the tenant that owns the domain.")
fmt.Println("Collapsing every account and domain into a single tenant also resolves it, as")
fmt.Println("does removing the tenant principals entirely.")
}
return nil
}