The repositories moved off LINUXexpert-org. Here that is not a documentation change: the old organisation was the module path, so it is declared in go.mod and repeated in every internal import. Leaving it would have been worse than a stale link. GitHub redirects the repository, but a go.mod whose module line disagrees with the path it was fetched from is an error rather than a redirect, so `go get` on the new address would have failed against the old declaration. go.mod, 34 files of imports, and the repository links in README and ARCHITECTURE. go mod tidy leaves go.sum untouched -- no dependency moved, only our own path.
122 lines
4.1 KiB
Go
122 lines
4.1 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
|
|
"github.com/Coffey-Labs/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
|
|
}
|