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

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:
2026-10-05 07:13:02 +02:00
parent 69e914534f
commit 7861bf9dde
16 changed files with 607 additions and 36 deletions
+36 -11
View File
@@ -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)
}