reconcile: tier-aware restore-task deadline (S4.1 unattended offsite restore-test)

A WAN (pbs-tier) restore of a large guest exceeds the restore-task wait's 10m
default → the wait expired mid-restore, teardown fired against a still-restoring
(not-yet-pool-associated) scratch guest → leak + a phantom VM.Allocate 403.

- RestoreTestSpec.RestoreTaskTimeout (0→10m default); the restore WaitTask passes
  it. Local tier unchanged (10m).
- config RestoreTestPBSRestoreTimeoutSeconds + accessor (default 120m).
- main restoreTaskTimeout(cfg,tier): configured PBS timeout only when tier==pbs,
  else 0. Both scheduler + selftest spec builds.
- Tests + WaitOptions red-proof + accessor contract.

The "grant scratch-band VM.Allocate" follow-up is diagnosed not blind-applied:
the scratch is restored INTO /pool/felhom (ACL already grants VM.Allocate), so
the earlier 403 was a consequence of the timeout. No ACL/host-install change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-04 19:45:26 +02:00
parent fee1fcfccd
commit dc70e15d28
7 changed files with 132 additions and 9 deletions
+23 -7
View File
@@ -779,6 +779,16 @@ func storageTier(ctx context.Context, px *proxmox.Client, storageID string) stri
return "local"
}
// restoreTaskTimeout returns the tier-aware restore-task wait: the generous configured PBS timeout
// for a WAN (pbs-tier) restore, else 0 (→ WaitOptions' 10m default) for a local restore. S4.1: a
// too-short wait kills a WAN restore mid-flight → mid-restore teardown → leaked scratch.
func restoreTaskTimeout(cfg config.Config, tier string) time.Duration {
if tier == "pbs" {
return cfg.Backup.RestoreTestPBSRestoreTimeout()
}
return 0
}
// readTrimmed reads a file and trims surrounding whitespace/newline (for the .pw secret).
func readTrimmed(path string) (string, error) {
b, err := os.ReadFile(path)
@@ -811,12 +821,16 @@ func buildRestoreTestScheduler(cfg config.Config, px *proxmox.Client, engine *re
Runner: engine,
Pick: runner.PickRestoreCandidate,
Store: store,
Spec: reconcile.RestoreTestSpec{
RestoreStorage: cfg.Backup.RestoreStorage,
ScratchMin: min,
ScratchMax: max,
SourceTier: storageTier(context.Background(), px, target),
},
Spec: func() reconcile.RestoreTestSpec {
tier := storageTier(context.Background(), px, target)
return reconcile.RestoreTestSpec{
RestoreStorage: cfg.Backup.RestoreStorage,
ScratchMin: min,
ScratchMax: max,
SourceTier: tier,
RestoreTaskTimeout: restoreTaskTimeout(cfg, tier),
}
}(),
Cadence: cadence,
Logger: logger,
})
@@ -1249,9 +1263,11 @@ func runSelftestRestoreTest(ctx context.Context, cfg config.Config, logger *slog
}
min, max := cfg.Backup.ScratchBand()
fmt.Printf(" restoring %s into scratch band [%d,%d] on %s …\n", archive, min, max, cfg.Backup.RestoreStorage)
rtTier := storageTier(ctx, px, target)
res := engine.RunRestoreTest(ctx, reconcile.RestoreTestSpec{
Archive: archive, RestoreStorage: cfg.Backup.RestoreStorage,
ScratchMin: min, ScratchMax: max, SourceTier: storageTier(ctx, px, target),
ScratchMin: min, ScratchMax: max, SourceTier: rtTier,
RestoreTaskTimeout: restoreTaskTimeout(cfg, rtTier),
})
printJSON("restore-test record", backup.ToHubRestoreTest(res, time.Now().UTC()))
if res.Skipped {