package backup import ( "encoding/json" "os" "path/filepath" "sort" "time" ) // R-1256 H8 (review round 2, HUNT-1): a restore copy (/backups/restore-undo//) that nothing // will ever use again used to stay on the drive for good. A copy is KEPT on purpose in three states — a put-back that // failed (the app is held, D20), a put-back waiting for its drive (the putback_pending hold, R-1251) and a restore cut // by a power loss (the app-stop marker names it, D22) — and for 30 minutes after a restore while the box watches for a // stable start (R-1270). Every one of those is still needed. A copy outside all of them — the app runs again, or was // removed, and nothing names the copy — is disk the household cannot see or use. // // The rule (safest reading, decided in the 2026-10-28 night run; operator may change the age): a copy is removed only // when ALL of these hold, and the sweep removes NOTHING when it cannot answer one of them: // - it is older than restoreCopyUnusedAge (its folder's stamp; the folder's time when the stamp does not parse); // - the app has no restore hold of any kind (held apps keep every copy — support decides); // - the app-stop marker does not name the app (a pending put-back), and the marker is readable; // - no restore op is in flight on the box (a new restore's copy and the stable-start watcher are left alone). // // Run every 6 hours from main.go (TestR1256_H8_TheSweepIsWiredAtStartup). Pinned by TestR1256_H8_*. // restoreCopyUnusedAge is how long an unused copy stays for support after its restore. const restoreCopyUnusedAge = 14 * 24 * time.Hour // restoreCopyStampLayout is copyLiveVolumes' folder name. const restoreCopyStampLayout = "20060102T150405Z" // SweepUnusedRestoreCopies removes the restore copies no hold, no pending put-back and no running restore needs, and // older than restoreCopyUnusedAge. Returns the folders removed. func (m *Manager) SweepUnusedRestoreCopies(now time.Time) []string { if busy, _ := m.RestoreBusy(); busy == RestoreBusyRestore { m.logger.Printf("[INFO] [backup] restore-copy sweep skipped: a restore runs") return nil } named, ok := m.appStopNamedApps() if !ok { m.logger.Printf("[WARN] [backup] restore-copy sweep skipped: the app-stop marker cannot be read — a copy it names must not go") return nil } var removed []string for _, nsRoot := range m.primaryUnitRoots() { base := filepath.Join(nsRoot, "backups", "restore-undo") apps, err := os.ReadDir(base) if err != nil { continue } for _, a := range apps { if !a.IsDir() { continue } app := a.Name() if named[app] { continue } if m.settings == nil { return removed // no hold store: a held app cannot be told apart } if _, held := m.settings.GetRestoreHold(app); held { continue } appDir := filepath.Join(base, app) copies, err := os.ReadDir(appDir) if err != nil { continue } sort.Slice(copies, func(i, j int) bool { return copies[i].Name() < copies[j].Name() }) for _, c := range copies { if !c.IsDir() { continue } dir := filepath.Join(appDir, c.Name()) at, err := time.Parse(restoreCopyStampLayout, c.Name()) if err != nil { fi, sErr := os.Stat(dir) if sErr != nil { continue } at = fi.ModTime() } if now.Sub(at) < restoreCopyUnusedAge { continue } if err := os.RemoveAll(dir); err != nil { m.logger.Printf("[WARN] [backup] %s: the unused restore copy %s could not be removed: %v", app, dir, err) continue } m.logger.Printf("[INFO] [backup] %s: the unused restore copy %s (from %s) is removed — no hold, no pending put-back, older than %s (R-1256)", app, dir, at.UTC().Format(time.RFC3339), restoreCopyUnusedAge) removed = append(removed, dir) } _ = os.Remove(appDir) // the per-app folder, when it is now empty } } return removed } // appStopNamedApps reads the app-stop marker without side effects: the apps of an ACTIVE marker and every app its Undo // names. ok=false when the marker exists and cannot be read — the caller then touches nothing. func (m *Manager) appStopNamedApps() (map[string]bool, bool) { named := map[string]bool{} if m.appStop == nil || m.appStop.path == "" { return named, true } data, err := os.ReadFile(m.appStop.path) if os.IsNotExist(err) { return named, true } if err != nil { return nil, false } var mk AppStopMarker if err := json.Unmarshal(data, &mk); err != nil { return nil, false } for app := range mk.Undo { named[app] = true } if mk.Active { for _, app := range mk.Stacks { named[app] = true } } return named, true }