From c1a73b24b3139298b196d07ff6e69d5865a9642e Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 4 Oct 2026 07:27:38 +0200 Subject: [PATCH] v0.290.0: the clean-up guard skips same-day superseded young snapshots instead of refusing (R-824), refuses above the weekly cap; a due set-aside deletion is handed to the hub's 7-day wait (decision 74, R-823) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 20 +++ REUSE.md | 2 +- controller/README.md | 2 +- controller/cmd/controller/main.go | 7 +- controller/internal/backup/backup.go | 2 + controller/internal/backup/offbox_abandon.go | 96 ++++++++++-- controller/internal/backup/offbox_window.go | 60 ++++++-- .../internal/backup/offbox_window_test.go | 143 ++++++++++++++---- controller/internal/offsiteapply/seams.go | 42 +++++ controller/internal/settings/settings.go | 3 + 10 files changed, 309 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8456b96..58c3340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## v0.290.0 — the clean-up guard lets an honest window through (R-824); a due set-aside deletion goes to the hub (decision 74, R-823) (2026-10-04) + +**MinAgent: 0.131.0** (unchanged). **Needs hub v0.128.0** (`abandon-request` / `-status` / `-cancel`; the window cap of +half). No new household string — the page keeps its dated deletion text, now with the hub's date. + +- **R-824 — the guard no longer refuses every window after a manual run.** A YOUNG (< 8 days) snapshot that a newer + same-day snapshot of its group (`--group-by host,tags`) supersedes is EXCLUDED from the plan — it is removed in a later + window once older — instead of refusing the run. Measured shape: demo-hp window 1 (2026-10-03), the night run's + copies superseded by a manual run. A young removal WITHOUT a same-day successor still REFUSES (the poisoning + signature). Future-dated snapshots and snapshots newer than the hub allows still refuse. A plan larger than the hub's + `max_remove` (one week's retention) now REFUSES (was: take the oldest) — per the 2026-10-04 brief; cost recorded in R-833. +- **R-823 — the household's "delete my earlier off-site backups" works again on the append-only tier.** When the + countdown is due, the box hands the set-aside path to the hub (`HubAbandonClient`), which deletes it after its own + 7-day wait; the box follows the hub's status (deleted → the two-phase commit completes as before; cancelled → the + countdown ends). A recovery on the box cancels at the hub. The backups page and the banner keep showing a dated, + cancellable deletion (the hub's date). `offbox_abandon_deferred` (v0.289) is gone. +- Tests: `TestOffsiteGuard_SameDaySupersededYoungExcluded`, `TestOffsiteGuard_AboveWeeklyCapRefused`, + `TestOffsiteGuard_HonestPlanPrunesOldestFirst`, `TestAbandon_PinnedHandsToHubAndFollows` — red-proofs in + `felhom.eu/documentation/audits/offsite-finish-2026-10-04/red-proofs-controller.txt`. + ## v0.289.1 — the provider's rclone notice no longer reads as "0 snapshots" (found live on demo-felhom) (2026-10-03) **MinAgent: 0.131.0** (unchanged). Needs hub v0.127.0 (unchanged). diff --git a/REUSE.md b/REUSE.md index 22601ec..807b7f5 100644 --- a/REUSE.md +++ b/REUSE.md @@ -246,7 +246,7 @@ | `metrics.FetchContainerLogTail` | controller/internal/metrics/logscanner.go | `(name, tailLines) (string, error)` | Raw per-container `docker logs --tail=N` | 15s timeout; caller caps/redacts (capTailLines) | | `ConfigRefresher.Reconcile` | controller/internal/report/config_refresh.go | `(ackVersion int)` | Pull-based config refresh | Re-pulls controller.yaml (re-merging local_api), then graceful self-restart; first-run = baseline, no restart | | `offsiteapply.HubRegistrar` / `HubWindowClient` / `PinnedProber` (v0.289.0, decisions 68–69) | controller/internal/offsiteapply/seams.go | `Register(ctx,pub)(fp,err)` · `Confirm` · `MoveAside` · `Open/Close` window · `Probe(ctx,host,user,port,kh,privPEM) bool` | EVERY off-site key install, the hub move-aside, the clean-up window | **The box never handles the sub-account password** — there is no consume path any more. `PinnedProber` is a POSITIVE observable (exit 0 + rclone output); "authenticates" is NOT enough — an unpinned key authenticates and can delete. | -| `Manager.offsiteWindowRetention` + `offsiteGuard` (v0.289.0, R-822) | controller/internal/backup/offbox_window.go | `(ctx, base, env, why)` · pure `(all, plan, now, newestAllowed, max) (ids, refuse)` | THE retention step for both callers (after a run, over quota) | Pinned tier: no window → nothing deleted; the guard runs BEFORE any forget; forget is by explicit ids, oldest first. NAS tier: the old SP-2 policy, unchanged. | +| `Manager.offsiteWindowRetention` + `offsiteGuard` (v0.289.0, R-822) | controller/internal/backup/offbox_window.go | `(ctx, base, env, why)` · pure `(all, plan, now, newestAllowed, max) (ids, refuse)` | THE retention step for both callers (after a run, over quota) | Pinned tier: no window → nothing deleted; the guard runs BEFORE any forget; a young snapshot superseded the same day is EXCLUDED, any other young removal / future date / plan above `max_remove` REFUSES; forget is by explicit ids, oldest first. A due abandonment goes to `OffsiteAbandonClient` (hub, 7-day wait). NAS tier: the old SP-2 policy, unchanged. | | `offsiteapply.SettleProvider` / `SettleFunc` / `Bridge.AwaitSettle` / `ReconcileWhenSettled` (R-71a, v0.162.0) | controller/internal/offsiteapply/offsiteapply.go + seams.go | `SettleState() (version, floor string, updateRunning, floorKnown bool)` | THE settle-gate: defers the offsite one-time-password consume past a managed day-0 floor-update (the F10 race). Wire the `SettleFunc` adapter over `updater.GetFloor()`/`IsUpdateRunning()` — **the updater's knowledge is the ONE floor source; never fetch the floor a second way**. Gate ONLY the bridge goroutine, and only when an updater exists (nil `Settle` = reconcile immediately). Bounds `settlePoll`/`settleFloorSubBound`/`settleOverallBound`; the floor is in-memory (report-ACK-derived, ~5–10 s), NOT persisted → unknown until the first ACK on any restart. Inject `Now`/`Sleep` in tests (no real sleeps). B′: at/above-floor GOes on the first poll, zero wait. Do NOT touch the consume/persist order or the 404 contract — ordering only | | `bootstrap.MaybeIngest` / `RefreshConfig` | controller/internal/bootstrap/bootstrap.go | bootstrap.json → controller.yaml | Day-0 + refresh | Overwrites controller.yaml, NEVER settings.json | | `api.GracefulSelfRestart` | controller/internal/api/selfrestart.go | `(logger)` | Controller self-restart | Detached exit; bootstrap unit re-runs the image | diff --git a/controller/README.md b/controller/README.md index e7b3f9f..34380ab 100644 --- a/controller/README.md +++ b/controller/README.md @@ -160,7 +160,7 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated the box sends only its public key (`offsiteapply.HubRegistrar`) and never sees the sub-account password. Transport is restic `rclone:` over ssh port 23 (`settings.OffboxTarget.Transport = "rclone-pinned"`); the household's own SFTP NAS is unchanged. Retention runs only inside a hub-opened weekly window, behind the fake-snapshot guard - (`backup/offbox_window.go`); the orphan move-aside is the hub's; a due abandonment is deferred to the operator. + (`backup/offbox_window.go`; v0.290.0: a young snapshot superseded the same day is skipped, not refused); the orphan move-aside is the hub's; a due abandonment is handed to the hub, which deletes the set-aside copy after a 7-day wait unless cancelled (decision 74). **Apps go off-site by themselves (v0.283.0, decision 50):** a fresh install on a box whose customer has off-site switches the app's off-site copy ON (`settings.DefaultOffboxOnForNewApp`, deploy-done hook); an earlier choice is kept. Older apps: one press on both backup pages (`/backup/offbox/enable-all`, „Nem most" dismisses). The size diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 3ca8b72..c877025 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -779,6 +779,8 @@ func main() { backupMgr.SetOffsiteMoveAside(offsiteRegistrar.MoveAside) // Decision 68: retention on the append-only tier happens only inside a hub-opened window. backupMgr.SetOffsiteWindowClient(offsiteapply.HubWindowClient{Registrar: offsiteRegistrar}) + // Decision 74: a due set-aside deletion is the hub's (after its own 7-day delay). + backupMgr.SetOffsiteAbandonClient(offsiteapply.HubAbandonClient{Registrar: offsiteRegistrar}) } if backupMgr != nil && cfg.Offsite.Enabled && cfg.Hub.URL != "" && cfg.Hub.APIKey != "" { bridge := &offsiteapply.Bridge{ @@ -1322,11 +1324,6 @@ func main() { case "offbox_repo_reset": notifier.PushEvent("offbox_repo_reset", "info", "A távoli mentési tároló visszaállítva: a régi előzmény félretéve (nem törölve), és egy üres, új tároló jött létre a mostani kulccsal.", map[string]string{"renamed_to": renamedTo}) - case "offbox_abandon_deferred": - // v0.289.0 (decision 69): the box's off-site key cannot delete, so the customer-chosen - // deletion of the set-aside history is the operator's (R-823). Operator-only on the hub. - notifier.PushEvent("offbox_abandon_deferred", "warning", - "A félretett régi távoli mentések törlése esedékes, de a doboz távoli kulcsa csak hozzáadni tud (69. döntés): semmi nem törlődött. A félretett másolatot az üzemeltető távolítja el.", map[string]string{"set_aside_path": renamedTo}) case "offbox_abandon_completed": // R-241: the ONLY event in the product that reports a customer's off-site history // being deleted. It is fired after the deletion, not before — the operator wants to diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 8d000e5..48de179 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -117,6 +117,8 @@ type Manager struct { offsiteMoveAside func(ctx context.Context) (string, error) // offsiteWindow (v0.289.0, decision 68) asks the hub for a clean-up window. nil → no box retention. offsiteWindow OffsiteWindowClient + // offsiteAbandon (v0.290.0, decision 74) hands a due set-aside deletion to the hub. + offsiteAbandon OffsiteAbandonClient // offboxSizer (3a) — the mandatory-set byte estimator for the pre-push enlargement gate, overridable // in tests so the gate is unit-testable without a real du. Nil → the real dirSizeBytes (du -sb). diff --git a/controller/internal/backup/offbox_abandon.go b/controller/internal/backup/offbox_abandon.go index c0cf366..5837b12 100644 --- a/controller/internal/backup/offbox_abandon.go +++ b/controller/internal/backup/offbox_abandon.go @@ -52,6 +52,16 @@ func (m *Manager) abandonNow() time.Time { // SetOffboxClock injects the abandonment clock (tests only). func (m *Manager) SetOffboxClock(fn func() time.Time) { m.offboxNow = fn } +// OffsiteAbandonClient is the hub's set-aside deletion (decision 74; offsiteapply.HubAbandonClient). +type OffsiteAbandonClient interface { + Request(ctx context.Context, path string) (dueAt time.Time, err error) + Status(ctx context.Context) (state string, err error) // none | pending | cancelled | deleted + Cancel(ctx context.Context) error +} + +// SetOffsiteAbandonClient wires the hub's set-aside deletion. +func (m *Manager) SetOffsiteAbandonClient(c OffsiteAbandonClient) { m.offsiteAbandon = c } + // startAbandonCountdown records the decision and the date the terminal step will run. Called by // resetOrphanedRepo AFTER the move-aside has succeeded — a countdown started before the store has // actually moved would count down to deleting a path that does not exist. @@ -90,6 +100,9 @@ type AbandonState struct { DaysLeft int // ceiling, so "0 days left" only ever means "today" RepoPath string // the set-aside store awaiting deletion PurgeRequested bool // the store is gone; awaiting the hub to drop the sealed package + // HubPending / HubDueAt (v0.290.0, decision 74): the deletion is the HUB's, due at HubDueAt. + HubPending bool + HubDueAt time.Time // RetrievalStillOffered (R-302) — may the banner still say the set-aside copies can be retrieved // with the recovery code? TRUE only while the hub is holding the SAME sealed package it held when // the customer decided. Derived here, once, so the banner and anything else asking cannot disagree. @@ -108,14 +121,25 @@ func (m *Manager) AbandonStatus() AbandonState { return AbandonState{} } st := AbandonState{RepoPath: t.AbandonRepoPath, PurgeRequested: t.AbandonPurgeRequested} - if t.AbandonAt == "" { + if t.AbandonHubDueAt != "" { + if d, err := time.Parse(time.RFC3339, t.AbandonHubDueAt); err == nil { + st.HubPending, st.HubDueAt = true, d + } + } + at := t.AbandonAt + if at == "" && st.HubPending { + // Decision 74: the countdown continues at the HUB — the household sees the hub's date, and the + // deletion it chose is still pending and still cancellable (the page and the banner stay true). + at = t.AbandonHubDueAt + } + if at == "" { return st } - due, err := time.Parse(time.RFC3339, t.AbandonAt) + due, err := time.Parse(time.RFC3339, at) if err != nil { // A malformed stamp must not silently mean "never due" — that would strand the store for ever // with a countdown the customer can see and nothing behind it. - m.logger.Printf("[WARN] [offbox] abandonment due-date is unparseable (%q) — treating the countdown as NOT running: %v", t.AbandonAt, err) + m.logger.Printf("[WARN] [offbox] abandonment due-date is unparseable (%q) — treating the countdown as NOT running: %v", at, err) return st } st.Active, st.DueAt = true, due @@ -148,12 +172,23 @@ func (m *Manager) AbandonStatus() AbandonState { // only thing that deletes, and it has not run. func (m *Manager) CancelAbandon(reason string) { t := m.settings.GetOffboxTarget() - if t == nil || (t.AbandonAt == "" && !t.AbandonPurgeRequested) { + if t == nil || (t.AbandonAt == "" && !t.AbandonPurgeRequested && t.AbandonHubDueAt == "") { return // nothing running — silent, so a healthy recovery does not log about a countdown } + if t.AbandonHubDueAt != "" && m.offsiteAbandon != nil { + // Decision 74: the hub holds the request — cancel it there, or the hub deletes on schedule. + cctx, cancel := context.WithTimeout(context.Background(), time.Minute) + if err := m.offsiteAbandon.Cancel(cctx); err != nil { + cancel() + m.logger.Printf("[ERROR] [offbox] could not cancel the hub's pending deletion of %s (%v) — the countdown is kept so the next sweep retries the cancel", t.AbandonRepoPath, err) + return + } + cancel() + } if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.AbandonStartedAt, o.AbandonAt = "", "" o.AbandonPurgeRequested = false + o.AbandonHubDueAt = "" }); err != nil { m.logger.Printf("[WARN] [offbox] could not cancel the abandonment countdown: %v", err) return @@ -183,6 +218,33 @@ func (m *Manager) AbandonSweep(ctx context.Context) (bool, error) { m.logger.Printf("[DEBUG] [offbox] abandonment: the set-aside store is deleted; awaiting the hub to drop the sealed package") return false, nil } + if st.HubPending && m.offsiteAbandon != nil { + state, err := m.offsiteAbandon.Status(ctx) + if err != nil { + m.logger.Printf("[DEBUG] [offbox] abandonment: hub status unreadable (retried): %v", err) + return false, nil + } + switch state { + case "deleted": + if uerr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { + o.AbandonPurgeRequested = true + o.AbandonHubDueAt = "" + }); uerr != nil { + return true, uerr + } + m.logger.Printf("[INFO] [offbox] abandonment: the hub deleted the set-aside copy %s; requesting the sealed package's removal", st.RepoPath) + if m.offboxOrphanEvent != nil { + m.offboxOrphanEvent("offbox_abandon_completed", st.RepoPath) + } + return true, nil + case "cancelled", "none": + _ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { + o.AbandonHubDueAt, o.AbandonStartedAt = "", "" + }) + m.logger.Printf("[INFO] [offbox] abandonment: the hub's deletion of %s was cancelled — the set-aside copy is kept", st.RepoPath) + } + return false, nil + } if !st.Active || st.DueAt.After(m.abandonNow()) { return false, nil // not due — quiet by construction on every healthy box } @@ -193,16 +255,26 @@ func (m *Manager) AbandonSweep(ctx context.Context) (bool, error) { return false, fmt.Errorf("abandonment due with no recorded path") } if t.Pinned() { - // Decision 69 (v0.289.0): the box's off-site key is append-only and cannot delete — by design, - // so that a broken-into box cannot erase history. The set-aside copy STAYS; the operator removes - // it (R-823). The schedule is closed so the sweep stops; nothing was deleted. - if uerr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.AbandonAt = "" }); uerr != nil { - m.logger.Printf("[WARN] [offbox] abandonment: could not close the schedule: %v", uerr) + // Decision 74 (v0.290.0): the box's key cannot delete (decision 69), so a due abandonment is a + // REQUEST to the hub, which deletes the set-aside copy after its own delay (7 days) unless the + // household (a recovery here cancels it) or the operator cancels. Two-phase as before: the + // store is gone only when the hub says "deleted"; then the sealed package is asked to go. + if m.offsiteAbandon == nil { + m.logger.Printf("[WARN] [offbox] abandonment DUE for %s but the hub's deletion service is not wired — nothing deleted; retried tomorrow", t.AbandonRepoPath) + return false, nil } - m.logger.Printf("[WARN] [offbox] abandonment DUE for %s, but the off-site key is append-only (decision 69) — NOTHING deleted; the set-aside copy stays until the operator removes it", t.AbandonRepoPath) - if m.offboxOrphanEvent != nil { - m.offboxOrphanEvent("offbox_abandon_deferred", t.AbandonRepoPath) + due, err := m.offsiteAbandon.Request(ctx, t.AbandonRepoPath) + if err != nil { + m.logger.Printf("[WARN] [offbox] abandonment: handing the deletion of %s to the hub failed (retried tomorrow): %v", t.AbandonRepoPath, err) + return false, err } + if uerr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { + o.AbandonAt = "" + o.AbandonHubDueAt = due.UTC().Format(time.RFC3339) + }); uerr != nil { + return false, uerr + } + m.logger.Printf("[INFO] [offbox] abandonment: the hub deletes the set-aside copy %s at %s unless cancelled (decision 74)", t.AbandonRepoPath, due.UTC().Format(time.RFC3339)) return false, nil } port := t.Port diff --git a/controller/internal/backup/offbox_window.go b/controller/internal/backup/offbox_window.go index 7d480ac..4a8be9e 100644 --- a/controller/internal/backup/offbox_window.go +++ b/controller/internal/backup/offbox_window.go @@ -25,11 +25,9 @@ import ( // - the plan would remove a snapshot younger than offsiteGuardMinAge — the honest policy // (--keep-daily 7) never removes the newest snapshot of any of the last 7 days, while a poisoning // shape does exactly that. -// And bound the damage of anything the guard cannot see: at most MaxRemove (the hub's number) snapshots -// per window, OLDEST first. DISAGREEMENT RECORDED (R-96 rule 4): the brief asked to ABORT when the plan -// exceeds a week's removal; the first window after the interim legitimately exceeds it (weeks of -// unpruned history), so an abort would never prune at all. Capping and taking the oldest gives the -// same bound on loss per window and still converges. +// And a plan larger than MaxRemove (the hub's number for one week) REFUSES (v0.290.0, per the 2026-10-04 +// brief, replacing v0.289's cap). The cost, recorded (R-96 rule 4): after a long gap without windows the +// honest backlog exceeds a week and the guard refuses until the operator grants a window by hand — R-833. // // The NAS tier (Transport "") is unchanged: the household's own disk, pruned by the box as before. // @@ -72,13 +70,39 @@ func (m *Manager) SetOffsiteWindowClient(c OffsiteWindowClient) { m.offsiteWindo var retentionPolicy = []string{"--group-by", "host,tags", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "6"} type guardSnap struct { - ID string `json:"id"` - ShortID string `json:"short_id"` - Time time.Time `json:"time"` + ID string `json:"id"` + ShortID string `json:"short_id"` + Time time.Time `json:"time"` + Hostname string `json:"hostname"` + Tags []string `json:"tags"` +} + +// group is the `--group-by host,tags` key. +func (g guardSnap) group() string { + t := append([]string{}, g.Tags...) + sort.Strings(t) + return g.Hostname + "|" + strings.Join(t, ",") +} + +// supersededSameDay: a NEWER snapshot of the same group exists on the same UTC day (a manual run after +// the night's) — the one benign reason the honest policy removes a young snapshot (R-824, measured on +// demo-hp 2026-10-03). +func supersededSameDay(s guardSnap, all []guardSnap) bool { + day := s.Time.UTC().Format("2006-01-02") + for _, o := range all { + if o.ID != s.ID && o.group() == s.group() && o.Time.After(s.Time) && o.Time.UTC().Format("2006-01-02") == day { + return true + } + } + return false } // offsiteGuard is the PURE decision: from all snapshots and the policy's remove-plan, either the ids to -// remove (oldest first, at most maxRemove) or a refusal reason. +// remove (oldest first) or a refusal reason. v0.290.0 (R-824): a YOUNG snapshot that a newer same-day +// snapshot of its group supersedes is EXCLUDED (kept for a later window, when it is old) instead of +// refusing the run — v0.289.x refused every window after any manual run. A young removal WITHOUT that +// explanation still refuses: it is the poisoning signature. Future-dated snapshots, snapshots newer than +// the hub allows, and a plan larger than a week's removal (maxRemove) refuse. func offsiteGuard(all, plan []guardSnap, now, newestAllowed time.Time, maxRemove int) ([]string, string) { for _, s := range all { if s.Time.After(now.Add(offsiteGuardSkew)) { @@ -88,19 +112,23 @@ func offsiteGuard(all, plan []guardSnap, now, newestAllowed time.Time, maxRemove return nil, fmt.Sprintf("snapshot %s (%s) is newer than the hub allows (%s)", s.ShortID, s.Time.UTC().Format(time.RFC3339), newestAllowed.UTC().Format(time.RFC3339)) } } + var keep []guardSnap for _, s := range plan { if now.Sub(s.Time) < offsiteGuardMinAge { - return nil, fmt.Sprintf("the policy would remove snapshot %s from %s — younger than %d days, which honest retention never does", + if supersededSameDay(s, all) { + continue // excluded: removed in a later window, once older than offsiteGuardMinAge + } + return nil, fmt.Sprintf("the policy would remove snapshot %s from %s — younger than %d days and not superseded the same day, which honest retention never does", s.ShortID, s.Time.UTC().Format(time.RFC3339), int(offsiteGuardMinAge.Hours()/24)) } + keep = append(keep, s) } - sorted := append([]guardSnap{}, plan...) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Time.Before(sorted[j].Time) }) - if maxRemove >= 0 && len(sorted) > maxRemove { - sorted = sorted[:maxRemove] + if maxRemove >= 0 && len(keep) > maxRemove { + return nil, fmt.Sprintf("the plan would remove %d snapshots, more than one week's retention may (%d)", len(keep), maxRemove) } - ids := make([]string, 0, len(sorted)) - for _, s := range sorted { + sort.Slice(keep, func(i, j int) bool { return keep[i].Time.Before(keep[j].Time) }) + ids := make([]string, 0, len(keep)) + for _, s := range keep { ids = append(ids, s.ID) } return ids, "" diff --git a/controller/internal/backup/offbox_window_test.go b/controller/internal/backup/offbox_window_test.go index 7a08e08..b880577 100644 --- a/controller/internal/backup/offbox_window_test.go +++ b/controller/internal/backup/offbox_window_test.go @@ -169,8 +169,8 @@ func TestOffsiteGuard_RecentRemovalRefused(t *testing.T) { } } -// Honest retention inside a window: the oldest first, at most MaxRemove, and the forget names ids. -func TestOffsiteGuard_HonestPlanPrunesOldestFirstCapped(t *testing.T) { +// Honest retention inside a window: oldest first, the forget names ids. +func TestOffsiteGuard_HonestPlanPrunesOldestFirst(t *testing.T) { m, sett := newOffboxManager(t) pinTarget(t, sett) now := time.Now() @@ -178,21 +178,60 @@ func TestOffsiteGuard_HonestPlanPrunesOldestFirstCapped(t *testing.T) { all := append([]guardSnap{snap("keep", now.Add(-time.Hour))}, plan...) wr := &windowRunner{snaps: all, plan: plan} m.SetOffboxRunner(wr.run) - fw := &fakeWindow{grant: OffsiteWindow{Granted: true, ID: 9, NewestAllowed: now, MaxRemove: 2}} + fw := &fakeWindow{grant: OffsiteWindow{Granted: true, ID: 9, NewestAllowed: now, MaxRemove: 5}} m.SetOffsiteWindowClient(fw) m.offsiteWindowRetention(context.Background(), nil, nil, "after-run") - if len(wr.forgets) != 1 { - t.Fatalf("forgets = %v", wr.forgets) - } - got := strings.Join(wr.forgets[0], " ") - if !strings.Contains(got, "forget a-full b-full --prune") || strings.Contains(got, "c-full") { - t.Fatalf("forget = %q (want the two OLDEST, capped)", got) + if len(wr.forgets) != 1 || !strings.Contains(strings.Join(wr.forgets[0], " "), "forget a-full b-full c-full --prune") { + t.Fatalf("forget = %v", wr.forgets) } if len(fw.closed) != 1 || fw.closed[0].Outcome != "pruned" || fw.closed[0].ID != 9 { t.Fatalf("close = %+v", fw.closed) } } +// A plan larger than one week's removal REFUSES (the 2026-10-04 brief), nothing removed. +func TestOffsiteGuard_AboveWeeklyCapRefused(t *testing.T) { + now := time.Now() + var plan []guardSnap + for i := 0; i < 6; i++ { + plan = append(plan, snap(fmt.Sprintf("o%d", i), now.Add(-time.Duration(30+i)*24*time.Hour))) + } + ids, why := offsiteGuard(plan, plan, now, now, 5) + if ids != nil || !strings.Contains(why, "more than one week") { + t.Fatalf("ids=%v why=%q", ids, why) + } +} + +// R-824, THE MEASURED SHAPE (demo-hp window 1, 2026-10-03): the night run's snapshots of each app were +// superseded the SAME DAY by a manual run, so the honest policy removes them while they are young. +// v0.289 refused the whole window; now they are EXCLUDED (kept for later) and the old removal goes ahead. +func TestOffsiteGuard_SameDaySupersededYoungExcluded(t *testing.T) { + m, sett := newOffboxManager(t) + pinTarget(t, sett) + now := time.Now() + day := now.Add(-26 * time.Hour).Truncate(24 * time.Hour) + night := guardSnap{ID: "night-full", ShortID: "night", Time: day.Add(2 * time.Hour), Hostname: "demo-hp", Tags: []string{"opengist"}} + manual := guardSnap{ID: "manual-full", ShortID: "manual", Time: day.Add(15 * time.Hour), Hostname: "demo-hp", Tags: []string{"opengist"}} + old := guardSnap{ID: "old-full", ShortID: "old", Time: now.Add(-40 * 24 * time.Hour), Hostname: "demo-hp", Tags: []string{"opengist"}} + wr := &windowRunner{snaps: []guardSnap{old, night, manual}, plan: []guardSnap{night, old}} + m.SetOffboxRunner(wr.run) + fw := &fakeWindow{grant: OffsiteWindow{Granted: true, ID: 2, NewestAllowed: now, MaxRemove: 5}} + m.SetOffsiteWindowClient(fw) + m.offsiteWindowRetention(context.Background(), nil, nil, "after-run") + if len(fw.closed) != 1 || fw.closed[0].Outcome != "pruned" { + t.Fatalf("the window must run, not refuse: %+v", fw.closed) + } + got := strings.Join(wr.forgets[0], " ") + if !strings.Contains(got, "old-full") || strings.Contains(got, "night-full") { + t.Fatalf("forget = %q (the young superseded copy must be KEPT for now)", got) + } + // A young removal with NO same-day successor in its group is still the poisoning signature. + lone := guardSnap{ID: "lone-full", ShortID: "lone", Time: now.Add(-50 * time.Hour), Hostname: "demo-hp", Tags: []string{"bookstack"}} + if _, why := offsiteGuard([]guardSnap{lone, manual}, []guardSnap{lone}, now, now, 5); !strings.Contains(why, "not superseded the same day") { + t.Fatalf("why = %q", why) + } +} + // The orphan reset on the pinned tier asks the HUB; no ssh `mv` from the box. func TestResetOrphaned_PinnedAsksTheHub(t *testing.T) { m, sett := newOffboxManager(t) @@ -212,30 +251,6 @@ func TestResetOrphaned_PinnedAsksTheHub(t *testing.T) { } } -// Abandonment on the pinned tier: due → nothing deleted, the operator is told, the sweep goes quiet. -func TestAbandon_PinnedDefersToOperator(t *testing.T) { - m, sett := newOffboxManager(t) - pinTarget(t, sett) - sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { - o.AbandonRepoPath = "/home/felhom-repo.orphaned-20260901" - o.AbandonStartedAt = time.Now().Add(-20 * 24 * time.Hour).UTC().Format(time.RFC3339) - o.AbandonAt = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) - }) - m.SetOffboxSSH(func(context.Context, string, string, int, string, string, string) ([]byte, error) { - t.Fatal("the box tried to delete on the pinned tier") - return nil, nil - }) - var evs []string - m.SetOffboxOrphanEvent(func(e, _ string) { evs = append(evs, e) }) - deleted, err := m.AbandonSweep(context.Background()) - if deleted || err != nil || len(evs) != 1 || evs[0] != "offbox_abandon_deferred" { - t.Fatalf("deleted=%v err=%v events=%v", deleted, err, evs) - } - if again, _ := m.AbandonSweep(context.Background()); again { - t.Fatal("second sweep deleted") - } -} - // The provider's rclone notice must not reach a JSON parser — measured live on demo-felhom (v0.289.0). func TestStripRcloneNotice(t *testing.T) { in := "rclone: 2026/10/03 15:05:42 NOTICE: Config file \"/home/.config/rclone/rclone.conf\" not found - using defaults\n[{\"id\":\"s1\"}]\n" @@ -266,3 +281,65 @@ func TestRunOffbox_UnreadableCountIsNotZero(t *testing.T) { t.Fatalf("an unreadable count was recorded as a measured zero: %+v", got.SnapshotCount) } } + +type fakeAbandon struct { + requested []string + state string + cancels int + due time.Time +} + +func (f *fakeAbandon) Request(_ context.Context, p string) (time.Time, error) { + f.requested = append(f.requested, p) + f.state = "pending" + return f.due, nil +} +func (f *fakeAbandon) Status(context.Context) (string, error) { return f.state, nil } +func (f *fakeAbandon) Cancel(context.Context) error { f.cancels++; f.state = "cancelled"; return nil } + +// Decision 74 (R-823), the box side: a due abandonment on the pinned tier is HANDED to the hub (nothing +// deleted by the box), the page keeps a dated, cancellable deletion, a recovery cancels it at the hub, and +// a "deleted" from the hub completes the two-phase commit. +func TestAbandon_PinnedHandsToHubAndFollows(t *testing.T) { + m, sett := newOffboxManager(t) + pinTarget(t, sett) + setDue := func() { + sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { + o.AbandonRepoPath = "/home/felhom-repo.orphaned-20260901" + o.AbandonStartedAt = time.Now().Add(-20 * 24 * time.Hour).UTC().Format(time.RFC3339) + o.AbandonAt = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + }) + } + setDue() + m.SetOffboxSSH(func(context.Context, string, string, int, string, string, string) ([]byte, error) { + t.Fatal("the box tried to delete on the pinned tier") + return nil, nil + }) + fa := &fakeAbandon{due: time.Now().Add(7 * 24 * time.Hour)} + m.SetOffsiteAbandonClient(fa) + if del, err := m.AbandonSweep(context.Background()); del || err != nil || len(fa.requested) != 1 { + t.Fatalf("del=%v err=%v requested=%v", del, err, fa.requested) + } + st := m.AbandonStatus() + if !st.Active || !st.HubPending || st.DaysLeft < 6 { + t.Fatalf("the page would lose the dated deletion: %+v", st) + } + // Still pending: nothing happens, nothing re-requested. + if del, _ := m.AbandonSweep(context.Background()); del || len(fa.requested) != 1 { + t.Fatal("re-requested or deleted while pending") + } + // The household recovers → cancelled at the hub, countdown gone. + m.CancelAbandon("recovery succeeded") + if fa.cancels != 1 || m.AbandonStatus().Active { + t.Fatalf("cancels=%d status=%+v", fa.cancels, m.AbandonStatus()) + } + // Again, and this time the hub deletes. + setDue() + _, _ = m.AbandonSweep(context.Background()) + fa.state = "deleted" + var evs []string + m.SetOffboxOrphanEvent(func(e, _ string) { evs = append(evs, e) }) + if del, err := m.AbandonSweep(context.Background()); !del || err != nil || !m.AbandonStatus().PurgeRequested || len(evs) != 1 || evs[0] != "offbox_abandon_completed" { + t.Fatalf("del=%v err=%v status=%+v evs=%v", del, err, m.AbandonStatus(), evs) + } +} diff --git a/controller/internal/offsiteapply/seams.go b/controller/internal/offsiteapply/seams.go index 7798a40..a7448af 100644 --- a/controller/internal/offsiteapply/seams.go +++ b/controller/internal/offsiteapply/seams.go @@ -158,6 +158,48 @@ func (c HubWindowClient) Close(ctx context.Context, res backup.OffsiteWindowResu return err } +// --- HubAbandonClient: the box's half of the hub's set-aside deletion (decision 74) --- + +type HubAbandonClient struct{ Registrar HubRegistrar } + +func (c HubAbandonClient) Request(ctx context.Context, path string) (time.Time, error) { + raw, err := c.Registrar.post(ctx, "abandon-request", map[string]string{"path": path}) + if err != nil { + return time.Time{}, err + } + var r struct { + State string `json:"state"` + DueAt string `json:"due_at"` + } + if err := json.Unmarshal(raw, &r); err != nil || r.State != "pending" { + return time.Time{}, fmt.Errorf("hub abandon-request: unexpected answer (state %q)", r.State) + } + t, err := time.Parse(time.RFC3339, r.DueAt) + if err != nil { + return time.Time{}, fmt.Errorf("hub abandon-request: bad due_at") + } + return t, nil +} + +func (c HubAbandonClient) Status(ctx context.Context) (string, error) { + raw, err := c.Registrar.post(ctx, "abandon-status", nil) + if err != nil { + return "", err + } + var r struct { + State string `json:"state"` + } + if err := json.Unmarshal(raw, &r); err != nil || r.State == "" { + return "", fmt.Errorf("hub abandon-status: malformed") + } + return r.State, nil +} + +func (c HubAbandonClient) Cancel(ctx context.Context) error { + _, err := c.Registrar.post(ctx, "abandon-cancel", nil) + return err +} + // --- KeyscanScanner: capture the box host key (x/crypto/ssh, no binary) → fingerprint + known_hosts line --- type KeyscanScanner struct { diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 4538be4..be31108 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -486,6 +486,9 @@ type OffboxTarget struct { // hub's ACK stops reporting a superseded package. If the two halves could not be removed together // this marker is what makes the box keep asking until they are (Scenario F). AbandonPurgeRequested bool `json:"abandon_purge_requested,omitempty"` + // AbandonHubDueAt (v0.290.0, decision 74) — RFC3339: the deletion was HANDED TO THE HUB, which deletes + // the set-aside copy at this time unless cancelled. The box's own key cannot delete (decision 69). + AbandonHubDueAt string `json:"abandon_hub_due_at,omitempty"` // AbandonPinnedEscrowKeySHA256 (R-302) — the hub's escrow key fingerprint AS CACHED AT THE MOMENT // THE CUSTOMER DECIDED. It is NOT the current key and NOT re-read: the banner's retrieval promise // is rendered only while the hub is still holding that same package.