Initial commit: stalwart-migrator design and scaffolding

In-place upgrade tool for Stalwart Mail Server (0.15.5 -> latest) with
checkpointed rollback and post-migration validation. Design stage; see
ARCHITECTURE.md.
This commit is contained in:
2026-08-22 18:17:17 -07:00
commit 719a945d64
71 changed files with 6677 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
package backup
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
)
func TestBackupRunEndToEndAndResume(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
if err := os.WriteFile(binaryPath, []byte("fake binary"), 0o755); err != nil {
t.Fatal(err)
}
dataDir := filepath.Join(dir, "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dataDir, "db.bin"), []byte("some rocksdb-shaped data"), 0o644); err != nil {
t.Fatal(err)
}
backupDir := filepath.Join(dir, "data-backup")
settingsPath := filepath.Join(dir, "settings.json")
principalsPath := filepath.Join(dir, "principals.json")
pythonScript := fmt.Sprintf("#!/bin/sh\necho fake-settings > %q\necho fake-principals > %q\n", settingsPath, principalsPath)
pythonDir := withFakeExecutable(t, "python3", pythonScript)
pythonScriptPath := filepath.Join(pythonDir, "python3")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("print('fake migrate_v016.py')\n"))
}))
defer srv.Close()
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("store.Create: %v", err)
}
rs.Topology = checkpoint.Topology{StoreBackend: "rocksdb"}
opts := Options{
BinaryPath: binaryPath,
DataDir: dataDir,
BackupDir: backupDir,
MigrationScriptURL: srv.URL,
ScriptDestPath: filepath.Join(dir, "migrate_v016.py"),
AdminURL: "https://mail.example.com",
AdminUser: "admin",
AdminPassword: "hunter2",
SettingsDumpPath: settingsPath,
PrincipalsDumpPath: principalsPath,
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run #1: %v", err)
}
for _, r := range report.Results {
if r.Status == StatusFail {
t.Errorf("Run #1: step %s failed: %s", r.Name, r.Detail)
}
}
for _, name := range []string{"old-binary", "fs-backup", "settings-dump", "principals-dump"} {
if _, ok := rs.Artifacts[name]; !ok {
t.Errorf("Run #1: expected artifact %q to be recorded, got %v", name, rs.Artifacts)
}
}
if _, err := os.Stat(binaryPath + ".v0.15.5"); err != nil {
t.Errorf("preserved binary missing: %v", err)
}
// -- simulate a crash and resume, making every non-idempotent step's
// -- inputs unusable so a re-execution (rather than a skip) fails loudly.
if err := os.RemoveAll(dataDir); err != nil {
t.Fatal(err)
}
srv.Close()
if err := os.WriteFile(pythonScriptPath, []byte("#!/bin/sh\necho should-not-run-again >&2\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatalf("store.Load (resume): %v", err)
}
report2, err := Run(context.Background(), store, resumed, opts)
if err != nil {
t.Fatalf("Run #2 (resume) should succeed without redoing completed steps: %v", err)
}
if len(report2.Results) != len(report.Results) {
t.Errorf("resumed report has %d results, want %d (same as first run)", len(report2.Results), len(report.Results))
}
for _, r := range report2.Results {
if r.Status == StatusFail {
t.Errorf("Run #2 (resume): step %s unexpectedly failed: %s", r.Name, r.Detail)
}
}
}
func TestBackupRunSkipsVandelayWhenNoAccountsGiven(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
os.WriteFile(binaryPath, []byte("fake binary"), 0o755)
dataDir := filepath.Join(dir, "data")
os.MkdirAll(dataDir, 0o755)
os.WriteFile(filepath.Join(dataDir, "db.bin"), []byte("x"), 0o644)
withFakeExecutable(t, "python3", fmt.Sprintf(
"#!/bin/sh\necho x > %q\necho x > %q\n",
filepath.Join(dir, "settings.json"), filepath.Join(dir, "principals.json"),
))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("script"))
}))
defer srv.Close()
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatal(err)
}
rs.Topology = checkpoint.Topology{StoreBackend: "rocksdb"}
opts := Options{
BinaryPath: binaryPath,
DataDir: dataDir,
BackupDir: filepath.Join(dir, "data-backup"),
MigrationScriptURL: srv.URL,
ScriptDestPath: filepath.Join(dir, "migrate_v016.py"),
AdminURL: "https://mail.example.com",
SettingsDumpPath: filepath.Join(dir, "settings.json"),
PrincipalsDumpPath: filepath.Join(dir, "principals.json"),
// Accounts intentionally left empty.
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run: %v", err)
}
found := false
for _, r := range report.Results {
if r.Name == "vandelay-export" {
found = true
if r.Status != StatusSkipped {
t.Errorf("vandelay-export status = %s, want skipped", r.Status)
}
}
}
if !found {
t.Error("report missing a vandelay-export entry")
}
}
func TestBackupRunSkipsBinaryPreservationForDryRun(t *testing.T) {
dir := t.TempDir()
binaryPath := filepath.Join(dir, "stalwart")
if err := os.WriteFile(binaryPath, []byte("fake binary"), 0o755); err != nil {
t.Fatal(err)
}
dataDir := filepath.Join(dir, "data")
os.MkdirAll(dataDir, 0o755)
os.WriteFile(filepath.Join(dataDir, "db.bin"), []byte("x"), 0o644)
withFakeExecutable(t, "python3", fmt.Sprintf(
"#!/bin/sh\necho x > %q\necho x > %q\n",
filepath.Join(dir, "settings.json"), filepath.Join(dir, "principals.json"),
))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("script"))
}))
defer srv.Close()
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatal(err)
}
rs.Topology = checkpoint.Topology{StoreBackend: "rocksdb"}
opts := Options{
BinaryPath: binaryPath,
SkipBinaryPreservation: true,
DataDir: dataDir,
BackupDir: filepath.Join(dir, "data-backup"),
MigrationScriptURL: srv.URL,
ScriptDestPath: filepath.Join(dir, "migrate_v016.py"),
AdminURL: "https://mail.example.com",
SettingsDumpPath: filepath.Join(dir, "settings.json"),
PrincipalsDumpPath: filepath.Join(dir, "principals.json"),
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run: %v", err)
}
for _, r := range report.Results {
if r.Name == "preserve-binary" && r.Status != StatusSkipped {
t.Errorf("preserve-binary status = %s, want skipped", r.Status)
}
}
if _, err := os.Stat(binaryPath); err != nil {
t.Errorf("production binary at %s should be untouched, but stat failed: %v", binaryPath, err)
}
if _, ok := rs.Artifacts["old-binary"]; ok {
t.Error("no old-binary artifact should be recorded when preservation is skipped")
}
}