Rollback was the one phase gating everything else: `run` without --dry-run refused because this tool could not undo a cutover it had committed to. That reason is now gone, and the refusal has narrowed to the fact that there is no real cutover to undo yet. internal/rollback implements ARCHITECTURE.md 4.8 as eight checkpointed steps under PhaseRollback: verify-backup, stop-service, preserve-failed-state, restore-data, restore-binary, restore-service-config, start-service, verify-rollback. Three things depart from what 4.8 specified, each for a reason: - The backup is re-verified against its manifest *before* the service is stopped, which the design didn't call out. Finding a corrupt backup is survivable while the failed instance is still up, and unsurvivable once its data directory has been moved aside. - BuildPlan is separate from Run, so every reason to refuse (closed rollback window, FoundationDB, no recorded backup, unknown deployment kind, missing database credentials) is found before anything is touched. The CLI prints that resolved plan and acts only with --yes. - The restore is re-verified against the same manifest after writing. A restore that put back truncated bytes and reported success would be worse than one that failed outright. Nothing from the failed attempt is deleted: the half-migrated data directory and the displaced binary are moved to .failed-<run-id> names, so a retry after the underlying issue is fixed still has both the evidence and the artifacts. Afterwards a reduced validation suite runs against the *restored* instance (version, reachability, directory counts) rather than assuming the restore worked. internal/service is a new package holding the systemd/Docker control this needs. It's separate rather than living inside internal/rollback because cutover will need the identical operations, and because the commands that can take mail delivery down belong in one auditable place - the same reasoning that makes stalwartapi the only thing speaking JMAP. preflight.DeploymentKind is now a type alias for service.Kind so detection and control can't drift apart. Its Active() reads `systemctl is-active`'s output rather than its exit status: systemctl exits non-zero for every non-active state, so exit-status logic would make "inactive" - the answer a rollback most needs - look like a failure to read the state at all. Also fixes a pre-existing bug in `status`: Go's flag package stops parsing at the first positional argument, so `status <run-id> --state-dir X` looked the run up in the default directory and reported it missing. `rollback` would have inherited the same footgun on a command whose flags decide what gets overwritten. Still open: `confirm` cannot set RollbackWindowClosed. Rollback honours the flag and refuses when it's set, but closing the window is the point of no return for the backups this restores from, so it should land with the retention policy 6 describes rather than before it. Verified end to end against a fake systemd deployment: half-migrated data restored to its original contents, failed state preserved, old binary reinstalled and reporting 0.15.5, unit restarted, and a re-run of the completed rollback inert.
247 lines
12 KiB
Go
247 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"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"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/recovery"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/validate"
|
|
)
|
|
|
|
// runRun implements `stalwart-migrate run`. Only --dry-run is available
|
|
// today: the cutover phase itself (§4.5) isn't built. internal/rollback and
|
|
// internal/service now exist, so the missing piece is no longer "this can't
|
|
// be undone" but "there's nothing here to undo" - see ARCHITECTURE.md §8.
|
|
// `run` without --dry-run refuses rather than doing a migration partway.
|
|
//
|
|
// --dry-run runs preflight and a real backup (see the caveat printed below
|
|
// about why backup still touches the live data directory), then - if the
|
|
// plan crosses the 0.15/0.16 boundary - clones the verified backup into a
|
|
// disposable sandbox, converts the settings snapshot to point at that
|
|
// sandbox (via migrate_v016.py's own documented --patch-paths mechanism,
|
|
// not by this tool guessing at config.json's schema), runs the real
|
|
// recovery-mode migration against the sandbox, and boots the result
|
|
// normally to confirm it comes up. Nothing at the real binary path or the
|
|
// real service is ever touched.
|
|
//
|
|
// Every byte a dry run writes - the fs-backup copy, the settings/principals
|
|
// dumps, the downloaded migrate_v016.py, the sandbox clone and its
|
|
// config/export files - lives under one per-run directory
|
|
// (work-dir/<run-id>) that a deferred cleanup at the bottom of this
|
|
// function removes on every exit path: success, a failed check partway
|
|
// through, or an early refusal. The only thing left behind afterward is the
|
|
// checkpoint's state.json under --state-dir, which is exactly the
|
|
// success/failure log a rerun's `status <run-id>` reads - not bulk data.
|
|
// --keep-artifacts opts out, for when a failure needs inspecting.
|
|
func runRun(args []string) (err error) {
|
|
fs := flag.NewFlagSet("run", flag.ExitOnError)
|
|
binaryPath := fs.String("binary", "/usr/local/bin/stalwart", "path to the currently-installed stalwart binary")
|
|
targetBinaryPath := fs.String("target-binary", "", "path to an already-downloaded target-version stalwart binary (required to simulate a major-boundary migration)")
|
|
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")
|
|
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 backups, dumps, and the dry-run sandbox (cleaned up afterward - see --keep-artifacts)")
|
|
stalwartCLI := fs.String("stalwart-cli", "stalwart-cli", "path to the stalwart-cli binary")
|
|
pythonPath := fs.String("python", "python3", "path to python3")
|
|
migrationScriptSHA256 := fs.String("migration-script-sha256", "", "pinned sha256 of migrate_v016.py (recommended; see preflight/backup output for the hash to pin after a first unpinned run)")
|
|
recoveryPort := fs.Int("recovery-port", 8080, "port recovery mode's HTTP listener binds, per UPGRADING/v0_16.md's own examples")
|
|
minFree := fs.Float64("min-free-multiple", 2.0, "required free disk space as a multiple of the data directory size")
|
|
dryRun := fs.Bool("dry-run", false, "simulate and validate the migration against a disposable sandbox, without touching production")
|
|
keepArtifacts := fs.Bool("keep-artifacts", false, "don't delete work-dir/<run-id> afterward (the fs-backup copy, dumps, and sandbox) - useful for inspecting a failure")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !*dryRun {
|
|
return fmt.Errorf(
|
|
"real (non-dry-run) migrations aren't available yet: the cutover phase - installing the new binary, rewriting the " +
|
|
"service definition, and starting the migrated instance for real (ARCHITECTURE.md §4.5) - isn't implemented. " +
|
|
"Rollback is, so a future cutover will be undoable; there just isn't one to undo yet. Run with --dry-run to " +
|
|
"validate the migration mechanics against a disposable sandbox copy of your data - nothing in production is touched",
|
|
)
|
|
}
|
|
if *adminURL == "" {
|
|
return fmt.Errorf("--admin-url is required")
|
|
}
|
|
|
|
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)
|
|
logPath := filepath.Join(*stateDir, rs.RunID, "state.json")
|
|
defer func() {
|
|
if _, statErr := os.Stat(runWorkDir); os.IsNotExist(statErr) {
|
|
return // nothing was ever written (e.g. refused before backup ran)
|
|
}
|
|
if *keepArtifacts {
|
|
fmt.Printf("\nartifacts kept at %s (--keep-artifacts) - remove manually when done inspecting\n", runWorkDir)
|
|
return
|
|
}
|
|
outcome := "succeeded"
|
|
if err != nil {
|
|
outcome = "failed"
|
|
}
|
|
if rmErr := os.RemoveAll(runWorkDir); rmErr != nil {
|
|
fmt.Fprintf(os.Stderr, "\nwarning: dry run %s, but failed to clean up %s: %v (remove it manually)\n", outcome, runWorkDir, rmErr)
|
|
return
|
|
}
|
|
fmt.Printf("\ndry run %s - cleaned up %s; the run log is at %s\n", outcome, runWorkDir, logPath)
|
|
}()
|
|
|
|
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)
|
|
|
|
fmt.Println("\n--- backup ---")
|
|
fmt.Println("(dry-run does not stop the live Stalwart service itself - internal/service can now do that, but dry-run " +
|
|
"isn't wired to offer it. For a guaranteed-consistent snapshot, stop stalwart before running this; otherwise the " +
|
|
"filesystem copy may reflect a live, in-use store. This is unrelated to whether production gets touched - it never does.)")
|
|
|
|
backupDir := filepath.Join(runWorkDir, "backup")
|
|
scriptDest := filepath.Join(runWorkDir, "migrate_v016.py")
|
|
settingsPath := filepath.Join(runWorkDir, "settings.json")
|
|
principalsPath := filepath.Join(runWorkDir, "principals.json")
|
|
|
|
backupOpts := backup.Options{
|
|
BinaryPath: *binaryPath,
|
|
SkipBinaryPreservation: true, // dry-run: never touch the production binary
|
|
DataDir: *dataDir,
|
|
BackupDir: backupDir,
|
|
MigrationScriptSHA256: *migrationScriptSHA256,
|
|
ScriptDestPath: scriptDest,
|
|
AdminURL: *adminURL,
|
|
AdminUser: *adminUser,
|
|
AdminPassword: *adminPassword,
|
|
SettingsDumpPath: settingsPath,
|
|
PrincipalsDumpPath: principalsPath,
|
|
PythonPath: *pythonPath,
|
|
HTTPClient: httpClient,
|
|
}
|
|
bkReport, err := backup.Run(ctx, store, rs, backupOpts)
|
|
fmt.Print(bkReport.String())
|
|
if err != nil {
|
|
return fmt.Errorf("backup failed: %w", err)
|
|
}
|
|
|
|
if !p.CrossesMajorBoundary {
|
|
fmt.Println("\nthis is a same-boundary patch upgrade: there's no recovery-mode phase to simulate. " +
|
|
"preflight and backup above are as far as a dry-run goes for this path - a real run would be a binary swap and restart.")
|
|
return nil
|
|
}
|
|
|
|
if *targetBinaryPath == "" {
|
|
return fmt.Errorf("--target-binary is required to simulate a major-boundary migration (0.15 -> 0.16 crosses one here)")
|
|
}
|
|
|
|
fmt.Println("\n--- convert (settings -> sandbox config) ---")
|
|
sandboxDataDir := filepath.Join(runWorkDir, "sandbox-data")
|
|
sandboxConfigPath := filepath.Join(runWorkDir, "sandbox-config.json")
|
|
sandboxExportPath := filepath.Join(runWorkDir, "sandbox-export.json")
|
|
|
|
if _, err := store.RunStep(rs, checkpoint.PhaseStage, "clone-sandbox-data", func() (checkpoint.StepOutcome, error) {
|
|
manifest, err := backup.CopyDataDir(backupDir, sandboxDataDir)
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
return checkpoint.StepOutcome{Detail: fmt.Sprintf("cloned the verified backup (%d files) into the sandbox at %s", len(manifest.Files), sandboxDataDir)}, nil
|
|
}); err != nil {
|
|
return fmt.Errorf("clone sandbox data: %w", err)
|
|
}
|
|
fmt.Printf("cloned verified backup into sandbox: %s\n", sandboxDataDir)
|
|
|
|
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: sandboxConfigPath, OutputPath: sandboxExportPath,
|
|
PatchPaths: map[string]string{*dataDir: sandboxDataDir},
|
|
}); err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
return checkpoint.StepOutcome{Detail: fmt.Sprintf("generated %s and %s, patched to point at the sandbox", sandboxConfigPath, sandboxExportPath)}, nil
|
|
}); err != nil {
|
|
return fmt.Errorf("convert settings: %w", err)
|
|
}
|
|
fmt.Println("generated sandbox config.json and export.json")
|
|
|
|
fmt.Println("\n--- recovery-mode migration (against the sandbox) ---")
|
|
listenURL := fmt.Sprintf("http://127.0.0.1:%d/", *recoveryPort)
|
|
recReport, err := recovery.Run(ctx, store, rs, recovery.Options{
|
|
BinaryPath: *targetBinaryPath, ConfigPath: sandboxConfigPath, ListenURL: listenURL,
|
|
AdminUser: "admin", ApplyFiles: []string{sandboxExportPath}, CLIBinaryPath: *stalwartCLI,
|
|
HTTPClient: httpClient,
|
|
})
|
|
fmt.Print(recReport.String())
|
|
if err != nil {
|
|
return fmt.Errorf("recovery-mode migration against the sandbox failed: %w", err)
|
|
}
|
|
|
|
fmt.Println("\n--- boot check (normal boot of the migrated sandbox) ---")
|
|
if rs.PreflightSnapshot != nil {
|
|
fmt.Println("(comparing against the pre-migration account/mailbox snapshot preflight captured - " +
|
|
"this is the actual no-data-loss check, not just a reachability probe)")
|
|
} else {
|
|
fmt.Println("(no pre-migration snapshot to compare against - preflight couldn't capture one, most likely " +
|
|
"because --admin-url wasn't set; only reachability is checked)")
|
|
}
|
|
valReport, err := validate.Run(ctx, store, rs, validate.BootCheckOptions{
|
|
BinaryPath: *targetBinaryPath, ConfigPath: sandboxConfigPath, ListenURL: listenURL, HTTPClient: httpClient,
|
|
ContentIntegrityBefore: rs.PreflightSnapshot,
|
|
AdminUser: *adminUser,
|
|
AdminPassword: *adminPassword,
|
|
})
|
|
fmt.Print(valReport.String())
|
|
if err != nil {
|
|
return fmt.Errorf("post-migration validation failed: %w", err)
|
|
}
|
|
|
|
verified := "the migration mechanics succeeded"
|
|
if rs.PreflightSnapshot != nil {
|
|
verified = "the migration mechanics succeeded AND every account/mailbox message count matched before vs. after"
|
|
}
|
|
fmt.Printf("\nDRY RUN COMPLETE for run %s: %s, against a disposable sandbox copy of your data. Nothing in production was touched.\n", rs.RunID, verified)
|
|
return nil
|
|
}
|