Files
felhom-controller/controller/internal/backup/restore_copy_sweep.go
T
admin 342a394bff
gates / gates (push) Successful in 31s
R-1256: four restore edges (review round 2 HUNT-1 H4/H5/H6/H8) — each test seen failing first
H4: a failed definition write in the off-site restore writes the live definition back before the start;
    a failed write-back holds the app (restore_mixed).
H5: a put-back whose stop fails pours nothing, keeps the copy and holds the app (off-site, local, power-cut);
    a failed definition write-back keeps the copy (D20); a failed 'volume rm -f' fails that volume (no merge).
H6: TryBeginRestoreOp/RestoreBusy: check every operation and record the restore in one step under m.mu;
    every web handler and the kept loads begin through it; install-time kept load asks about the mirror too.
H8: SweepUnusedRestoreCopies every 6 h: removes a copy only with no hold, no marker naming it, no restore
    running, and older than 14 days.

Commit only; rides the next controller release.
2026-10-28 22:24:16 +01:00

130 lines
4.6 KiB
Go

package backup
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"time"
)
// R-1256 H8 (review round 2, HUNT-1): a restore copy (<namespace>/backups/restore-undo/<app>/<stamp>) 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
}