v0.102.0 — R-82 Slice D: an unprovisioned tier DEFERS instead of failing

Prerequisite for the installer default (host-install 1.20.0). A fresh box now
carries the offsite tier, but felhom-pbs only exists once the hub provisions the
DR tier. Without this the tier would report due in that window and the
controller would quiesce the apps and fire a vzdump at a missing storage every
cadence.

- GET /backup/due?target= defers when the target storage is absent
  (targetStoragePresent): due:false with a reason that says why. The tier goes
  live with NO restart once the storage appears.

Fail-safe: a storage-view ERROR returns present and the tier stays due. 'I could
not check' must never be read as 'not there' — that would silently suppress
backups, the absence-is-not-failure rule relearned three times now (R-80, R-81,
the R-82 wait timeout).

Full suite green.
This commit is contained in:
Claude Code
2026-07-26 17:40:31 +02:00
parent 0fabc15896
commit 005083b558
3 changed files with 137 additions and 0 deletions
+76
View File
@@ -464,3 +464,79 @@ func TestNormalizeBackupTiers(t *testing.T) {
}
})
}
// R-82 Slice D: a tier whose TARGET STORAGE does not exist yet is DEFERRED, not due.
//
// A fresh box carries the offsite tier in its installer defaults, but `felhom-pbs` only appears when
// the hub provisions the DR tier. Reporting "due" in that window would have the controller quiesce
// the apps and fire a vzdump at a non-existent storage every cadence until provisioning happens.
func TestBackupDue_TargetStorageMissing_Defers(t *testing.T) {
st := &fakeStore{}
// Storage view knows only "local" — the PBS tier's target is not provisioned yet.
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0", Guests: &fakeGuests{}, Backups: &fakeBackups{}, Store: st,
Storage: fakeStorage{targets: []hub.StorageTarget{{Name: "local", Type: "local"}}},
Tokens: staticTokens{"A": 8200},
BackupTiers: []BackupTier{
{TargetID: "local", Cadence: 24 * time.Hour, Primary: true, Service: &fakeBackups{}},
{TargetID: "felhom-pbs", Cadence: 7 * 24 * time.Hour, Service: &fakeBackups{}},
},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatal(err)
}
srv.baseCtx = context.Background()
srv.now = func() time.Time { return testNow }
h := srv.Handler()
var pbs, local struct {
Data BackupDueResponse `json:"data"`
}
if err := json.Unmarshal(do(t, h, "GET", "/backup/due?target=felhom-pbs", "A", "").Body.Bytes(), &pbs); err != nil {
t.Fatal(err)
}
if pbs.Data.Due {
t.Fatalf("an unprovisioned tier must DEFER, not fire a vzdump at a storage that does not exist; got %+v", pbs.Data)
}
if !strings.Contains(pbs.Data.Reason, "not present") {
t.Fatalf("the deferral must say WHY, or it is indistinguishable from a healthy tier; got %q", pbs.Data.Reason)
}
// The provisioned tier is unaffected — no evidence yet, so due.
if err := json.Unmarshal(do(t, h, "GET", "/backup/due?target=local", "A", "").Body.Bytes(), &local); err != nil {
t.Fatal(err)
}
if !local.Data.Due {
t.Fatalf("a PROVISIONED tier with no backup yet must still be due; got %+v", local.Data)
}
}
// A storage-view ERROR must NOT defer. "I could not check" is not "not there" — reading it that way
// would silently suppress backups, the absence-is-not-failure rule this project keeps relearning.
func TestBackupDue_StorageViewError_DoesNotSuppress(t *testing.T) {
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0", Guests: &fakeGuests{}, Backups: &fakeBackups{}, Store: &fakeStore{},
Storage: errStorage{}, // reused from f2_role_fallback_test.go — Observe always fails
Tokens: staticTokens{"A": 8200},
BackupTiers: []BackupTier{
{TargetID: "local", Cadence: 24 * time.Hour, Primary: true, Service: &fakeBackups{}},
{TargetID: "felhom-pbs", Cadence: 7 * 24 * time.Hour, Service: &fakeBackups{}},
},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatal(err)
}
srv.baseCtx = context.Background()
srv.now = func() time.Time { return testNow }
var pbs struct {
Data BackupDueResponse `json:"data"`
}
if err := json.Unmarshal(do(t, srv.Handler(), "GET", "/backup/due?target=felhom-pbs", "A", "").Body.Bytes(), &pbs); err != nil {
t.Fatal(err)
}
if !pbs.Data.Due {
t.Fatalf("a storage-view error must not suppress the backup (fail toward due); got %+v", pbs.Data)
}
}