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,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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user