gates / gates (push) Successful in 31s
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.
130 lines
4.6 KiB
Go
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
|
|
}
|