v0.133.0: a restore-test can never fill a box's disk; leftovers retried on a timer (R-672, R-673)
gates / gates (push) Successful in 13s

Space preflight before anything is created (uncompressed size from the vzdump log / PBS
snapshot, x1.2 + 5 GiB, thin metadata, off the tested guest's pool when another storage
is eligible, unknown refuses, reported as a non-pass result). Failed scratch teardown and
the stale-lock sweep retried every 10 min (the sweep under the heavy-op gate). A thin
pool crossing 90% requests an immediate host report. Six red-proofs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-24 16:23:15 +02:00
parent d9864a94bf
commit 9bdb4dae8f
22 changed files with 1223 additions and 14 deletions
@@ -0,0 +1,156 @@
package reconcile
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// R-672 (v0.133.0) — the restore-test's space preflight. Every test asserts the CONSEQUENCE: whether the
// Proxmox API was asked to restore anything, where to, and what the result says — never only the verdict.
const gb = int64(1000 * 1000 * 1000)
// fakeSpace is a configurable RestoreSpace.
type fakeSpace struct {
restored int64
restoredErr error
free map[string]StorageFree
freeErr map[string]error
eligible []string
}
func (f fakeSpace) RestoredBytes(context.Context, string) (int64, string, error) {
return f.restored, "fake", f.restoredErr
}
func (f fakeSpace) Free(_ context.Context, s string) (StorageFree, error) {
if err := f.freeErr[s]; err != nil {
return StorageFree{}, err
}
fr, ok := f.free[s]
if !ok {
return StorageFree{}, errors.New("unknown storage")
}
return fr, nil
}
func (f fakeSpace) Eligible(context.Context) ([]string, error) { return f.eligible, nil }
// thin9201 is demo-hp's local-lvm at 10:29 on 2026-09-24, just before the restore-test that filled it:
// 23.2 GB free, 33.3 GB used, metadata 2.65 %.
var thin9201 = StorageFree{AvailBytes: 23210892 * 1024, UsedBytes: 33277043 * 1024, Thin: true, MetaUsedFraction: 0.0265, MetaKnown: true}
// archive9201 is 9201's archive config: both volumes on local-lvm.
const archive9201 = "hostname: demo-hp\nrootfs: local-lvm:vm-9201-disk-0,size=32G\nmp0: local-lvm:vm-9201-disk-1,mp=/var/lib/felhom,backup=1,size=70G\nmp8: /mnt/felhom-drives,mp=/mnt/felhom-drives\n"
func spaceEngine(t *testing.T, api *fakeAPI, sp RestoreSpace) (*Engine, *Journal) {
t.Helper()
j, err := OpenJournal(filepath.Join(t.TempDir(), "journal.log"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { j.Close() })
q := NewQueue()
t.Cleanup(q.Close)
return NewEngine(EngineOptions{API: api, Queue: q, Journal: j, RestoreSpace: sp}), j
}
func run9201(e *Engine) RestoreTestResult {
return e.RunRestoreTest(context.Background(), RestoreTestSpec{
Archive: "local:backup/vzdump-lxc-9201-2026_09_23-06_55_25.tar.zst", RestoreStorage: "local-lvm",
ScratchMin: 990000, ScratchMax: 990009, SourceTier: "local",
})
}
// TestSpace_The2026_09_24TestIsRefused replays R-672: 9201's archive restores 22.6 GB (its vzdump log),
// the pool has 23.2 GB free. The test must NOT start — no restore call, no journaled scratch — and the
// result must say why, as a non-pass.
//
// COMPANION RED-PROOFS (REPORT): (1) the preflight removed (v0.132.0's shape) → a restore into local-lvm
// is issued; (2) `restored` taken from the archive FILE (6.9 GB, the brief's "archive × 1.2 + 5 GiB") →
// 6.9×1.2+5.4 = 13.7 GB < 23.2 GB free, so the test starts — the defect the uncompressed size exists for.
func TestSpace_The2026_09_24TestIsRefused(t *testing.T) {
api := &fakeAPI{extractCfg: archive9201, cfg: map[int]proxmox.GuestConfig{990000: scratchCfg()}}
e, j := spaceEngine(t, api, fakeSpace{restored: 22607360000, free: map[string]StorageFree{"local-lvm": thin9201}})
res := run9201(e)
if len(api.restores) != 0 {
t.Fatalf("a restore was issued into a pool that cannot take it: %+v", api.restores)
}
if len(j.InFlight()) != 0 {
t.Fatalf("a scratch entry was journaled for a test that must not start: %+v", j.InFlight())
}
if res.Pass || !res.Skipped || !strings.Contains(res.SkipReason, "not enough space on local-lvm") {
t.Fatalf("result = pass=%v skipped=%v reason=%q — want a non-pass skip naming the storage", res.Pass, res.Skipped, res.SkipReason)
}
if res.RequiredBytes < 32*gb || res.AvailBytes != thin9201.AvailBytes {
t.Fatalf("required=%d avail=%d — want ≥ 32 GB required (22.6 × 1.2 + 5 GiB) against 23.2 GB", res.RequiredBytes, res.AvailBytes)
}
}
// TestSpace_KeepsOffTheTestedGuestsPool — rule 2: another eligible storage that fits takes the restore.
func TestSpace_KeepsOffTheTestedGuestsPool(t *testing.T) {
api := &fakeAPI{extractCfg: archive9201, cfg: map[int]proxmox.GuestConfig{990000: scratchCfg()}}
e, _ := spaceEngine(t, api, fakeSpace{restored: 22607360000, eligible: []string{"local-lvm", "big-dir"},
free: map[string]StorageFree{"local-lvm": {AvailBytes: 900 * gb, UsedBytes: 10 * gb, Thin: true, MetaKnown: true}, "big-dir": {AvailBytes: 500 * gb}}})
res := run9201(e)
if len(api.restores) != 1 || api.restores[0].Storage != "big-dir" {
t.Fatalf("restores = %+v — want ONE restore onto big-dir, off 9201's own pool", api.restores)
}
if res.TargetStorage != "big-dir" {
t.Fatalf("target=%q", res.TargetStorage)
}
for k, v := range api.restores[0].MountOverrides {
if strings.HasPrefix(v, "local-lvm:") {
t.Fatalf("%s still lands on the tested guest's pool: %s", k, v)
}
}
}
// TestSpace_OnlyOnePool_RuleOneDecides — no other eligible storage: the tested guest's pool is used when it
// fits (demo-hp's real shape: nvme-scratch takes rootdir but the agent holds no AllocateSpace there).
func TestSpace_OnlyOnePool_RuleOneDecides(t *testing.T) {
api := &fakeAPI{extractCfg: archive9201, cfg: map[int]proxmox.GuestConfig{990000: scratchCfg()}}
e, _ := spaceEngine(t, api, fakeSpace{restored: 2 * gb, eligible: []string{"local-lvm"}, free: map[string]StorageFree{"local-lvm": thin9201}})
res := run9201(e)
if len(api.restores) != 1 || api.restores[0].Storage != "local-lvm" || res.Skipped || res.TargetStorage != "local-lvm" {
t.Fatalf("restores=%+v skipped=%v — a 2 GB restore fits 23 GB free on the only pool", api.restores, res.Skipped)
}
}
// TestSpace_UnknownRefuses — rule 3, one case per unknown. Nothing is restored in any of them.
func TestSpace_UnknownRefuses(t *testing.T) {
cases := map[string]RestoreSpace{
"no space check wired": nil,
"restore size unknown": fakeSpace{restoredErr: errors.New("no vzdump log"), free: map[string]StorageFree{"local-lvm": thin9201}},
"free space unreadable": fakeSpace{restored: gb, freeErr: map[string]error{"local-lvm": errors.New("api down")}},
"thin metadata unknown": fakeSpace{restored: gb, free: map[string]StorageFree{"local-lvm": {AvailBytes: 900 * gb, UsedBytes: gb, Thin: true}}},
"metadata would overrun": fakeSpace{restored: 10 * gb, free: map[string]StorageFree{"local-lvm": {AvailBytes: 900 * gb, UsedBytes: 10 * gb, Thin: true, MetaUsedFraction: 0.5, MetaKnown: true}}},
}
for name, sp := range cases {
t.Run(name, func(t *testing.T) {
api := &fakeAPI{extractCfg: archive9201, cfg: map[int]proxmox.GuestConfig{990000: scratchCfg()}}
var e *Engine
if sp == nil {
e, _ = spaceEngine(t, api, nil)
} else {
e, _ = spaceEngine(t, api, sp)
}
res := run9201(e)
if len(api.restores) != 0 || res.Pass || !res.Skipped || res.SkipReason == "" {
t.Fatalf("restores=%d pass=%v skipped=%v reason=%q — an unknown must refuse before anything moves",
len(api.restores), res.Pass, res.Skipped, res.SkipReason)
}
})
}
}
// TestSpace_SourceStorages reads the tested guest's pools from the ARCHIVE's config, binds excluded.
func TestSpace_SourceStorages(t *testing.T) {
got := sourceStorages(archive9201 + "mp1: other:vm-9201-disk-2,mp=/x,size=1G\n[snap]\nrootfs: snapstore:x\n")
if !got["local-lvm"] || !got["other"] || got["snapstore"] || len(got) != 2 {
t.Fatalf("sourceStorages = %v — want local-lvm + other, binds and snapshot sections excluded", got)
}
}