R-100: record the offsite last-SUCCESS anchor (v0.181.0)

LastRun records an attempt, not a result. New OffboxTarget.LastSuccess, set only on the
success branch via the pure offboxAnchorAfterRun rule, carried to the hub as last_success.
Closes two silent-wipe sites (settings save, hub re-apply).
This commit is contained in:
2026-07-28 13:12:37 +02:00
parent 4056feccee
commit e000e201af
6 changed files with 298 additions and 6 deletions
+39 -4
View File
@@ -363,6 +363,10 @@ func (m *Manager) ApplyOffsiteTarget(ctx context.Context, tgt *settings.OffboxTa
tgt.EscrowState = cur.EscrowState
tgt.LastRun, tgt.LastStatus, tgt.LastError = cur.LastRun, cur.LastStatus, cur.LastError
tgt.LastDuration, tgt.LastWarning = cur.LastDuration, cur.LastWarning
// R-100: carry the staleness anchor across a hub re-apply, for the same reason as the rest of
// this block — a re-apply is not a new tier. Dropping it would reset an established tier to
// "never succeeded" every time the hub re-pushes the descriptor.
tgt.LastSuccess = cur.LastSuccess
tgt.RepoSizeHuman, tgt.RepoSizeBytes, tgt.SnapshotCount = cur.RepoSizeHuman, cur.RepoSizeBytes, cur.SnapshotCount
}
if tgt.EscrowState != "escrowed" {
@@ -714,6 +718,10 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
}
if perr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.LastRun = time.Now().UTC().Format(time.RFC3339)
// R-100: LastRun above records the ATTEMPT; this records the RESULT. The hub's staleness
// verdict counts from the anchor, never from the attempt. INVARIANT: a failed run neither
// advances nor clears it — pinned by TestOffboxAnchorAfterRun_* , not asserted in prose.
o.LastSuccess = offboxAnchorAfterRun(o.LastSuccess, o.LastRun, runErr)
o.LastDuration = dur.Round(time.Second).String()
if errors.Is(runErr, ErrOffboxOrphaned) {
// First-detection of the orphaned repo: RepoState (set by markOrphaned) drives the orphan
@@ -994,13 +1002,39 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
// multiplied by the retained-snapshot count (SP-1). The displayed size drops one-time after deploy.
const offboxGiB = int64(1) << 30
// offboxAnchorAfterRun returns the last-SUCCESS anchor after a run that finished at `at` with
// `runErr`, given the anchor value `prev` from before the run. R-100.
//
// THE RULE THIS ENCODES: a timestamp recording an ATTEMPT is not evidence of a RESULT. `LastRun` is
// written unconditionally at the end of every run, failures included, so "how long since LastRun"
// answers "how long since we last TRIED" — and the hub's staleness verdict was asking exactly that of
// exactly that field, so a tier failing on every run read as perfectly fresh forever.
//
// Both directions matter and each is a different bug if got wrong:
// - a FAILURE must not ADVANCE it → otherwise the original defect survives;
// - a FAILURE must not CLEAR it → otherwise one bad night makes an established tier read as
// never-succeeded, which is the mirror-image over-correction (and on the hub, the newborn-box path).
//
// It is a function rather than two lines inside the status closure so the rule can be red-proofed
// directly; the first version of this fix modelled the rule in its own test and was therefore hollow.
func offboxAnchorAfterRun(prev, at string, runErr error) string {
if runErr != nil {
return prev // failures neither advance nor clear the anchor
}
return at
}
// OffboxReportStatus is the NON-SECRET offsite summary carried on the hub report (SLICE 4) — the input
// to the hub's OffsiteChecker (fill + staleness alerts). nil when no offbox target is configured.
type OffboxReportStatus struct {
Enabled bool `json:"enabled"`
EscrowState string `json:"escrow_state"`
LastRun string `json:"last_run,omitempty"` // RFC3339
LastStatus string `json:"last_status,omitempty"` // "ok" | "error" | "running"
Enabled bool `json:"enabled"`
EscrowState string `json:"escrow_state"`
LastRun string `json:"last_run,omitempty"` // RFC3339
LastStatus string `json:"last_status,omitempty"` // "ok" | "error" | "running"
// LastSuccess (R-100) is the last run that SUCCEEDED — the hub's staleness anchor. Absent on a
// pre-v0.181.0 controller, which the hub must degrade on explicitly rather than by accident:
// treating absence as failure alarms every un-upgraded box, treating it as success keeps the bug.
LastSuccess string `json:"last_success,omitempty"` // RFC3339
SnapshotCount int `json:"snapshot_count"`
RepoSizeBytes int64 `json:"repo_size_bytes"`
QuotaGB int `json:"quota_gb"`
@@ -1015,6 +1049,7 @@ func (m *Manager) OffboxReportStatus() *OffboxReportStatus {
}
return &OffboxReportStatus{
Enabled: true, EscrowState: t.EscrowState, LastRun: t.LastRun, LastStatus: t.LastStatus,
LastSuccess: t.LastSuccess,
SnapshotCount: t.SnapshotCount, RepoSizeBytes: t.RepoSizeBytes, QuotaGB: t.QuotaGB,
}
}