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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-10 00:06:06 +02:00
parent f830325ca3
commit ddac21cd91
3 changed files with 72 additions and 0 deletions
+11
View File
@@ -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"
}
+45
View File
@@ -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)