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