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)
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user