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.
350 lines
13 KiB
Go
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)
|
|
}
|
|
}
|