From ddac21cd91c5c7df32f1a35db7179c85c5ee59af Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 10 Jul 2026 00:06:06 +0200 Subject: [PATCH] v0.109.1: re-apply preserves escrow custody + runtime status (live finding) The QuotaGB hash change triggered a live re-apply that demoted the escrowed demo to pending and wiped its runtime status. ApplyOffsiteTarget now carries over EscrowState (custody tracks the preserved repo password, not the coords) + status fields; fresh guests still land pending. Red-proofed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- CHANGELOG.md | 16 ++++++++ controller/internal/backup/offbox.go | 11 ++++++ controller/internal/backup/offbox_test.go | 45 +++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 618ead8..3416991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ ## Changelog +### v0.109.1 — re-apply must preserve escrow custody + runtime status (live finding) (2026-07-10) + +Found deploying v0.109.0: including `QuotaGB` in the bridge's descriptor hash triggered a one-time +re-apply on the demo — key-auth-first re-pinned cleanly (proven live, no password consumed) but +`ApplyOffsiteTarget` REPLACED the target with the freshly-built struct: the escrowed demo was **demoted to +pending** and its runtime status (last_run/size/snapshots) wiped — which would also false-trigger the new +staleness alert after re-confirming. + +- `ApplyOffsiteTarget` now carries over the EXISTING target's `EscrowState` + runtime status fields on a + re-apply: EscrowState tracks the REPO PASSWORD's custody (preserved by `WriteOffboxSecrets`, never + rotated by this path), not the target coords; the status belongs to the runner. A fresh guest (no + existing target) still lands `pending`. **Companion red-proof:** dropped the EscrowState carry-over → + "a re-apply must NOT demote an escrowed target, got pending" → FAIL. Reverted. +- Demo repair: one manual confirm-escrow (the deprecated fallback — truthful: the same already-escrowed + password) restored `escrowed`; a manual run restored the runtime status. + ### v0.109.0 — SLICE 4: soft-quota gate + usage bar + offsite report status (2026-07-09) The shared-model soft quota (`quota_gb`) enforced controller-side (pairs with hub v0.41.0's diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index 2068ba6..527757d 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -173,6 +173,17 @@ func (m *Manager) ApplyOffsiteTarget(ctx context.Context, tgt *settings.OffboxTa if err := m.WriteOffboxSecrets(sshKeyPEM, knownHosts); err != nil { return fmt.Errorf("apply offsite secrets: %w", err) } + // Re-apply (v0.109.1 live finding): the bridge rebuilds the target from the descriptor, but the + // EXISTING target's custody + runtime status must carry over — EscrowState tracks the REPO PASSWORD + // (preserved by WriteOffboxSecrets above, never rotated by this path), not the target coords; and the + // status fields belong to the runner. Without this, a quota bump demoted an escrowed demo target to + // pending and wiped its history (which would also false-trigger the hub's staleness alert). + if cur := m.settings.GetOffboxTarget(); cur != nil { + tgt.EscrowState = cur.EscrowState + tgt.LastRun, tgt.LastStatus, tgt.LastError = cur.LastRun, cur.LastStatus, cur.LastError + tgt.LastDuration, tgt.LastWarning = cur.LastDuration, cur.LastWarning + tgt.RepoSizeHuman, tgt.RepoSizeBytes, tgt.SnapshotCount = cur.RepoSizeHuman, cur.RepoSizeBytes, cur.SnapshotCount + } if tgt.EscrowState != "escrowed" { tgt.EscrowState = "pending" } diff --git a/controller/internal/backup/offbox_test.go b/controller/internal/backup/offbox_test.go index da26cae..6227124 100644 --- a/controller/internal/backup/offbox_test.go +++ b/controller/internal/backup/offbox_test.go @@ -414,6 +414,51 @@ func TestOffboxReportStatus(t *testing.T) { } } +// v0.109.1 live finding — a RE-apply (quota bump / re-pin) must PRESERVE the existing target's escrow +// custody + runtime status (the repo password is preserved, so its escrow state carries over); a FRESH +// apply still lands pending. +func TestApplyOffsiteTarget_PreservesEscrowAndStatusOnReapply(t *testing.T) { + m, sett := newOffboxManager(t) // existing target: escrowed + _ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { + o.LastRun = "2026-07-09T20:00:00Z" + o.LastStatus = "ok" + o.SnapshotCount = 5 + o.RepoSizeBytes = 2 << 30 + }) + // the bridge re-applies with a freshly-built target (quota raised to 50) + fresh := &settings.OffboxTarget{Enabled: true, Host: "nas.local", User: "felhom", Port: 22, RepoPath: "/srv/repo", Schedule: "daily", QuotaGB: 50} + if err := m.ApplyOffsiteTarget(context.Background(), fresh, "KEY2", "nas.local ssh-ed25519 K2", nil); err != nil { + t.Fatal(err) + } + got := sett.GetOffboxTarget() + if got.EscrowState != "escrowed" { + t.Fatalf("a re-apply must NOT demote an escrowed target, got %q", got.EscrowState) + } + if got.QuotaGB != 50 { + t.Fatalf("the new quota must land, got %d", got.QuotaGB) + } + if got.LastRun != "2026-07-09T20:00:00Z" || got.SnapshotCount != 5 || got.RepoSizeBytes != 2<<30 { + t.Fatalf("runtime status must survive a re-apply (staleness/usage feed): %+v", got) + } + + // fresh guest (no existing target) → pending, as before + logger := log.New(os.Stderr, "", 0) + dataDir := t.TempDir() + sett2, err := settings.Load(filepath.Join(dataDir, "settings.json"), logger) + if err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Paths.DataDir = dataDir + m2 := NewManager(cfg, sett2, logger) + if err := m2.ApplyOffsiteTarget(context.Background(), &settings.OffboxTarget{Enabled: true, Host: "h", User: "u", Port: 23, RepoPath: "/r", Schedule: "daily"}, "K", "kh", nil); err != nil { + t.Fatal(err) + } + if got2 := sett2.GetOffboxTarget(); got2.EscrowState != "pending" { + t.Fatalf("a fresh apply must land pending, got %q", got2.EscrowState) + } +} + // TestOffbox_SingleFlight: an off-box run while another backup holds m.running skips (no runner call). func TestOffbox_SingleFlight(t *testing.T) { m, _ := newOffboxManager(t)