Files
stalwart-migrator/internal/rollback/rollback_test.go
T
jcoffey-dev ade9906275 Implement internal/rollback and the service control it needs
Rollback was the one phase gating everything else: `run` without --dry-run
refused because this tool could not undo a cutover it had committed to.
That reason is now gone, and the refusal has narrowed to the fact that
there is no real cutover to undo yet.

internal/rollback implements ARCHITECTURE.md 4.8 as eight checkpointed
steps under PhaseRollback: verify-backup, stop-service,
preserve-failed-state, restore-data, restore-binary,
restore-service-config, start-service, verify-rollback.

Three things depart from what 4.8 specified, each for a reason:

- The backup is re-verified against its manifest *before* the service is
  stopped, which the design didn't call out. Finding a corrupt backup is
  survivable while the failed instance is still up, and unsurvivable once
  its data directory has been moved aside.
- BuildPlan is separate from Run, so every reason to refuse (closed
  rollback window, FoundationDB, no recorded backup, unknown deployment
  kind, missing database credentials) is found before anything is touched.
  The CLI prints that resolved plan and acts only with --yes.
- The restore is re-verified against the same manifest after writing. A
  restore that put back truncated bytes and reported success would be
  worse than one that failed outright.

Nothing from the failed attempt is deleted: the half-migrated data
directory and the displaced binary are moved to .failed-<run-id> names, so
a retry after the underlying issue is fixed still has both the evidence and
the artifacts. Afterwards a reduced validation suite runs against the
*restored* instance (version, reachability, directory counts) rather than
assuming the restore worked.

internal/service is a new package holding the systemd/Docker control this
needs. It's separate rather than living inside internal/rollback because
cutover will need the identical operations, and because the commands that
can take mail delivery down belong in one auditable place - the same
reasoning that makes stalwartapi the only thing speaking JMAP.
preflight.DeploymentKind is now a type alias for service.Kind so detection
and control can't drift apart. Its Active() reads `systemctl is-active`'s
output rather than its exit status: systemctl exits non-zero for every
non-active state, so exit-status logic would make "inactive" - the answer a
rollback most needs - look like a failure to read the state at all.

Also fixes a pre-existing bug in `status`: Go's flag package stops parsing
at the first positional argument, so `status <run-id> --state-dir X` looked
the run up in the default directory and reported it missing. `rollback`
would have inherited the same footgun on a command whose flags decide what
gets overwritten.

Still open: `confirm` cannot set RollbackWindowClosed. Rollback honours the
flag and refuses when it's set, but closing the window is the point of no
return for the backups this restores from, so it should land with the
retention policy 6 describes rather than before it.

Verified end to end against a fake systemd deployment: half-migrated data
restored to its original contents, failed state preserved, old binary
reinstalled and reporting 0.15.5, unit restarted, and a re-run of the
completed rollback inert.
2026-08-23 15:54:48 -07:00

350 lines
13 KiB
Go

package rollback
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/LINUXexpert-org/stalwart-migrator/internal/backup"
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
"github.com/LINUXexpert-org/stalwart-migrator/internal/service"
)
// fsRun builds a checkpoint that looks like a real embedded-backend run
// that got as far as taking (and recording) a verified filesystem backup:
// the state a rollback is actually invoked against.
func fsRun(t *testing.T) (store *checkpoint.Store, rs *checkpoint.RunState, dataDir, backupDir string) {
t.Helper()
root := t.TempDir()
dataDir = writeTree(t, filepath.Join(root, "data"), map[string]string{
"config": "original settings", "blobs/one": "original mail",
})
backupDir = filepath.Join(root, "backup")
store = checkpoint.NewStore(filepath.Join(root, "runs"))
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatal(err)
}
rs.Topology = checkpoint.Topology{DeploymentKind: "systemd", StoreBackend: "rocksdb"}
manifest, err := backup.CopyDataDir(dataDir, backupDir)
if err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(root, "backup.manifest.json")
if err := backup.WriteManifest(manifestPath, manifest); err != nil {
t.Fatal(err)
}
sum, err := manifest.Checksum()
if err != nil {
t.Fatal(err)
}
rs.RecordArtifact(ArtifactFSBackup, checkpoint.Artifact{Path: backupDir, SHA256: sum, SizeBytes: manifest.TotalBytes})
if _, err := store.RunStep(rs, checkpoint.PhaseBackup, "fs-snapshot", func() (checkpoint.StepOutcome, error) {
return checkpoint.StepOutcome{Detail: "copied", Extra: manifestPath}, nil
}); err != nil {
t.Fatal(err)
}
// Simulate the half-migrated state a failed cutover leaves behind.
if err := os.WriteFile(filepath.Join(dataDir, "config"), []byte("half-migrated settings"), 0o640); err != nil {
t.Fatal(err)
}
return store, rs, dataDir, backupDir
}
func TestBuildPlanRefusesOnceTheRollbackWindowIsClosed(t *testing.T) {
_, rs, dataDir, _ := fsRun(t)
rs.RollbackWindowClosed = true
_, err := BuildPlan(rs, Options{DataDir: dataDir, Controller: &fakeController{}})
if err == nil {
t.Fatal("BuildPlan: want refusal once the operator has confirmed the migration, got nil")
}
if !strings.Contains(err.Error(), "rollback window closed") {
t.Errorf("error %q should say why it refuses", err)
}
}
func TestBuildPlanRefusesWhatItCannotRestore(t *testing.T) {
for _, tc := range []struct {
name string
backend string
wantIn string
}{
{"foundationdb", "foundationdb", "fdbrestore"},
{"unrecognized", "", "no recognized store backend"},
} {
t.Run(tc.name, func(t *testing.T) {
_, rs, dataDir, _ := fsRun(t)
rs.Topology.StoreBackend = tc.backend
_, err := BuildPlan(rs, Options{DataDir: dataDir, Controller: &fakeController{}})
if err == nil {
t.Fatalf("BuildPlan for backend %q: want refusal, got nil", tc.backend)
}
if !strings.Contains(err.Error(), tc.wantIn) {
t.Errorf("error %q should mention %q", err, tc.wantIn)
}
})
}
}
func TestBuildPlanRefusesWhenNoBackupWasEverRecorded(t *testing.T) {
_, rs, dataDir, _ := fsRun(t)
delete(rs.Artifacts, ArtifactFSBackup)
_, err := BuildPlan(rs, Options{DataDir: dataDir, Controller: &fakeController{}})
if err == nil {
t.Fatal("BuildPlan: want refusal when there's no backup to restore, got nil")
}
if !strings.Contains(err.Error(), "cannot be rolled back") {
t.Errorf("error %q should say the run can't be rolled back by this tool", err)
}
}
func TestBuildPlanRefusesAnUnknownDeploymentKind(t *testing.T) {
_, rs, dataDir, _ := fsRun(t)
rs.Topology.DeploymentKind = string(service.Unknown)
_, err := BuildPlan(rs, Options{DataDir: dataDir})
if err == nil {
t.Fatal("BuildPlan: want refusal when it doesn't know how to stop Stalwart, got nil")
}
}
func TestBuildPlanFillsPathsFromTheCheckpoint(t *testing.T) {
_, rs, dataDir, backupDir := fsRun(t)
rs.RecordArtifact(ArtifactOldBinary, checkpoint.Artifact{Path: "/usr/local/bin/stalwart.v0.15.5", SHA256: "abc"})
plan, err := BuildPlan(rs, Options{
DataDir: dataDir, BinaryPath: "/usr/local/bin/stalwart", Controller: &fakeController{},
})
if err != nil {
t.Fatalf("BuildPlan: %v", err)
}
if plan.Method != "filesystem" {
t.Errorf("Method = %q, want filesystem", plan.Method)
}
if plan.BackupDir != backupDir {
t.Errorf("BackupDir = %q, want the recorded artifact %q", plan.BackupDir, backupDir)
}
if plan.ManifestPath == "" {
t.Error("ManifestPath should come from the fs-snapshot step's recorded outcome")
}
if plan.PreservedBinary != "/usr/local/bin/stalwart.v0.15.5" {
t.Errorf("PreservedBinary = %q, want the recorded artifact", plan.PreservedBinary)
}
if !strings.Contains(plan.String(), "nothing is deleted") {
t.Errorf("plan text should tell the operator nothing is deleted:\n%s", plan)
}
}
func TestRunRestoresTheDataDirectoryWhileTheServiceIsDown(t *testing.T) {
store, rs, dataDir, _ := fsRun(t)
var configAtStop string
ctl := &fakeController{active: true}
ctl.onStop = func() { configAtStop = readFile(t, filepath.Join(dataDir, "config")) }
report, err := Run(context.Background(), store, rs, Options{DataDir: dataDir, Controller: ctl})
if err != nil {
t.Fatalf("Run: %v\n%s", err, report)
}
if got, want := strings.Join(ctl.calls, ","), "stop,start"; got != want {
t.Errorf("controller calls = %q, want %q", got, want)
}
if configAtStop != "half-migrated settings" {
t.Errorf("data was already touched when the service stopped (config=%q) - the restore must happen after the stop", configAtStop)
}
if got := readFile(t, filepath.Join(dataDir, "config")); got != "original settings" {
t.Errorf("restored config = %q, want the pre-migration contents", got)
}
if got := readFile(t, filepath.Join(dataDir, "blobs/one")); got != "original mail" {
t.Errorf("restored mail = %q, want the pre-migration contents", got)
}
if got := readFile(t, filepath.Join(dataDir+".failed-"+rs.RunID, "config")); got != "half-migrated settings" {
t.Errorf("failed attempt's data = %q, want it preserved rather than deleted", got)
}
if report.Blocking() {
t.Errorf("report should be clean:\n%s", report)
}
}
// Discovering a corrupt backup is survivable while the failed instance is
// still up, and unsurvivable once its data directory has been moved aside.
func TestRunVerifiesTheBackupBeforeStoppingAnything(t *testing.T) {
store, rs, dataDir, backupDir := fsRun(t)
if err := os.WriteFile(filepath.Join(backupDir, "blobs/one"), []byte("corrupt"), 0o640); err != nil {
t.Fatal(err)
}
ctl := &fakeController{active: true}
report, err := Run(context.Background(), store, rs, Options{DataDir: dataDir, Controller: ctl})
if err == nil {
t.Fatal("Run: want failure for a corrupt backup, got nil")
}
if len(ctl.calls) != 0 {
t.Errorf("controller was called %v - the service must not be stopped when the backup can't be trusted", ctl.calls)
}
if got := readFile(t, filepath.Join(dataDir, "config")); got != "half-migrated settings" {
t.Errorf("data directory was modified (%q) despite the refusal", got)
}
if !report.Blocking() {
t.Error("report should be blocking")
}
}
func TestRunResumesWithoutRedoingCompletedSteps(t *testing.T) {
store, rs, dataDir, _ := fsRun(t)
first := &fakeController{active: true}
if _, err := Run(context.Background(), store, rs, Options{DataDir: dataDir, Controller: first}); err != nil {
t.Fatal(err)
}
// Re-invoking a completed rollback must be inert: stopping the service
// a second time, or re-restoring over a data directory that has been
// live since, would turn a no-op into an outage.
second := &fakeController{active: true}
reloaded, err := store.Load(rs.RunID)
if err != nil {
t.Fatal(err)
}
if _, err := Run(context.Background(), store, reloaded, Options{DataDir: dataDir, Controller: second}); err != nil {
t.Fatal(err)
}
if len(second.calls) != 0 {
t.Errorf("second invocation called the controller %v, want nothing - every step was already done", second.calls)
}
}
func TestRunReinstallsThePreservedBinary(t *testing.T) {
store, rs, dataDir, _ := fsRun(t)
binDir := t.TempDir()
binaryPath := filepath.Join(binDir, "stalwart")
preserved := binaryPath + ".v0.15.5"
// Real scripts, not placeholder bytes: the verification step runs the
// restored binary's --version, so this also proves the instance really
// came back on the version the run started from.
if err := os.WriteFile(preserved, []byte("#!/bin/sh\necho 'stalwart 0.15.5'\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\necho 'stalwart 0.16.14'\n"), 0o755); err != nil {
t.Fatal(err)
}
sum, _, err := hashFile(preserved)
if err != nil {
t.Fatal(err)
}
rs.RecordArtifact(ArtifactOldBinary, checkpoint.Artifact{Path: preserved, SHA256: sum})
report, err := Run(context.Background(), store, rs, Options{
DataDir: dataDir, BinaryPath: binaryPath, Controller: &fakeController{active: true},
})
if err != nil {
t.Fatalf("Run: %v\n%s", err, report)
}
if got := readFile(t, binaryPath); !strings.Contains(got, "0.15.5") {
t.Errorf("binary = %q, want the preserved old one reinstalled", got)
}
if got := readFile(t, binaryPath+".failed-"+rs.RunID); !strings.Contains(got, "0.16.14") {
t.Errorf("displaced binary = %q, want the new one kept for a retry", got)
}
}
func TestRunRestoresAPreservedServiceUnit(t *testing.T) {
store, rs, dataDir, _ := fsRun(t)
unitDir := t.TempDir()
unitPath := filepath.Join(unitDir, "stalwart.service")
preservedUnit := filepath.Join(unitDir, "stalwart.service.preserved")
if err := os.WriteFile(preservedUnit, []byte("ExecStart=/usr/local/bin/stalwart\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(unitPath, []byte("ExecStart=/usr/local/bin/stalwart-0.16\n"), 0o644); err != nil {
t.Fatal(err)
}
rs.RecordArtifact(ArtifactServiceUnit, checkpoint.Artifact{Path: preservedUnit})
ctl := &fakeController{active: true}
report, err := Run(context.Background(), store, rs, Options{
DataDir: dataDir, ServiceUnitPath: unitPath, Controller: ctl,
})
if err != nil {
t.Fatalf("Run: %v\n%s", err, report)
}
if got := readFile(t, unitPath); !strings.Contains(got, "/usr/local/bin/stalwart\n") {
t.Errorf("unit = %q, want the preserved definition restored", got)
}
if got, want := strings.Join(ctl.calls, ","), "stop,reload,start"; got != want {
t.Errorf("controller calls = %q, want %q - a restored unit has to be reloaded before the start", got, want)
}
}
// Nothing writes a service-unit artifact yet (cutover, which would rewrite
// the unit in the first place, doesn't exist). That has to read as an
// explicit skip, not a silent success.
func TestRunSkipsServiceConfigRestoreWithAnExplanation(t *testing.T) {
store, rs, dataDir, _ := fsRun(t)
report, err := Run(context.Background(), store, rs, Options{DataDir: dataDir, Controller: &fakeController{active: true}})
if err != nil {
t.Fatal(err)
}
var found bool
for _, res := range report.Results {
if res.Name == "restore-service-config" {
found = true
if res.Status != StatusSkipped {
t.Errorf("restore-service-config status = %q, want %q", res.Status, StatusSkipped)
}
if !strings.Contains(res.Detail, "cutover") {
t.Errorf("skip detail %q should say what would record one", res.Detail)
}
}
}
if !found {
t.Errorf("no restore-service-config result in report:\n%s", report)
}
}
func TestRunFailsWhenTheServiceWontStop(t *testing.T) {
store, rs, dataDir, _ := fsRun(t)
ctl := &fakeController{active: true, stopErr: os.ErrPermission}
report, err := Run(context.Background(), store, rs, Options{DataDir: dataDir, Controller: ctl})
if err == nil {
t.Fatal("Run: want failure when the service can't be stopped, got nil")
}
if got := readFile(t, filepath.Join(dataDir, "config")); got != "half-migrated settings" {
t.Errorf("data directory was touched (%q) even though the service never stopped", got)
}
if !report.Blocking() {
t.Error("report should be blocking")
}
}
func TestRunSurfacesVerificationFailures(t *testing.T) {
store, rs, dataDir, _ := fsRun(t)
binDir := withFakeExecutable(t, "stalwart", "#!/bin/sh\necho 'stalwart 0.16.14'\n")
report, err := Run(context.Background(), store, rs, Options{
DataDir: dataDir, BinaryPath: filepath.Join(binDir, "stalwart"), Controller: &fakeController{active: true},
})
if err == nil {
t.Fatal("Run: want failure when the restored instance isn't on the original version, got nil")
}
var sawVersionFailure bool
for _, res := range report.Results {
if res.Name == "version" && res.Status == StatusFail {
sawVersionFailure = true
if !strings.Contains(res.Detail, "0.15.5") {
t.Errorf("version failure %q should name the version it expected", res.Detail)
}
}
}
if !sawVersionFailure {
t.Errorf("individual verification results should be in the report, not collapsed into one line:\n%s", report)
}
}