Initial commit: stalwart-migrator design and scaffolding

In-place upgrade tool for Stalwart Mail Server (0.15.5 -> latest) with
checkpointed rollback and post-migration validation. Design stage; see
ARCHITECTURE.md.
This commit is contained in:
2026-08-22 18:17:17 -07:00
commit 719a945d64
71 changed files with 6677 additions and 0 deletions
+260
View File
@@ -0,0 +1,260 @@
package backup
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/johnellis/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
}
+217
View File
@@ -0,0 +1,217 @@
package backup
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
)
func TestBackupRunEndToEndAndResume(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
if err := os.WriteFile(binaryPath, []byte("fake binary"), 0o755); err != nil {
t.Fatal(err)
}
dataDir := filepath.Join(dir, "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dataDir, "db.bin"), []byte("some rocksdb-shaped data"), 0o644); err != nil {
t.Fatal(err)
}
backupDir := filepath.Join(dir, "data-backup")
settingsPath := filepath.Join(dir, "settings.json")
principalsPath := filepath.Join(dir, "principals.json")
pythonScript := fmt.Sprintf("#!/bin/sh\necho fake-settings > %q\necho fake-principals > %q\n", settingsPath, principalsPath)
pythonDir := withFakeExecutable(t, "python3", pythonScript)
pythonScriptPath := filepath.Join(pythonDir, "python3")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("print('fake migrate_v016.py')\n"))
}))
defer srv.Close()
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("store.Create: %v", err)
}
rs.Topology = checkpoint.Topology{StoreBackend: "rocksdb"}
opts := Options{
BinaryPath: binaryPath,
DataDir: dataDir,
BackupDir: backupDir,
MigrationScriptURL: srv.URL,
ScriptDestPath: filepath.Join(dir, "migrate_v016.py"),
AdminURL: "https://mail.example.com",
AdminUser: "admin",
AdminPassword: "hunter2",
SettingsDumpPath: settingsPath,
PrincipalsDumpPath: principalsPath,
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run #1: %v", err)
}
for _, r := range report.Results {
if r.Status == StatusFail {
t.Errorf("Run #1: step %s failed: %s", r.Name, r.Detail)
}
}
for _, name := range []string{"old-binary", "fs-backup", "settings-dump", "principals-dump"} {
if _, ok := rs.Artifacts[name]; !ok {
t.Errorf("Run #1: expected artifact %q to be recorded, got %v", name, rs.Artifacts)
}
}
if _, err := os.Stat(binaryPath + ".v0.15.5"); err != nil {
t.Errorf("preserved binary missing: %v", err)
}
// -- simulate a crash and resume, making every non-idempotent step's
// -- inputs unusable so a re-execution (rather than a skip) fails loudly.
if err := os.RemoveAll(dataDir); err != nil {
t.Fatal(err)
}
srv.Close()
if err := os.WriteFile(pythonScriptPath, []byte("#!/bin/sh\necho should-not-run-again >&2\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatalf("store.Load (resume): %v", err)
}
report2, err := Run(context.Background(), store, resumed, opts)
if err != nil {
t.Fatalf("Run #2 (resume) should succeed without redoing completed steps: %v", err)
}
if len(report2.Results) != len(report.Results) {
t.Errorf("resumed report has %d results, want %d (same as first run)", len(report2.Results), len(report.Results))
}
for _, r := range report2.Results {
if r.Status == StatusFail {
t.Errorf("Run #2 (resume): step %s unexpectedly failed: %s", r.Name, r.Detail)
}
}
}
func TestBackupRunSkipsVandelayWhenNoAccountsGiven(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
os.WriteFile(binaryPath, []byte("fake binary"), 0o755)
dataDir := filepath.Join(dir, "data")
os.MkdirAll(dataDir, 0o755)
os.WriteFile(filepath.Join(dataDir, "db.bin"), []byte("x"), 0o644)
withFakeExecutable(t, "python3", fmt.Sprintf(
"#!/bin/sh\necho x > %q\necho x > %q\n",
filepath.Join(dir, "settings.json"), filepath.Join(dir, "principals.json"),
))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("script"))
}))
defer srv.Close()
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatal(err)
}
rs.Topology = checkpoint.Topology{StoreBackend: "rocksdb"}
opts := Options{
BinaryPath: binaryPath,
DataDir: dataDir,
BackupDir: filepath.Join(dir, "data-backup"),
MigrationScriptURL: srv.URL,
ScriptDestPath: filepath.Join(dir, "migrate_v016.py"),
AdminURL: "https://mail.example.com",
SettingsDumpPath: filepath.Join(dir, "settings.json"),
PrincipalsDumpPath: filepath.Join(dir, "principals.json"),
// Accounts intentionally left empty.
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run: %v", err)
}
found := false
for _, r := range report.Results {
if r.Name == "vandelay-export" {
found = true
if r.Status != StatusSkipped {
t.Errorf("vandelay-export status = %s, want skipped", r.Status)
}
}
}
if !found {
t.Error("report missing a vandelay-export entry")
}
}
func TestBackupRunSkipsBinaryPreservationForDryRun(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
if err := os.WriteFile(binaryPath, []byte("fake binary"), 0o755); err != nil {
t.Fatal(err)
}
dataDir := filepath.Join(dir, "data")
os.MkdirAll(dataDir, 0o755)
os.WriteFile(filepath.Join(dataDir, "db.bin"), []byte("x"), 0o644)
withFakeExecutable(t, "python3", fmt.Sprintf(
"#!/bin/sh\necho x > %q\necho x > %q\n",
filepath.Join(dir, "settings.json"), filepath.Join(dir, "principals.json"),
))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("script"))
}))
defer srv.Close()
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatal(err)
}
rs.Topology = checkpoint.Topology{StoreBackend: "rocksdb"}
opts := Options{
BinaryPath: binaryPath,
SkipBinaryPreservation: true,
DataDir: dataDir,
BackupDir: filepath.Join(dir, "data-backup"),
MigrationScriptURL: srv.URL,
ScriptDestPath: filepath.Join(dir, "migrate_v016.py"),
AdminURL: "https://mail.example.com",
SettingsDumpPath: filepath.Join(dir, "settings.json"),
PrincipalsDumpPath: filepath.Join(dir, "principals.json"),
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run: %v", err)
}
for _, r := range report.Results {
if r.Name == "preserve-binary" && r.Status != StatusSkipped {
t.Errorf("preserve-binary status = %s, want skipped", r.Status)
}
}
if _, err := os.Stat(binaryPath); err != nil {
t.Errorf("production binary at %s should be untouched, but stat failed: %v", binaryPath, err)
}
if _, ok := rs.Artifacts["old-binary"]; ok {
t.Error("no old-binary artifact should be recorded when preservation is skipped")
}
}
+31
View File
@@ -0,0 +1,31 @@
package backup
import (
"fmt"
"os"
)
// PreserveBinary moves the currently-installed binary aside to
// "<binaryPath>.v<sourceVersion>" so rollback can restart the exact old
// binary without re-downloading anything, and cutover can install the new
// one at the original path. It never deletes the old binary, and it's
// idempotent: if a prior attempt at this run already preserved it, calling
// this again just returns the existing preserved path rather than erroring
// on a missing source file.
func PreserveBinary(binaryPath, sourceVersion string) (preservedPath string, err error) {
if sourceVersion == "" {
return "", fmt.Errorf("backup: cannot preserve %s without a source version to suffix it with", binaryPath)
}
preservedPath = binaryPath + ".v" + sourceVersion
if _, statErr := os.Stat(preservedPath); statErr == nil {
return preservedPath, nil
} else if !os.IsNotExist(statErr) {
return "", fmt.Errorf("backup: stat %s: %w", preservedPath, statErr)
}
if err := os.Rename(binaryPath, preservedPath); err != nil {
return "", fmt.Errorf("backup: preserve %s as %s: %w", binaryPath, preservedPath, err)
}
return preservedPath, nil
}
+63
View File
@@ -0,0 +1,63 @@
package backup
import (
"os"
"path/filepath"
"testing"
)
func TestPreserveBinary(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
if err := os.WriteFile(binaryPath, []byte("fake binary"), 0o755); err != nil {
t.Fatal(err)
}
preserved, err := PreserveBinary(binaryPath, "0.15.5")
if err != nil {
t.Fatalf("PreserveBinary: %v", err)
}
wantPath := binaryPath + ".v0.15.5"
if preserved != wantPath {
t.Errorf("preserved = %s, want %s", preserved, wantPath)
}
if _, err := os.Stat(binaryPath); !os.IsNotExist(err) {
t.Error("original binary path should no longer exist after preservation")
}
if _, err := os.Stat(preserved); err != nil {
t.Errorf("preserved binary missing: %v", err)
}
}
func TestPreserveBinaryIsIdempotent(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
if err := os.WriteFile(binaryPath, []byte("fake binary"), 0o755); err != nil {
t.Fatal(err)
}
first, err := PreserveBinary(binaryPath, "0.15.5")
if err != nil {
t.Fatalf("PreserveBinary #1: %v", err)
}
// Simulate a resumed run: the binary is already gone from binaryPath
// (moved on the prior attempt). Calling again must not error just
// because the source no longer exists.
second, err := PreserveBinary(binaryPath, "0.15.5")
if err != nil {
t.Fatalf("PreserveBinary #2 (resume): %v", err)
}
if first != second {
t.Errorf("preserved paths differ across calls: %s vs %s", first, second)
}
}
func TestPreserveBinaryRequiresSourceVersion(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
os.WriteFile(binaryPath, []byte("x"), 0o755)
if _, err := PreserveBinary(binaryPath, ""); err == nil {
t.Fatal("PreserveBinary with an empty source version should error")
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package backup implements the defense-in-depth backup: filesystem/DB snapshot, settings dump, and Vandelay content export.
// See ARCHITECTURE.md §4.2 for the design.
package backup
+48
View File
@@ -0,0 +1,48 @@
package backup
import (
"fmt"
"os"
"path/filepath"
"testing"
)
// withFakeExecutable puts a fake executable named `name` at the front of
// PATH for the duration of the test, so code that shells out to a
// real-world tool (pg_dump, mysqldump, fdbbackup, vandelay, python3) can be
// exercised without that tool actually being installed. t.Setenv restores
// PATH automatically and marks the test non-parallel.
func withFakeExecutable(t *testing.T, name, script string) (dir string) {
t.Helper()
dir = t.TempDir()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return dir
}
// argsFile is a shared convention the fake scripts below use: they append
// their own argv (space-joined, one invocation per line) to a file so the
// test can assert on exactly what was passed.
func argsFile(t *testing.T, dir string) string {
t.Helper()
return filepath.Join(dir, "invoked-args.log")
}
func readArgsFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ""
}
if err != nil {
t.Fatal(err)
}
return string(data)
}
func fakeScriptLoggingArgs(logPath string, extraBody string) string {
return fmt.Sprintf("#!/bin/sh\necho \"$@\" >> %q\n%s\n", logPath, extraBody)
}
+63
View File
@@ -0,0 +1,63 @@
package backup
import (
"context"
"fmt"
"os/exec"
)
// FDBOptions configures a FoundationDB backup via fdbbackup, FDB's own
// backup CLI (not something Stalwart-specific) - see ARCHITECTURE.md §4.2.
type FDBOptions struct {
ClusterFile string // -C; empty uses fdbbackup's own default cluster file
Destination string // -d, a backup URL e.g. "file:///var/backups/stalwart-fdb"
Tag string // -t; defaults to "default" if empty
}
func (o FDBOptions) tag() string {
if o.Tag == "" {
return "default"
}
return o.Tag
}
// BuildFDBBackupStartArgs returns the fdbbackup argv for starting a backup,
// without the leading "fdbbackup".
func BuildFDBBackupStartArgs(o FDBOptions) []string {
args := []string{"start"}
if o.ClusterFile != "" {
args = append(args, "-C", o.ClusterFile)
}
args = append(args, "-d", o.Destination, "-t", o.tag())
return args
}
// StartFDBBackup kicks off an fdbbackup run. fdbbackup start returns as soon
// as the backup job is registered, not when it finishes - callers that need
// to know it's done should poll FDBBackupStatus and look for their own
// definition of "complete" in its output, since this wrapper doesn't parse
// fdbbackup's status text (that format isn't stable enough here to depend
// on without verifying it against the fdbbackup version actually in use).
func StartFDBBackup(ctx context.Context, o FDBOptions) error {
cmd := exec.CommandContext(ctx, "fdbbackup", BuildFDBBackupStartArgs(o)...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("backup: fdbbackup start failed: %w (output: %s)", err, out)
}
return nil
}
// FDBBackupStatus returns fdbbackup's raw status output for the given tag,
// for a human or a caller-supplied parser to interpret.
func FDBBackupStatus(ctx context.Context, o FDBOptions) (string, error) {
args := []string{"status", "-t", o.tag()}
if o.ClusterFile != "" {
args = append(args, "-C", o.ClusterFile)
}
cmd := exec.CommandContext(ctx, "fdbbackup", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("backup: fdbbackup status failed: %w (output: %s)", err, out)
}
return string(out), nil
}
+48
View File
@@ -0,0 +1,48 @@
package backup
import (
"context"
"strings"
"testing"
)
func TestBuildFDBBackupStartArgs(t *testing.T) {
args := BuildFDBBackupStartArgs(FDBOptions{ClusterFile: "/etc/foundationdb/fdb.cluster", Destination: "file:///var/backups/stalwart-fdb", Tag: "stalwart-migrate"})
joined := strings.Join(args, " ")
for _, want := range []string{"start", "-C /etc/foundationdb/fdb.cluster", "-d file:///var/backups/stalwart-fdb", "-t stalwart-migrate"} {
if !strings.Contains(joined, want) {
t.Errorf("BuildFDBBackupStartArgs = %q, missing %q", joined, want)
}
}
}
func TestBuildFDBBackupStartArgsDefaultTag(t *testing.T) {
args := BuildFDBBackupStartArgs(FDBOptions{Destination: "file:///var/backups/stalwart-fdb"})
joined := strings.Join(args, " ")
if !strings.Contains(joined, "-t default") {
t.Errorf("BuildFDBBackupStartArgs = %q, want default tag when none given", joined)
}
}
func TestStartFDBBackupInvokesFdbbackup(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
withFakeExecutable(t, "fdbbackup", fakeScriptLoggingArgs(log, "exit 0"))
err := StartFDBBackup(context.Background(), FDBOptions{Destination: "file:///var/backups/stalwart-fdb"})
if err != nil {
t.Fatalf("StartFDBBackup: %v", err)
}
got := readArgsFile(t, log)
if !strings.Contains(got, "start") || !strings.Contains(got, "-d file:///var/backups/stalwart-fdb") {
t.Errorf("fdbbackup was invoked with %q", got)
}
}
func TestStartFDBBackupPropagatesFailure(t *testing.T) {
withFakeExecutable(t, "fdbbackup", "#!/bin/sh\necho 'cluster unreachable' >&2\nexit 1\n")
err := StartFDBBackup(context.Background(), FDBOptions{Destination: "file:///var/backups/stalwart-fdb"})
if err == nil {
t.Fatal("StartFDBBackup should error when fdbbackup exits non-zero")
}
}
+205
View File
@@ -0,0 +1,205 @@
package backup
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
// Manifest records the per-file checksums produced by CopyDataDir, so a
// later Verify pass can detect corruption or truncation introduced by the
// copy itself. It is NOT authentication that the copied store is a valid,
// openable Stalwart database - confirming that would mean booting the old
// binary read-only against the backup, which needs config/CLI details this
// tool doesn't verify yet (see the parallel caveat on
// stalwartapi.Client.AccountSnapshot). Treat a clean Verify as "the bytes we
// wrote match the bytes we copied", not "Stalwart can open this".
type Manifest struct {
SourceDir string `json:"source_dir"`
Files []ManifestEntry `json:"files"`
TotalBytes int64 `json:"total_bytes"`
}
type ManifestEntry struct {
RelPath string `json:"rel_path"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
// Checksum returns a single content hash identifying this manifest (and
// transitively, the exact set of files and bytes it describes), suitable
// for recording as a checkpoint.Artifact's SHA256 - a directory doesn't
// have one natural hash, so this stands in for it.
func (m *Manifest) Checksum() (string, error) {
data, err := json.Marshal(m)
if err != nil {
return "", fmt.Errorf("backup: marshal manifest: %w", err)
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]), nil
}
// WriteManifest persists a manifest as JSON next to the backup it describes.
func WriteManifest(path string, m *Manifest) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return fmt.Errorf("backup: marshal manifest: %w", err)
}
if err := os.WriteFile(path, data, 0o640); err != nil {
return fmt.Errorf("backup: write manifest %s: %w", path, err)
}
return nil
}
// ReadManifest loads a manifest previously written by WriteManifest.
func ReadManifest(path string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("backup: read manifest %s: %w", path, err)
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("backup: parse manifest %s: %w", path, err)
}
return &m, nil
}
// CopyDataDir copies srcDir to dstDir the way `cp -a` would (directories,
// regular files, and symlinks, preserving regular-file permissions),
// hashing every regular file as it's written. dstDir is cleared first if it
// already exists, so a retried step after a partial failure produces a
// clean copy rather than a mix of old and new files - filesystem copies
// aren't resumable at the byte level in any way worth building here, so a
// retry just redoes the whole thing.
func CopyDataDir(srcDir, dstDir string) (*Manifest, error) {
srcAbs, err := filepath.Abs(srcDir)
if err != nil {
return nil, fmt.Errorf("backup: resolve source %s: %w", srcDir, err)
}
dstAbs, err := filepath.Abs(dstDir)
if err != nil {
return nil, fmt.Errorf("backup: resolve destination %s: %w", dstDir, err)
}
if srcAbs == dstAbs {
return nil, fmt.Errorf("backup: source and destination are the same path: %s", srcAbs)
}
if strings.HasPrefix(dstAbs+string(filepath.Separator), srcAbs+string(filepath.Separator)) {
return nil, fmt.Errorf("backup: destination %s is inside source %s - refusing to copy a directory into itself", dstAbs, srcAbs)
}
if err := os.RemoveAll(dstAbs); err != nil {
return nil, fmt.Errorf("backup: clear stale destination %s: %w", dstAbs, err)
}
if err := os.MkdirAll(dstAbs, 0o750); err != nil {
return nil, fmt.Errorf("backup: create destination %s: %w", dstAbs, err)
}
manifest := &Manifest{SourceDir: srcAbs}
walkErr := filepath.WalkDir(srcAbs, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(srcAbs, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
dstPath := filepath.Join(dstAbs, rel)
if d.Type()&fs.ModeSymlink != 0 {
target, err := os.Readlink(path)
if err != nil {
return fmt.Errorf("readlink %s: %w", path, err)
}
return os.Symlink(target, dstPath)
}
if d.IsDir() {
info, err := d.Info()
if err != nil {
return err
}
return os.MkdirAll(dstPath, info.Mode().Perm())
}
info, err := d.Info()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("backup: unsupported file type at %s (mode %s)", path, info.Mode())
}
sum, size, err := copyFileWithChecksum(path, dstPath, info.Mode().Perm())
if err != nil {
return fmt.Errorf("copy %s: %w", path, err)
}
manifest.Files = append(manifest.Files, ManifestEntry{RelPath: rel, SHA256: sum, Size: size})
manifest.TotalBytes += size
return nil
})
if walkErr != nil {
return nil, fmt.Errorf("backup: copy %s to %s: %w", srcAbs, dstAbs, walkErr)
}
return manifest, nil
}
func copyFileWithChecksum(src, dst string, perm fs.FileMode) (sha256Hex string, size int64, err error) {
in, err := os.Open(src)
if err != nil {
return "", 0, err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return "", 0, err
}
defer out.Close()
h := sha256.New()
n, err := io.Copy(io.MultiWriter(out, h), in)
if err != nil {
return "", 0, err
}
if err := out.Sync(); err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
// VerifyDataDirBackup recomputes every file's checksum under dstDir and
// compares it against the manifest CopyDataDir produced, returning a single
// error describing every mismatch found (not just the first) so a human
// sees the full extent of any corruption in one pass.
func VerifyDataDirBackup(dstDir string, m *Manifest) error {
var problems []string
for _, entry := range m.Files {
path := filepath.Join(dstDir, entry.RelPath)
f, err := os.Open(path)
if err != nil {
problems = append(problems, fmt.Sprintf("%s: %v", entry.RelPath, err))
continue
}
h := sha256.New()
size, err := io.Copy(h, f)
f.Close()
if err != nil {
problems = append(problems, fmt.Sprintf("%s: read error: %v", entry.RelPath, err))
continue
}
if size != entry.Size {
problems = append(problems, fmt.Sprintf("%s: size %d, want %d", entry.RelPath, size, entry.Size))
continue
}
if got := hex.EncodeToString(h.Sum(nil)); got != entry.SHA256 {
problems = append(problems, fmt.Sprintf("%s: sha256 %s, want %s", entry.RelPath, got, entry.SHA256))
}
}
if len(problems) > 0 {
return fmt.Errorf("backup: verification failed for %d of %d file(s):\n%s", len(problems), len(m.Files), strings.Join(problems, "\n"))
}
return nil
}
+168
View File
@@ -0,0 +1,168 @@
package backup
import (
"os"
"path/filepath"
"testing"
)
func writeTree(t *testing.T, root string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(root, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "a.db"), []byte("alpha data"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "sub", "b.db"), []byte("bravo data, a bit longer"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink("a.db", filepath.Join(root, "link-to-a")); err != nil {
t.Fatal(err)
}
}
func TestCopyDataDirAndVerify(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
if err := os.MkdirAll(src, 0o755); err != nil {
t.Fatal(err)
}
writeTree(t, src)
dst := filepath.Join(t.TempDir(), "data-backup")
manifest, err := CopyDataDir(src, dst)
if err != nil {
t.Fatalf("CopyDataDir: %v", err)
}
if len(manifest.Files) != 2 {
t.Fatalf("manifest has %d files, want 2 (symlinks aren't hashed): %+v", len(manifest.Files), manifest.Files)
}
if manifest.TotalBytes != int64(len("alpha data")+len("bravo data, a bit longer")) {
t.Errorf("TotalBytes = %d, want %d", manifest.TotalBytes, len("alpha data")+len("bravo data, a bit longer"))
}
// The copy should be byte-identical, including the symlink.
got, err := os.ReadFile(filepath.Join(dst, "sub", "b.db"))
if err != nil || string(got) != "bravo data, a bit longer" {
t.Errorf("copied sub/b.db = %q, %v", got, err)
}
target, err := os.Readlink(filepath.Join(dst, "link-to-a"))
if err != nil || target != "a.db" {
t.Errorf("copied symlink target = %q, %v, want a.db", target, err)
}
if err := VerifyDataDirBackup(dst, manifest); err != nil {
t.Errorf("VerifyDataDirBackup on an untouched copy: %v", err)
}
}
func TestCopyDataDirRefusesSelfCopy(t *testing.T) {
dir := filepath.Join(t.TempDir(), "data")
os.MkdirAll(dir, 0o755)
if _, err := CopyDataDir(dir, dir); err == nil {
t.Fatal("CopyDataDir(dir, dir) should refuse to copy a directory onto itself")
}
}
func TestCopyDataDirRefusesNestedDestination(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
os.MkdirAll(src, 0o755)
nested := filepath.Join(src, "backup")
if _, err := CopyDataDir(src, nested); err == nil {
t.Fatal("CopyDataDir should refuse a destination nested inside the source")
}
}
func TestCopyDataDirRetryClearsStaleFiles(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
os.MkdirAll(src, 0o755)
writeTree(t, src)
dst := filepath.Join(t.TempDir(), "data-backup")
// Simulate a stale partial copy from a previous failed attempt.
os.MkdirAll(dst, 0o755)
if err := os.WriteFile(filepath.Join(dst, "stale-leftover.tmp"), []byte("junk"), 0o644); err != nil {
t.Fatal(err)
}
manifest, err := CopyDataDir(src, dst)
if err != nil {
t.Fatalf("CopyDataDir: %v", err)
}
if _, err := os.Stat(filepath.Join(dst, "stale-leftover.tmp")); !os.IsNotExist(err) {
t.Error("stale-leftover.tmp should have been cleared by a fresh copy, but still exists")
}
if err := VerifyDataDirBackup(dst, manifest); err != nil {
t.Errorf("VerifyDataDirBackup after retry: %v", err)
}
}
func TestVerifyDataDirBackupDetectsTampering(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
os.MkdirAll(src, 0o755)
writeTree(t, src)
dst := filepath.Join(t.TempDir(), "data-backup")
manifest, err := CopyDataDir(src, dst)
if err != nil {
t.Fatalf("CopyDataDir: %v", err)
}
// Corrupt the backup after the fact - Verify must catch it.
if err := os.WriteFile(filepath.Join(dst, "a.db"), []byte("corrupted!"), 0o644); err != nil {
t.Fatal(err)
}
if err := VerifyDataDirBackup(dst, manifest); err == nil {
t.Fatal("VerifyDataDirBackup should have detected the tampered file")
}
}
func TestManifestChecksumIsDeterministic(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
os.MkdirAll(src, 0o755)
writeTree(t, src)
dst := filepath.Join(t.TempDir(), "data-backup")
m1, err := CopyDataDir(src, dst)
if err != nil {
t.Fatal(err)
}
sum1, err := m1.Checksum()
if err != nil {
t.Fatal(err)
}
dst2 := filepath.Join(t.TempDir(), "data-backup-2")
m2, err := CopyDataDir(src, dst2)
if err != nil {
t.Fatal(err)
}
sum2, err := m2.Checksum()
if err != nil {
t.Fatal(err)
}
if sum1 != sum2 {
t.Errorf("two copies of the same source produced different manifest checksums: %s vs %s", sum1, sum2)
}
}
func TestWriteReadManifestRoundtrip(t *testing.T) {
m := &Manifest{
SourceDir: "/var/lib/stalwart",
Files: []ManifestEntry{{RelPath: "a.db", SHA256: "deadbeef", Size: 42}},
TotalBytes: 42,
}
path := filepath.Join(t.TempDir(), "manifest.json")
if err := WriteManifest(path, m); err != nil {
t.Fatalf("WriteManifest: %v", err)
}
got, err := ReadManifest(path)
if err != nil {
t.Fatalf("ReadManifest: %v", err)
}
if got.TotalBytes != 42 || len(got.Files) != 1 || got.Files[0].SHA256 != "deadbeef" {
t.Errorf("roundtrip mismatch: %+v", got)
}
}
+25
View File
@@ -0,0 +1,25 @@
package backup
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
)
// hashFile returns a file's SHA256 and size, for recording as a
// checkpoint.Artifact.
func hashFile(path string) (sha256Hex string, size int64, err error) {
f, err := os.Open(path)
if err != nil {
return "", 0, fmt.Errorf("backup: hash %s: %w", path, err)
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, fmt.Errorf("backup: hash %s: %w", path, err)
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
+36
View File
@@ -0,0 +1,36 @@
package backup
import (
"fmt"
"strings"
)
// Status is a single backup step's verdict. Unlike preflight, most backup
// steps that fail are hard failures (Run aborts) rather than advisory - see
// Run's doc comment - but Status still distinguishes "did the thing" from
// "correctly skipped because it doesn't apply to this deployment".
type Status string
const (
StatusOK Status = "ok"
StatusSkipped Status = "skipped"
StatusFail Status = "fail"
)
type CheckResult struct {
Name string
Status Status
Detail string
}
type Report struct {
Results []CheckResult
}
func (r Report) String() string {
var b strings.Builder
for _, res := range r.Results {
fmt.Fprintf(&b, "[%-7s] %-18s %s\n", strings.ToUpper(string(res.Status)), res.Name, res.Detail)
}
return b.String()
}
+164
View File
@@ -0,0 +1,164 @@
package backup
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"sort"
"strings"
"time"
)
// DefaultMigrationScriptURL is Stalwart's own v0.15->v0.16 settings
// converter, referenced directly from UPGRADING/v0_16.md. It's an external,
// Stalwart-owned dependency this tool doesn't vendor a copy of - see the
// pinning discussion on DownloadFile and ARCHITECTURE.md §8.
const DefaultMigrationScriptURL = "https://raw.githubusercontent.com/stalwartlabs/stalwart/main/resources/scripts/migrate_v016.py"
// DownloadFile fetches url to destPath and returns its SHA256. If
// expectedSHA256 is non-empty, a mismatching download is rejected (and the
// partial file removed) - this is how a pinned migration-script hash is
// enforced, so a run never silently executes a different version of a
// script than the one it was reviewed against. If expectedSHA256 is empty,
// the download is accepted unconditionally and its hash is returned so the
// caller can record it as the pin for next time; first-run trust-on-first-use
// is a known gap, flagged in ARCHITECTURE.md §8.
func DownloadFile(ctx context.Context, httpClient *http.Client, url, destPath, expectedSHA256 string) (sha256Hex string, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 60 * time.Second}
}
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("backup: fetch %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("backup: fetch %s: unexpected status %s", url, resp.Status)
}
f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640)
if err != nil {
return "", fmt.Errorf("backup: create %s: %w", destPath, err)
}
h := sha256.New()
_, copyErr := io.Copy(io.MultiWriter(f, h), resp.Body)
closeErr := f.Close()
if copyErr != nil {
os.Remove(destPath)
return "", fmt.Errorf("backup: download %s: %w", url, copyErr)
}
if closeErr != nil {
os.Remove(destPath)
return "", fmt.Errorf("backup: close %s: %w", destPath, closeErr)
}
sha256Hex = hex.EncodeToString(h.Sum(nil))
if expectedSHA256 != "" && sha256Hex != expectedSHA256 {
os.Remove(destPath)
return "", fmt.Errorf(
"backup: %s checksum mismatch: got %s, want %s (refusing to run an unexpected version of a script that irreversibly wipes settings on first v0.16 start)",
url, sha256Hex, expectedSHA256,
)
}
return sha256Hex, nil
}
// SettingsDumpOptions configures a migrate_v016.py `dump` invocation
// against a live v0.15.x instance - see UPGRADING/v0_16.md.
type SettingsDumpOptions struct {
PythonPath string // defaults to "python3"
ScriptPath string // local path to the already-downloaded, checksum-verified script
URL string // the running v0.15.x instance's base URL
Username string
Password string
SettingsPath string
PrincipalsPath string
}
// RunSettingsDump runs migrate_v016.py's dump subcommand, which reads the
// live v0.15.x server's settings and principals over its admin API and
// writes them to SettingsPath/PrincipalsPath for the later convert step
// (ARCHITECTURE.md §4.3). This step is read-only against the server, so
// it's safe to run well before cutover - ARCHITECTURE.md §4.2 calls for it
// both at preflight time and again immediately before cutover, since the
// live settings may have changed in between.
func RunSettingsDump(ctx context.Context, o SettingsDumpOptions) error {
python := o.PythonPath
if python == "" {
python = "python3"
}
args := []string{
o.ScriptPath, "dump",
"--url", o.URL,
"--username", o.Username,
"--password", o.Password,
"--settings", o.SettingsPath,
"--principals", o.PrincipalsPath,
}
cmd := exec.CommandContext(ctx, python, args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("backup: migrate_v016.py dump failed: %w (output: %s)", err, out)
}
return nil
}
// SettingsConvertOptions configures a migrate_v016.py `convert` invocation,
// which turns the settings/principals dump into the v0.16 config.json and
// export.json that recovery mode consumes - see UPGRADING/v0_16.md.
type SettingsConvertOptions struct {
PythonPath string // defaults to "python3"
ScriptPath string
SettingsPath string
PrincipalsPath string
ConfigPath string // output: config.json for the new binary's --config flag
OutputPath string // output: export.json for `stalwart-cli apply`
// PatchPaths rewrites path prefixes in the generated config (documented
// for Docker deployments as "--patch-paths /opt/stalwart=/var/lib/stalwart",
// e.g. old-path -> new-path). This is the officially documented
// mechanism a dry-run relies on to point the generated config at a
// sandbox data directory instead of the production one - see
// ARCHITECTURE.md's dry-run design - rather than this tool editing
// config.json's contents directly, which would require depending on its
// exact schema.
PatchPaths map[string]string
}
// RunSettingsConvert runs migrate_v016.py's convert subcommand.
func RunSettingsConvert(ctx context.Context, o SettingsConvertOptions) error {
python := o.PythonPath
if python == "" {
python = "python3"
}
args := []string{
o.ScriptPath, "convert",
"--settings", o.SettingsPath,
"--principals", o.PrincipalsPath,
"--config", o.ConfigPath,
"--output", o.OutputPath,
}
if len(o.PatchPaths) > 0 {
pairs := make([]string, 0, len(o.PatchPaths))
for old, new := range o.PatchPaths {
pairs = append(pairs, old+"="+new)
}
sort.Strings(pairs) // deterministic argv, easier to test and to log
args = append(args, "--patch-paths", strings.Join(pairs, ","))
}
cmd := exec.CommandContext(ctx, python, args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("backup: migrate_v016.py convert failed: %w (output: %s)", err, out)
}
return nil
}
+175
View File
@@ -0,0 +1,175 @@
package backup
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDownloadFileAcceptsMatchingChecksum(t *testing.T) {
content := "print('fake migration script')\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(content))
}))
defer srv.Close()
sum := sha256.Sum256([]byte(content))
expected := hex.EncodeToString(sum[:])
dest := filepath.Join(t.TempDir(), "script.py")
got, err := DownloadFile(context.Background(), nil, srv.URL, dest, expected)
if err != nil {
t.Fatalf("DownloadFile: %v", err)
}
if got != expected {
t.Errorf("returned checksum = %s, want %s", got, expected)
}
data, _ := os.ReadFile(dest)
if string(data) != content {
t.Errorf("downloaded content = %q, want %q", data, content)
}
}
func TestDownloadFileRejectsMismatchedChecksum(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("unexpected content"))
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "script.py")
_, err := DownloadFile(context.Background(), nil, srv.URL, dest, "0000000000000000000000000000000000000000000000000000000000000000")
if err == nil {
t.Fatal("DownloadFile should reject a checksum mismatch")
}
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
t.Error("DownloadFile should remove the file it wrote after a checksum mismatch")
}
}
func TestDownloadFileWithoutPinReturnsComputedHash(t *testing.T) {
content := "arbitrary content"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(content))
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "script.py")
got, err := DownloadFile(context.Background(), nil, srv.URL, dest, "")
if err != nil {
t.Fatalf("DownloadFile: %v", err)
}
sum := sha256.Sum256([]byte(content))
want := hex.EncodeToString(sum[:])
if got != want {
t.Errorf("returned checksum = %s, want %s", got, want)
}
}
func TestRunSettingsDumpInvokesScriptWithFlags(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
err := RunSettingsDump(context.Background(), SettingsDumpOptions{
PythonPath: filepath.Join(pythonDir, "python3"),
ScriptPath: "/opt/migrate_v016.py",
URL: "https://mail.example.com",
Username: "admin",
Password: "hunter2",
SettingsPath: filepath.Join(dir, "settings.json"),
PrincipalsPath: filepath.Join(dir, "principals.json"),
})
if err != nil {
t.Fatalf("RunSettingsDump: %v", err)
}
got := readArgsFile(t, log)
for _, want := range []string{"/opt/migrate_v016.py", "dump", "--url https://mail.example.com", "--username admin"} {
if !strings.Contains(got, want) {
t.Errorf("script invoked with %q, missing %q", got, want)
}
}
}
func TestRunSettingsDumpPropagatesFailure(t *testing.T) {
pythonDir := withFakeExecutable(t, "python3", "#!/bin/sh\necho 'auth failed' >&2\nexit 1\n")
err := RunSettingsDump(context.Background(), SettingsDumpOptions{
PythonPath: filepath.Join(pythonDir, "python3"),
ScriptPath: "/opt/migrate_v016.py",
})
if err == nil {
t.Fatal("RunSettingsDump should error when the script exits non-zero")
}
if !strings.Contains(err.Error(), "auth failed") {
t.Errorf("error = %v, want it to include the script's stderr", err)
}
}
func TestRunSettingsConvertInvokesScriptWithFlags(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
PythonPath: filepath.Join(pythonDir, "python3"),
ScriptPath: "/opt/migrate_v016.py",
SettingsPath: filepath.Join(dir, "settings.json"),
PrincipalsPath: filepath.Join(dir, "principals.json"),
ConfigPath: filepath.Join(dir, "config.json"),
OutputPath: filepath.Join(dir, "export.json"),
})
if err != nil {
t.Fatalf("RunSettingsConvert: %v", err)
}
got := readArgsFile(t, log)
for _, want := range []string{"convert", "--settings", "--config", "--output"} {
if !strings.Contains(got, want) {
t.Errorf("script invoked with %q, missing %q", got, want)
}
}
if strings.Contains(got, "--patch-paths") {
t.Errorf("script invoked with %q, should not include --patch-paths when none given", got)
}
}
func TestRunSettingsConvertWithPatchPaths(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
PythonPath: filepath.Join(pythonDir, "python3"),
ScriptPath: "/opt/migrate_v016.py",
SettingsPath: filepath.Join(dir, "settings.json"),
PrincipalsPath: filepath.Join(dir, "principals.json"),
ConfigPath: filepath.Join(dir, "config.json"),
OutputPath: filepath.Join(dir, "export.json"),
PatchPaths: map[string]string{"/var/lib/stalwart": "/tmp/sandbox/stalwart"},
})
if err != nil {
t.Fatalf("RunSettingsConvert: %v", err)
}
got := readArgsFile(t, log)
if !strings.Contains(got, "--patch-paths /var/lib/stalwart=/tmp/sandbox/stalwart") {
t.Errorf("script invoked with %q, missing the expected --patch-paths flag", got)
}
}
func TestRunSettingsConvertPropagatesFailure(t *testing.T) {
pythonDir := withFakeExecutable(t, "python3", "#!/bin/sh\necho 'unsupported settings key' >&2\nexit 1\n")
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
PythonPath: filepath.Join(pythonDir, "python3"),
ScriptPath: "/opt/migrate_v016.py",
})
if err == nil {
t.Fatal("RunSettingsConvert should error when the script exits non-zero")
}
if !strings.Contains(err.Error(), "unsupported settings key") {
t.Errorf("error = %v, want it to include the script's stderr", err)
}
}
+91
View File
@@ -0,0 +1,91 @@
package backup
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
)
// criticalTables is the exact table set Stalwart's own v0.16 upgrade guide
// backs up before migrating (principals/directory, domains, and the other
// tables its migration script and recovery mode depend on) - a targeted
// dump, not a full-instance one, matching the guide's own tested restore
// path and staying fast on large installs. See ARCHITECTURE.md §4.2 and
// UPGRADING/v0_16.md.
var criticalTables = []string{"s", "d", "r", "h", "b", "g", "j", "f", "u"}
// SQLOptions configures a targeted critical-table dump for an external SQL
// store backend.
type SQLOptions struct {
Host string
Port string
Database string
User string
Password string
OutPath string
}
// BuildPgDumpArgs returns pg_dump's argv for the critical-table backup,
// without the leading "pg_dump" itself.
func BuildPgDumpArgs(o SQLOptions) []string {
args := []string{"-U", o.User, "-d", o.Database}
if o.Host != "" {
args = append(args, "-h", o.Host)
}
if o.Port != "" {
args = append(args, "-p", o.Port)
}
for _, t := range criticalTables {
args = append(args, "-t", t)
}
return append(args, "-f", o.OutPath)
}
// BuildMySQLDumpArgs returns mysqldump's argv for the same critical-table
// set. mysqldump writes to stdout, so RunMySQLDump redirects it rather than
// this function taking an output path flag.
func BuildMySQLDumpArgs(o SQLOptions) []string {
args := []string{"-u", o.User, o.Database}
if o.Host != "" {
args = append(args, "-h", o.Host)
}
if o.Port != "" {
args = append(args, "-P", o.Port)
}
return append(args, criticalTables...)
}
// RunPgDump executes pg_dump with the password passed via the standard
// PGPASSWORD environment variable, never on the command line where it would
// be visible to anything reading this process's argv (e.g. `ps`).
func RunPgDump(ctx context.Context, o SQLOptions) error {
cmd := exec.CommandContext(ctx, "pg_dump", BuildPgDumpArgs(o)...)
cmd.Env = append(os.Environ(), "PGPASSWORD="+o.Password)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("backup: pg_dump failed: %w (output: %s)", err, strings.TrimSpace(string(out)))
}
return nil
}
// RunMySQLDump executes mysqldump with the password passed via the standard
// MYSQL_PWD environment variable, redirecting its stdout to o.OutPath.
func RunMySQLDump(ctx context.Context, o SQLOptions) error {
f, err := os.OpenFile(o.OutPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640)
if err != nil {
return fmt.Errorf("backup: create %s: %w", o.OutPath, err)
}
defer f.Close()
cmd := exec.CommandContext(ctx, "mysqldump", BuildMySQLDumpArgs(o)...)
cmd.Env = append(os.Environ(), "MYSQL_PWD="+o.Password)
cmd.Stdout = f
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("backup: mysqldump failed: %w (stderr: %s)", err, strings.TrimSpace(stderr.String()))
}
return nil
}
+85
View File
@@ -0,0 +1,85 @@
package backup
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestBuildPgDumpArgs(t *testing.T) {
args := BuildPgDumpArgs(SQLOptions{
Host: "db.internal", Port: "5432", Database: "stalwart", User: "stalwart", OutPath: "/backups/out.sql",
})
joined := strings.Join(args, " ")
for _, want := range []string{"-U stalwart", "-d stalwart", "-h db.internal", "-p 5432", "-f /backups/out.sql"} {
if !strings.Contains(joined, want) {
t.Errorf("BuildPgDumpArgs = %q, missing %q", joined, want)
}
}
for _, table := range criticalTables {
if !strings.Contains(joined, "-t "+table) {
t.Errorf("BuildPgDumpArgs missing critical table flag for %q: %q", table, joined)
}
}
}
func TestBuildMySQLDumpArgs(t *testing.T) {
args := BuildMySQLDumpArgs(SQLOptions{Host: "db.internal", Port: "3306", Database: "stalwart", User: "stalwart"})
joined := strings.Join(args, " ")
if !strings.Contains(joined, "-u stalwart") || !strings.Contains(joined, "-h db.internal") || !strings.Contains(joined, "-P 3306") {
t.Errorf("BuildMySQLDumpArgs = %q, missing expected flags", joined)
}
if !strings.HasSuffix(joined, strings.Join(criticalTables, " ")) {
t.Errorf("BuildMySQLDumpArgs = %q, want it to end with the critical table list", joined)
}
}
func TestRunPgDumpInvokesPgDumpWithArgs(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
withFakeExecutable(t, "pg_dump", fakeScriptLoggingArgs(log, "exit 0"))
err := RunPgDump(context.Background(), SQLOptions{
User: "stalwart", Database: "stalwart", OutPath: filepath.Join(dir, "out.sql"),
})
if err != nil {
t.Fatalf("RunPgDump: %v", err)
}
got := readArgsFile(t, log)
if !strings.Contains(got, "-U stalwart") {
t.Errorf("pg_dump was invoked with %q, missing -U stalwart", got)
}
}
func TestRunPgDumpPropagatesFailure(t *testing.T) {
dir := t.TempDir()
withFakeExecutable(t, "pg_dump", "#!/bin/sh\necho 'connection refused' >&2\nexit 1\n")
err := RunPgDump(context.Background(), SQLOptions{User: "stalwart", Database: "stalwart", OutPath: filepath.Join(dir, "out.sql")})
if err == nil {
t.Fatal("RunPgDump should have returned an error when pg_dump exits non-zero")
}
if !strings.Contains(err.Error(), "connection refused") {
t.Errorf("error = %v, want it to include pg_dump's stderr", err)
}
}
func TestRunMySQLDumpWritesStdoutToOutPath(t *testing.T) {
dir := t.TempDir()
withFakeExecutable(t, "mysqldump", "#!/bin/sh\necho '-- fake dump output'\n")
outPath := filepath.Join(dir, "out.sql")
err := RunMySQLDump(context.Background(), SQLOptions{User: "stalwart", Database: "stalwart", OutPath: outPath})
if err != nil {
t.Fatalf("RunMySQLDump: %v", err)
}
data, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !strings.Contains(string(data), "fake dump output") {
t.Errorf("out.sql = %q, want fake dump output redirected into it", data)
}
}
+67
View File
@@ -0,0 +1,67 @@
package backup
import (
"context"
"fmt"
"os/exec"
"path/filepath"
)
// VandelayOptions configures a per-account content export via Stalwart's
// own Vandelay import/export tool - the documented, backend-independent
// backup mechanism (ARCHITECTURE.md §4.2's belt-and-suspenders layer): each
// account's mail, calendars, contacts, Sieve scripts, and identities land in
// one self-contained SQLite archive.
type VandelayOptions struct {
BinaryPath string // defaults to "vandelay"
URL string // the source JMAP server
AuthBasic string // "user:app-password", per vandelay's own --auth-basic flag
OutDir string // one <account>.sqlite file per account goes here
}
// BuildVandelayImportArgs returns vandelay's argv for exporting one
// account's content into a self-contained SQLite archive, without the
// leading "vandelay" itself. "import" is vandelay's own verb for this - it
// names the direction relative to the archive file, not the live server;
// see ARCHITECTURE.md §4.2.
func BuildVandelayImportArgs(o VandelayOptions, accountName, outFile string) []string {
return []string{
"import", "jmap",
"--url", o.URL,
"--auth-basic", o.AuthBasic,
"--account-name", accountName,
outFile,
}
}
// ExportAccount runs one account's Vandelay export and returns the archive
// path it wrote.
func ExportAccount(ctx context.Context, o VandelayOptions, accountName string) (outFile string, err error) {
binary := o.BinaryPath
if binary == "" {
binary = "vandelay"
}
outFile = filepath.Join(o.OutDir, accountName+".sqlite")
cmd := exec.CommandContext(ctx, binary, BuildVandelayImportArgs(o, accountName, outFile)...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("backup: vandelay export of %s failed: %w (output: %s)", accountName, err, out)
}
return outFile, nil
}
// ExportAccounts exports every account in accounts, stopping at the first
// failure. A partial per-account backup set is reported precisely (how many
// succeeded, which one failed and why) rather than silently continuing past
// a failure that might indicate a systemic problem - bad credentials, an
// unreachable server - rather than a one-off.
func ExportAccounts(ctx context.Context, o VandelayOptions, accounts []string) (outFiles []string, err error) {
for _, acct := range accounts {
f, err := ExportAccount(ctx, o, acct)
if err != nil {
return outFiles, fmt.Errorf("backup: exported %d/%d accounts before failing: %w", len(outFiles), len(accounts), err)
}
outFiles = append(outFiles, f)
}
return outFiles, nil
}
+56
View File
@@ -0,0 +1,56 @@
package backup
import (
"context"
"path/filepath"
"strings"
"testing"
)
func TestBuildVandelayImportArgs(t *testing.T) {
args := BuildVandelayImportArgs(VandelayOptions{URL: "https://mail.example.com", AuthBasic: "alice:app-pass"}, "[email protected]", "/backups/alice.sqlite")
joined := strings.Join(args, " ")
for _, want := range []string{"import", "jmap", "--url https://mail.example.com", "--auth-basic alice:app-pass", "--account-name [email protected]", "/backups/alice.sqlite"} {
if !strings.Contains(joined, want) {
t.Errorf("BuildVandelayImportArgs = %q, missing %q", joined, want)
}
}
}
func TestExportAccountsRunsEachAccount(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
withFakeExecutable(t, "vandelay", fakeScriptLoggingArgs(log, "exit 0"))
outDir := filepath.Join(dir, "out")
files, err := ExportAccounts(context.Background(), VandelayOptions{URL: "https://mail.example.com", AuthBasic: "a:b", OutDir: outDir}, []string{"[email protected]", "[email protected]"})
if err != nil {
t.Fatalf("ExportAccounts: %v", err)
}
if len(files) != 2 {
t.Fatalf("got %d files, want 2: %v", len(files), files)
}
got := readArgsFile(t, log)
if !strings.Contains(got, "[email protected]") || !strings.Contains(got, "[email protected]") {
t.Errorf("vandelay invocations = %q, want both accounts", got)
}
}
func TestExportAccountsStopsAtFirstFailure(t *testing.T) {
dir := t.TempDir()
// Fails on the second invocation (bob), succeeds on the first (alice).
script := "#!/bin/sh\ncase \"$*\" in\n *bob*) echo 'account not found' >&2; exit 1 ;;\n *) exit 0 ;;\nesac\n"
withFakeExecutable(t, "vandelay", script)
outDir := filepath.Join(dir, "out")
files, err := ExportAccounts(context.Background(), VandelayOptions{URL: "https://mail.example.com", AuthBasic: "a:b", OutDir: outDir}, []string{"[email protected]", "[email protected]", "[email protected]"})
if err == nil {
t.Fatal("ExportAccounts should have failed on [email protected]")
}
if len(files) != 1 {
t.Errorf("got %d successful exports before failure, want 1 (alice only)", len(files))
}
if !strings.Contains(err.Error(), "1/3") {
t.Errorf("error = %v, want it to report 1/3 accounts exported before failing", err)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package checkpoint implements run-id and state.json persistence, resume logic, and rollback-window tracking.
// See ARCHITECTURE.md §5 for the design.
package checkpoint
+88
View File
@@ -0,0 +1,88 @@
package checkpoint
import (
"fmt"
"time"
)
func (rs *RunState) stepIndex(phase Phase, name string) int {
for i := range rs.Steps {
if rs.Steps[i].Phase == phase && rs.Steps[i].Name == name {
return i
}
}
return -1
}
// Status returns the current status of a step, or StepPending if it has
// never been started.
func (rs *RunState) Status(phase Phase, name string) StepStatus {
if i := rs.stepIndex(phase, name); i >= 0 {
return rs.Steps[i].Status
}
return StepPending
}
// Done reports whether a step already completed successfully. Callers use
// this to decide whether to skip work on resume.
func (rs *RunState) Done(phase Phase, name string) bool {
return rs.Status(phase, name) == StepDone
}
// Outcome returns the StepOutcome recorded for a step, zero-valued if none.
// This is what lets a resumed run reconstruct a skipped step's result
// without re-executing it.
func (rs *RunState) Outcome(phase Phase, name string) StepOutcome {
if i := rs.stepIndex(phase, name); i >= 0 {
return rs.Steps[i].StepOutcome
}
return StepOutcome{}
}
// Begin marks a step as running, creating its record on first attempt or
// resetting it on a retry after a prior failure.
func (rs *RunState) Begin(phase Phase, name string) {
now := time.Now().UTC()
if i := rs.stepIndex(phase, name); i >= 0 {
rs.Steps[i].Status = StepRunning
rs.Steps[i].StartedAt = &now
rs.Steps[i].CompletedAt = nil
rs.Steps[i].StepOutcome = StepOutcome{}
rs.Steps[i].Error = ""
} else {
rs.Steps = append(rs.Steps, StepRecord{
Phase: phase, Name: name, Status: StepRunning, StartedAt: &now,
})
}
rs.UpdatedAt = now
}
// Complete marks a step done with its outcome. It panics if Begin was never
// called for this step - that's a bug in the calling phase, not a runtime
// condition callers should need to handle.
func (rs *RunState) Complete(phase Phase, name string, outcome StepOutcome) {
i := rs.stepIndex(phase, name)
if i < 0 {
panic(fmt.Sprintf("checkpoint: Complete(%s/%s) called without Begin", phase, name))
}
now := time.Now().UTC()
rs.Steps[i].Status = StepDone
rs.Steps[i].CompletedAt = &now
rs.Steps[i].StepOutcome = outcome
rs.Steps[i].Error = ""
rs.UpdatedAt = now
}
// Fail marks a step failed, so a later Begin for the same (phase, name)
// knows to retry it rather than treat it as done.
func (rs *RunState) Fail(phase Phase, name string, stepErr error) {
i := rs.stepIndex(phase, name)
if i < 0 {
panic(fmt.Sprintf("checkpoint: Fail(%s/%s) called without Begin", phase, name))
}
now := time.Now().UTC()
rs.Steps[i].Status = StepFailed
rs.Steps[i].CompletedAt = &now
rs.Steps[i].Error = stepErr.Error()
rs.UpdatedAt = now
}
+179
View File
@@ -0,0 +1,179 @@
package checkpoint
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
// DefaultBaseDir is where runs are persisted when the operator doesn't
// override it.
const DefaultBaseDir = "/var/lib/stalwart-migrator/runs"
// Store persists RunState to disk. It's the only thing in this package that
// touches the filesystem - RunState itself is a plain data type.
type Store struct {
baseDir string
}
func NewStore(baseDir string) *Store {
if baseDir == "" {
baseDir = DefaultBaseDir
}
return &Store{baseDir: baseDir}
}
func (s *Store) runDir(runID string) string { return filepath.Join(s.baseDir, runID) }
func (s *Store) statePath(runID string) string { return filepath.Join(s.runDir(runID), "state.json") }
// Create starts a new run, assigns it an ID, and persists its initial
// state before returning it.
func (s *Store) Create(sourceVersion, targetVersion string) (*RunState, error) {
id, err := newRunID()
if err != nil {
return nil, fmt.Errorf("checkpoint: generate run id: %w", err)
}
now := time.Now().UTC()
rs := &RunState{
RunID: id,
SourceVersion: sourceVersion,
TargetVersion: targetVersion,
CreatedAt: now,
UpdatedAt: now,
Artifacts: map[string]Artifact{},
}
if err := s.Save(rs); err != nil {
return nil, err
}
return rs, nil
}
// Load reads an existing run's state from disk.
func (s *Store) Load(runID string) (*RunState, error) {
data, err := os.ReadFile(s.statePath(runID))
if err != nil {
return nil, fmt.Errorf("checkpoint: load run %s: %w", runID, err)
}
var rs RunState
if err := json.Unmarshal(data, &rs); err != nil {
return nil, fmt.Errorf("checkpoint: parse run %s: %w", runID, err)
}
return &rs, nil
}
// Save writes state to disk atomically: write to a temp file in the same
// directory, fsync it, then rename over the real path. A crash mid-write
// leaves the temp file orphaned and state.json untouched, never a
// truncated or corrupt state.json - that file is the one thing every phase
// and a human operator both trust as the source of truth for what's
// already happened, so a half-written version of it would be worse than an
// old one.
func (s *Store) Save(rs *RunState) error {
dir := s.runDir(rs.RunID)
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("checkpoint: create run directory: %w", err)
}
data, err := json.MarshalIndent(rs, "", " ")
if err != nil {
return fmt.Errorf("checkpoint: marshal state: %w", err)
}
tmp, err := os.CreateTemp(dir, "state-*.json.tmp")
if err != nil {
return fmt.Errorf("checkpoint: create temp state file: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("checkpoint: write temp state file: %w", err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("checkpoint: sync temp state file: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("checkpoint: close temp state file: %w", err)
}
if err := os.Rename(tmpPath, s.statePath(rs.RunID)); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("checkpoint: rename temp state file into place: %w", err)
}
return nil
}
// List returns known run IDs, most recently created first.
func (s *Store) List() ([]string, error) {
entries, err := os.ReadDir(s.baseDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("checkpoint: list runs: %w", err)
}
type run struct {
id string
created time.Time
}
var runs []run
for _, e := range entries {
if !e.IsDir() {
continue
}
rs, err := s.Load(e.Name())
if err != nil {
continue // not a run directory (or a corrupt one) - skip it
}
runs = append(runs, run{rs.RunID, rs.CreatedAt})
}
sort.Slice(runs, func(i, j int) bool { return runs[i].created.After(runs[j].created) })
ids := make([]string, len(runs))
for i, r := range runs {
ids[i] = r.id
}
return ids, nil
}
// RunStep executes fn for (phase, name) unless it already completed
// successfully in a prior attempt at this run, in which case fn is skipped
// and the previously recorded StepOutcome is returned instead - this is
// what makes an interrupted run resumable without redoing (or worse,
// double-applying) work that already happened. State is persisted both
// before fn runs (so a crash during fn is visible as "running", not silently
// forgotten) and after (recording success or failure).
func (s *Store) RunStep(rs *RunState, phase Phase, name string, fn func() (StepOutcome, error)) (StepOutcome, error) {
if rs.Done(phase, name) {
return rs.Outcome(phase, name), nil
}
rs.Begin(phase, name)
if err := s.Save(rs); err != nil {
return StepOutcome{}, fmt.Errorf("checkpoint: persist step start for %s/%s: %w", phase, name, err)
}
outcome, stepErr := fn()
if stepErr != nil {
rs.Fail(phase, name, stepErr)
} else {
rs.Complete(phase, name, outcome)
}
if err := s.Save(rs); err != nil {
if stepErr != nil {
return outcome, fmt.Errorf("%w (additionally failed to persist checkpoint: %v)", stepErr, err)
}
return outcome, fmt.Errorf("step %s/%s succeeded but failed to persist checkpoint: %w", phase, name, err)
}
return outcome, stepErr
}
func newRunID() (string, error) {
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
return "", err
}
return fmt.Sprintf("%s-%s", time.Now().UTC().Format("20060102-150405"), hex.EncodeToString(b)), nil
}
+168
View File
@@ -0,0 +1,168 @@
package checkpoint
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func TestCreateSaveLoadRoundtrip(t *testing.T) {
store := NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create: %v", err)
}
if rs.RunID == "" {
t.Fatal("Create: expected a non-empty run id")
}
rs.Topology = Topology{DeploymentKind: "systemd", StoreBackend: "rocksdb"}
rs.RecordArtifact("fs-backup", Artifact{Path: "/var/lib/stalwart.v0155-backup", SHA256: "deadbeef", SizeBytes: 1024})
if err := store.Save(rs); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, err := store.Load(rs.RunID)
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded.SourceVersion != "0.15.5" || loaded.TargetVersion != "0.16.14" {
t.Errorf("Load: versions = %s -> %s, want 0.15.5 -> 0.16.14", loaded.SourceVersion, loaded.TargetVersion)
}
if loaded.Topology.DeploymentKind != "systemd" {
t.Errorf("Load: DeploymentKind = %q, want systemd", loaded.Topology.DeploymentKind)
}
if got := loaded.Artifacts["fs-backup"].SHA256; got != "deadbeef" {
t.Errorf("Load: artifact sha256 = %q, want deadbeef", got)
}
}
func TestSaveIsAtomicNoLeftoverTempFiles(t *testing.T) {
dir := t.TempDir()
store := NewStore(dir)
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create: %v", err)
}
for i := 0; i < 5; i++ {
rs.Begin(PhasePreflight, "version")
rs.Complete(PhasePreflight, "version", StepOutcome{Verdict: "ok", Detail: "ok"})
if err := store.Save(rs); err != nil {
t.Fatalf("Save #%d: %v", i, err)
}
}
entries, err := os.ReadDir(filepath.Join(dir, rs.RunID))
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, e := range entries {
if e.Name() != "state.json" {
t.Errorf("unexpected leftover file in run dir: %s", e.Name())
}
}
}
func TestRunStepSkipsAlreadyDoneStep(t *testing.T) {
store := NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create: %v", err)
}
calls := 0
fn := func() (StepOutcome, error) {
calls++
return StepOutcome{Verdict: "ok", Detail: "did the thing"}, nil
}
outcome1, err := store.RunStep(rs, PhasePreflight, "version", fn)
if err != nil {
t.Fatalf("RunStep #1: %v", err)
}
if outcome1.Detail != "did the thing" {
t.Errorf("RunStep #1 detail = %q, want %q", outcome1.Detail, "did the thing")
}
if calls != 1 {
t.Fatalf("calls after first RunStep = %d, want 1", calls)
}
// Simulate a resumed run: same rs, same step name. fn must not run again,
// and the previously recorded outcome must come back unchanged.
outcome2, err := store.RunStep(rs, PhasePreflight, "version", fn)
if err != nil {
t.Fatalf("RunStep #2 (resume): %v", err)
}
if calls != 1 {
t.Errorf("calls after resumed RunStep = %d, want 1 (fn should be skipped)", calls)
}
if outcome2 != outcome1 {
t.Errorf("RunStep #2 outcome = %+v, want %+v (unchanged from before)", outcome2, outcome1)
}
}
func TestRunStepRetriesAfterFailure(t *testing.T) {
store := NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create: %v", err)
}
calls := 0
failThenSucceed := func() (StepOutcome, error) {
calls++
if calls == 1 {
return StepOutcome{}, errors.New("transient failure")
}
return StepOutcome{Verdict: "ok", Detail: "succeeded on retry"}, nil
}
if _, err := store.RunStep(rs, PhaseBackup, "fs-snapshot", failThenSucceed); err == nil {
t.Fatal("RunStep #1: expected error, got nil")
}
if rs.Status(PhaseBackup, "fs-snapshot") != StepFailed {
t.Errorf("status after failed attempt = %s, want failed", rs.Status(PhaseBackup, "fs-snapshot"))
}
outcome, err := store.RunStep(rs, PhaseBackup, "fs-snapshot", failThenSucceed)
if err != nil {
t.Fatalf("RunStep #2 (retry): %v", err)
}
if calls != 2 {
t.Errorf("calls = %d, want 2 (failed step must retry, not skip)", calls)
}
if outcome.Detail != "succeeded on retry" {
t.Errorf("detail = %q, want %q", outcome.Detail, "succeeded on retry")
}
if !rs.Done(PhaseBackup, "fs-snapshot") {
t.Error("step should be Done after a successful retry")
}
}
func TestListOrdersNewestFirst(t *testing.T) {
store := NewStore(t.TempDir())
first, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create first: %v", err)
}
first.CreatedAt = first.CreatedAt.Add(-time.Hour)
if err := store.Save(first); err != nil {
t.Fatalf("Save first: %v", err)
}
second, err := store.Create("0.16.14", "0.16.15")
if err != nil {
t.Fatalf("Create second: %v", err)
}
ids, err := store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(ids) != 2 || ids[0] != second.RunID || ids[1] != first.RunID {
t.Errorf("List = %v, want [%s %s]", ids, second.RunID, first.RunID)
}
}
+121
View File
@@ -0,0 +1,121 @@
package checkpoint
import "time"
// Phase identifies one of the top-level migration phases from
// ARCHITECTURE.md §4. Step records are scoped to a phase so the same step
// name can be reused across phases without colliding.
type Phase string
const (
PhasePreflight Phase = "preflight"
PhaseBackup Phase = "backup"
PhaseStage Phase = "stage"
PhaseRecovery Phase = "recovery"
PhaseCutover Phase = "cutover"
PhaseValidate Phase = "validate"
PhaseRollback Phase = "rollback"
)
// StepStatus is the lifecycle state of one checkpointed step.
type StepStatus string
const (
StepPending StepStatus = "pending"
StepRunning StepStatus = "running"
StepDone StepStatus = "done"
StepFailed StepStatus = "failed"
)
// StepOutcome is what a step reports back on success: a verdict
// classification the calling phase defines the meaning of (e.g. preflight's
// "ok"/"warn"/"fail"), a human-readable summary, and an optional
// machine-readable value later steps - or a resumed run reconstructing this
// step's result without re-executing it - need. Keeping these three
// separate (rather than one free-text field) is what lets `stalwart-migrate
// status` print a clean human summary while still round-tripping the data a
// resumed run depends on.
type StepOutcome struct {
Verdict string `json:"verdict,omitempty"`
Detail string `json:"detail,omitempty"`
Extra string `json:"extra,omitempty"`
}
// StepRecord captures one step's lifecycle status plus its StepOutcome.
type StepRecord struct {
Phase Phase `json:"phase"`
Name string `json:"name"`
Status StepStatus `json:"status"`
StepOutcome // embedded (untagged) so its fields flatten into this JSON object
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Error string `json:"error,omitempty"`
}
// Artifact is a content-addressed record of a file a run produced (a
// backup, a settings dump, a downloaded release binary) so later phases and
// a human operator can confirm it hasn't changed underfoot.
type Artifact struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
SizeBytes int64 `json:"size_bytes"`
}
// MailboxCount is one mailbox's message count as observed at preflight
// time, for later comparison against the post-migration count.
type MailboxCount struct {
Mailbox string `json:"mailbox"`
Messages int `json:"messages"`
}
// PreflightSnapshot holds the facts captured before anything is touched,
// which the validate phase later compares against the migrated instance.
// See ARCHITECTURE.md §4.1 and §4.7.
type PreflightSnapshot struct {
TakenAt time.Time `json:"taken_at"`
AccountCount int `json:"account_count"`
Domains []string `json:"domains,omitempty"`
MailboxCounts map[string][]MailboxCount `json:"mailbox_counts,omitempty"` // account -> mailboxes
DKIMFingerprints map[string]string `json:"dkim_fingerprints,omitempty"`
TLSFingerprints []string `json:"tls_fingerprints,omitempty"`
ListenerPorts []int `json:"listener_ports,omitempty"`
}
// Topology records how this Stalwart instance is deployed, as detected
// during preflight, so later phases (cutover, rollback) know whether
// they're managing a systemd unit or a container and what backend they're
// dealing with.
type Topology struct {
DeploymentKind string `json:"deployment_kind,omitempty"` // "systemd", "docker", "unknown"
ClusterNodes []string `json:"cluster_nodes,omitempty"`
StoreBackend string `json:"store_backend,omitempty"`
BlobStore string `json:"blob_store,omitempty"`
FTSBackend string `json:"fts_backend,omitempty"`
}
// RunState is the full persisted state of one migration run: everything
// needed to resume it after a crash, decide whether to roll back, or
// report on it later. See ARCHITECTURE.md §5.
type RunState struct {
RunID string `json:"run_id"`
SourceVersion string `json:"source_version"`
TargetVersion string `json:"target_version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Topology Topology `json:"topology,omitempty"`
Steps []StepRecord `json:"steps"`
Artifacts map[string]Artifact `json:"artifacts,omitempty"`
PreflightSnapshot *PreflightSnapshot `json:"preflight_snapshot,omitempty"`
RollbackWindowClosed bool `json:"rollback_window_closed"`
}
// RecordArtifact stores a content-addressed record of a file this run
// produced, keyed by a short logical name (e.g. "fs-backup", "settings-dump",
// "target-binary") rather than its path, since the path alone doesn't prove
// the content is what this run actually wrote.
func (rs *RunState) RecordArtifact(name string, a Artifact) {
if rs.Artifacts == nil {
rs.Artifacts = map[string]Artifact{}
}
rs.Artifacts[name] = a
}
+3
View File
@@ -0,0 +1,3 @@
// Package config implements the tool's own configuration: paths, thresholds, and credential handling.
// See ARCHITECTURE.md §6 for the design.
package config
+3
View File
@@ -0,0 +1,3 @@
// Package plan implements the version-boundary migration plans (ordered step lists) that the engine executes.
// See ARCHITECTURE.md §4 for the design.
package plan
+122
View File
@@ -0,0 +1,122 @@
package plan
import (
"fmt"
"regexp"
"strconv"
)
// semver is a minimal major.minor.patch version - package-local like the
// equivalent in internal/preflight, since this comparison is the only thing
// plan needs from it and duplicating ~20 lines keeps phase packages
// independent per ARCHITECTURE.md §7.
type semver struct{ Major, Minor, Patch int }
var versionPattern = regexp.MustCompile(`v?(\d+)\.(\d+)\.(\d+)`)
func parseSemver(s string) (semver, error) {
m := versionPattern.FindStringSubmatch(s)
if m == nil {
return semver{}, fmt.Errorf("plan: no version number found in %q", s)
}
major, _ := strconv.Atoi(m[1])
minor, _ := strconv.Atoi(m[2])
patch, _ := strconv.Atoi(m[3])
return semver{major, minor, patch}, nil
}
func (v semver) String() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) }
func (v semver) Compare(o semver) int {
if v.Major != o.Major {
return cmp(v.Major, o.Major)
}
if v.Minor != o.Minor {
return cmp(v.Minor, o.Minor)
}
return cmp(v.Patch, o.Patch)
}
func cmp(a, b int) int {
switch {
case a < b:
return -1
case a > b:
return 1
default:
return 0
}
}
// PhaseName identifies one phase in an ordered migration plan - the phase
// packages this names are internal/preflight, internal/backup,
// internal/recovery, internal/rollback (invoked on failure, not part of the
// forward list), and internal/validate.
type PhaseName string
const (
PhasePreflight PhaseName = "preflight"
PhaseBackup PhaseName = "backup"
PhaseRecovery PhaseName = "recovery" // only present when crossing the 0.15/0.16 boundary
PhaseCutover PhaseName = "cutover"
PhaseValidate PhaseName = "validate"
)
// Plan is the ordered list of phases one migration run needs, decided once
// from the source and target versions. See ARCHITECTURE.md §4.6: crossing
// the 0.15/0.16 boundary needs the full recovery-mode migration (§4.4); a
// same-boundary patch bump (every 0.16.1-0.16.14 release so far, per
// Stalwart's own changelog) is the fast path with no recovery phase at all.
type Plan struct {
Phases []PhaseName
CrossesMajorBoundary bool
SourceVersion string
TargetVersion string
Reason string
}
// HasPhase reports whether name appears in the plan.
func (p *Plan) HasPhase(name PhaseName) bool {
for _, ph := range p.Phases {
if ph == name {
return true
}
}
return false
}
// Decide returns the plan for migrating from sourceVersion to
// targetVersion. It refuses (rather than guesses) if the source is already
// at or beyond the target, since that's not a migration this tool should
// attempt to run.
func Decide(sourceVersion, targetVersion string) (*Plan, error) {
src, err := parseSemver(sourceVersion)
if err != nil {
return nil, fmt.Errorf("plan: parse source version %q: %w", sourceVersion, err)
}
tgt, err := parseSemver(targetVersion)
if err != nil {
return nil, fmt.Errorf("plan: parse target version %q: %w", targetVersion, err)
}
if src.Compare(tgt) >= 0 {
return nil, fmt.Errorf("plan: source %s is already at or beyond target %s - nothing to migrate", src, tgt)
}
crosses := src.Major == 0 && src.Minor < 16 && (tgt.Major > 0 || tgt.Minor >= 16)
if crosses {
return &Plan{
Phases: []PhaseName{PhasePreflight, PhaseBackup, PhaseRecovery, PhaseCutover, PhaseValidate},
CrossesMajorBoundary: true,
SourceVersion: src.String(),
TargetVersion: tgt.String(),
Reason: fmt.Sprintf("%s -> %s crosses the 0.15/0.16 major boundary: full recovery-mode migration required", src, tgt),
}, nil
}
return &Plan{
Phases: []PhaseName{PhasePreflight, PhaseBackup, PhaseCutover, PhaseValidate},
CrossesMajorBoundary: false,
SourceVersion: src.String(),
TargetVersion: tgt.String(),
Reason: fmt.Sprintf("%s -> %s is a same-boundary patch upgrade: fast path applies (no recovery-mode phase)", src, tgt),
}, nil
}
+71
View File
@@ -0,0 +1,71 @@
package plan
import "testing"
func TestDecideCrossesMajorBoundary(t *testing.T) {
p, err := Decide("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Decide: %v", err)
}
if !p.CrossesMajorBoundary {
t.Error("CrossesMajorBoundary = false, want true for 0.15.5 -> 0.16.14")
}
if !p.HasPhase(PhaseRecovery) {
t.Errorf("phases %v missing PhaseRecovery", p.Phases)
}
wantOrder := []PhaseName{PhasePreflight, PhaseBackup, PhaseRecovery, PhaseCutover, PhaseValidate}
if len(p.Phases) != len(wantOrder) {
t.Fatalf("phases = %v, want %v", p.Phases, wantOrder)
}
for i, ph := range wantOrder {
if p.Phases[i] != ph {
t.Errorf("phases[%d] = %s, want %s", i, p.Phases[i], ph)
}
}
}
func TestDecidePatchBumpFastPath(t *testing.T) {
p, err := Decide("0.16.5", "0.16.14")
if err != nil {
t.Fatalf("Decide: %v", err)
}
if p.CrossesMajorBoundary {
t.Error("CrossesMajorBoundary = true, want false for a 0.16.x -> 0.16.x bump")
}
if p.HasPhase(PhaseRecovery) {
t.Errorf("phases %v should not include PhaseRecovery on the fast path", p.Phases)
}
}
func TestDecideRefusesNoOp(t *testing.T) {
if _, err := Decide("0.16.14", "0.16.14"); err == nil {
t.Fatal("Decide should refuse when source == target")
}
}
func TestDecideRefusesRegression(t *testing.T) {
if _, err := Decide("0.16.14", "0.15.5"); err == nil {
t.Fatal("Decide should refuse when source is already beyond target")
}
}
func TestDecideFutureMajorCrossesBoundaryToo(t *testing.T) {
// A hypothetical 0.15.x -> 1.0.0 jump should still be treated as
// crossing the boundary this tool knows how to automate.
p, err := Decide("0.15.9", "1.0.0")
if err != nil {
t.Fatalf("Decide: %v", err)
}
if !p.CrossesMajorBoundary {
t.Error("CrossesMajorBoundary = false, want true for 0.15.9 -> 1.0.0")
}
}
func TestDecideRejectsUnparseableVersions(t *testing.T) {
if _, err := Decide("not-a-version", "0.16.14"); err == nil {
t.Fatal("Decide should reject an unparseable source version")
}
if _, err := Decide("0.15.5", "not-a-version"); err == nil {
t.Fatal("Decide should reject an unparseable target version")
}
}
+290
View File
@@ -0,0 +1,290 @@
package preflight
import (
"context"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi"
)
// Options configures a Checker. Every field has a conservative default
// applied by New except the ones that must name a real path on this host.
type Options struct {
BinaryPath string // installed stalwart binary, e.g. /usr/local/bin/stalwart
ConfigPath string // its config file (TOML pre-0.16, JSON 0.16+)
DataDir string // data directory to size/space-check
ContainerName string // docker container name, if applicable
AdminURL string // base URL for the JMAP reachability check; empty skips it
AdminUser string
AdminPassword string
TargetVersion string // e.g. "0.16.14" or "latest"
MinFreeMultiple float64
HTTPClient *http.Client
}
// Checker runs the preflight checks described in ARCHITECTURE.md §4.1.
type Checker struct {
opts Options
}
func New(opts Options) *Checker {
if opts.MinFreeMultiple <= 0 {
opts.MinFreeMultiple = 2.0
}
return &Checker{opts: opts}
}
// Run executes every preflight check, checkpointing each one so a killed
// and re-invoked run skips checks that already completed - see
// checkpoint.Store.RunStep. It never aborts early on a single Fail: the
// point of preflight is to surface every blocking issue in one pass rather
// than fail-stop-fix-retry one at a time. Callers decide what to do with a
// Report whose Blocking() is true. It only returns a non-nil error for a
// genuine execution fault (e.g. the checkpoint store itself can't be
// written to) - a check finding a real problem is reported via
// Status: StatusFail in the Report, not a Go error.
func (c *Checker) Run(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState) (Report, error) {
var report Report
// runCheck wraps fn as a checkpointed step and appends its result to
// report, whether fn actually ran or was skipped because a prior
// attempt already completed it - either way report ends up with the
// same entries, and the returned checkpoint.StepOutcome.Extra carries
// whatever machine-readable value a later check in this same Run needs.
runCheck := func(name string, fn func() (CheckResult, string)) (checkpoint.StepOutcome, error) {
outcome, err := store.RunStep(rs, checkpoint.PhasePreflight, name, func() (checkpoint.StepOutcome, error) {
res, extra := fn()
return checkpoint.StepOutcome{Verdict: string(res.Status), Detail: res.Detail, Extra: extra}, nil
})
if err != nil {
return checkpoint.StepOutcome{}, err
}
report.Results = append(report.Results, CheckResult{Name: name, Status: Status(outcome.Verdict), Detail: outcome.Detail})
return outcome, nil
}
versionOutcome, err := runCheck("version", func() (CheckResult, string) {
cur, err := DetectVersion(ctx, c.opts.BinaryPath)
if err != nil {
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
}
curV, _ := parseSemver(cur)
if curV.Compare(minSupportedSource) < 0 {
return CheckResult{
Status: StatusFail,
Detail: fmt.Sprintf("current version %s is older than the minimum supported %s - upgrade to 0.15.x first", cur, minSupportedSource),
}, cur
}
return CheckResult{Status: StatusOK, Detail: fmt.Sprintf("current version %s", cur)}, cur
})
if err != nil {
return report, err
}
targetOutcome, err := runCheck("target-release", func() (CheckResult, string) {
rel, err := ResolveRelease(ctx, c.opts.HTTPClient, c.opts.TargetVersion)
if err != nil {
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
}
tag := strings.TrimPrefix(rel.TagName, "v")
detail := fmt.Sprintf("resolved target to %s (%d release assets)", rel.TagName, len(rel.Assets))
if asset := ChecksumAsset(rel); asset != nil {
detail += fmt.Sprintf(", checksum manifest available: %s", asset.Name)
} else {
detail += "; no published checksum manifest found - integrity relies on the one-time HTTPS download only"
}
return CheckResult{Status: StatusOK, Detail: detail}, tag
})
if err != nil {
return report, err
}
if _, err := runCheck("upgrade-direction", func() (CheckResult, string) {
curV, errCur := parseSemver(versionOutcome.Extra)
tgtV, errTgt := parseSemver(targetOutcome.Extra)
if errCur != nil || errTgt != nil {
return CheckResult{Status: StatusWarn, Detail: "could not compare current and target versions (one or both unresolved above)"}, ""
}
if curV.Compare(tgtV) >= 0 {
return CheckResult{
Status: StatusFail,
Detail: fmt.Sprintf("current version %s is already at or beyond target %s - nothing to migrate", curV, tgtV),
}, ""
}
if curV.Major == 0 && curV.Minor < 16 && (tgtV.Major > 0 || tgtV.Minor >= 16) {
return CheckResult{
Status: StatusOK,
Detail: fmt.Sprintf("%s -> %s crosses the 0.15/0.16 major boundary: full recovery-mode migration plan required (ARCHITECTURE.md §4.4)", curV, tgtV),
}, ""
}
return CheckResult{
Status: StatusOK,
Detail: fmt.Sprintf("%s -> %s is a same-boundary patch upgrade: fast-path plan applies (ARCHITECTURE.md §4.6)", curV, tgtV),
}, ""
}); err != nil {
return report, err
}
deploymentOutcome, err := runCheck("deployment-kind", func() (CheckResult, string) {
kind := DetectDeploymentKind(ctx, c.opts.ContainerName)
status := StatusOK
if kind == DeploymentUnknown {
status = StatusWarn
}
return CheckResult{Status: status, Detail: fmt.Sprintf("detected deployment kind: %s", kind)}, string(kind)
})
if err != nil {
return report, err
}
storeOutcome, err := runCheck("store-backend", func() (CheckResult, string) {
matches, err := DetectStoreBackends(c.opts.ConfigPath)
if err != nil {
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
}
if len(matches) == 0 {
return CheckResult{Status: StatusWarn, Detail: "no known store backend type found in config - confirm manually before proceeding"}, ""
}
names := make([]string, len(matches))
backends := make([]string, len(matches))
for i, m := range matches {
names[i] = fmt.Sprintf("%s (%s)", m.Backend, m.Path)
backends[i] = m.Backend
}
return CheckResult{Status: StatusOK, Detail: "found: " + strings.Join(names, ", ")}, strings.Join(backends, ",")
})
if err != nil {
return report, err
}
if _, err := runCheck("cluster-config", func() (CheckResult, string) {
clustered, err := LooksClustered(c.opts.ConfigPath)
if err != nil {
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
}
if clustered {
return CheckResult{
Status: StatusWarn,
Detail: "config mentions clustering - confirm every peer node is stopped before this run proceeds; the tool does not verify this for you",
}, ""
}
return CheckResult{Status: StatusOK, Detail: "no cluster configuration detected"}, ""
}); err != nil {
return report, err
}
if _, err := runCheck("disk-space", func() (CheckResult, string) {
size, err := DirSize(c.opts.DataDir)
if err != nil {
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
}
free, err := FreeBytes(c.opts.DataDir)
if err != nil {
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
}
required := uint64(float64(size) * c.opts.MinFreeMultiple)
detail := fmt.Sprintf("data dir %s is %s, %s free, need >= %s (%.1fx, for the fs-snapshot backup)",
c.opts.DataDir, humanBytes(uint64(size)), humanBytes(free), humanBytes(required), c.opts.MinFreeMultiple)
if free < required {
return CheckResult{Status: StatusFail, Detail: detail}, ""
}
return CheckResult{Status: StatusOK, Detail: detail}, ""
}); err != nil {
return report, err
}
if c.opts.AdminURL != "" {
if _, err := runCheck("admin-reachable", func() (CheckResult, string) {
client := &stalwartapi.Client{
BaseURL: c.opts.AdminURL,
Username: c.opts.AdminUser,
Password: c.opts.AdminPassword,
HTTPClient: c.opts.HTTPClient,
}
if err := client.Ping(ctx); err != nil {
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
}
return CheckResult{Status: StatusOK, Detail: fmt.Sprintf("JMAP session reachable at %s with the given credentials", c.opts.AdminURL)}, ""
}); err != nil {
return report, err
}
if _, err := runCheck("account-snapshot", func() (CheckResult, string) {
client := &stalwartapi.Client{
BaseURL: c.opts.AdminURL,
Username: c.opts.AdminUser,
Password: c.opts.AdminPassword,
HTTPClient: c.opts.HTTPClient,
}
snap, err := client.AccountSnapshot(ctx)
if err != nil {
return CheckResult{
Status: StatusWarn,
Detail: fmt.Sprintf("could not capture the account/domain snapshot: %v - the post-migration directory-integrity check won't have anything to compare against", err),
}, ""
}
mailboxCounts := make(map[string][]checkpoint.MailboxCount, len(snap.MailboxCounts))
for account, counts := range snap.MailboxCounts {
converted := make([]checkpoint.MailboxCount, len(counts))
for i, mc := range counts {
converted[i] = checkpoint.MailboxCount{Mailbox: mc.Mailbox, Messages: mc.Messages}
}
mailboxCounts[account] = converted
}
rs.PreflightSnapshot = &checkpoint.PreflightSnapshot{
TakenAt: time.Now().UTC(),
AccountCount: snap.AccountCount,
Domains: snap.Domains,
MailboxCounts: mailboxCounts,
}
detail := fmt.Sprintf("captured snapshot: %d account(s) across %d domain(s), mailbox counts for %d account(s)",
snap.AccountCount, len(snap.Domains), len(mailboxCounts))
status := StatusOK
if len(snap.MailboxErrors) > 0 {
status = StatusWarn
accounts := make([]string, 0, len(snap.MailboxErrors))
for account := range snap.MailboxErrors {
accounts = append(accounts, account)
}
sort.Strings(accounts)
for i, account := range accounts {
if i >= 3 {
detail += fmt.Sprintf(" (and %d more)", len(accounts)-3)
break
}
detail += fmt.Sprintf("; mailbox count failed for %s: %s", account, snap.MailboxErrors[account])
}
}
return CheckResult{Status: status, Detail: detail}, ""
}); err != nil {
return report, err
}
} else {
report.Results = append(report.Results, CheckResult{
Name: "admin-reachable",
Status: StatusWarn,
Detail: "no --admin-url configured - skipped; the account/mailbox snapshot validate needs later can't be captured without it",
})
}
rs.Topology = checkpoint.Topology{
DeploymentKind: deploymentOutcome.Extra,
StoreBackend: storeOutcome.Extra,
}
if versionOutcome.Extra != "" {
rs.SourceVersion = versionOutcome.Extra
}
if targetOutcome.Extra != "" {
rs.TargetVersion = targetOutcome.Extra
}
if err := store.Save(rs); err != nil {
return report, fmt.Errorf("preflight: persist topology: %w", err)
}
return report, nil
}
+304
View File
@@ -0,0 +1,304 @@
package preflight
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
)
// writeFakeBinary creates a shell script that behaves like `stalwart
// --version` and records each invocation to counterPath, so tests can
// assert a checkpointed step was (or wasn't) re-executed on resume.
func writeFakeBinary(t *testing.T, version, counterPath string) string {
t.Helper()
dir := t.TempDir()
scriptPath := filepath.Join(dir, "fake-stalwart.sh")
script := fmt.Sprintf("#!/bin/sh\necho invoked >> %q\necho 'stalwart %s'\n", counterPath, version)
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return scriptPath
}
func countLines(t *testing.T, path string) int {
t.Helper()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return 0
}
if err != nil {
t.Fatal(err)
}
return len(strings.Split(strings.TrimSpace(string(data)), "\n"))
}
func TestCheckerRunEndToEndAndResume(t *testing.T) {
// -- fixtures --------------------------------------------------------
counterPath := filepath.Join(t.TempDir(), "invocations")
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
configPath := filepath.Join(t.TempDir(), "config.toml")
tomlCfg := "[store.\"rocksdb\"]\ntype = \"rocksdb\"\npath = \"/var/lib/stalwart/data\"\n"
if err := os.WriteFile(configPath, []byte(tomlCfg), 0o644); err != nil {
t.Fatal(err)
}
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Release{
TagName: "v0.16.14",
Assets: []ReleaseAsset{{Name: "checksums.txt"}},
})
})
stateDir := t.TempDir()
store := checkpoint.NewStore(stateDir)
rs, err := store.Create("", "latest")
if err != nil {
t.Fatalf("store.Create: %v", err)
}
checker := New(Options{
BinaryPath: binaryPath,
ConfigPath: configPath,
DataDir: dataDir,
TargetVersion: "latest",
})
// -- first run: everything should execute -----------------------------
report, err := checker.Run(context.Background(), store, rs)
if err != nil {
t.Fatalf("Run #1: %v", err)
}
if report.Blocking() {
t.Fatalf("Run #1: unexpected blocking report:\n%s", report.String())
}
if got := countLines(t, counterPath); got != 1 {
t.Fatalf("binary invocations after Run #1 = %d, want 1", got)
}
if rs.SourceVersion != "0.15.5" {
t.Errorf("rs.SourceVersion = %q, want 0.15.5", rs.SourceVersion)
}
if rs.TargetVersion != "0.16.14" {
t.Errorf("rs.TargetVersion = %q, want 0.16.14 (resolved from \"latest\")", rs.TargetVersion)
}
if rs.Topology.StoreBackend != "rocksdb" {
t.Errorf("rs.Topology.StoreBackend = %q, want rocksdb", rs.Topology.StoreBackend)
}
foundDirection := false
for _, res := range report.Results {
if res.Name == "upgrade-direction" {
foundDirection = true
if !strings.Contains(res.Detail, "major boundary") {
t.Errorf("upgrade-direction detail = %q, want mention of the major boundary", res.Detail)
}
}
}
if !foundDirection {
t.Error("report missing upgrade-direction check")
}
// -- simulate a crash and resume: reload state from disk fresh --------
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatalf("store.Load (resume): %v", err)
}
report2, err := checker.Run(context.Background(), store, resumed)
if err != nil {
t.Fatalf("Run #2 (resume): %v", err)
}
if got := countLines(t, counterPath); got != 1 {
t.Errorf("binary invocations after resumed Run = %d, want 1 (already-done steps must not re-execute)", got)
}
if len(report2.Results) != len(report.Results) {
t.Errorf("resumed report has %d results, want %d (same as first run)", len(report2.Results), len(report.Results))
}
}
func TestCheckerRunFlagsTooOldSource(t *testing.T) {
counterPath := filepath.Join(t.TempDir(), "invocations")
binaryPath := writeFakeBinary(t, "0.14.2", counterPath)
configPath := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(configPath, []byte("[server]\nhostname = \"mail.example.com\"\n"), 0o644); err != nil {
t.Fatal(err)
}
dataDir := t.TempDir()
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
})
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("", "latest")
if err != nil {
t.Fatal(err)
}
checker := New(Options{BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir, TargetVersion: "latest"})
report, err := checker.Run(context.Background(), store, rs)
if err != nil {
t.Fatalf("Run: %v", err)
}
if !report.Blocking() {
t.Fatalf("expected a blocking report for a too-old source version, got:\n%s", report.String())
}
}
func TestCheckerRunFlagsInsufficientDiskSpace(t *testing.T) {
counterPath := filepath.Join(t.TempDir(), "invocations")
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
configPath := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(configPath, []byte("[store.\"rocksdb\"]\ntype = \"rocksdb\"\n"), 0o644); err != nil {
t.Fatal(err)
}
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
})
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("", "latest")
if err != nil {
t.Fatal(err)
}
// An absurd multiple guarantees the free-space check fails regardless
// of how much space the test host actually has free.
checker := New(Options{
BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir,
TargetVersion: "latest", MinFreeMultiple: 1e12,
})
report, err := checker.Run(context.Background(), store, rs)
if err != nil {
t.Fatalf("Run: %v", err)
}
if !report.Blocking() {
t.Fatalf("expected a blocking report for insufficient disk space, got:\n%s", report.String())
}
}
func TestCheckerRunCapturesAccountSnapshotWhenAdminURLSet(t *testing.T) {
counterPath := filepath.Join(t.TempDir(), "invocations")
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
configPath := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(configPath, []byte("[store.\"rocksdb\"]\ntype = \"rocksdb\"\n"), 0o644); err != nil {
t.Fatal(err)
}
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
})
// A fake admin server: answers Ping's session-discovery GET, the
// impersonated session-discovery GET MailboxSnapshot makes for
// [email protected], and the POST /api calls for x:Account/query,
// x:Account/get, and Mailbox/get.
var apiURL string
adminSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap":
user, _, _ := r.BasicAuth()
if strings.Contains(user, "%") {
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"primaryAccounts": map[string]string{"urn:ietf:params:jmap:mail": "mail-alice"},
})
return
}
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPost && r.URL.Path == "/api":
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls := body["methodCalls"].([]any)
name := methodCalls[0].([]any)[0].(string)
switch name {
case "x:Account/query":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Account/query", map[string]any{"ids": []string{"a1"}}, "q"},
}})
case "x:Account/get":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Account/get", map[string]any{"list": []map[string]any{
{"id": "a1", "name": "[email protected]", "domainId": "example.com"},
}}, "g"},
}})
case "Mailbox/get":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"Mailbox/get", map[string]any{"list": []map[string]any{
{"name": "Inbox", "totalEmails": 5},
}}, "m"},
}})
}
default:
w.WriteHeader(http.StatusNotFound)
}
}))
apiURL = adminSrv.URL + "/api"
defer adminSrv.Close()
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("", "latest")
if err != nil {
t.Fatal(err)
}
checker := New(Options{
BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir, TargetVersion: "latest",
AdminURL: adminSrv.URL, AdminUser: "admin", AdminPassword: "hunter2",
})
report, err := checker.Run(context.Background(), store, rs)
if err != nil {
t.Fatalf("Run: %v", err)
}
if report.Blocking() {
t.Fatalf("unexpected blocking report:\n%s", report.String())
}
if rs.PreflightSnapshot == nil {
t.Fatal("PreflightSnapshot should be populated when AdminURL is set")
}
if rs.PreflightSnapshot.AccountCount != 1 {
t.Errorf("AccountCount = %d, want 1", rs.PreflightSnapshot.AccountCount)
}
if len(rs.PreflightSnapshot.Domains) != 1 || rs.PreflightSnapshot.Domains[0] != "example.com" {
t.Errorf("Domains = %v, want [example.com]", rs.PreflightSnapshot.Domains)
}
aliceMailboxes := rs.PreflightSnapshot.MailboxCounts["[email protected]"]
if len(aliceMailboxes) != 1 || aliceMailboxes[0].Mailbox != "Inbox" || aliceMailboxes[0].Messages != 5 {
t.Errorf("alice's mailbox counts = %+v, want [{Inbox 5}]", aliceMailboxes)
}
// Resume: the snapshot must survive a reload from disk without
// re-running the check (the admin server would still work, but this
// confirms the persisted value is what's actually being relied on).
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatalf("store.Load: %v", err)
}
if resumed.PreflightSnapshot == nil || resumed.PreflightSnapshot.AccountCount != 1 {
t.Errorf("resumed PreflightSnapshot = %+v, want AccountCount 1", resumed.PreflightSnapshot)
}
}
+22
View File
@@ -0,0 +1,22 @@
package preflight
import (
"fmt"
"os"
"strings"
)
// LooksClustered does a conservative, heuristic scan for cluster-related
// configuration. It exists to force a manual confirmation gate (see
// ARCHITECTURE.md §4.1's cluster gate: one live node on the old version
// during migration corrupts a shared store), not to enumerate peers
// precisely. A false positive just costs an extra confirmation prompt; a
// false negative is the dangerous direction, so this errs toward matching
// broadly rather than requiring an exact schema match.
func LooksClustered(configPath string) (bool, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return false, fmt.Errorf("preflight: read config %s: %w", configPath, err)
}
return strings.Contains(strings.ToLower(string(data)), "cluster"), nil
}
+34
View File
@@ -0,0 +1,34 @@
package preflight
import (
"os"
"path/filepath"
"testing"
)
func TestLooksClustered(t *testing.T) {
cases := []struct {
name string
content string
want bool
}{
{"no-cluster", "[server]\nhostname = \"mail.example.com\"\n", false},
{"has-cluster-section", "[cluster]\nnode-id = 1\npeers = [\"10.0.0.2\"]\n", true},
{"cluster-mentioned-in-key", "[server]\ncluster-coordinator = \"redis://localhost\"\n", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(tc.content), 0o644); err != nil {
t.Fatal(err)
}
got, err := LooksClustered(path)
if err != nil {
t.Fatalf("LooksClustered: %v", err)
}
if got != tc.want {
t.Errorf("LooksClustered(%s) = %v, want %v", tc.name, got, tc.want)
}
})
}
}
+45
View File
@@ -0,0 +1,45 @@
package preflight
import (
"context"
"os"
"os/exec"
)
// DeploymentKind is how a Stalwart instance appears to be run, which
// determines how cutover and rollback restart it.
type DeploymentKind string
const (
DeploymentSystemd DeploymentKind = "systemd"
DeploymentDocker DeploymentKind = "docker"
DeploymentUnknown DeploymentKind = "unknown"
)
var systemdUnitPaths = []string{
"/etc/systemd/system/stalwart.service",
"/lib/systemd/system/stalwart.service",
"/usr/lib/systemd/system/stalwart.service",
}
// DetectDeploymentKind makes a best-effort guess at how Stalwart is run
// here. It's deliberately conservative and cheap (file stats, one docker
// inspect) rather than exhaustive - an operator-supplied override should
// always be able to win over this, since the cost of guessing wrong here is
// cutover targeting the wrong thing.
func DetectDeploymentKind(ctx context.Context, containerName string) DeploymentKind {
for _, p := range systemdUnitPaths {
if _, err := os.Stat(p); err == nil {
return DeploymentSystemd
}
}
if containerName == "" {
containerName = "stalwart"
}
if _, err := exec.LookPath("docker"); err == nil {
if err := exec.CommandContext(ctx, "docker", "inspect", containerName).Run(); err == nil {
return DeploymentDocker
}
}
return DeploymentUnknown
}
+55
View File
@@ -0,0 +1,55 @@
package preflight
import (
"fmt"
"io/fs"
"path/filepath"
"syscall"
)
// DirSize walks dir and sums the size of every regular file in it. Used to
// estimate how much free space a filesystem-level backup copy will need -
// see ARCHITECTURE.md §4.2.
func DirSize(dir string) (int64, error) {
var total int64
err := filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type().IsRegular() {
info, err := d.Info()
if err != nil {
return err
}
total += info.Size()
}
return nil
})
if err != nil {
return 0, fmt.Errorf("preflight: measure size of %s: %w", dir, err)
}
return total, nil
}
// FreeBytes returns the free space available (to an unprivileged process)
// on the filesystem containing path.
func FreeBytes(path string) (uint64, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return 0, fmt.Errorf("preflight: statfs %s: %w", path, err)
}
return stat.Bavail * uint64(stat.Bsize), nil
}
func humanBytes(n uint64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := uint64(unit), 0
for n/div >= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
+55
View File
@@ -0,0 +1,55 @@
package preflight
import (
"os"
"path/filepath"
"testing"
)
func TestDirSize(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.bin"), make([]byte, 100), 0o644); err != nil {
t.Fatal(err)
}
sub := filepath.Join(dir, "sub")
if err := os.Mkdir(sub, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "b.bin"), make([]byte, 250), 0o644); err != nil {
t.Fatal(err)
}
got, err := DirSize(dir)
if err != nil {
t.Fatalf("DirSize: %v", err)
}
if got != 350 {
t.Errorf("DirSize = %d, want 350", got)
}
}
func TestFreeBytes(t *testing.T) {
got, err := FreeBytes(t.TempDir())
if err != nil {
t.Fatalf("FreeBytes: %v", err)
}
if got == 0 {
t.Error("FreeBytes = 0, want > 0 for a live filesystem")
}
}
func TestHumanBytes(t *testing.T) {
cases := []struct {
in uint64
want string
}{
{500, "500 B"},
{1536, "1.5 KiB"},
{5 * 1024 * 1024, "5.0 MiB"},
}
for _, tc := range cases {
if got := humanBytes(tc.in); got != tc.want {
t.Errorf("humanBytes(%d) = %q, want %q", tc.in, got, tc.want)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package preflight implements the read-only preflight checks that gate a run before anything is touched.
// See ARCHITECTURE.md §4.1 for the design.
package preflight
+74
View File
@@ -0,0 +1,74 @@
package preflight
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// githubAPIBase is a var, not a const, so tests can point it at an
// httptest server instead of hitting the real GitHub API.
var githubAPIBase = "https://api.github.com/repos/stalwartlabs/stalwart/releases"
type ReleaseAsset struct {
Name string `json:"name"`
DownloadURL string `json:"browser_download_url"`
SizeBytes int64 `json:"size"`
}
type Release struct {
TagName string `json:"tag_name"`
Assets []ReleaseAsset `json:"assets"`
}
// ResolveRelease looks up a Stalwart release from the public GitHub API.
// version is either an exact tag like "0.16.14" (a "v" prefix is added if
// missing) or "latest".
func ResolveRelease(ctx context.Context, httpClient *http.Client, version string) (*Release, error) {
url := githubAPIBase + "/latest"
if version != "" && version != "latest" {
tag := version
if !strings.HasPrefix(tag, "v") {
tag = "v" + tag
}
url = githubAPIBase + "/tags/" + tag
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("preflight: fetch release %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("preflight: fetch release %s: unexpected status %s", url, resp.Status)
}
var rel Release
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return nil, fmt.Errorf("preflight: parse release response from %s: %w", url, err)
}
return &rel, nil
}
// ChecksumAsset returns the release asset that looks like a published
// checksum manifest, if any. Its presence isn't guaranteed by Stalwart's
// release process, so callers must treat a nil result as "no independently
// published checksum to verify the download against", not an error.
func ChecksumAsset(rel *Release) *ReleaseAsset {
for i := range rel.Assets {
name := strings.ToLower(rel.Assets[i].Name)
if strings.Contains(name, "sha256") || strings.Contains(name, "checksum") {
return &rel.Assets[i]
}
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package preflight
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func withFakeGithub(t *testing.T, handler http.HandlerFunc) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
old := githubAPIBase
githubAPIBase = srv.URL
t.Cleanup(func() { githubAPIBase = old })
}
func TestResolveReleaseLatest(t *testing.T) {
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/latest" {
t.Errorf("path = %s, want /latest", r.URL.Path)
}
json.NewEncoder(w).Encode(Release{
TagName: "v0.16.14",
Assets: []ReleaseAsset{
{Name: "stalwart-x86_64-linux", DownloadURL: "https://example.com/stalwart"},
{Name: "checksums.txt", DownloadURL: "https://example.com/checksums.txt"},
},
})
})
rel, err := ResolveRelease(context.Background(), nil, "latest")
if err != nil {
t.Fatalf("ResolveRelease: %v", err)
}
if rel.TagName != "v0.16.14" {
t.Errorf("TagName = %s, want v0.16.14", rel.TagName)
}
if asset := ChecksumAsset(rel); asset == nil || asset.Name != "checksums.txt" {
t.Errorf("ChecksumAsset = %+v, want checksums.txt", asset)
}
}
func TestResolveReleaseExactTag(t *testing.T) {
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tags/v0.16.5" {
t.Errorf("path = %s, want /tags/v0.16.5", r.URL.Path)
}
json.NewEncoder(w).Encode(Release{TagName: "v0.16.5"})
})
rel, err := ResolveRelease(context.Background(), nil, "0.16.5")
if err != nil {
t.Fatalf("ResolveRelease: %v", err)
}
if rel.TagName != "v0.16.5" {
t.Errorf("TagName = %s, want v0.16.5", rel.TagName)
}
}
func TestChecksumAssetAbsent(t *testing.T) {
rel := &Release{Assets: []ReleaseAsset{{Name: "stalwart-x86_64-linux"}}}
if asset := ChecksumAsset(rel); asset != nil {
t.Errorf("ChecksumAsset = %+v, want nil", asset)
}
}
+46
View File
@@ -0,0 +1,46 @@
package preflight
import (
"fmt"
"strings"
)
// Status is a single check's verdict.
type Status string
const (
StatusOK Status = "ok"
StatusWarn Status = "warn"
StatusFail Status = "fail"
)
// CheckResult is one named check's outcome.
type CheckResult struct {
Name string
Status Status
Detail string
}
// Report is the full set of preflight check outcomes.
type Report struct {
Results []CheckResult
}
// Blocking reports whether any check failed hard enough that a run must
// not proceed.
func (r Report) Blocking() bool {
for _, res := range r.Results {
if res.Status == StatusFail {
return true
}
}
return false
}
func (r Report) String() string {
var b strings.Builder
for _, res := range r.Results {
fmt.Fprintf(&b, "[%-4s] %-20s %s\n", strings.ToUpper(string(res.Status)), res.Name, res.Detail)
}
return b.String()
}
+106
View File
@@ -0,0 +1,106 @@
package preflight
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"os"
"regexp"
"strings"
)
// knownBackends is the set of store/blob/FTS backend identifiers Stalwart's
// documentation names: RocksDB, SQLite, FoundationDB, PostgreSQL, MySQL,
// S3-compatible storage, and Elasticsearch.
var knownBackends = map[string]bool{
"rocksdb": true, "sqlite": true, "foundationdb": true,
"postgresql": true, "mysql": true, "s3": true, "elasticsearch": true,
}
// BackendMatch is one "type = <backend>" assignment found in a config
// file, tagged with where it was found (the enclosing TOML section, or the
// dotted JSON key path).
type BackendMatch struct {
Path string
Backend string
}
// DetectStoreBackends scans a Stalwart config file for store/blob/FTS
// backend declarations. It deliberately does not assume one fixed schema
// path: the exact TOML (pre-0.16) or JSON (0.16+) layout has already
// changed once between those versions and may change again, so this
// searches structurally for `type = "<known backend>"` assignments wherever
// they appear and reports every match with its location. Treat the result
// as "here's what to confirm before an unattended run", not ground truth.
func DetectStoreBackends(configPath string) ([]BackendMatch, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("preflight: read config %s: %w", configPath, err)
}
if json.Valid(data) {
return scanJSONBackends(data)
}
return scanTOMLBackends(data)
}
var (
tomlSectionRe = regexp.MustCompile(`^\[(.+)\]$`)
tomlKVRe = regexp.MustCompile(`^([A-Za-z0-9_.-]+)\s*=\s*"([^"]*)"$`)
)
func scanTOMLBackends(data []byte) ([]BackendMatch, error) {
var matches []BackendMatch
section := ""
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if m := tomlSectionRe.FindStringSubmatch(line); m != nil {
section = m[1]
continue
}
if m := tomlKVRe.FindStringSubmatch(line); m != nil {
key, value := m[1], strings.ToLower(m[2])
if key == "type" && knownBackends[value] {
matches = append(matches, BackendMatch{Path: section, Backend: value})
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("preflight: scan toml config: %w", err)
}
return matches, nil
}
func scanJSONBackends(data []byte) ([]BackendMatch, error) {
var root any
if err := json.Unmarshal(data, &root); err != nil {
return nil, fmt.Errorf("preflight: parse json config: %w", err)
}
var matches []BackendMatch
walkJSONForBackends(root, "", &matches)
return matches, nil
}
func walkJSONForBackends(node any, path string, matches *[]BackendMatch) {
switch v := node.(type) {
case map[string]any:
if t, ok := v["type"].(string); ok && knownBackends[strings.ToLower(t)] {
*matches = append(*matches, BackendMatch{Path: path, Backend: strings.ToLower(t)})
}
for k, child := range v {
childPath := k
if path != "" {
childPath = path + "." + k
}
walkJSONForBackends(child, childPath, matches)
}
case []any:
for i, child := range v {
walkJSONForBackends(child, fmt.Sprintf("%s[%d]", path, i), matches)
}
}
}
+86
View File
@@ -0,0 +1,86 @@
package preflight
import (
"os"
"path/filepath"
"testing"
)
func TestDetectStoreBackendsTOML(t *testing.T) {
toml := `
[server]
hostname = "mail.example.com"
[store."rocksdb"]
type = "rocksdb"
path = "/var/lib/stalwart/data"
[store."blob"]
type = "s3"
bucket = "stalwart-blobs"
`
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(toml), 0o644); err != nil {
t.Fatal(err)
}
matches, err := DetectStoreBackends(path)
if err != nil {
t.Fatalf("DetectStoreBackends: %v", err)
}
if len(matches) != 2 {
t.Fatalf("got %d matches, want 2: %+v", len(matches), matches)
}
got := map[string]string{}
for _, m := range matches {
got[m.Path] = m.Backend
}
if got[`store."rocksdb"`] != "rocksdb" {
t.Errorf(`store."rocksdb" backend = %q, want rocksdb`, got[`store."rocksdb"`])
}
if got[`store."blob"`] != "s3" {
t.Errorf(`store."blob" backend = %q, want s3`, got[`store."blob"`])
}
}
func TestDetectStoreBackendsJSON(t *testing.T) {
jsonCfg := `{
"store": {
"data": {"type": "postgresql", "host": "db.internal"},
"blob": {"type": "s3", "bucket": "stalwart-blobs"}
}
}`
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(jsonCfg), 0o644); err != nil {
t.Fatal(err)
}
matches, err := DetectStoreBackends(path)
if err != nil {
t.Fatalf("DetectStoreBackends: %v", err)
}
if len(matches) != 2 {
t.Fatalf("got %d matches, want 2: %+v", len(matches), matches)
}
backends := map[string]bool{}
for _, m := range matches {
backends[m.Backend] = true
}
if !backends["postgresql"] || !backends["s3"] {
t.Errorf("matches = %+v, want postgresql and s3", matches)
}
}
func TestDetectStoreBackendsNoMatch(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte("[server]\nhostname = \"mail.example.com\"\n"), 0o644); err != nil {
t.Fatal(err)
}
matches, err := DetectStoreBackends(path)
if err != nil {
t.Fatalf("DetectStoreBackends: %v", err)
}
if len(matches) != 0 {
t.Errorf("got %d matches, want 0: %+v", len(matches), matches)
}
}
+78
View File
@@ -0,0 +1,78 @@
package preflight
import (
"bytes"
"context"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
)
// semver is a minimal major.minor.patch version - enough for this tool's
// comparisons since Stalwart's pre-1.0 release tags don't use pre-release
// or build metadata.
type semver struct {
Major, Minor, Patch int
}
var versionPattern = regexp.MustCompile(`v?(\d+)\.(\d+)\.(\d+)`)
func parseSemver(s string) (semver, error) {
m := versionPattern.FindStringSubmatch(s)
if m == nil {
return semver{}, fmt.Errorf("preflight: no version number found in %q", s)
}
major, _ := strconv.Atoi(m[1])
minor, _ := strconv.Atoi(m[2])
patch, _ := strconv.Atoi(m[3])
return semver{major, minor, patch}, nil
}
func (v semver) String() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) }
// Compare returns -1, 0, or 1 as v is less than, equal to, or greater than o.
func (v semver) Compare(o semver) int {
if v.Major != o.Major {
return cmp(v.Major, o.Major)
}
if v.Minor != o.Minor {
return cmp(v.Minor, o.Minor)
}
return cmp(v.Patch, o.Patch)
}
func cmp(a, b int) int {
switch {
case a < b:
return -1
case a > b:
return 1
default:
return 0
}
}
// minSupportedSource is the oldest version this tool will start a migration
// from. Stalwart's own upgrade guidance says older installs need to reach
// 0.15.x first before crossing the 0.15/0.16 schema boundary this tool
// automates - see ARCHITECTURE.md §1/§4.1.
var minSupportedSource = semver{0, 15, 0}
// DetectVersion runs the installed binary's --version flag and extracts a
// semver from its output.
func DetectVersion(ctx context.Context, binaryPath string) (string, error) {
cmd := exec.CommandContext(ctx, binaryPath, "--version")
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("preflight: run %s --version: %w (output: %s)", binaryPath, err, strings.TrimSpace(out.String()))
}
v, err := parseSemver(out.String())
if err != nil {
return "", fmt.Errorf("preflight: parse version from %s --version output %q: %w", binaryPath, strings.TrimSpace(out.String()), err)
}
return v.String(), nil
}
+58
View File
@@ -0,0 +1,58 @@
package preflight
import "testing"
func TestParseSemver(t *testing.T) {
cases := []struct {
in string
want string
wantErr bool
}{
{"stalwart 0.15.5\n", "0.15.5", false},
{"v0.16.14", "0.16.14", false},
{"Stalwart Mail Server v0.16.0 (build abc123)", "0.16.0", false},
{"no version here", "", true},
{"", "", true},
}
for _, tc := range cases {
got, err := parseSemver(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("parseSemver(%q) = %v, want error", tc.in, got)
}
continue
}
if err != nil {
t.Errorf("parseSemver(%q) unexpected error: %v", tc.in, err)
continue
}
if got.String() != tc.want {
t.Errorf("parseSemver(%q) = %s, want %s", tc.in, got, tc.want)
}
}
}
func TestSemverCompare(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"0.15.5", "0.16.0", -1},
{"0.16.14", "0.16.14", 0},
{"0.16.1", "0.15.5", 1},
{"1.0.0", "0.16.14", 1},
}
for _, tc := range cases {
a, err := parseSemver(tc.a)
if err != nil {
t.Fatalf("parseSemver(%q): %v", tc.a, err)
}
b, err := parseSemver(tc.b)
if err != nil {
t.Fatalf("parseSemver(%q): %v", tc.b, err)
}
if got := a.Compare(b); got != tc.want {
t.Errorf("%s.Compare(%s) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
+54
View File
@@ -0,0 +1,54 @@
package recovery
import (
"context"
"fmt"
"os"
"os/exec"
)
// ApplyOptions configures stalwart-cli apply invocations against a
// recovery-mode instance - see UPGRADING/v0_16.md's documented
// STALWART_URL/STALWART_USER/STALWART_PASSWORD + `stalwart-cli apply --file`
// sequence.
type ApplyOptions struct {
CLIBinaryPath string // defaults to "stalwart-cli"
URL string
User string
Password string
}
// Apply runs `stalwart-cli apply --file <file>` once, with credentials
// passed as environment variables exactly as the upgrade guide's own
// example does, rather than on the command line where they'd be visible to
// anything reading this process's argv.
func Apply(ctx context.Context, o ApplyOptions, file string) error {
binary := o.CLIBinaryPath
if binary == "" {
binary = "stalwart-cli"
}
cmd := exec.CommandContext(ctx, binary, "apply", "--file", file)
cmd.Env = append(os.Environ(),
"STALWART_URL="+o.URL,
"STALWART_USER="+o.User,
"STALWART_PASSWORD="+o.Password,
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("recovery: stalwart-cli apply --file %s failed: %w (output: %s)", file, err, out)
}
return nil
}
// ApplyAll runs Apply for each file in order - export.json first, then any
// additional test-deployment snapshots (ARCHITECTURE.md §4.3/§4.4) -
// stopping at the first failure so a partial, silently-incomplete settings
// replay never gets reported as success.
func ApplyAll(ctx context.Context, o ApplyOptions, files []string) error {
for i, f := range files {
if err := Apply(ctx, o, f); err != nil {
return fmt.Errorf("applied %d/%d file(s) before failing: %w", i, len(files), err)
}
}
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package recovery
import (
"context"
"fmt"
"path/filepath"
"strings"
"testing"
)
func TestApplyInvokesCLIWithEnvCredentials(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
withFakeExecutable(t, "stalwart-cli", fmt.Sprintf("#!/bin/sh\necho \"$@ URL=$STALWART_URL USER=$STALWART_USER\" >> %q\nexit 0\n", log))
err := Apply(context.Background(), ApplyOptions{URL: "http://127.0.0.1:8080", User: "admin", Password: "secret"}, "/tmp/export.json")
if err != nil {
t.Fatalf("Apply: %v", err)
}
got := readArgsFile(t, log)
for _, want := range []string{"apply", "--file /tmp/export.json", "URL=http://127.0.0.1:8080", "USER=admin"} {
if !strings.Contains(got, want) {
t.Errorf("stalwart-cli invoked with %q, missing %q", got, want)
}
}
if strings.Contains(got, "secret") {
t.Error("password should not appear in argv")
}
}
func TestApplyPropagatesFailure(t *testing.T) {
withFakeExecutable(t, "stalwart-cli", "#!/bin/sh\necho 'invalid object' >&2\nexit 1\n")
err := Apply(context.Background(), ApplyOptions{URL: "http://127.0.0.1:8080", User: "admin", Password: "x"}, "/tmp/export.json")
if err == nil {
t.Fatal("Apply should error when stalwart-cli exits non-zero")
}
if !strings.Contains(err.Error(), "invalid object") {
t.Errorf("error = %v, want it to include stderr", err)
}
}
func TestApplyAllStopsAtFirstFailure(t *testing.T) {
dir := t.TempDir()
log := argsFile(t, dir)
script := fmt.Sprintf("#!/bin/sh\necho \"$@\" >> %q\ncase \"$*\" in\n *bad.json*) exit 1 ;;\n *) exit 0 ;;\nesac\n", log)
withFakeExecutable(t, "stalwart-cli", script)
err := ApplyAll(context.Background(), ApplyOptions{URL: "http://127.0.0.1:8080", User: "admin", Password: "x"},
[]string{filepath.Join(dir, "good1.json"), filepath.Join(dir, "bad.json"), filepath.Join(dir, "good2.json")})
if err == nil {
t.Fatal("ApplyAll should fail on bad.json")
}
got := readArgsFile(t, log)
if strings.Contains(got, "good2.json") {
t.Error("ApplyAll should stop before applying good2.json after bad.json failed")
}
if !strings.Contains(err.Error(), "1/3") {
t.Errorf("error = %v, want it to report 1/3 files applied before failing", err)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package recovery implements supervision of the Stalwart recovery-mode process and the settings apply step.
// See ARCHITECTURE.md §4.4 for the design.
package recovery
+39
View File
@@ -0,0 +1,39 @@
package recovery
import (
"os"
"path/filepath"
"testing"
)
// withFakeExecutable puts a fake executable named `name` at the front of
// PATH for the duration of the test, so code that shells out to a
// real-world tool (stalwart-cli) can be exercised without that tool
// actually being installed. t.Setenv restores PATH automatically.
func withFakeExecutable(t *testing.T, name, script string) (dir string) {
t.Helper()
dir = t.TempDir()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return dir
}
func argsFile(t *testing.T, dir string) string {
t.Helper()
return filepath.Join(dir, "invoked-args.log")
}
func readArgsFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ""
}
if err != nil {
t.Fatal(err)
}
return string(data)
}
+44
View File
@@ -0,0 +1,44 @@
package recovery
import (
"context"
"fmt"
"net/http"
"time"
)
// WaitForHealthy polls url until it responds (any status - even 401 proves
// the HTTP server itself is up and routing requests, which is what this
// check exists to confirm) or timeout elapses, returning a descriptive
// error on timeout rather than hanging indefinitely. This is what makes
// recovery mode's startup supervised rather than fire-and-forget - see
// ARCHITECTURE.md §4.4.
func WaitForHealthy(ctx context.Context, httpClient *http.Client, url string, timeout time.Duration) error {
if httpClient == nil {
httpClient = &http.Client{Timeout: 5 * time.Second}
}
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
if err := ctx.Err(); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
lastErr = err
} else {
resp.Body.Close()
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(250 * time.Millisecond):
}
}
return fmt.Errorf("recovery: %s did not become reachable within %s: %w", url, timeout, lastErr)
}
+51
View File
@@ -0,0 +1,51 @@
package recovery
import (
"fmt"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"testing"
)
// TestMain lets this test binary also act as a fake Stalwart binary for
// subprocess tests - the standard os/exec "helper process" technique (see
// Go's own os/exec_test.go). When STALWART_MIGRATOR_TEST_HELPER=1 is set,
// the binary runs a minimal HTTP server on STALWART_MIGRATOR_TEST_PORT
// until it receives SIGTERM (or, if STALWART_MIGRATOR_TEST_IGNORE_SIGTERM=1,
// ignores SIGTERM to exercise the SIGKILL escalation path) instead of
// running the actual test suite. This avoids needing a real Stalwart binary
// - or a fabricated stand-in for its HTTP behavior - anywhere in these
// tests: it's real net/http and real process signaling, just running under
// this package's own compiled binary.
func TestMain(m *testing.M) {
if os.Getenv("STALWART_MIGRATOR_TEST_HELPER") == "1" {
runFakeStalwartServer()
return
}
os.Exit(m.Run())
}
func runFakeStalwartServer() {
port := os.Getenv("STALWART_MIGRATOR_TEST_PORT")
ln, err := net.Listen("tcp", "127.0.0.1:"+port)
if err != nil {
fmt.Fprintln(os.Stderr, "fake stalwart: listen:", err)
os.Exit(1)
}
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})}
go srv.Serve(ln)
sigCh := make(chan os.Signal, 1)
if os.Getenv("STALWART_MIGRATOR_TEST_IGNORE_SIGTERM") == "1" {
signal.Ignore(syscall.SIGTERM)
select {} // block forever; the test must SIGKILL this process itself
}
signal.Notify(sigCh, syscall.SIGTERM)
<-sigCh
os.Exit(0)
}
+96
View File
@@ -0,0 +1,96 @@
package recovery
import (
"context"
"fmt"
"os"
"os/exec"
"syscall"
"time"
)
// ProcessOptions configures how the target binary is launched.
type ProcessOptions struct {
BinaryPath string
ConfigPath string
// RecoveryMode, when true, sets STALWART_RECOVERY_MODE=1 and
// STALWART_RECOVERY_ADMIN=<AdminUser>:<AdminPassword> - exactly the
// environment variables Stalwart's own upgrade guide uses to bring the
// new binary up in recovery mode against a not-yet-migrated store (see
// UPGRADING/v0_16.md). When false, the binary is started as a normal
// boot against ConfigPath - used after recovery mode to confirm the
// migrated store comes up cleanly under an ordinary start, not just a
// recovery one (see ARCHITECTURE.md's dry-run design).
RecoveryMode bool
AdminUser string
AdminPassword string
// ExtraEnv is appended after any recovery-mode env vars. This is what
// lets a dry-run point the process at a sandbox without RecoveryMode's
// two env vars becoming the only way to parameterize the child process.
ExtraEnv []string
}
// Process supervises one run of the target Stalwart binary as a background
// child process, so a caller can start it, wait for it to become healthy,
// interact with it, and stop it again - without ever touching a real
// systemd unit or Docker container. See ARCHITECTURE.md §4.4.
type Process struct {
cmd *exec.Cmd
}
// Start launches the binary. It returns as soon as the OS has started the
// process - it does not wait for Stalwart itself to become ready; use
// WaitForHealthy for that.
func (p *Process) Start(ctx context.Context, o ProcessOptions) error {
var env []string
if o.RecoveryMode {
env = append(env,
"STALWART_RECOVERY_MODE=1",
fmt.Sprintf("STALWART_RECOVERY_ADMIN=%s:%s", o.AdminUser, o.AdminPassword),
)
}
env = append(env, o.ExtraEnv...)
cmd := exec.CommandContext(ctx, o.BinaryPath, "--config", o.ConfigPath)
cmd.Env = append(os.Environ(), env...)
if err := cmd.Start(); err != nil {
return fmt.Errorf("recovery: start %s: %w", o.BinaryPath, err)
}
p.cmd = cmd
return nil
}
// Stop sends SIGTERM and waits up to gracePeriod for the process to exit -
// mirroring the upgrade guide's own "Ctrl+C in the first terminal" step,
// just automated - escalating to SIGKILL if it doesn't exit in time so a
// stuck child process can never hang a migration run indefinitely. Safe to
// call on a Process that was never successfully started.
func (p *Process) Stop(gracePeriod time.Duration) error {
if p.cmd == nil || p.cmd.Process == nil {
return nil
}
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
return fmt.Errorf("recovery: signal process (pid %d): %w", p.cmd.Process.Pid, err)
}
done := make(chan error, 1)
go func() { done <- p.cmd.Wait() }()
select {
case err := <-done:
// A non-zero exit from a SIGTERM-based shutdown is expected and not
// itself a failure worth reporting - only an unexpected Wait error is.
if err != nil {
if _, ok := err.(*exec.ExitError); !ok {
return fmt.Errorf("recovery: wait for process (pid %d): %w", p.cmd.Process.Pid, err)
}
}
return nil
case <-time.After(gracePeriod):
_ = p.cmd.Process.Kill()
<-done
return fmt.Errorf("recovery: process (pid %d) did not exit within %s of SIGTERM - sent SIGKILL", p.cmd.Process.Pid, gracePeriod)
}
}
+104
View File
@@ -0,0 +1,104 @@
package recovery
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"testing"
"time"
)
// freePort asks the OS for an unused TCP port by binding to :0 and
// immediately releasing it. There's a small window where something else
// could grab it before the fake server binds, but that's an acceptable,
// standard tradeoff for tests.
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
func helperProcessEnv(port int, extra ...string) []string {
env := []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
}
return append(env, extra...)
}
func testBinaryPath(t *testing.T) string {
t.Helper()
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return self
}
func TestProcessStartWaitHealthyStop(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
proc := &Process{}
err := proc.Start(context.Background(), ProcessOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ExtraEnv: helperProcessEnv(port),
})
if err != nil {
t.Fatalf("Start: %v", err)
}
url := fmt.Sprintf("http://127.0.0.1:%d/", port)
if err := WaitForHealthy(context.Background(), nil, url, 5*time.Second); err != nil {
t.Fatalf("WaitForHealthy: %v", err)
}
if err := proc.Stop(5 * time.Second); err != nil {
t.Fatalf("Stop: %v", err)
}
}
func TestProcessStopEscalatesToSIGKILL(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
proc := &Process{}
err := proc.Start(context.Background(), ProcessOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ExtraEnv: helperProcessEnv(port, "STALWART_MIGRATOR_TEST_IGNORE_SIGTERM=1"),
})
if err != nil {
t.Fatalf("Start: %v", err)
}
url := fmt.Sprintf("http://127.0.0.1:%d/", port)
if err := WaitForHealthy(context.Background(), nil, url, 5*time.Second); err != nil {
t.Fatalf("WaitForHealthy: %v", err)
}
err = proc.Stop(500 * time.Millisecond)
if err == nil {
t.Fatal("Stop should report an error when it had to escalate to SIGKILL")
}
}
func TestWaitForHealthyTimesOut(t *testing.T) {
// Nothing listens on this port.
port := freePort(t)
url := fmt.Sprintf("http://127.0.0.1:%d/", port)
err := WaitForHealthy(context.Background(), nil, url, 500*time.Millisecond)
if err == nil {
t.Fatal("WaitForHealthy should time out when nothing is listening")
}
}
+107
View File
@@ -0,0 +1,107 @@
package recovery
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
)
// Options configures one full recovery-mode migration cycle: starting the
// target binary in recovery mode, waiting for it to come up, applying the
// settings snapshot(s), and stopping it again. See ARCHITECTURE.md §4.4.
type Options struct {
BinaryPath string
ConfigPath string
ListenURL string // recovery mode's own HTTP listener, e.g. "http://127.0.0.1:8080"
AdminUser string
ApplyFiles []string
CLIBinaryPath string
ExtraEnv []string // lets a dry-run point ports/paths at a sandbox without touching production config
StartupTimeout time.Duration
StopGrace time.Duration
HTTPClient *http.Client
}
// GenerateRecoveryPassword returns a fresh random one-time password for
// STALWART_RECOVERY_ADMIN - never the operator's real admin password, never
// logged, never reused across runs or persisted to the checkpoint.
func GenerateRecoveryPassword() (string, error) {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("recovery: generate password: %w", err)
}
return hex.EncodeToString(b), nil
}
// Run executes one recovery-mode cycle as a single checkpointed step. It is
// deliberately not decomposed into per-sub-step checkpoints the way
// preflight and backup are: if this tool's own process crashes mid-cycle,
// the child Stalwart process it started may or may not still be running
// independently, and blindly "resuming" by reattaching to a guessed PID or
// killing an unrelated process on the recovery port would be more dangerous
// than just retrying cleanly. A retry that hits "address already in use"
// surfaces the real problem (an orphaned process from the failed attempt)
// for a human to clear, rather than this tool guessing at cleanup.
//
// Whatever happens after Start succeeds, Stop is always attempted on the
// way out (via a deferred call), so a failure partway through this cycle
// doesn't leak the child process within a single invocation.
func Run(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState, opts Options) (Report, error) {
var report Report
outcome, err := store.RunStep(rs, checkpoint.PhaseRecovery, "recovery-cycle", func() (out checkpoint.StepOutcome, err error) {
password, err := GenerateRecoveryPassword()
if err != nil {
return checkpoint.StepOutcome{}, err
}
proc := &Process{}
if startErr := proc.Start(ctx, ProcessOptions{
BinaryPath: opts.BinaryPath, ConfigPath: opts.ConfigPath,
RecoveryMode: true, AdminUser: opts.AdminUser, AdminPassword: password,
ExtraEnv: opts.ExtraEnv,
}); startErr != nil {
return checkpoint.StepOutcome{}, startErr
}
stopGrace := opts.StopGrace
if stopGrace <= 0 {
stopGrace = 10 * time.Second
}
defer func() {
if stopErr := proc.Stop(stopGrace); stopErr != nil && err == nil {
err = stopErr
}
}()
startupTimeout := opts.StartupTimeout
if startupTimeout <= 0 {
startupTimeout = 60 * time.Second
}
if healthErr := WaitForHealthy(ctx, opts.HTTPClient, opts.ListenURL, startupTimeout); healthErr != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("recovery mode did not come up: %w", healthErr)
}
if applyErr := ApplyAll(ctx, ApplyOptions{
CLIBinaryPath: opts.CLIBinaryPath, URL: opts.ListenURL, User: opts.AdminUser, Password: password,
}, opts.ApplyFiles); applyErr != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("settings apply failed: %w", applyErr)
}
return checkpoint.StepOutcome{
Detail: fmt.Sprintf("recovery mode came up at %s, applied %d settings file(s), stopped cleanly", opts.ListenURL, len(opts.ApplyFiles)),
}, nil
})
if err != nil {
report.Results = append(report.Results, CheckResult{Name: "recovery-cycle", Status: StatusFail, Detail: err.Error()})
return report, err
}
report.Results = append(report.Results, CheckResult{Name: "recovery-cycle", Status: StatusOK, Detail: outcome.Detail})
return report, nil
}
+113
View File
@@ -0,0 +1,113 @@
package recovery
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
)
func TestRecoveryRunEndToEndAndResume(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
applyDir := t.TempDir()
applyLog := argsFile(t, applyDir)
withFakeExecutable(t, "stalwart-cli", fmt.Sprintf("#!/bin/sh\necho \"$@\" >> %q\nexit 0\n", applyLog))
exportFile := filepath.Join(t.TempDir(), "export.json")
os.WriteFile(exportFile, []byte("{}"), 0o644)
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("store.Create: %v", err)
}
opts := Options{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
AdminUser: "admin",
ApplyFiles: []string{exportFile},
ExtraEnv: helperProcessEnv(port),
StartupTimeout: 5 * time.Second,
StopGrace: 5 * time.Second,
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run #1: %v", err)
}
if len(report.Results) != 1 || report.Results[0].Status != StatusOK {
t.Fatalf("Run #1 report = %+v, want a single OK result", report.Results)
}
if !rs.Done(checkpoint.PhaseRecovery, "recovery-cycle") {
t.Fatal("recovery-cycle should be marked done after a successful run")
}
if got := readArgsFile(t, applyLog); got == "" {
t.Fatal("stalwart-cli apply was never invoked")
}
// -- resume: break the CLI so a re-invocation would fail loudly, and use
// -- a port nothing listens on, so re-starting the process would time out.
withFakeExecutable(t, "stalwart-cli", "#!/bin/sh\necho should-not-run-again >&2\nexit 1\n")
badPort := freePort(t)
resumedOpts := opts
resumedOpts.ListenURL = fmt.Sprintf("http://127.0.0.1:%d/", badPort)
resumedOpts.ExtraEnv = helperProcessEnv(badPort)
resumedOpts.StartupTimeout = 300 * time.Millisecond
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatalf("store.Load (resume): %v", err)
}
report2, err := Run(context.Background(), store, resumed, resumedOpts)
if err != nil {
t.Fatalf("Run #2 (resume) should succeed without redoing the cycle: %v", err)
}
if len(report2.Results) != 1 || report2.Results[0].Detail != report.Results[0].Detail {
t.Errorf("resumed report = %+v, want the cached outcome from Run #1", report2.Results)
}
}
func TestRecoveryRunFailsWhenProcessNeverBecomesHealthy(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
// Nothing listens on this port - the binary never actually starts an
// HTTP server, since BinaryPath here is a script that just exits.
binDir := t.TempDir()
binPath := filepath.Join(binDir, "stalwart")
os.WriteFile(binPath, []byte("#!/bin/sh\nsleep 5\n"), 0o755)
port := freePort(t)
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatal(err)
}
opts := Options{
BinaryPath: binPath,
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
AdminUser: "admin",
ApplyFiles: []string{},
StartupTimeout: 300 * time.Millisecond,
StopGrace: 2 * time.Second,
}
_, err = Run(context.Background(), store, rs, opts)
if err == nil {
t.Fatal("Run should fail when the process never becomes healthy")
}
if rs.Status(checkpoint.PhaseRecovery, "recovery-cycle") != checkpoint.StepFailed {
t.Errorf("step status = %s, want failed (so a retry is possible)", rs.Status(checkpoint.PhaseRecovery, "recovery-cycle"))
}
}
+31
View File
@@ -0,0 +1,31 @@
package recovery
import (
"fmt"
"strings"
)
type Status string
const (
StatusOK Status = "ok"
StatusFail Status = "fail"
)
type CheckResult struct {
Name string
Status Status
Detail string
}
type Report struct {
Results []CheckResult
}
func (r Report) String() string {
var b strings.Builder
for _, res := range r.Results {
fmt.Fprintf(&b, "[%-4s] %-16s %s\n", strings.ToUpper(string(res.Status)), res.Name, res.Detail)
}
return b.String()
}
+3
View File
@@ -0,0 +1,3 @@
// Package rollback implements restoring the pre-migration backup and old binary on failure.
// See ARCHITECTURE.md §4.8 for the design.
package rollback
+73
View File
@@ -0,0 +1,73 @@
package stalwartapi
import (
"context"
"fmt"
"net/http"
"strings"
"time"
)
// Client is the shared JMAP/management-API client every migration phase
// uses to talk to a Stalwart instance. It stays deliberately thin: phases
// that need Stalwart-specific behavior (recovery-mode control, apply-plan
// replay, account/mailbox introspection) get methods added here only as
// their wire-level details are confirmed against Stalwart's actual source
// and documentation, never guessed - see management.go and mailbox.go for
// what that grounding looked like for account enumeration and mailbox
// counts respectively.
type Client struct {
BaseURL string // e.g. "https://mail.example.com"
Username string
Password string
HTTPClient *http.Client
}
func (c *Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return &http.Client{Timeout: 15 * time.Second}
}
// Ping confirms the instance is reachable and the given credentials are
// accepted, via JMAP session discovery (RFC 8620 §2, the well-known
// /.well-known/jmap endpoint) over HTTP Basic auth. This is preflight's
// "dry-run" reachability check (ARCHITECTURE.md §4.1): it does nothing but
// read the session document, so it's safe to run against a live production
// server before anything else in the migration happens.
func (c *Client) Ping(ctx context.Context) error {
url := strings.TrimRight(c.BaseURL, "/") + "/.well-known/jmap"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.SetBasicAuth(c.Username, c.Password)
resp, err := c.httpClient().Do(req)
if err != nil {
return fmt.Errorf("stalwartapi: reach %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("stalwartapi: session request to %s returned %s", url, resp.Status)
}
return nil
}
// Snapshot mirrors checkpoint.PreflightSnapshot's shape without this
// package depending on the checkpoint package's format.
type Snapshot struct {
AccountCount int
Domains []string
MailboxCounts map[string][]MailboxCount // account email -> its mailboxes
// MailboxErrors records, per account email, why that account's mailbox
// counts couldn't be captured (e.g. impersonation not permitted for
// that account). A non-empty entry here means MailboxCounts has no
// entry for that account - it's not silently treated as zero messages.
MailboxErrors map[string]string
}
type MailboxCount struct {
Mailbox string
Messages int
}
+3
View File
@@ -0,0 +1,3 @@
// Package stalwartapi implements the JMAP and management-API client shared by every other phase.
// See ARCHITECTURE.md §7 for the design.
package stalwartapi
+112
View File
@@ -0,0 +1,112 @@
package stalwartapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// jmapSession is the subset of RFC 8620 §2's Session object this package
// needs: apiUrl (where to POST standard JMAP method calls - a different
// endpoint from Stalwart's /api management API, confirmed in
// docs/ref/object/account.md) and primaryAccounts (which accountId the
// urn:ietf:params:jmap:mail capability maps to for the authenticated
// identity).
type jmapSession struct {
APIURL string `json:"apiUrl"`
PrimaryAccounts map[string]string `json:"primaryAccounts"`
}
const jmapMailCapability = "urn:ietf:params:jmap:mail"
// fetchSession performs JMAP session discovery (RFC 8620 §2,
// /.well-known/jmap) with the given credentials.
func (c *Client) fetchSession(ctx context.Context, username, password string) (*jmapSession, error) {
url := strings.TrimRight(c.BaseURL, "/") + "/.well-known/jmap"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(username, password)
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("session discovery at %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("session discovery at %s returned %s", url, resp.Status)
}
var session jmapSession
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
return nil, fmt.Errorf("parse session document from %s: %w", url, err)
}
if session.APIURL == "" {
return nil, fmt.Errorf("session document from %s has no apiUrl", url)
}
return &session, nil
}
type mailboxGetEntry struct {
Name string `json:"name"`
TotalEmails int `json:"totalEmails"`
}
// MailboxSnapshot captures every mailbox's message count for one account,
// authenticating as that account via Stalwart's documented impersonation
// mechanism rather than assuming this Client's own credentials get direct
// cross-account access. That assumption would be wrong: Stalwart's JMAP
// session `accounts`/`primaryAccounts` map is built only from the
// authenticated identity's own membership and sharing grants - it is NOT
// expanded for a superuser (confirmed against
// crates/jmap/src/api/session.rs), so a plain Mailbox/get call for an
// arbitrary accountId under this Client's own login would be rejected.
//
// Instead, this Client's Username must hold Stalwart's `impersonate`
// permission (see docs/auth/authorization/administrator.md), and this
// method logs in AS the target account using the documented composite
// login format "<target>%<impersonator>" with the impersonator's password,
// then calls standard RFC 8621 Mailbox/get - reading the exact wire
// property name `totalEmails` - against the URL that account's own JMAP
// session document reports as apiUrl (not the /api management endpoint
// x:Account/* uses; confirmed as a distinct endpoint in
// docs/ref/object/account.md).
func (c *Client) MailboxSnapshot(ctx context.Context, targetEmail string) ([]MailboxCount, error) {
impersonatedUser := fmt.Sprintf("%s%%%s", targetEmail, c.Username)
session, err := c.fetchSession(ctx, impersonatedUser, c.Password)
if err != nil {
return nil, fmt.Errorf("stalwartapi: impersonate %s: %w", targetEmail, err)
}
accountID, ok := session.PrimaryAccounts[jmapMailCapability]
if !ok || accountID == "" {
return nil, fmt.Errorf("stalwartapi: impersonated session for %s has no %s account", targetEmail, jmapMailCapability)
}
responses, err := c.callAs(ctx, impersonatedUser, c.Password, session.APIURL,
[]string{"urn:ietf:params:jmap:core", jmapMailCapability},
[]any{[]any{"Mailbox/get", map[string]any{"accountId": accountID, "properties": []string{"name", "totalEmails"}}, "m"}},
)
if err != nil {
return nil, fmt.Errorf("stalwartapi: Mailbox/get for %s: %w", targetEmail, err)
}
if len(responses) == 0 {
return nil, fmt.Errorf("stalwartapi: Mailbox/get for %s returned no method responses", targetEmail)
}
if responses[0].Name == "error" {
return nil, fmt.Errorf("stalwartapi: Mailbox/get for %s error: %s", targetEmail, responses[0].Args)
}
var result struct {
List []mailboxGetEntry `json:"list"`
}
if err := json.Unmarshal(responses[0].Args, &result); err != nil {
return nil, fmt.Errorf("stalwartapi: parse Mailbox/get response for %s: %w", targetEmail, err)
}
counts := make([]MailboxCount, len(result.List))
for i, m := range result.List {
counts[i] = MailboxCount{Mailbox: m.Name, Messages: m.TotalEmails}
}
return counts, nil
}
+112
View File
@@ -0,0 +1,112 @@
package stalwartapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestMailboxSnapshotImpersonatesAndFetchesCounts(t *testing.T) {
var apiURL string
var sessionAuthUser, sessionAuthPass string
var gotAccountID string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap":
sessionAuthUser, sessionAuthPass, _ = r.BasicAuth()
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"primaryAccounts": map[string]string{jmapMailCapability: "mail-acct-1"},
})
case r.Method == http.MethodPost && r.URL.Path == "/jmap-api":
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls := body["methodCalls"].([]any)
args := methodCalls[0].([]any)[1].(map[string]any)
gotAccountID, _ = args["accountId"].(string)
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"Mailbox/get", map[string]any{"list": []map[string]any{
{"name": "Inbox", "totalEmails": 42},
{"name": "Sent", "totalEmails": 7},
}}, "m"},
}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
apiURL = srv.URL + "/jmap-api"
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
counts, err := client.MailboxSnapshot(context.Background(), "[email protected]")
if err != nil {
t.Fatalf("MailboxSnapshot: %v", err)
}
if sessionAuthUser != "[email protected]%admin" || sessionAuthPass != "hunter2" {
t.Errorf("session discovery auth = (%s, %s), want ([email protected]%%admin, hunter2)", sessionAuthUser, sessionAuthPass)
}
if gotAccountID != "mail-acct-1" {
t.Errorf("Mailbox/get accountId = %s, want mail-acct-1", gotAccountID)
}
if len(counts) != 2 || counts[0].Mailbox != "Inbox" || counts[0].Messages != 42 || counts[1].Mailbox != "Sent" || counts[1].Messages != 7 {
t.Errorf("counts = %+v, want [{Inbox 42} {Sent 7}]", counts)
}
}
func TestMailboxSnapshotFailsWhenImpersonationRejected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "notasuperuser", Password: "x"}
_, err := client.MailboxSnapshot(context.Background(), "[email protected]")
if err == nil {
t.Fatal("MailboxSnapshot should error when session discovery (impersonation) is rejected")
}
}
func TestMailboxSnapshotFailsWhenSessionHasNoMailAccount(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": "http://unused/",
"primaryAccounts": map[string]string{},
})
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
_, err := client.MailboxSnapshot(context.Background(), "[email protected]")
if err == nil {
t.Fatal("MailboxSnapshot should error when the session has no jmap:mail primary account")
}
}
func TestMailboxSnapshotPropagatesMailboxGetError(t *testing.T) {
var apiURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/.well-known/jmap":
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"primaryAccounts": map[string]string{jmapMailCapability: "mail-acct-1"},
})
case r.URL.Path == "/jmap-api":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"error", map[string]any{"type": "accountNotFound"}, "m"},
}})
}
}))
defer srv.Close()
apiURL = srv.URL + "/jmap-api"
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
_, err := client.MailboxSnapshot(context.Background(), "[email protected]")
if err == nil {
t.Fatal("MailboxSnapshot should propagate a JMAP-level error from Mailbox/get")
}
}
+215
View File
@@ -0,0 +1,215 @@
package stalwartapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
)
// managementCapabilities are the JMAP capability URNs Stalwart requires for
// its management object calls (x:Account/*): standard JMAP core plus its
// own urn:stalwart:jmap extension. Confirmed against
// docs/ref/object/account.md and crates/jmap-proto/src/request/capability.rs
// in stalwartlabs/stalwart.
var managementCapabilities = []string{"urn:ietf:params:jmap:core", "urn:stalwart:jmap"}
type jmapRequest struct {
Using []string `json:"using"`
MethodCalls []any `json:"methodCalls"`
}
type jmapRawResponse struct {
MethodResponses []json.RawMessage `json:"methodResponses"`
}
// methodResponse is one [name, args, callId] triple from a JMAP response,
// per RFC 8620 §3.2 - Stalwart's management API follows the same envelope
// shape as its regular JMAP methods.
type methodResponse struct {
Name string
Args json.RawMessage
CallID string
}
// call POSTs one JMAP-style request to the management API (Stalwart's /api
// endpoint - see docs/ref/object/account.md, which is distinct from /jmap)
// using this Client's own credentials, and returns its parsed method
// responses in order.
func (c *Client) call(ctx context.Context, using []string, methodCalls []any) ([]methodResponse, error) {
return c.callAs(ctx, c.Username, c.Password, strings.TrimRight(c.BaseURL, "/")+"/api", using, methodCalls)
}
// callAs is call's underlying primitive: it accepts an explicit
// username/password/URL rather than always using this Client's own
// credentials and the management endpoint. MailboxSnapshot uses this to
// call standard JMAP methods (not Stalwart's x: management objects) against
// the URL a JMAP session document says to use, authenticated as an
// impersonated identity rather than this Client's own.
func (c *Client) callAs(ctx context.Context, username, password, url string, using []string, methodCalls []any) ([]methodResponse, error) {
reqBody, err := json.Marshal(jmapRequest{Using: using, MethodCalls: methodCalls})
if err != nil {
return nil, fmt.Errorf("stalwartapi: encode request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(username, password)
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("stalwartapi: call %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("stalwartapi: %s returned %s: %s", url, resp.Status, strings.TrimSpace(string(body)))
}
var raw jmapRawResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("stalwartapi: decode response from %s: %w", url, err)
}
responses := make([]methodResponse, 0, len(raw.MethodResponses))
for _, r := range raw.MethodResponses {
var triple [3]json.RawMessage
if err := json.Unmarshal(r, &triple); err != nil {
return nil, fmt.Errorf("stalwartapi: parse method response envelope: %w", err)
}
var name, callID string
if err := json.Unmarshal(triple[0], &name); err != nil {
return nil, fmt.Errorf("stalwartapi: parse method response name: %w", err)
}
if err := json.Unmarshal(triple[2], &callID); err != nil {
return nil, fmt.Errorf("stalwartapi: parse method response call id: %w", err)
}
responses = append(responses, methodResponse{Name: name, Args: triple[1], CallID: callID})
}
return responses, nil
}
// account is the subset of x:Account/get's response fields this tool needs,
// confirmed against Stalwart's own docs/ref/object/account.md (whose
// stalwart-cli example is `query Account --fields id,name,domainId,usedDiskQuota`).
type account struct {
ID string `json:"id"`
Name string `json:"name"`
DomainID string `json:"domainId"`
}
// AccountSnapshot enumerates every account on the instance via Stalwart's
// management API - x:Account/query to list ids, then x:Account/get to fetch
// their name/domainId - and returns the account count, set of domains in
// use, and (via MailboxSnapshot, per account) every mailbox's message
// count. This is what preflight's snapshot (ARCHITECTURE.md §4.1) and
// validate's directory-integrity and content-integrity checks (§4.7)
// compare before and after migration - the latter being the actual
// no-data-loss guarantee.
//
// A per-account mailbox-count failure (most likely: this Client's Username
// lacks the `impersonate` permission MailboxSnapshot depends on) does not
// fail the whole snapshot - the account/domain enumeration above is already
// useful on its own, and one account's failure shouldn't hide a working
// result for every other account. Instead it's recorded in
// Snapshot.MailboxErrors, keyed by account email, so callers can report
// exactly what's missing rather than silently treating an unreachable
// account's mailboxes as having zero messages.
func (c *Client) AccountSnapshot(ctx context.Context) (*Snapshot, error) {
queryResp, err := c.call(ctx, managementCapabilities, []any{
[]any{"x:Account/query", map[string]any{"filter": map[string]any{}}, "q"},
})
if err != nil {
return nil, fmt.Errorf("stalwartapi: Account/query: %w", err)
}
ids, err := accountQueryIDs(queryResp)
if err != nil {
return nil, err
}
if len(ids) == 0 {
return &Snapshot{}, nil
}
getResp, err := c.call(ctx, managementCapabilities, []any{
[]any{"x:Account/get", map[string]any{"ids": ids, "properties": []string{"id", "name", "domainId"}}, "g"},
})
if err != nil {
return nil, fmt.Errorf("stalwartapi: Account/get: %w", err)
}
accounts, err := accountGetList(getResp)
if err != nil {
return nil, err
}
mailboxCounts := map[string][]MailboxCount{}
mailboxErrors := map[string]string{}
for _, a := range accounts {
if a.Name == "" {
continue // no login/email to impersonate against
}
counts, err := c.MailboxSnapshot(ctx, a.Name)
if err != nil {
mailboxErrors[a.Name] = err.Error()
continue
}
mailboxCounts[a.Name] = counts
}
domainSet := map[string]bool{}
for _, a := range accounts {
if a.DomainID != "" {
domainSet[a.DomainID] = true
}
}
domains := make([]string, 0, len(domainSet))
for d := range domainSet {
domains = append(domains, d)
}
sort.Strings(domains)
return &Snapshot{
AccountCount: len(accounts),
Domains: domains,
MailboxCounts: mailboxCounts,
MailboxErrors: mailboxErrors,
}, nil
}
func accountQueryIDs(responses []methodResponse) ([]string, error) {
if len(responses) == 0 {
return nil, fmt.Errorf("stalwartapi: Account/query returned no method responses")
}
r := responses[0]
if r.Name == "error" {
return nil, fmt.Errorf("stalwartapi: Account/query error: %s", r.Args)
}
var result struct {
IDs []string `json:"ids"`
}
if err := json.Unmarshal(r.Args, &result); err != nil {
return nil, fmt.Errorf("stalwartapi: parse Account/query response: %w", err)
}
return result.IDs, nil
}
func accountGetList(responses []methodResponse) ([]account, error) {
if len(responses) == 0 {
return nil, fmt.Errorf("stalwartapi: Account/get returned no method responses")
}
r := responses[0]
if r.Name == "error" {
return nil, fmt.Errorf("stalwartapi: Account/get error: %s", r.Args)
}
var result struct {
List []account `json:"list"`
}
if err := json.Unmarshal(r.Args, &result); err != nil {
return nil, fmt.Errorf("stalwartapi: parse Account/get response: %w", err)
}
return result.List, nil
}
+201
View File
@@ -0,0 +1,201 @@
package stalwartapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// jmapEnvelope mirrors the wire shape this package's call() parses: a
// top-level {"methodResponses": [...]} object where each entry is a
// [name, args, callId] triple (RFC 8620 §3.2).
type jmapEnvelope struct {
MethodResponses []any `json:"methodResponses"`
}
// accountManagementAndMailboxServer builds a fake server that answers both
// the x:Account/* management calls AccountSnapshot makes directly, and the
// session-discovery + Mailbox/get calls it makes indirectly (per account)
// via MailboxSnapshot. mailboxesFor maps an account email to the mailbox
// list its Mailbox/get should return; an account absent from the map gets a
// 403 on session discovery, simulating a missing `impersonate` grant.
func accountManagementAndMailboxServer(t *testing.T, mailboxesFor map[string][]map[string]any) (*httptest.Server, *[]string) {
t.Helper()
var gotPaths []string
var apiURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPaths = append(gotPaths, r.URL.Path)
if r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap" {
user, _, _ := r.BasicAuth()
target := strings.SplitN(user, "%", 2)[0]
if _, ok := mailboxesFor[target]; !ok {
w.WriteHeader(http.StatusForbidden)
return
}
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"primaryAccounts": map[string]string{jmapMailCapability: "mail-" + target},
})
return
}
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls := body["methodCalls"].([]any)
first := methodCalls[0].([]any)
methodName := first[0].(string)
switch methodName {
case "x:Account/query":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/query", map[string]any{"ids": []string{"a1", "a2"}}, "q"},
}})
case "x:Account/get":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/get", map[string]any{"list": []map[string]any{
{"id": "a1", "name": "[email protected]", "domainId": "example.com"},
{"id": "a2", "name": "[email protected]", "domainId": "example.org"},
}}, "g"},
}})
case "Mailbox/get":
args := first[1].(map[string]any)
accountID := args["accountId"].(string)
target := strings.TrimPrefix(accountID, "mail-")
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"Mailbox/get", map[string]any{"list": mailboxesFor[target]}, "m"},
}})
default:
t.Errorf("unexpected method call: %s", methodName)
}
}))
apiURL = srv.URL + "/api"
return srv, &gotPaths
}
func TestAccountSnapshotQueriesThenGets(t *testing.T) {
srv, _ := accountManagementAndMailboxServer(t, map[string][]map[string]any{
"[email protected]": {{"name": "Inbox", "totalEmails": 10}},
"[email protected]": {{"name": "Inbox", "totalEmails": 3}, {"name": "Archive", "totalEmails": 100}},
})
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
snap, err := client.AccountSnapshot(context.Background())
if err != nil {
t.Fatalf("AccountSnapshot: %v", err)
}
if snap.AccountCount != 2 {
t.Errorf("AccountCount = %d, want 2", snap.AccountCount)
}
if len(snap.Domains) != 2 || snap.Domains[0] != "example.com" || snap.Domains[1] != "example.org" {
t.Errorf("Domains = %v, want [example.com example.org] (sorted)", snap.Domains)
}
if len(snap.MailboxErrors) != 0 {
t.Errorf("MailboxErrors = %v, want none (both accounts should succeed)", snap.MailboxErrors)
}
alice := snap.MailboxCounts["[email protected]"]
if len(alice) != 1 || alice[0].Mailbox != "Inbox" || alice[0].Messages != 10 {
t.Errorf("alice's mailboxes = %+v, want [{Inbox 10}]", alice)
}
bob := snap.MailboxCounts["[email protected]"]
if len(bob) != 2 || bob[1].Mailbox != "Archive" || bob[1].Messages != 100 {
t.Errorf("bob's mailboxes = %+v, want Inbox then Archive(100)", bob)
}
}
func TestAccountSnapshotRecordsPerAccountMailboxFailureWithoutFailingOverall(t *testing.T) {
// [email protected] is deliberately absent from mailboxesFor, simulating
// a missing `impersonate` grant for that one account.
srv, _ := accountManagementAndMailboxServer(t, map[string][]map[string]any{
"[email protected]": {{"name": "Inbox", "totalEmails": 10}},
})
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
snap, err := client.AccountSnapshot(context.Background())
if err != nil {
t.Fatalf("AccountSnapshot should not fail overall just because one account's mailbox capture failed: %v", err)
}
if snap.AccountCount != 2 {
t.Errorf("AccountCount = %d, want 2 (account enumeration is unaffected by the mailbox-capture failure)", snap.AccountCount)
}
if _, ok := snap.MailboxCounts["[email protected]"]; !ok {
t.Error("alice's mailbox counts should still be captured")
}
if _, ok := snap.MailboxCounts["[email protected]"]; ok {
t.Error("bob's mailbox counts should NOT be present - his capture failed")
}
if _, ok := snap.MailboxErrors["[email protected]"]; !ok {
t.Error("bob's failure should be recorded in MailboxErrors, not silently dropped")
}
}
func TestAccountSnapshotEmptyInstance(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/query", map[string]any{"ids": []string{}}, "q"},
}})
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
snap, err := client.AccountSnapshot(context.Background())
if err != nil {
t.Fatalf("AccountSnapshot: %v", err)
}
if snap.AccountCount != 0 {
t.Errorf("AccountCount = %d, want 0", snap.AccountCount)
}
}
func TestAccountSnapshotPropagatesJMAPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"error", map[string]any{"type": "forbidden"}, "q"},
}})
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
_, err := client.AccountSnapshot(context.Background())
if err == nil {
t.Fatal("AccountSnapshot should surface a JMAP-level error response")
}
}
func TestAccountSnapshotPropagatesHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("invalid credentials"))
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "wrong"}
_, err := client.AccountSnapshot(context.Background())
if err == nil {
t.Fatal("AccountSnapshot should error on a non-200 response")
}
}
func TestAccountSnapshotSendsBasicAuth(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "admin" || pass != "hunter2" {
t.Errorf("BasicAuth = (%s, %s, %v), want (admin, hunter2, true)", user, pass, ok)
}
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/query", map[string]any{"ids": []string{}}, "q"},
}})
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
if _, err := client.AccountSnapshot(context.Background()); err != nil {
t.Fatalf("AccountSnapshot: %v", err)
}
}
+119
View File
@@ -0,0 +1,119 @@
package validate
import (
"context"
"fmt"
"net/http"
"time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/recovery"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi"
)
// BootCheckOptions configures a normal (non-recovery-mode) boot of the
// migrated instance, to confirm it comes up cleanly outside recovery mode -
// not just that recovery mode itself could apply settings to it - and,
// optionally, a content-integrity comparison performed against that same
// boot before it's stopped again.
type BootCheckOptions struct {
BinaryPath string
ConfigPath string
ListenURL string
ExtraEnv []string
Timeout time.Duration
StopGrace time.Duration
HTTPClient *http.Client
// ContentIntegrityBefore, if non-nil, is the pre-migration snapshot
// preflight captured (checkpoint.RunState.PreflightSnapshot). When set,
// BootCheck captures a fresh snapshot from the instance it just booted
// - authenticating with AdminUser/AdminPassword, which migrate over
// unchanged with the account (they don't need to differ from the
// pre-migration admin credentials) - and compares the two: this is the
// actual no-data-loss guarantee from ARCHITECTURE.md §4.7, not just
// "the migration mechanics ran". Left nil, only the boot-reachability
// check runs, e.g. when preflight never captured a snapshot because
// --admin-url wasn't set.
ContentIntegrityBefore *checkpoint.PreflightSnapshot
AdminUser string
AdminPassword string
}
// BootCheck starts the target binary the way cutover eventually will (an
// ordinary boot, no STALWART_RECOVERY_MODE), waits for its HTTP listener to
// answer, optionally compares its content against ContentIntegrityBefore
// while it's up, then stops it. It reuses recovery.Process and
// recovery.WaitForHealthy rather than re-implementing process supervision,
// since "start the binary and confirm it's reachable" is exactly what those
// already do.
//
// Like recovery.Run, this is deliberately one atomic operation rather than
// separately checkpointed sub-steps: if this tool's own process crashes
// between the boot succeeding and the content check running, there's no
// safe way to reattach to whatever's left of the child process on resume,
// so a retry just redoes the whole cycle - see recovery.Run's doc comment
// for the full reasoning, which applies identically here.
func BootCheck(ctx context.Context, o BootCheckOptions) (detail string, result *ContentIntegrityResult, err error) {
proc := &recovery.Process{}
if startErr := proc.Start(ctx, recovery.ProcessOptions{
BinaryPath: o.BinaryPath, ConfigPath: o.ConfigPath, RecoveryMode: false, ExtraEnv: o.ExtraEnv,
}); startErr != nil {
return "", nil, fmt.Errorf("validate: start normal boot: %w", startErr)
}
stopGrace := o.StopGrace
if stopGrace <= 0 {
stopGrace = 10 * time.Second
}
defer func() {
if stopErr := proc.Stop(stopGrace); stopErr != nil && err == nil {
err = stopErr
}
}()
timeout := o.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
if healthErr := recovery.WaitForHealthy(ctx, o.HTTPClient, o.ListenURL, timeout); healthErr != nil {
return "", nil, fmt.Errorf("migrated instance did not come up under a normal (non-recovery-mode) boot: %w", healthErr)
}
detail = fmt.Sprintf("migrated instance booted normally (not in recovery mode) and answered at %s", o.ListenURL)
if o.ContentIntegrityBefore == nil {
return detail, nil, nil
}
client := &stalwartapi.Client{BaseURL: o.ListenURL, Username: o.AdminUser, Password: o.AdminPassword, HTTPClient: o.HTTPClient}
result, ciErr := compareContentIntegrity(ctx, client, o.ContentIntegrityBefore)
if ciErr != nil {
return detail, nil, fmt.Errorf("content-integrity comparison failed: %w", ciErr)
}
if !result.OK() {
return detail, result, fmt.Errorf("content integrity check found problems: %s", result.String())
}
return detail, result, nil
}
// Run executes BootCheck as a single checkpointed step, mirroring
// preflight/backup/recovery's pattern.
func Run(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState, opts BootCheckOptions) (Report, error) {
var report Report
outcome, err := store.RunStep(rs, checkpoint.PhaseValidate, "boot-check", func() (checkpoint.StepOutcome, error) {
detail, result, err := BootCheck(ctx, opts)
if err != nil {
return checkpoint.StepOutcome{}, err
}
if result != nil {
detail += " - " + result.String()
}
return checkpoint.StepOutcome{Detail: detail}, nil
})
if err != nil {
report.Results = append(report.Results, CheckResult{Name: "boot-check", Status: StatusFail, Detail: err.Error()})
return report, err
}
report.Results = append(report.Results, CheckResult{Name: "boot-check", Status: StatusOK, Detail: outcome.Detail})
return report, nil
}
+251
View File
@@ -0,0 +1,251 @@
package validate
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
)
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
func testBinaryPath(t *testing.T) string {
t.Helper()
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return self
}
func TestBootCheckSucceedsWhenInstanceComesUp(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
detail, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
})
if err != nil {
t.Fatalf("BootCheck: %v", err)
}
if detail == "" {
t.Error("BootCheck returned an empty detail on success")
}
if result != nil {
t.Errorf("result = %+v, want nil when ContentIntegrityBefore wasn't set", result)
}
}
func TestBootCheckFailsWhenInstanceNeverComesUp(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
binPath := filepath.Join(t.TempDir(), "stalwart")
os.WriteFile(binPath, []byte("#!/bin/sh\nsleep 5\n"), 0o755)
port := freePort(t)
_, _, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: binPath,
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
Timeout: 300 * time.Millisecond,
StopGrace: 2 * time.Second,
})
if err == nil {
t.Fatal("BootCheck should fail when nothing ever answers ListenURL")
}
}
func TestRunEndToEndAndResume(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatal(err)
}
opts := BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run #1: %v", err)
}
if report.Blocking() {
t.Fatalf("Run #1: unexpected failure: %s", report.String())
}
// Resume with a config that would fail if re-executed (nothing listens
// on badPort) - a skip proves the step didn't re-run.
badPort := freePort(t)
resumedOpts := opts
resumedOpts.ListenURL = fmt.Sprintf("http://127.0.0.1:%d/", badPort)
resumedOpts.Timeout = 300 * time.Millisecond
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatal(err)
}
report2, err := Run(context.Background(), store, resumed, resumedOpts)
if err != nil {
t.Fatalf("Run #2 (resume) should succeed without redoing the check: %v", err)
}
if report2.Blocking() {
t.Fatalf("Run #2 (resume): unexpected failure: %s", report2.String())
}
}
// beforeSnapshotWithAliceInbox builds a checkpoint.PreflightSnapshot
// matching the fake server's single hardcoded account ([email protected],
// mailbox "Inbox") with the given pre-migration message count.
func beforeSnapshotWithAliceInbox(messages int) *checkpoint.PreflightSnapshot {
return &checkpoint.PreflightSnapshot{
AccountCount: 1,
Domains: []string{"example.com"},
MailboxCounts: map[string][]checkpoint.MailboxCount{
"[email protected]": {{Mailbox: "Inbox", Messages: messages}},
},
}
}
func TestBootCheckContentIntegrityPassesWhenCountsMatch(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
detail, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
"STALWART_MIGRATOR_TEST_MAILBOX_COUNT=42", // matches beforeSnapshotWithAliceInbox(42)
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
ContentIntegrityBefore: beforeSnapshotWithAliceInbox(42),
AdminUser: "admin",
AdminPassword: "hunter2",
})
if err != nil {
t.Fatalf("BootCheck: %v", err)
}
if result == nil {
t.Fatal("result should be populated when ContentIntegrityBefore was set")
}
if !result.OK() {
t.Errorf("result.OK() = false, want true: %s", result.String())
}
if result.AccountsChecked != 1 || result.MailboxesChecked != 1 {
t.Errorf("AccountsChecked=%d MailboxesChecked=%d, want 1 and 1", result.AccountsChecked, result.MailboxesChecked)
}
if detail == "" {
t.Error("detail should still describe the boot")
}
}
func TestBootCheckContentIntegrityFailsWhenCountsMismatch(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
_, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
"STALWART_MIGRATOR_TEST_MAILBOX_COUNT=40", // the "after" server reports 40
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
ContentIntegrityBefore: beforeSnapshotWithAliceInbox(42), // but "before" said 42 - two messages went missing
AdminUser: "admin",
AdminPassword: "hunter2",
})
if err == nil {
t.Fatal("BootCheck should fail when a post-migration mailbox count doesn't match the pre-migration one")
}
if result == nil || result.OK() {
t.Fatalf("result = %+v, want a non-OK result describing the mismatch", result)
}
if len(result.MessageCountMismatches) != 1 {
t.Fatalf("MessageCountMismatches = %+v, want exactly one entry", result.MessageCountMismatches)
}
mismatch := result.MessageCountMismatches[0]
if mismatch.Account != "[email protected]" || mismatch.Mailbox != "Inbox" || mismatch.Before != 42 || mismatch.After != 40 {
t.Errorf("mismatch = %+v, want [email protected]/Inbox 42->40", mismatch)
}
}
func TestBootCheckContentIntegrityDetectsMissingAccount(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
before := &checkpoint.PreflightSnapshot{
AccountCount: 2,
Domains: []string{"example.com", "example.net"},
MailboxCounts: map[string][]checkpoint.MailboxCount{
"[email protected]": {{Mailbox: "Inbox", Messages: 42}},
"[email protected]": {{Mailbox: "Inbox", Messages: 5}}, // the fake server only ever knows about alice
},
}
_, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
"STALWART_MIGRATOR_TEST_MAILBOX_COUNT=42",
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
ContentIntegrityBefore: before,
AdminUser: "admin",
AdminPassword: "hunter2",
})
if err == nil {
t.Fatal("BootCheck should fail when an account present before migration can't be found afterward")
}
if result == nil || len(result.MissingAccounts) != 1 || result.MissingAccounts[0] != "[email protected]" {
t.Fatalf("result = %+v, want MissingAccounts = [[email protected]]", result)
}
}
+113
View File
@@ -0,0 +1,113 @@
package validate
import (
"context"
"fmt"
"sort"
"strings"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi"
)
// MailboxDelta is one mailbox whose message count didn't match between the
// pre- and post-migration snapshots.
type MailboxDelta struct {
Account string
Mailbox string
Before int
After int
}
// ContentIntegrityResult is the outcome of comparing a pre-migration
// snapshot against a freshly captured post-migration one - the actual
// no-data-loss check described in ARCHITECTURE.md §4.7.
type ContentIntegrityResult struct {
AccountsChecked int
MailboxesChecked int
MissingAccounts []string // present before, not found after (even accounting for the email-address rewrite)
MessageCountMismatches []MailboxDelta // present both before and after, but with a different message count
}
// OK reports whether every account and mailbox the pre-migration snapshot
// knew about was found afterward with an identical message count.
func (r ContentIntegrityResult) OK() bool {
return len(r.MissingAccounts) == 0 && len(r.MessageCountMismatches) == 0
}
func (r ContentIntegrityResult) String() string {
if r.OK() {
return fmt.Sprintf("content integrity: %d account(s), %d mailbox(es) checked, all message counts match", r.AccountsChecked, r.MailboxesChecked)
}
var b strings.Builder
fmt.Fprintf(&b, "content integrity: %d account(s), %d mailbox(es) checked", r.AccountsChecked, r.MailboxesChecked)
for _, a := range r.MissingAccounts {
fmt.Fprintf(&b, "; MISSING ACCOUNT %s", a)
}
for _, d := range r.MessageCountMismatches {
fmt.Fprintf(&b, "; MESSAGE COUNT MISMATCH %s/%s: %d before, %d after", d.Account, d.Mailbox, d.Before, d.After)
}
return b.String()
}
// compareContentIntegrity captures a fresh snapshot via client and compares
// it against before, matching accounts by exact name first and falling
// back to the local part (the text before "@") since Stalwart's v0.16
// migration rewrites bare usernames to full email addresses
// (UPGRADING/v0_16.md: "the migration script automatically assigns the
// default domain to accounts lacking one") - an exact-string comparison
// alone would misreport every rewritten account as missing.
func compareContentIntegrity(ctx context.Context, client *stalwartapi.Client, before *checkpoint.PreflightSnapshot) (*ContentIntegrityResult, error) {
after, err := client.AccountSnapshot(ctx)
if err != nil {
return nil, fmt.Errorf("capture post-migration snapshot: %w", err)
}
result := &ContentIntegrityResult{}
beforeAccounts := make([]string, 0, len(before.MailboxCounts))
for a := range before.MailboxCounts {
beforeAccounts = append(beforeAccounts, a)
}
sort.Strings(beforeAccounts)
for _, beforeAccount := range beforeAccounts {
result.AccountsChecked++
afterMailboxes, found := after.MailboxCounts[beforeAccount]
if !found {
afterMailboxes, found = findByLocalPart(after.MailboxCounts, beforeAccount)
}
if !found {
result.MissingAccounts = append(result.MissingAccounts, beforeAccount)
continue
}
afterByName := make(map[string]int, len(afterMailboxes))
for _, m := range afterMailboxes {
afterByName[m.Mailbox] = m.Messages
}
beforeMailboxes := append([]checkpoint.MailboxCount(nil), before.MailboxCounts[beforeAccount]...)
sort.Slice(beforeMailboxes, func(i, j int) bool { return beforeMailboxes[i].Mailbox < beforeMailboxes[j].Mailbox })
for _, bm := range beforeMailboxes {
result.MailboxesChecked++
afterCount, ok := afterByName[bm.Mailbox]
if !ok || afterCount != bm.Messages {
result.MessageCountMismatches = append(result.MessageCountMismatches, MailboxDelta{
Account: beforeAccount, Mailbox: bm.Mailbox, Before: bm.Messages, After: afterCount,
})
}
}
}
return result, nil
}
func findByLocalPart(mailboxCounts map[string][]stalwartapi.MailboxCount, beforeAccount string) ([]stalwartapi.MailboxCount, bool) {
local := strings.SplitN(beforeAccount, "@", 2)[0]
for afterAccount, mb := range mailboxCounts {
if strings.SplitN(afterAccount, "@", 2)[0] == local {
return mb, true
}
}
return nil, false
}
+165
View File
@@ -0,0 +1,165 @@
package validate
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi"
)
// jmapEnvelope mirrors the wire shape stalwartapi.Client.call() parses.
type jmapEnvelope struct {
MethodResponses []any `json:"methodResponses"`
}
// fakeManagementServer serves x:Account/query + x:Account/get from
// accounts, and, for each of them, session discovery + Mailbox/get from
// mailboxesByEmail (keyed by the account's post-migration email).
func fakeManagementServer(t *testing.T, accounts []map[string]any, mailboxesByEmail map[string][]map[string]any) *httptest.Server {
t.Helper()
var apiURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap" {
user, _, _ := r.BasicAuth()
target := strings.SplitN(user, "%", 2)[0]
if _, ok := mailboxesByEmail[target]; !ok {
w.WriteHeader(http.StatusForbidden)
return
}
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"primaryAccounts": map[string]string{"urn:ietf:params:jmap:mail": "mail-" + target},
})
return
}
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls := body["methodCalls"].([]any)
call := methodCalls[0].([]any)
name := call[0].(string)
switch name {
case "x:Account/query":
ids := make([]string, len(accounts))
for i, a := range accounts {
ids[i] = a["id"].(string)
}
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/query", map[string]any{"ids": ids}, "q"},
}})
case "x:Account/get":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/get", map[string]any{"list": accounts}, "g"},
}})
case "Mailbox/get":
args := call[1].(map[string]any)
accountID := args["accountId"].(string)
target := strings.TrimPrefix(accountID, "mail-")
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"Mailbox/get", map[string]any{"list": mailboxesByEmail[target]}, "m"},
}})
}
}))
apiURL = srv.URL + "/api"
return srv
}
func TestCompareContentIntegrityMatchesRewrittenBareUsernameByLocalPart(t *testing.T) {
// Pre-migration, the account was a bare username "alice" (pre-0.16
// style). Post-migration, v0.16's own conversion rewrote it to a full
// email address - see UPGRADING/v0_16.md. An exact-string match would
// wrongly report "alice" as missing.
srv := fakeManagementServer(t,
[]map[string]any{{"id": "a1", "name": "[email protected]", "domainId": "example.com"}},
map[string][]map[string]any{"[email protected]": {{"name": "Inbox", "totalEmails": 42}}},
)
defer srv.Close()
before := &checkpoint.PreflightSnapshot{
MailboxCounts: map[string][]checkpoint.MailboxCount{
"alice": {{Mailbox: "Inbox", Messages: 42}}, // bare username, pre-migration
},
}
client := &stalwartapi.Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
result, err := compareContentIntegrity(context.Background(), client, before)
if err != nil {
t.Fatalf("compareContentIntegrity: %v", err)
}
if !result.OK() {
t.Errorf("result.OK() = false, want true (local-part match should have found [email protected]): %s", result.String())
}
if len(result.MissingAccounts) != 0 {
t.Errorf("MissingAccounts = %v, want none", result.MissingAccounts)
}
}
func TestCompareContentIntegrityNoFalseMatchAcrossUnrelatedAccounts(t *testing.T) {
// "alice" (before) must not spuriously match "[email protected]"
// (after) just because one contains the other - local-part comparison
// must be an exact match on the part before "@", not a substring check.
srv := fakeManagementServer(t,
[]map[string]any{{"id": "a1", "name": "[email protected]", "domainId": "example.com"}},
map[string][]map[string]any{"[email protected]": {{"name": "Inbox", "totalEmails": 1}}},
)
defer srv.Close()
before := &checkpoint.PreflightSnapshot{
MailboxCounts: map[string][]checkpoint.MailboxCount{
"alice": {{Mailbox: "Inbox", Messages: 42}},
},
}
client := &stalwartapi.Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
result, err := compareContentIntegrity(context.Background(), client, before)
if err != nil {
t.Fatalf("compareContentIntegrity: %v", err)
}
if result.OK() {
t.Fatal("result.OK() = true, want a missing-account failure - [email protected] is a different account than alice")
}
if len(result.MissingAccounts) != 1 || result.MissingAccounts[0] != "alice" {
t.Errorf("MissingAccounts = %v, want [alice]", result.MissingAccounts)
}
}
func TestCompareContentIntegrityMultipleMailboxesPerAccount(t *testing.T) {
srv := fakeManagementServer(t,
[]map[string]any{{"id": "a1", "name": "[email protected]", "domainId": "example.org"}},
map[string][]map[string]any{"[email protected]": {
{"name": "Inbox", "totalEmails": 10},
{"name": "Archive", "totalEmails": 200},
}},
)
defer srv.Close()
before := &checkpoint.PreflightSnapshot{
MailboxCounts: map[string][]checkpoint.MailboxCount{
"[email protected]": {
{Mailbox: "Inbox", Messages: 10},
{Mailbox: "Archive", Messages: 199}, // one message short
},
},
}
client := &stalwartapi.Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
result, err := compareContentIntegrity(context.Background(), client, before)
if err != nil {
t.Fatalf("compareContentIntegrity: %v", err)
}
if result.AccountsChecked != 1 || result.MailboxesChecked != 2 {
t.Errorf("AccountsChecked=%d MailboxesChecked=%d, want 1 and 2", result.AccountsChecked, result.MailboxesChecked)
}
if len(result.MessageCountMismatches) != 1 {
t.Fatalf("MessageCountMismatches = %+v, want exactly one (Archive)", result.MessageCountMismatches)
}
m := result.MessageCountMismatches[0]
if m.Mailbox != "Archive" || m.Before != 199 || m.After != 200 {
t.Errorf("mismatch = %+v, want Archive 199->200", m)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package validate implements the post-migration validation suite and its structured report.
// See ARCHITECTURE.md §4.7 for the design.
package validate
+101
View File
@@ -0,0 +1,101 @@
package validate
import (
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"testing"
)
// TestMain lets this test binary also act as a fake Stalwart binary,
// mirroring internal/recovery's own TestMain - see that package's doc
// comment for why (the standard os/exec "helper process" technique). Beyond
// plain reachability, it also speaks just enough JMAP to serve
// stalwartapi.Client.AccountSnapshot (x:Account/query, x:Account/get,
// session discovery, Mailbox/get) for a single fixed fake account
// "[email protected]", so BootCheck's content-integrity comparison can be
// exercised against a real subprocess rather than mocked in-process. The
// mailbox message count it reports is configurable via
// STALWART_MIGRATOR_TEST_MAILBOX_COUNT (default 42), so tests can produce
// both a matching and a mismatching post-migration snapshot.
func TestMain(m *testing.M) {
if os.Getenv("STALWART_MIGRATOR_TEST_HELPER") == "1" {
runFakeStalwartServer()
return
}
os.Exit(m.Run())
}
func runFakeStalwartServer() {
port := os.Getenv("STALWART_MIGRATOR_TEST_PORT")
messageCount := 42
if v := os.Getenv("STALWART_MIGRATOR_TEST_MAILBOX_COUNT"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
messageCount = n
}
}
ln, err := net.Listen("tcp", "127.0.0.1:"+port)
if err != nil {
fmt.Fprintln(os.Stderr, "fake stalwart: listen:", err)
os.Exit(1)
}
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap":
user, _, _ := r.BasicAuth()
if strings.Contains(user, "%") {
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": "http://127.0.0.1:" + port + "/api",
"primaryAccounts": map[string]string{"urn:ietf:params:jmap:mail": "mail-alice"},
})
return
}
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPost && r.URL.Path == "/api":
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls, _ := body["methodCalls"].([]any)
if len(methodCalls) == 0 {
w.WriteHeader(http.StatusBadRequest)
return
}
call := methodCalls[0].([]any)
name := call[0].(string)
switch name {
case "x:Account/query":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Account/query", map[string]any{"ids": []string{"a1"}}, "q"},
}})
case "x:Account/get":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Account/get", map[string]any{"list": []map[string]any{
{"id": "a1", "name": "[email protected]", "domainId": "example.com"},
}}, "g"},
}})
case "Mailbox/get":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"Mailbox/get", map[string]any{"list": []map[string]any{
{"name": "Inbox", "totalEmails": messageCount},
}}, "m"},
}})
default:
w.WriteHeader(http.StatusBadRequest)
}
default:
w.WriteHeader(http.StatusOK)
}
})}
go srv.Serve(ln)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM)
<-sigCh
os.Exit(0)
}
+40
View File
@@ -0,0 +1,40 @@
package validate
import (
"fmt"
"strings"
)
type Status string
const (
StatusOK Status = "ok"
StatusFail Status = "fail"
)
type CheckResult struct {
Name string
Status Status
Detail string
}
type Report struct {
Results []CheckResult
}
func (r Report) Blocking() bool {
for _, res := range r.Results {
if res.Status == StatusFail {
return true
}
}
return false
}
func (r Report) String() string {
var b strings.Builder
for _, res := range r.Results {
fmt.Fprintf(&b, "[%-4s] %-16s %s\n", strings.ToUpper(string(res.Status)), res.Name, res.Detail)
}
return b.String()
}