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) } }