diff --git a/.changeset/ha-backup-driver-links.md b/.changeset/ha-backup-driver-links.md index b5f9f4e75..eea84cd94 100644 --- a/.changeset/ha-backup-driver-links.md +++ b/.changeset/ha-backup-driver-links.md @@ -1,5 +1,5 @@ --- -"ftw": patch +"ftw": minor --- Add an offline Home Assistant backup exporter that keeps internal driver links, checks protected archives and leaves existing data in place on failure. diff --git a/.changeset/revert-crash-recovery.md b/.changeset/revert-crash-recovery.md new file mode 100644 index 000000000..e5a4bec79 --- /dev/null +++ b/.changeset/revert-crash-recovery.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep interrupted revert state separate from complete active data. Retain abandoned extraction copies after a successful retry so normal restore and revert can continue. diff --git a/go/internal/backup/archive.go b/go/internal/backup/archive.go index 9b5e119a1..7e59f3fe2 100644 --- a/go/internal/backup/archive.go +++ b/go/internal/backup/archive.go @@ -946,6 +946,9 @@ func RestoreContents(archivePath, dataDir string, now time.Time) (RestoreResult, ) return RestoreResult{}, errors.Join(fmt.Errorf("backup: sync activated restore: %w", err), rollbackErr) } + if err := retireRestoreStaging(dataDir, staging); err != nil { + return RestoreResult{}, fmt.Errorf("backup: archive active but old extraction copies could not be retained separately: %w", err) + } return RestoreResult{DataDir: dataDir, SafetyDir: safetyDir, SafetyComplete: true}, nil } @@ -1070,7 +1073,7 @@ func topLevelEntries(dir string, exclude ...string) ([]string, error) { } func isRestoreInternalName(name string) bool { - return name == restorePointFile || strings.HasPrefix(name, ".ftw-restore-stage-") || + return name == restorePointFile || strings.HasPrefix(name, ".ftw-abandoned-stage-") || strings.HasPrefix(name, ".ftw-restore-stage-") || strings.HasPrefix(name, ".ftw-pre-restore-") || strings.HasPrefix(name, ".ftw-rejected-restore-") } diff --git a/go/internal/backup/archive_test.go b/go/internal/backup/archive_test.go index badf6e28a..443b8f867 100644 --- a/go/internal/backup/archive_test.go +++ b/go/internal/backup/archive_test.go @@ -573,3 +573,45 @@ func TestDescribeSourceRejectsExternalAbsoluteLinkWithoutReadingIt(t *testing.T) t.Fatalf("external link error = %v", err) } } + +func TestExtractionRetryRetainsOldStageAndAllowsCompleteRevert(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "source") + if err := os.Mkdir(source, 0o700); err != nil { + t.Fatal(err) + } + st, err := state.Open(filepath.Join(source, "state.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.SaveConfig("generation", "backup"); err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(source, "config.yaml"), "generation: backup\n") + archive, err := Create(context.Background(), CreateOptions{State: st, StatePath: filepath.Join(source, "state.db"), DataDir: source, OutputDir: filepath.Join(root, "backup")}) + if err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "target") + writeTestFile(t, filepath.Join(target, "old"), "original data") + writeTestFile(t, filepath.Join(target, ".ftw-restore-stage-interrupted", "partial"), "retained extraction") + result, err := RestoreContents(archive.Path, target, time.Now()) + if err != nil { + t.Fatal(err) + } + back, err := RevertContents(target, result.SafetyDir) + if err != nil || !back.SafetyComplete { + t.Fatalf("retry rejected a completed archive: %+v %v", back, err) + } + if _, err := RevertContents(target, result.SafetyDir); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(target, ".ftw-abandoned-stage-interrupted", "partial")) + if err != nil || string(got) != "retained extraction" { + t.Fatalf("old extraction changed: %q %v", got, err) + } +} diff --git a/go/internal/backup/restore_crash_test.go b/go/internal/backup/restore_crash_test.go index b03664de3..48707c0ae 100644 --- a/go/internal/backup/restore_crash_test.go +++ b/go/internal/backup/restore_crash_test.go @@ -26,6 +26,14 @@ func TestRestoreCrashChild(t *testing.T) { realMove := moveRestoreEntry moved := 0 moveRestoreEntry = func(from, to string) error { + if phase == "reverting" && filepath.Dir(to) == data && strings.HasPrefix(filepath.Base(filepath.Dir(from)), ".ftw-pre-restore-") { + if err := os.WriteFile(marker, []byte(filepath.Dir(from)), 0o600); err != nil { + return err + } + for { + time.Sleep(time.Minute) + } + } if err := realMove(from, to); err != nil { return err } @@ -46,6 +54,12 @@ func TestRestoreCrashChild(t *testing.T) { } return nil } + if phase == "reverting" { + if _, err := RevertContents(data, os.Getenv("FTW_RESTORE_CRASH_SAFETY")); err != nil { + t.Fatal(err) + } + t.Fatal("revert completed without reaching the crash window") + } if _, err := RestoreContents(os.Getenv("FTW_RESTORE_CRASH_ARCHIVE"), data, time.Now()); err != nil { t.Fatal(err) } @@ -53,7 +67,7 @@ func TestRestoreCrashChild(t *testing.T) { } func TestRestoreProcessCrashPreservesDataAndRefusesPartialPoints(t *testing.T) { - for _, phase := range []string{"preserving", "activating"} { + for _, phase := range []string{"preserving", "activating", "reverting"} { t.Run(phase, func(t *testing.T) { root := t.TempDir() source := filepath.Join(root, "source") @@ -82,9 +96,17 @@ func TestRestoreProcessCrashPreservesDataAndRefusesPartialPoints(t *testing.T) { for i := range 128 { writeTestFile(t, filepath.Join(data, fmt.Sprintf("%04d-old", i)), "original data") } + safety := "" + if phase == "reverting" { + result, err := RestoreContents(archive.Path, data, time.Now()) + if err != nil { + t.Fatal(err) + } + safety = result.SafetyDir + } marker := filepath.Join(root, "crash-window") child := exec.Command(os.Args[0], "-test.run=^TestRestoreCrashChild$", "-test.count=1") - child.Env = append(os.Environ(), "FTW_RESTORE_CRASH_PHASE="+phase, "FTW_RESTORE_CRASH_DATA="+data, "FTW_RESTORE_CRASH_ARCHIVE="+archive.Path, "FTW_RESTORE_CRASH_MARKER="+marker) + child.Env = append(os.Environ(), "FTW_RESTORE_CRASH_PHASE="+phase, "FTW_RESTORE_CRASH_DATA="+data, "FTW_RESTORE_CRASH_ARCHIVE="+archive.Path, "FTW_RESTORE_CRASH_MARKER="+marker, "FTW_RESTORE_CRASH_SAFETY="+safety) log, err := os.Create(filepath.Join(root, "child.log")) if err != nil { t.Fatal(err) @@ -119,6 +141,22 @@ func TestRestoreProcessCrashPreservesDataAndRefusesPartialPoints(t *testing.T) { t.Fatalf("missing retained point: %v %v", points, err) } point := points[0] + if phase == "reverting" { + active, err := topLevelEntries(data) + if err != nil || len(active) != 0 { + t.Fatalf("revert crash did not leave the empty-root window: %v %v", active, err) + } + if err := verifyRestorePoint(point); err != nil { + t.Fatal(err) + } + rejected, err := filepath.Glob(filepath.Join(data, ".ftw-rejected-restore-*")) + if err != nil || len(rejected) != 1 { + t.Fatalf("rejected data missing: %v %v", rejected, err) + } + if err := verifyRestorePoint(rejected[0]); err != nil { + t.Fatal(err) + } + } if phase == "preserving" { if _, err := RevertContents(data, point); err == nil { t.Fatal("partial preservation was accepted") @@ -139,13 +177,22 @@ func TestRestoreProcessCrashPreservesDataAndRefusesPartialPoints(t *testing.T) { name := fmt.Sprintf("%04d-old", i) atRoot, rootErr := os.ReadFile(filepath.Join(data, name)) atPoint, pointErr := os.ReadFile(filepath.Join(point, name)) - if phase == "activating" && (rootErr != nil || string(atRoot) != "original data") { + if phase != "preserving" && (rootErr != nil || string(atRoot) != "original data") { t.Fatalf("original %s not active after recovery", name) } if (rootErr != nil || string(atRoot) != "original data") && (pointErr != nil || string(atPoint) != "original data") { t.Fatalf("original %s lost after %s crash", name, phase) } } + if phase == "reverting" { + rejected, _ := filepath.Glob(filepath.Join(data, ".ftw-rejected-restore-*")) + for i := range 128 { + got, err := os.ReadFile(filepath.Join(rejected[0], fmt.Sprintf("%04d-new", i))) + if err != nil || string(got) != "new data" { + t.Fatalf("revert crash lost new data %d: %q %v", i, got, err) + } + } + } fresh := filepath.Join(root, "fresh-recovery") if err := os.Mkdir(fresh, 0o700); err != nil { t.Fatal(err) diff --git a/go/internal/backup/restore_point.go b/go/internal/backup/restore_point.go index e795b9bf0..560de5e01 100644 --- a/go/internal/backup/restore_point.go +++ b/go/internal/backup/restore_point.go @@ -102,7 +102,11 @@ func checkRestorePointsBeforeRestore(dataDir string) error { if err != nil { return err } + staging := hasRestoreStaging(entries) for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".ftw-rejected-restore-") || (staging && strings.HasPrefix(entry.Name(), ".ftw-pre-restore-")) { + return fmt.Errorf("backup: unresolved interrupted restore at %s; retain all directories and recover a complete point or a full backup on a separate target", filepath.Join(dataDir, entry.Name())) + } if !strings.HasPrefix(entry.Name(), ".ftw-pre-restore-") && !strings.HasPrefix(entry.Name(), ".ftw-rejected-restore-") { continue } @@ -140,3 +144,26 @@ func rollbackRestorePoint(pointDir, dataDir string, moved []string) error { } return os.Remove(pointDir) } + +// Retain old extraction copies under a distinct name once a retry has fully +// activated its verified archive. They no longer describe an active operation. +func retireRestoreStaging(dataDir, currentStage string) error { + entries, err := os.ReadDir(dataDir) + if err != nil { + return err + } + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), ".ftw-restore-stage-") || entry.Name() == filepath.Base(currentStage) { + continue + } + from := filepath.Join(dataDir, entry.Name()) + to := filepath.Join(dataDir, ".ftw-abandoned-stage-"+strings.TrimPrefix(entry.Name(), ".ftw-restore-stage-")) + if _, err := os.Lstat(to); !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("backup: retained extraction path already exists: %s", to) + } + if err := os.Rename(from, to); err != nil { + return err + } + } + return syncDir(dataDir) +} diff --git a/go/internal/backup/restore_point_test.go b/go/internal/backup/restore_point_test.go index 9ffef0a32..2afdf2760 100644 --- a/go/internal/backup/restore_point_test.go +++ b/go/internal/backup/restore_point_test.go @@ -172,3 +172,44 @@ func TestRevertRejectsMissingRecordedEntry(t *testing.T) { t.Fatalf("rejected revert moved current data: %q %v", got, err) } } + +func TestRetryRevertDoesNotCertifyEmptyActiveRoot(t *testing.T) { + data := t.TempDir() + safety := filepath.Join(data, ".ftw-pre-restore-proof") + rejected := filepath.Join(data, ".ftw-rejected-restore-interrupted") + writeTestFile(t, filepath.Join(safety, "old"), "original data") + writeTestFile(t, filepath.Join(rejected, "new"), "new readings") + if err := writeRestorePoint(safety, []string{"old"}, true); err != nil { + t.Fatal(err) + } + if err := writeRestorePoint(rejected, []string{"new"}, true); err != nil { + t.Fatal(err) + } + result, err := RevertContents(data, safety) + if err != nil || result.SafetyComplete { + t.Fatalf("retry certified empty root: %+v %v", result, err) + } + if got, err := os.ReadFile(filepath.Join(data, "old")); err != nil || string(got) != "original data" { + t.Fatalf("old data not recovered: %q %v", got, err) + } + if _, err := RevertContents(data, safety); err == nil { + t.Fatal("revert activated empty state") + } + if got, err := os.ReadFile(filepath.Join(rejected, "new")); err != nil || string(got) != "new readings" { + t.Fatalf("new data not retained: %q %v", got, err) + } +} + +func TestRetryImportRejectsInterruptedActivationWithCompletePoint(t *testing.T) { + data := t.TempDir() + safety := filepath.Join(data, ".ftw-pre-restore-proof") + writeTestFile(t, filepath.Join(safety, "old"), "complete original") + writeTestFile(t, filepath.Join(data, "new"), "partial target") + writeTestFile(t, filepath.Join(data, ".ftw-restore-stage-proof", "pending"), "unactivated target") + if err := writeRestorePoint(safety, []string{"old"}, true); err != nil { + t.Fatal(err) + } + if _, err := RestoreContents("unused.ftwbak", data, time.Time{}); err == nil || !strings.Contains(err.Error(), "unresolved") { + t.Fatalf("import certified partial activation: %v", err) + } +}