Chased down why a migrated instance had no working administrator. The
account authenticated fine and was refused every management call, and the
cause is that migrate_v016.py assigns every migrated account the User role
regardless of what it held before: an account that was `roles: ["admin"]`
in v0.15 comes out the far side as `roles: {"@type": "User"}`.
Ordinary users were never affected - User is what they had and what they
get - and their credentials, mail and mailboxes survive untouched. It is
specifically administrators who lose their privileges, which is a bad thing
to discover after cutting over.
The v0.16 shape came from the server's own schema document rather than the
published reference: GET /api/schema defines x:UserRoles as a multi-variant
type with variants User, Admin and Custom. Account is itself multi-variant,
so an upsert needs its own "@type" too - without it the server rejects the
operation outright ("upsert entry is missing `@type`").
applyplan.AccountRoleOperations restores roles from the principals dump,
emitting operations only for accounts whose role actually changes.
Rewriting every account would be a much larger blast radius for no benefit.
Where v0.15 listed several roles, admin wins - under-privileging an
administrator locks them out, which is the failure being fixed - and the
collapse is reported rather than done silently, as are roles with no known
v0.16 equivalent.
Verified end to end on the smoke VM: rehearse against the real 0.15.5 put
the role operation in the supplement, applying that supplement to a
migrated 0.16.14 whose admin was broken restored management access
(accounts=3), and alice and bob logged in over IMAPS with unchanged
credentials, read their mail, and accepted new SMTP delivery.
Also recorded: x:Account.domainId returns an internal id on v0.16, not a
domain name, so the post-migration directory comparison would read every
domain as missing. Resolving that needs an x:Domain/get call not yet
confirmed against the binary.
327 lines
14 KiB
Go
327 lines
14 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/applyplan"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/backup"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/plan"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/preflight"
|
|
)
|
|
|
|
// runRehearse implements `stalwart-migrate rehearse` (ARCHITECTURE.md §4.9):
|
|
// preflight, dump this instance's settings and principals, convert them with
|
|
// migrate_v016.py, and report both halves of the result - what will carry
|
|
// over, and what will not.
|
|
//
|
|
// It copies no data, clones nothing, starts no server, and never writes to
|
|
// the store, so it is safe to run against production repeatedly and without
|
|
// a maintenance window. That is a deliberate narrowing from the sandbox-
|
|
// cloning dry run this replaces: cloning the store proved only that the
|
|
// store migrates and opens, at the cost of copying the data twice and
|
|
// reading a live store to do it, while the half that found every real
|
|
// problem - an empty defaultHostname v0.16 rejects, passwords v0.16 refuses,
|
|
// and a 12,182-key reconstruction worklist - needs no copy at all.
|
|
//
|
|
// The worklist is the point. Measured against a real production instance,
|
|
// migrate_v016.py carried 219 of 12,401 settings; server.listener was not
|
|
// among them, so a migrated instance answers on no ports until an operator
|
|
// rebuilds them. Anything that reported such a migration as a success
|
|
// without saying so would be actively misleading.
|
|
func runRehearse(args []string) (err error) {
|
|
fs := flag.NewFlagSet("rehearse", flag.ExitOnError)
|
|
binaryPath := fs.String("binary", "/usr/local/bin/stalwart", "path to the currently-installed stalwart binary")
|
|
configPath := fs.String("config", "/etc/stalwart/config.toml", "path to stalwart's current config file")
|
|
dataDir := fs.String("data-dir", "/var/lib/stalwart", "stalwart data directory (read-only here; used by preflight's checks)")
|
|
containerName := fs.String("container", "stalwart", "docker container name, if applicable")
|
|
adminURL := fs.String("admin-url", "", "base URL for the live instance's admin/JMAP 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)")
|
|
targetVersion := fs.String("target", "latest", `target Stalwart version, or "latest"`)
|
|
stateDir := fs.String("state-dir", checkpoint.DefaultBaseDir, "directory to store run checkpoints in")
|
|
workDir := fs.String("work-dir", "/var/lib/stalwart-migrator/work", "scratch directory for the dumps and converted plan (cleaned up afterward - see --keep-artifacts)")
|
|
pythonPath := fs.String("python", "python3", "path to python3")
|
|
migrationScriptSHA256 := fs.String("migration-script-sha256", "", "pinned sha256 of migrate_v016.py (recommended; the first run prints the hash to pin)")
|
|
minFree := fs.Float64("min-free-multiple", 2.0, "free-space multiple preflight checks for; rehearsal itself copies nothing")
|
|
keepArtifacts := fs.Bool("keep-artifacts", false, "don't delete work-dir/<run-id> afterward")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *adminURL == "" {
|
|
return fmt.Errorf("--admin-url is required: rehearsal converts the settings this instance actually has, which means reading them from it")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
httpClient := &http.Client{}
|
|
|
|
if err := os.MkdirAll(*workDir, 0o750); err != nil {
|
|
return fmt.Errorf("create work dir %s: %w", *workDir, err)
|
|
}
|
|
|
|
store := checkpoint.NewStore(*stateDir)
|
|
rs, err := store.Create("", *targetVersion)
|
|
if err != nil {
|
|
return fmt.Errorf("create run: %w", err)
|
|
}
|
|
fmt.Printf("run id: %s\n\n", rs.RunID)
|
|
|
|
runWorkDir := filepath.Join(*workDir, rs.RunID)
|
|
runStateDir := filepath.Join(*stateDir, rs.RunID)
|
|
// The rehearsal's two conclusions survive cleanup: the worklist of what
|
|
// won't carry over, and the plan of what will. Everything else in the
|
|
// work directory is scratch. Recording an artifact that points into a
|
|
// directory about to be deleted would leave the checkpoint referring to
|
|
// files that aren't there.
|
|
keptWorklist := filepath.Join(runStateDir, "unmigrated.txt")
|
|
keptPlan := filepath.Join(runStateDir, "export.json")
|
|
keptSupplement := filepath.Join(runStateDir, "supplement.json")
|
|
defer func() {
|
|
if _, statErr := os.Stat(runWorkDir); os.IsNotExist(statErr) {
|
|
return
|
|
}
|
|
if *keepArtifacts {
|
|
fmt.Printf("\nartifacts kept at %s (--keep-artifacts)\n", runWorkDir)
|
|
return
|
|
}
|
|
if rmErr := os.RemoveAll(runWorkDir); rmErr != nil {
|
|
fmt.Fprintf(os.Stderr, "\nwarning: failed to clean up %s: %v (remove it manually)\n", runWorkDir, rmErr)
|
|
return
|
|
}
|
|
fmt.Printf("\ncleaned up %s; the run log is at %s\n", runWorkDir, filepath.Join(runStateDir, "state.json"))
|
|
}()
|
|
|
|
fmt.Println("--- preflight ---")
|
|
checker := preflight.New(preflight.Options{
|
|
BinaryPath: *binaryPath, ConfigPath: *configPath, DataDir: *dataDir, ContainerName: *containerName,
|
|
AdminURL: *adminURL, AdminUser: *adminUser, AdminPassword: *adminPassword,
|
|
TargetVersion: *targetVersion, MinFreeMultiple: *minFree, HTTPClient: httpClient,
|
|
})
|
|
pfReport, err := checker.Run(ctx, store, rs)
|
|
fmt.Print(pfReport.String())
|
|
if err != nil {
|
|
return fmt.Errorf("preflight failed to complete: %w", err)
|
|
}
|
|
if pfReport.Blocking() {
|
|
return fmt.Errorf("preflight found blocking issues - see FAIL lines above")
|
|
}
|
|
|
|
p, err := plan.Decide(rs.SourceVersion, rs.TargetVersion)
|
|
if err != nil {
|
|
return fmt.Errorf("plan: %w", err)
|
|
}
|
|
fmt.Printf("\nplan: %s\n", p.Reason)
|
|
if !p.CrossesMajorBoundary {
|
|
fmt.Println("\nthis is a same-boundary patch upgrade: its settings don't need converting, " +
|
|
"so there is nothing to rehearse. A real run would be a binary swap and restart.")
|
|
return nil
|
|
}
|
|
|
|
scriptDest := filepath.Join(runWorkDir, "migrate_v016.py")
|
|
settingsPath := filepath.Join(runWorkDir, "settings.json")
|
|
principalsPath := filepath.Join(runWorkDir, "principals.json")
|
|
convertedConfig := filepath.Join(runWorkDir, "config.json")
|
|
convertedExport := filepath.Join(runWorkDir, "export.json")
|
|
unmigratedPath := filepath.Join(runWorkDir, "unmigrated.txt")
|
|
|
|
fmt.Println("\n--- dump (read-only) ---")
|
|
if _, err := store.RunStep(rs, checkpoint.PhaseBackup, "settings-dump", func() (checkpoint.StepOutcome, error) {
|
|
if err := os.MkdirAll(runWorkDir, 0o750); err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
sum, err := backup.DownloadFile(ctx, httpClient, backup.DefaultMigrationScriptURL, scriptDest, *migrationScriptSHA256)
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
pinNote := ""
|
|
if *migrationScriptSHA256 == "" {
|
|
pinNote = fmt.Sprintf(" (no pin configured - record sha256 %s as --migration-script-sha256 to pin it)", sum)
|
|
}
|
|
if err := backup.RunSettingsDump(ctx, backup.SettingsDumpOptions{
|
|
PythonPath: *pythonPath, ScriptPath: scriptDest, URL: *adminURL,
|
|
Username: *adminUser, Password: *adminPassword,
|
|
SettingsPath: settingsPath, PrincipalsPath: principalsPath,
|
|
}); err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
_, settingsSize, err := backup.HashFile(settingsPath)
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
_, principalsSize, err := backup.HashFile(principalsPath)
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
return checkpoint.StepOutcome{
|
|
Detail: fmt.Sprintf("dumped settings (%d bytes) and principals (%d bytes) from %s%s",
|
|
settingsSize, principalsSize, *adminURL, pinNote),
|
|
}, nil
|
|
}); err != nil {
|
|
return fmt.Errorf("settings dump: %w", err)
|
|
}
|
|
fmt.Println(rs.Outcome(checkpoint.PhaseBackup, "settings-dump").Detail)
|
|
|
|
fmt.Println("\n--- convert ---")
|
|
if _, err := store.RunStep(rs, checkpoint.PhaseStage, "convert-settings", func() (checkpoint.StepOutcome, error) {
|
|
if err := backup.RunSettingsConvert(ctx, backup.SettingsConvertOptions{
|
|
PythonPath: *pythonPath, ScriptPath: scriptDest,
|
|
SettingsPath: settingsPath, PrincipalsPath: principalsPath,
|
|
ConfigPath: convertedConfig, OutputPath: convertedExport,
|
|
// migrate_v016.py writes unmigrated.txt into its working
|
|
// directory; without this it lands wherever the operator
|
|
// happened to be, or fails the convert if that isn't writable.
|
|
WorkDir: runWorkDir,
|
|
}); err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
detail := "converted this instance's settings into a v0.16 apply plan"
|
|
if report, readErr := backup.ReadUnmigratedReport(unmigratedPath); readErr == nil && report != nil {
|
|
detail += fmt.Sprintf("; %d setting(s) will NOT carry over", report.TotalKeys)
|
|
}
|
|
return checkpoint.StepOutcome{Detail: detail}, nil
|
|
}); err != nil {
|
|
return fmt.Errorf("convert settings: %w", err)
|
|
}
|
|
|
|
if err := copyFile(convertedExport, keptPlan); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: couldn't preserve the apply plan: %v\n", err)
|
|
} else if sum, size, hashErr := backup.HashFile(keptPlan); hashErr == nil {
|
|
rs.RecordArtifact("converted-export", checkpoint.Artifact{Path: keptPlan, SHA256: sum, SizeBytes: size})
|
|
fmt.Printf("apply plan: %s (%d bytes) - what WILL carry over\n", keptPlan, size)
|
|
}
|
|
|
|
unmigrated, err := backup.ReadUnmigratedReport(unmigratedPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: couldn't read the unmigrated-settings report: %v\n", err)
|
|
} else if unmigrated != nil && unmigrated.TotalKeys > 0 {
|
|
if err := copyFile(unmigratedPath, keptWorklist); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: couldn't preserve the worklist: %v\n", err)
|
|
} else {
|
|
unmigrated.Path = keptWorklist
|
|
if sum, size, hashErr := backup.HashFile(keptWorklist); hashErr == nil {
|
|
rs.RecordArtifact("unmigrated-settings", checkpoint.Artifact{Path: keptWorklist, SHA256: sum, SizeBytes: size})
|
|
}
|
|
}
|
|
fmt.Printf("\n !! %s\n", unmigrated.Summary(10))
|
|
fmt.Println(" These do not carry over on their own. See the supplemental plan below for the part")
|
|
fmt.Println(" this tool can rebuild for you.")
|
|
}
|
|
|
|
// Generate what we can of the gap (ARCHITECTURE.md §4.3). This is
|
|
// best-effort by design and reports its own coverage: the plan is
|
|
// worth having precisely because it is honest about how much of the
|
|
// worklist it does not touch.
|
|
fmt.Println("\n--- supplemental plan (best-effort) ---")
|
|
if err := generateSupplement(store, rs, settingsPath, principalsPath, unmigratedPath, keptSupplement); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: couldn't generate the supplemental plan: %v\n", err)
|
|
}
|
|
if err := store.Save(rs); err != nil {
|
|
return fmt.Errorf("save run state: %w", err)
|
|
}
|
|
|
|
fmt.Printf("\nREHEARSAL COMPLETE for run %s. Nothing was modified: no data was copied, no server was started,\n"+
|
|
"and the store was never written to.\n", rs.RunID)
|
|
return nil
|
|
}
|
|
|
|
// copyFile duplicates src to dst, used to lift the worklist out of the
|
|
// scratch directory before it's cleaned up.
|
|
func copyFile(src, dst string) error {
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
|
|
return err
|
|
}
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
return err
|
|
}
|
|
return out.Sync()
|
|
}
|
|
|
|
// generateSupplement builds the best-effort apply plan for settings
|
|
// migrate_v016.py left behind, writes it beside the run's other
|
|
// conclusions, and reports honestly how much of the gap it closed.
|
|
//
|
|
// It is deliberately applied *after* export.json rather than merged into
|
|
// it: the official conversion is the authority on everything it handles,
|
|
// and a generated plan that overlapped it could silently override a
|
|
// correct mapping with a guessed one.
|
|
func generateSupplement(store *checkpoint.Store, rs *checkpoint.RunState, settingsPath, principalsPath, unmigratedPath, outPath string) error {
|
|
settings, err := backup.ReadSettingsDump(settingsPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
unmigrated, err := backup.ReadUnmigratedKeys(unmigratedPath, settings)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
plan, coverage, err := applyplan.Build(settings, unmigrated, applyplan.DefaultGenerators())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Account roles don't live in the settings dump, so they come from the
|
|
// principals dump rather than through a settings Generator. Without
|
|
// this the migrated instance has no administrator: migrate_v016.py
|
|
// gives every account the User role, whatever it had before.
|
|
principals, err := backup.ReadPrincipalsDump(principalsPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
roleOps, _, roleWarnings, err := applyplan.AccountRoleOperations(principals)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
plan.Operations = append(plan.Operations, roleOps...)
|
|
coverage.Warnings = append(coverage.Warnings, roleWarnings...)
|
|
if len(roleOps) > 0 {
|
|
coverage.ObjectsByType["Account role"] += len(roleOps)
|
|
}
|
|
if len(plan.Operations) == 0 {
|
|
fmt.Println("nothing this tool can rebuild automatically yet - the whole worklist is manual")
|
|
return nil
|
|
}
|
|
if err := plan.WriteNDJSON(outPath); err != nil {
|
|
return err
|
|
}
|
|
if sum, size, hashErr := backup.HashFile(outPath); hashErr == nil {
|
|
rs.RecordArtifact("supplemental-plan", checkpoint.Artifact{Path: outPath, SHA256: sum, SizeBytes: size})
|
|
}
|
|
|
|
fmt.Printf("%s\n", coverage.Summary(6))
|
|
for _, w := range coverage.Warnings {
|
|
fmt.Printf(" warning: %s\n", w)
|
|
}
|
|
fmt.Printf("\n supplement: %s\n", outPath)
|
|
fmt.Println(" Review it, then apply it after export.json:")
|
|
fmt.Printf(" stalwart-cli apply --file %s --url <migrated-instance>\n", outPath)
|
|
fmt.Println(" It is generated, not authoritative - read it before you run it.")
|
|
|
|
_, err = store.RunStep(rs, checkpoint.PhaseStage, "supplemental-plan", func() (checkpoint.StepOutcome, error) {
|
|
return checkpoint.StepOutcome{
|
|
Detail: fmt.Sprintf("generated %d operation(s) covering %d of %d unmigrated setting(s)",
|
|
len(plan.Operations), coverage.CoveredKeys, coverage.TotalKeys),
|
|
}, nil
|
|
})
|
|
return err
|
|
}
|