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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user