9bdb4dae8f
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
157 lines
7.2 KiB
Go
157 lines
7.2 KiB
Go
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)
|
||
}
|
||
}
|