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.
This commit is contained in:
@@ -27,6 +27,8 @@ func main() {
|
|||||||
err = runRehearse(os.Args[2:])
|
err = runRehearse(os.Args[2:])
|
||||||
case "run":
|
case "run":
|
||||||
err = runRun(os.Args[2:])
|
err = runRun(os.Args[2:])
|
||||||
|
case "tenants":
|
||||||
|
err = runTenants(os.Args[2:])
|
||||||
case "status":
|
case "status":
|
||||||
err = runStatus(os.Args[2:])
|
err = runStatus(os.Args[2:])
|
||||||
case "report":
|
case "report":
|
||||||
@@ -49,6 +51,7 @@ commands:
|
|||||||
preflight run read-only checks and print the migration plan
|
preflight run read-only checks and print the migration plan
|
||||||
rehearse convert this instance's settings and report what will NOT carry over (read-only)
|
rehearse convert this instance's settings and report what will NOT carry over (read-only)
|
||||||
run perform the migration (needs --yes and --recovery-point-confirmed)
|
run perform the migration (needs --yes and --recovery-point-confirmed)
|
||||||
|
tenants show which tenant owns which domain, and what blocks a migration (read-only)
|
||||||
status show the state of an in-progress or completed run
|
status show the state of an in-progress or completed run
|
||||||
report print the validation report for a run`)
|
report print the validation report for a run`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user