Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/ha-backup-driver-links.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/revert-crash-recovery.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion go/internal/backup/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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-")
}
Expand Down
42 changes: 42 additions & 0 deletions go/internal/backup/archive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
53 changes: 50 additions & 3 deletions go/internal/backup/restore_crash_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -46,14 +54,20 @@ 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)
}
t.Fatal("restore completed without reaching the crash window")
}

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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions go/internal/backup/restore_point.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-")) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish older safety points from the interrupted restore

If a site already has a complete safety point from an earlier successful restore and a later restore is killed during extraction, the resulting staging directory has no safety point of its own. This condition nevertheless treats the older .ftw-pre-restore-* directory as evidence of interrupted activation, so every retry is refused even though the active root and prior point are both verified. Associate staging with its restore transaction, or otherwise allow this extraction-only retry without weakening the partial-activation check.

AGENTS.md reference: AGENTS.md:L24-L25

Useful? React with 👍 / 👎.

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
}
Expand Down Expand Up @@ -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)
}
41 changes: 41 additions & 0 deletions go/internal/backup/restore_point_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading