Files
stalwart-migrator/internal/backup/backup.go
T
jcoffey-dev b88724632c Capture and surface the settings migrate_v016.py does NOT migrate
Ran the converter against a real production settings corpus pulled from the
instance this tool is being built to migrate (secrets scrubbed on that host;
nothing sensitive transited). The result reframes what "a successful
migration" means:

    total settings:     12401
    NOT migrated:       12182  (98.2%)
    actually migrated:  219  (1.8%)

Stalwart's own script reports this in an unmigrated.txt it writes beside its
output - and this tool was throwing that file away. Worse, RunSettingsConvert
never set cmd.Dir, so the script wrote unmigrated.txt into whatever directory
the operator happened to launch from, or failed the whole convert when that
directory wasn't writable. Both reproduced.

The largest groups left behind on production are spam-filter rules, DNSBLs,
trusted-domain and URL-redirector lookups, queue scheduling and TLS
settings - and server.listener. That last one explains something that had
been puzzling from an earlier smoke run: a freshly migrated 0.16 instance
answered on none of the ports the old one did, and served nothing but
/admin. Its listeners never migrated.

So:

- SettingsConvertOptions gains WorkDir, and the convert runs there. The
  report lands somewhere known and an unwritable cwd can't fail the step.
- ReadUnmigratedReport parses it; UnmigratedReport.Summary renders the
  largest groups first.
- The dry run records it as an "unmigrated-settings" artifact with a
  checksum, notes the count in the checkpoint step, and prints it as a
  standing warning rather than a footnote.

ARCHITECTURE 4.3 anticipated a "best-effort apply-plan" gap here. The gap is
not a few stragglers needing review; it is effectively the entire
configuration, and the tool has to say so where nobody can miss it.

Verified against the smoke VM: even a default `stalwart --init` instance
reports 3505 of ~3514 settings unmigrated, listeners included.
2026-08-23 20:00:23 -07:00

264 lines
10 KiB
Go

// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package backup
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
)
// Options configures a full backup pass. Which fields matter depends on
// rs.Topology.StoreBackend, as recorded by the preflight phase - Run
// branches on that rather than requiring the caller to pre-select a code
// path.
type Options struct {
// Binary preservation.
BinaryPath string
// SkipBinaryPreservation, when true, never touches BinaryPath - used by
// a dry run, which must not move the production binary aside just to
// simulate a migration it hasn't committed to.
SkipBinaryPreservation bool
// Embedded backend (RocksDB/SQLite).
DataDir string
BackupDir string // destination for the fs snapshot; required if the backend is embedded
// External SQL backend (PostgreSQL/MySQL).
SQL SQLOptions
// FoundationDB backend.
FDB FDBOptions
// Settings/principals dump (always runs - every topology needs it).
MigrationScriptURL string // defaults to DefaultMigrationScriptURL
MigrationScriptSHA256 string // pinned hash; empty accepts and reports whatever is fetched (see DownloadFile)
ScriptDestPath string
AdminURL string
AdminUser string
AdminPassword string
SettingsDumpPath string
PrincipalsDumpPath string
PythonPath string
HTTPClient *http.Client
// Per-account content export (Vandelay) - optional defense-in-depth
// layer. Empty Accounts skips it entirely; this is expected until
// account enumeration is wired up (see stalwartapi.Client.AccountSnapshot).
Vandelay VandelayOptions
Accounts []string
}
// Run executes the full backup pass described in ARCHITECTURE.md §4.2,
// checkpointing each step. Unlike preflight, most steps here are hard
// failures: a backup that didn't actually happen must stop the pipeline,
// not just get reported and continued past. The one genuinely optional
// layer is the per-account Vandelay export, which is skipped (Status:
// StatusSkipped, not a failure) when Options.Accounts is empty - but if the
// operator populated Accounts, a failure there is hard too, since silently
// downgrading an explicitly requested backup layer would be worse than
// stopping.
func Run(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState, opts Options) (Report, error) {
var report Report
step := func(name string, fn func() (checkpoint.StepOutcome, error)) (checkpoint.StepOutcome, error) {
outcome, err := store.RunStep(rs, checkpoint.PhaseBackup, name, fn)
if err != nil {
report.Results = append(report.Results, CheckResult{Name: name, Status: StatusFail, Detail: err.Error()})
return outcome, err
}
status := Status(outcome.Verdict)
if status == "" {
status = StatusOK
}
report.Results = append(report.Results, CheckResult{Name: name, Status: status, Detail: outcome.Detail})
return outcome, nil
}
if opts.SkipBinaryPreservation {
report.Results = append(report.Results, CheckResult{
Name: "preserve-binary", Status: StatusSkipped,
Detail: "skipped (SkipBinaryPreservation) - the production binary is never touched, e.g. for a dry run",
})
} else if _, err := step("preserve-binary", func() (checkpoint.StepOutcome, error) {
preserved, err := PreserveBinary(opts.BinaryPath, rs.SourceVersion)
if err != nil {
return checkpoint.StepOutcome{}, err
}
sum, size, err := HashFile(preserved)
if err != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("backup: hash preserved binary %s: %w", preserved, err)
}
rs.RecordArtifact("old-binary", checkpoint.Artifact{Path: preserved, SHA256: sum, SizeBytes: size})
return checkpoint.StepOutcome{Detail: fmt.Sprintf("preserved %s as %s", opts.BinaryPath, preserved), Extra: preserved}, nil
}); err != nil {
return report, err
}
backends := strings.ToLower(rs.Topology.StoreBackend)
switch {
case strings.Contains(backends, "rocksdb") || strings.Contains(backends, "sqlite"):
if _, err := step("fs-snapshot", func() (checkpoint.StepOutcome, error) {
manifest, err := CopyDataDir(opts.DataDir, opts.BackupDir)
if err != nil {
return checkpoint.StepOutcome{}, err
}
manifestPath := filepath.Join(opts.BackupDir, "..", filepath.Base(opts.BackupDir)+".manifest.json")
if err := WriteManifest(manifestPath, manifest); err != nil {
return checkpoint.StepOutcome{}, err
}
sum, err := manifest.Checksum()
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact("fs-backup", checkpoint.Artifact{Path: opts.BackupDir, SHA256: sum, SizeBytes: manifest.TotalBytes})
return checkpoint.StepOutcome{
Detail: fmt.Sprintf("copied %d file(s), %d bytes, from %s to %s", len(manifest.Files), manifest.TotalBytes, opts.DataDir, opts.BackupDir),
Extra: manifestPath,
}, nil
}); err != nil {
return report, err
}
if _, err := step("fs-verify", func() (checkpoint.StepOutcome, error) {
manifestOutcome := rs.Outcome(checkpoint.PhaseBackup, "fs-snapshot")
manifest, err := ReadManifest(manifestOutcome.Extra)
if err != nil {
return checkpoint.StepOutcome{}, err
}
if err := VerifyDataDirBackup(opts.BackupDir, manifest); err != nil {
return checkpoint.StepOutcome{}, err
}
return checkpoint.StepOutcome{Detail: fmt.Sprintf("re-hashed %d file(s), all match the manifest recorded at copy time", len(manifest.Files))}, nil
}); err != nil {
return report, err
}
case strings.Contains(backends, "postgresql"):
if _, err := step("sql-dump", func() (checkpoint.StepOutcome, error) {
if err := RunPgDump(ctx, opts.SQL); err != nil {
return checkpoint.StepOutcome{}, err
}
sum, size, err := HashFile(opts.SQL.OutPath)
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact("sql-dump", checkpoint.Artifact{Path: opts.SQL.OutPath, SHA256: sum, SizeBytes: size})
return checkpoint.StepOutcome{Detail: fmt.Sprintf("pg_dump of critical tables (%s) to %s, %d bytes", strings.Join(criticalTables, " "), opts.SQL.OutPath, size)}, nil
}); err != nil {
return report, err
}
case strings.Contains(backends, "mysql"):
if _, err := step("sql-dump", func() (checkpoint.StepOutcome, error) {
if err := RunMySQLDump(ctx, opts.SQL); err != nil {
return checkpoint.StepOutcome{}, err
}
sum, size, err := HashFile(opts.SQL.OutPath)
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact("sql-dump", checkpoint.Artifact{Path: opts.SQL.OutPath, SHA256: sum, SizeBytes: size})
return checkpoint.StepOutcome{Detail: fmt.Sprintf("mysqldump of critical tables (%s) to %s, %d bytes", strings.Join(criticalTables, " "), opts.SQL.OutPath, size)}, nil
}); err != nil {
return report, err
}
case strings.Contains(backends, "foundationdb"):
if _, err := step("fdb-backup", func() (checkpoint.StepOutcome, error) {
if err := StartFDBBackup(ctx, opts.FDB); err != nil {
return checkpoint.StepOutcome{}, err
}
return checkpoint.StepOutcome{
Detail: fmt.Sprintf("fdbbackup start issued for destination %s (tag %s) - this only confirms the job was accepted, not that it finished; check `fdbbackup status` before relying on it", opts.FDB.Destination, opts.FDB.Tag),
}, nil
}); err != nil {
return report, err
}
default:
report.Results = append(report.Results, CheckResult{
Name: "backend-backup", Status: StatusSkipped,
Detail: fmt.Sprintf("no known store backend recorded for this run (topology.store_backend=%q) - preflight must run first, or the backend wasn't recognized; no filesystem/DB backup was taken", rs.Topology.StoreBackend),
})
}
if _, err := step("settings-dump", func() (checkpoint.StepOutcome, error) {
scriptURL := opts.MigrationScriptURL
if scriptURL == "" {
scriptURL = DefaultMigrationScriptURL
}
sum, err := DownloadFile(ctx, opts.HTTPClient, scriptURL, opts.ScriptDestPath, opts.MigrationScriptSHA256)
if err != nil {
return checkpoint.StepOutcome{}, err
}
pinNote := ""
if opts.MigrationScriptSHA256 == "" {
pinNote = fmt.Sprintf(" (no pin was configured - record sha256 %s as MigrationScriptSHA256 to pin it for future runs)", sum)
}
if err := RunSettingsDump(ctx, SettingsDumpOptions{
PythonPath: opts.PythonPath,
ScriptPath: opts.ScriptDestPath,
URL: opts.AdminURL,
Username: opts.AdminUser,
Password: opts.AdminPassword,
SettingsPath: opts.SettingsDumpPath,
PrincipalsPath: opts.PrincipalsDumpPath,
}); err != nil {
return checkpoint.StepOutcome{}, err
}
settingsSum, settingsSize, err := HashFile(opts.SettingsDumpPath)
if err != nil {
return checkpoint.StepOutcome{}, err
}
principalsSum, principalsSize, err := HashFile(opts.PrincipalsDumpPath)
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact("settings-dump", checkpoint.Artifact{Path: opts.SettingsDumpPath, SHA256: settingsSum, SizeBytes: settingsSize})
rs.RecordArtifact("principals-dump", checkpoint.Artifact{Path: opts.PrincipalsDumpPath, SHA256: principalsSum, SizeBytes: principalsSize})
return checkpoint.StepOutcome{
Detail: fmt.Sprintf("dumped settings (%d bytes) and principals (%d bytes) from %s%s", settingsSize, principalsSize, opts.AdminURL, pinNote),
Extra: sum,
}, nil
}); err != nil {
return report, err
}
if len(opts.Accounts) == 0 {
report.Results = append(report.Results, CheckResult{
Name: "vandelay-export", Status: StatusSkipped,
Detail: "no account list supplied - skipped; pass Options.Accounts (or --full-content-backup with an account source once account enumeration is wired up) to enable this belt-and-suspenders layer",
})
} else if _, err := step("vandelay-export", func() (checkpoint.StepOutcome, error) {
if err := os.MkdirAll(opts.Vandelay.OutDir, 0o750); err != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("backup: create vandelay output dir %s: %w", opts.Vandelay.OutDir, err)
}
files, err := ExportAccounts(ctx, opts.Vandelay, opts.Accounts)
if err != nil {
return checkpoint.StepOutcome{}, err
}
var totalSize int64
for i, f := range files {
sum, size, err := HashFile(f)
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact(fmt.Sprintf("vandelay-%s", opts.Accounts[i]), checkpoint.Artifact{Path: f, SHA256: sum, SizeBytes: size})
totalSize += size
}
return checkpoint.StepOutcome{Detail: fmt.Sprintf("exported %d account(s), %d bytes total, to %s", len(files), totalSize, opts.Vandelay.OutDir)}, nil
}); err != nil {
return report, err
}
return report, nil
}