v0.294.0: off-site clean-up guard follows the policy's own constants (R-867); no image clean-up while compose pulls (R-863); stderr tail (R-864); move-aside destination logged (R-869)
gates / gates (push) Successful in 28s
gates / gates (push) Successful in 28s
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:
@@ -331,8 +331,8 @@ func (m *Manager) resetOrphanedRepo(ctx context.Context, base, env []string, rea
|
||||
if err != nil {
|
||||
return fmt.Errorf("offbox move-aside failed: %w", err)
|
||||
}
|
||||
newPath = np // R-869 (v0.294.0): assigned BEFORE the log line — v0.293.0 logged an empty destination
|
||||
m.logger.Printf("[INFO] [offbox] the hub set the orphaned repo aside: %s -> %s (nothing deleted)", t.RepoPath, newPath)
|
||||
newPath = np
|
||||
} else {
|
||||
port := t.Port
|
||||
if port == 0 {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -22,9 +23,14 @@ import (
|
||||
// snapshot for removal. So, refuse when:
|
||||
// - any snapshot is dated in the future (beyond offsiteGuardSkew), or after the hub's newest-allowed
|
||||
// bound (the moment the window opened, plus the same skew);
|
||||
// - 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.
|
||||
// - the plan would remove a snapshot whose calendar day is fewer than keepDaily days before today and
|
||||
// that is not superseded the same day — the honest policy (--keep-daily keepDaily) never does that,
|
||||
// while a poisoning shape does exactly that. v0.294.0 (R-867): the line is DERIVED from keepDaily, the
|
||||
// same constant the policy is built from. v0.289–0.293 used a fixed 8-day AGE, which sat inside the
|
||||
// keep window: the snapshot a keep-7-dailies policy drops each night is 7 days + seconds old, so every
|
||||
// window on a box with more than 7 nightly snapshots refused and mailed an error (measured
|
||||
// demo-felhom 2026-10-05). Pinned by TestOffsiteGuard_RealPolicy* (the policy itself, simulated as
|
||||
// restic 0.14.0 applies it, over 15+ nightly snapshots and a month boundary).
|
||||
// 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.
|
||||
@@ -33,9 +39,14 @@ import (
|
||||
//
|
||||
// Pinned by TestOffsiteGuard_* (offbox_window_test.go), including the lab's 13-fake shape.
|
||||
|
||||
const offsiteGuardSkew = time.Hour
|
||||
|
||||
// The ruled retention policy (SP-2), as constants: BOTH the policy's arguments and the guard's day line
|
||||
// are built from these, so the guard can never sit inside the keep window (R-867).
|
||||
const (
|
||||
offsiteGuardSkew = time.Hour
|
||||
offsiteGuardMinAge = 8 * 24 * time.Hour
|
||||
keepDaily = 7
|
||||
keepWeekly = 4
|
||||
keepMonthly = 6
|
||||
)
|
||||
|
||||
// OffsiteWindow is the hub's answer to "may I prune now?".
|
||||
@@ -67,7 +78,20 @@ type OffsiteWindowClient interface {
|
||||
func (m *Manager) SetOffsiteWindowClient(c OffsiteWindowClient) { m.offsiteWindow = c }
|
||||
|
||||
// retentionPolicy is the ruled policy, unchanged since SP-2 (`--group-by host,tags`).
|
||||
var retentionPolicy = []string{"--group-by", "host,tags", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "6"}
|
||||
var retentionPolicy = []string{"--group-by", "host,tags",
|
||||
"--keep-daily", strconv.Itoa(keepDaily), "--keep-weekly", strconv.Itoa(keepWeekly), "--keep-monthly", strconv.Itoa(keepMonthly)}
|
||||
|
||||
// calendarDaysBefore: how many calendar days s lies before now, both read in s's own zone — the zone
|
||||
// restic 0.14.0 buckets a snapshot's day in (the offset stored with the snapshot). Edge, recorded: in the
|
||||
// hour after local midnight on a DST change, a box whose snapshots carry two different offsets can read one
|
||||
// day short; the guard then REFUSES (the safe direction) and the next week's window passes.
|
||||
func calendarDaysBefore(s, now time.Time) int {
|
||||
loc := s.Location()
|
||||
a, b := s.In(loc), now.In(loc)
|
||||
da := time.Date(a.Year(), a.Month(), a.Day(), 0, 0, 0, 0, time.UTC)
|
||||
db := time.Date(b.Year(), b.Month(), b.Day(), 0, 0, 0, 0, time.UTC)
|
||||
return int(db.Sub(da).Hours() / 24)
|
||||
}
|
||||
|
||||
type guardSnap struct {
|
||||
ID string `json:"id"`
|
||||
@@ -98,7 +122,8 @@ func supersededSameDay(s guardSnap, all []guardSnap) bool {
|
||||
}
|
||||
|
||||
// offsiteGuard is the PURE decision: from all snapshots and the policy's remove-plan, either the ids to
|
||||
// remove (oldest first) or a refusal reason. v0.290.0 (R-824): a YOUNG snapshot that a newer same-day
|
||||
// remove (oldest first) or a refusal reason. v0.294.0 (R-867): "young" means fewer than keepDaily calendar
|
||||
// days before today, not an age in hours. 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
|
||||
@@ -114,12 +139,12 @@ func offsiteGuard(all, plan []guardSnap, now, newestAllowed time.Time, maxRemove
|
||||
}
|
||||
var keep []guardSnap
|
||||
for _, s := range plan {
|
||||
if now.Sub(s.Time) < offsiteGuardMinAge {
|
||||
if calendarDaysBefore(s.Time, now) < keepDaily {
|
||||
if supersededSameDay(s, all) {
|
||||
continue // excluded: removed in a later window, once older than offsiteGuardMinAge
|
||||
continue // excluded: removed in a later window, once keepDaily days old
|
||||
}
|
||||
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))
|
||||
return nil, fmt.Sprintf("the policy would remove snapshot %s from %s — within the last %d days kept daily and not superseded the same day, which honest retention never does",
|
||||
s.ShortID, s.Time.UTC().Format(time.RFC3339), keepDaily)
|
||||
}
|
||||
keep = append(keep, s)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -160,7 +163,7 @@ func TestOffsiteGuard_RecentRemovalRefused(t *testing.T) {
|
||||
now := time.Now()
|
||||
all := []guardSnap{snap("old", now.Add(-60*24*time.Hour)), snap("recent", now.Add(-3*24*time.Hour))}
|
||||
_, why := offsiteGuard(all, []guardSnap{snap("recent", now.Add(-3*24*time.Hour))}, now, now, 50)
|
||||
if !strings.Contains(why, "younger than 8 days") {
|
||||
if !strings.Contains(why, "within the last 7 days kept daily") {
|
||||
t.Fatalf("why = %q", why)
|
||||
}
|
||||
_, why = offsiteGuard(all, nil, now, now.Add(-10*24*time.Hour), 50)
|
||||
@@ -251,6 +254,24 @@ func TestResetOrphaned_PinnedAsksTheHub(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// R-869 (v0.294.0): the move-aside line names the destination. MEASURED 2026-10-05 03:08 UTC, Tester 1 box
|
||||
// (v0.293.0): `the hub set the orphaned repo aside: /home/felhom-repo -> (nothing deleted)`.
|
||||
// COMPANION RED-PROOF: move `newPath = np` below the log line → "the line names no destination".
|
||||
func TestR869_MoveAsideLineNamesTheDestination(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
var buf bytes.Buffer
|
||||
m.logger = log.New(&buf, "", 0)
|
||||
pinTarget(t, sett)
|
||||
m.SetOffsiteMoveAside(func(context.Context) (string, error) { return "/home/felhom-repo.orphaned-20261005", nil })
|
||||
m.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) { return nil, nil })
|
||||
if err := m.resetOrphanedRepo(context.Background(), nil, nil, "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "aside: /home/felhom-repo -> /home/felhom-repo.orphaned-20261005 (nothing deleted)") {
|
||||
t.Fatalf("the line names no destination:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
@@ -343,3 +364,200 @@ func TestAbandon_PinnedHandsToHubAndFollows(t *testing.T) {
|
||||
t.Fatalf("del=%v err=%v status=%+v evs=%v", del, err, m.AbandonStatus(), evs)
|
||||
}
|
||||
}
|
||||
|
||||
// ── R-867 (v0.294.0): the guard against the REAL policy ─────────────────────────────────────────────────
|
||||
|
||||
// restic0140Plan is restic 0.14.0's `forget --group-by host,tags --keep-daily D --keep-weekly W
|
||||
// --keep-monthly M` (internal/restic/snapshot_policy.go ApplyPolicy): per group, newest first, a snapshot
|
||||
// is kept when it opens a new day / ISO week / month bucket while that rule still has count left; every
|
||||
// other snapshot is removed. Equivalence with the real binary over the same snapshot set is recorded in
|
||||
// felhom.eu/documentation/audits/night-fixes-2026-10-05/partA/ (the lab run).
|
||||
func restic0140Plan(all []guardSnap, daily, weekly, monthly int) (remove []guardSnap) {
|
||||
groups := map[string][]guardSnap{}
|
||||
var order []string
|
||||
for _, s := range all {
|
||||
if _, ok := groups[s.group()]; !ok {
|
||||
order = append(order, s.group())
|
||||
}
|
||||
groups[s.group()] = append(groups[s.group()], s)
|
||||
}
|
||||
for _, g := range order {
|
||||
list := append([]guardSnap{}, groups[g]...)
|
||||
sort.SliceStable(list, func(i, j int) bool { return list[i].Time.After(list[j].Time) })
|
||||
type bucket struct {
|
||||
count int
|
||||
f func(time.Time) int
|
||||
last int
|
||||
}
|
||||
b := []bucket{
|
||||
{daily, func(d time.Time) int { return d.Year()*10000 + int(d.Month())*100 + d.Day() }, -1},
|
||||
{weekly, func(d time.Time) int { y, w := d.ISOWeek(); return y*100 + w }, -1},
|
||||
{monthly, func(d time.Time) int { return d.Year()*100 + int(d.Month()) }, -1},
|
||||
}
|
||||
for _, cur := range list {
|
||||
keep := false
|
||||
for i := range b {
|
||||
if b[i].count > 0 {
|
||||
if v := b[i].f(cur.Time); v != b[i].last {
|
||||
keep = true
|
||||
b[i].last = v
|
||||
b[i].count--
|
||||
}
|
||||
}
|
||||
}
|
||||
if !keep {
|
||||
remove = append(remove, cur)
|
||||
}
|
||||
}
|
||||
}
|
||||
return remove
|
||||
}
|
||||
|
||||
func without(all []guardSnap, ids []string) []guardSnap {
|
||||
gone := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
gone[id] = true
|
||||
}
|
||||
var out []guardSnap
|
||||
for _, s := range all {
|
||||
if !gone[s.ID] {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// THE MEASURED SHAPE (demo-felhom window 3, 2026-10-05 02:15 UTC): the night's snapshot is taken, then the
|
||||
// window runs; the policy drops the snapshot of 7 days + 5 s ago. v0.293.0's 8-day line refused it.
|
||||
func TestOffsiteGuard_RealPolicyMeasuredShapeAllowed(t *testing.T) {
|
||||
var all []guardSnap
|
||||
for d := 0; d < 8; d++ {
|
||||
at := time.Date(2026, 9, 28+d, 2, 15, 5, 0, time.UTC)
|
||||
all = append(all, guardSnap{ID: fmt.Sprintf("n%d-full", d), ShortID: fmt.Sprintf("n%d", d), Time: at, Hostname: "demo-felhom", Tags: []string{"opengist"}})
|
||||
}
|
||||
now := time.Date(2026, 10, 5, 2, 15, 10, 0, time.UTC)
|
||||
plan := restic0140Plan(all, keepDaily, keepWeekly, keepMonthly)
|
||||
if len(plan) != 1 || plan[0].ShortID != "n0" {
|
||||
t.Fatalf("the policy's plan = %v, want the 2026-09-28 snapshot only", plan)
|
||||
}
|
||||
ids, why := offsiteGuard(all, plan, now, now, 5)
|
||||
if why != "" || len(ids) != 1 || ids[0] != "n0-full" {
|
||||
t.Fatalf("the honest drop of a 7 d + 5 s snapshot must pass: ids=%v why=%q", ids, why)
|
||||
}
|
||||
}
|
||||
|
||||
// Sixty nights of a household with three apps, a window after every night's run (the worst case: the hub
|
||||
// opens weekly), a same-day manual run on two days, and a month boundary: the real policy's plan is never
|
||||
// refused, deletes happen, and the store settles at the policy's size. Weekly and monthly keeps survive.
|
||||
func TestOffsiteGuard_RealPolicySixtyNightsNeverRefuses(t *testing.T) {
|
||||
apps := []string{"opengist", "bookstack", "immich"}
|
||||
var all []guardSnap
|
||||
removedTotal, windowsWithRemoval := 0, 0
|
||||
start := time.Date(2026, 8, 20, 2, 15, 0, 0, time.UTC)
|
||||
for night := 0; night < 60; night++ {
|
||||
at := start.AddDate(0, 0, night)
|
||||
for i, a := range apps {
|
||||
all = append(all, guardSnap{ID: fmt.Sprintf("%s-%d-full", a, night), ShortID: fmt.Sprintf("%s%d", a, night),
|
||||
Time: at.Add(time.Duration(i) * time.Second), Hostname: "box", Tags: []string{a}})
|
||||
}
|
||||
if night == 20 || night == 41 { // the household pressed "back up now" in the afternoon
|
||||
for _, a := range apps {
|
||||
all = append(all, guardSnap{ID: fmt.Sprintf("%s-%d-manual-full", a, night), ShortID: fmt.Sprintf("%s%dm", a, night),
|
||||
Time: at.Add(13 * time.Hour), Hostname: "box", Tags: []string{a}})
|
||||
}
|
||||
}
|
||||
now := at.Add(2 * time.Minute)
|
||||
if night == 20 || night == 41 {
|
||||
now = at.Add(13*time.Hour + 2*time.Minute)
|
||||
}
|
||||
plan := restic0140Plan(all, keepDaily, keepWeekly, keepMonthly)
|
||||
ids, why := offsiteGuard(all, plan, now, now, 500)
|
||||
if why != "" {
|
||||
t.Fatalf("night %d (%s): the guard refused the honest policy: %s", night, now.Format(time.RFC3339), why)
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
windowsWithRemoval++
|
||||
removedTotal += len(ids)
|
||||
}
|
||||
all = without(all, ids)
|
||||
}
|
||||
if windowsWithRemoval < 15 || removedTotal < 45 {
|
||||
t.Fatalf("too little was ever removed (%d windows, %d snapshots): the test does not exercise deletion", windowsWithRemoval, removedTotal)
|
||||
}
|
||||
// The store holds the policy's shape per app, never only the last 7 days: the end-of-month keeps remain.
|
||||
per := map[string]int{}
|
||||
monthEnds := 0
|
||||
for _, s := range all {
|
||||
per[s.Tags[0]]++
|
||||
if s.Tags[0] == "opengist" && (s.Time.Format("01-02") == "08-31" || s.Time.Format("01-02") == "09-30") {
|
||||
monthEnds++
|
||||
}
|
||||
}
|
||||
for _, a := range apps {
|
||||
if per[a] < keepDaily || per[a] > keepDaily+keepWeekly+keepMonthly {
|
||||
t.Fatalf("%s holds %d snapshots after 60 nights; policy bounds %d..%d", a, per[a], keepDaily, keepDaily+keepWeekly+keepMonthly)
|
||||
}
|
||||
}
|
||||
if monthEnds != 2 {
|
||||
t.Fatalf("the monthly keeps of 31 Aug and 30 Sep must survive; found %d", monthEnds)
|
||||
}
|
||||
}
|
||||
|
||||
// A weekly window (the hub's real cadence) over the same nights: the backlog of one week is removed in one
|
||||
// go and is never refused by the day line.
|
||||
func TestOffsiteGuard_RealPolicyWeeklyWindows(t *testing.T) {
|
||||
var all []guardSnap
|
||||
start := time.Date(2026, 9, 1, 2, 15, 0, 0, time.UTC)
|
||||
removed := 0
|
||||
for night := 0; night < 35; night++ {
|
||||
at := start.AddDate(0, 0, night)
|
||||
all = append(all, guardSnap{ID: fmt.Sprintf("s%d-full", night), ShortID: fmt.Sprintf("s%d", night), Time: at, Hostname: "box", Tags: []string{"app"}})
|
||||
if night%7 != 6 {
|
||||
continue
|
||||
}
|
||||
now := at.Add(time.Minute)
|
||||
plan := restic0140Plan(all, keepDaily, keepWeekly, keepMonthly)
|
||||
ids, why := offsiteGuard(all, plan, now, now, 7)
|
||||
if why != "" {
|
||||
t.Fatalf("weekly window on %s refused: %s", now.Format("2006-01-02"), why)
|
||||
}
|
||||
removed += len(ids)
|
||||
all = without(all, ids)
|
||||
}
|
||||
if removed == 0 {
|
||||
t.Fatal("five weekly windows removed nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// Poisoning that the day line still catches with the REAL policy: just before midnight an add-only
|
||||
// attacker plants a snapshot 50 minutes ahead — inside the future-date skew, so not refused as future —
|
||||
// dated TOMORROW. The policy then counts tomorrow as a day and drops the real snapshot of six days ago.
|
||||
// (Past-dated fakes cannot make the policy drop a snapshot inside the last keepDaily calendar days: those
|
||||
// days are at most keepDaily distinct days and keep-daily keeps them all. Past-dated gap-fills that steer
|
||||
// OLDER keeps are R-822's residual, bounded by MaxRemove and the hub's count check, not by this line.)
|
||||
func TestOffsiteGuard_RealPolicySkewWindowFakeRefused(t *testing.T) {
|
||||
now := time.Date(2026, 10, 12, 23, 30, 0, 0, time.UTC)
|
||||
var all []guardSnap
|
||||
for d := 0; d < 7; d++ {
|
||||
all = append(all, guardSnap{ID: fmt.Sprintf("real%d-full", d), ShortID: fmt.Sprintf("real%d", d),
|
||||
Time: time.Date(2026, 10, 12-d, 2, 15, 0, 0, time.UTC), Hostname: "box", Tags: []string{"app"}})
|
||||
}
|
||||
all = append(all, guardSnap{ID: "fake-full", ShortID: "fake", Time: now.Add(50 * time.Minute), Hostname: "box", Tags: []string{"app"}})
|
||||
plan := restic0140Plan(all, keepDaily, keepWeekly, keepMonthly)
|
||||
if len(plan) != 1 || plan[0].ShortID != "real6" {
|
||||
t.Fatalf("plan = %v (want the real snapshot of 6 days ago)", plan)
|
||||
}
|
||||
_, why := offsiteGuard(all, plan, now, now, 50)
|
||||
if !strings.Contains(why, "within the last 7 days kept daily") {
|
||||
t.Fatalf("why = %q — a real snapshot inside the daily window must not be removed", why)
|
||||
}
|
||||
}
|
||||
|
||||
// The policy's arguments and the guard's line come from the same constants (R-867).
|
||||
func TestRetentionPolicy_BuiltFromTheGuardConstants(t *testing.T) {
|
||||
got := strings.Join(retentionPolicy, " ")
|
||||
want := fmt.Sprintf("--group-by host,tags --keep-daily %d --keep-weekly %d --keep-monthly %d", keepDaily, keepWeekly, keepMonthly)
|
||||
if got != want || keepDaily != 7 || keepWeekly != 4 || keepMonthly != 6 {
|
||||
t.Fatalf("policy = %q (want %q, the ruled 7/4/6)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user