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:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user