package thincheck import ( "context" "encoding/json" "errors" "io" "log/slog" "os/exec" "path/filepath" "reflect" "runtime" "strings" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) // The helper's output on demo-hp 2026-10-14, before the repair: the pool held 1, 2 and 4; LVM knew 1 and 2 // (audits/power-cut-fixes-2026-10-14/E/thin-ls.txt). const orphanOut = `lvm-before 1 vm-9201-disk-0 lvm-before 2 vm-9201-disk-1 pool 1 pool 2 pool 4 lvm-after 1 vm-9201-disk-0 lvm-after 2 vm-9201-disk-1 ok ` const healthyOut = `lvm-before 1 vm-9201-disk-0 lvm-before 2 vm-9201-disk-1 pool 1 pool 2 lvm-after 1 vm-9201-disk-0 lvm-after 2 vm-9201-disk-1 ok ` func quiet() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func TestParse_TheOrphanOf20261014(t *testing.T) { r, ok := Parse(orphanOut) if !ok { t.Fatal("a complete reading was not accepted") } if got := r.Orphans(); !reflect.DeepEqual(got, []int{4}) { t.Fatalf("orphans = %v, want [4]", got) } h, _ := Parse(healthyOut) if got := h.Orphans(); len(got) != 0 { t.Fatalf("a healthy pool reported orphans %v", got) } } // A volume LVM created WHILE the pool was read appears only in the after-list: never an orphan. func TestOrphans_AVolumeCreatedDuringTheReadIsNotAnOrphan(t *testing.T) { r, _ := Parse(strings.Replace(orphanOut, "lvm-after 2 vm-9201-disk-1\n", "lvm-after 2 vm-9201-disk-1\nlvm-after 4 snap_vm-9201-disk-1_vzdump\n", 1)) if got := r.Orphans(); len(got) != 0 { t.Fatalf("orphans = %v, want none", got) } } // A run cut off before its closing "ok" (the helper died, the output is partial) is no reading at all. func TestParse_NoClosingLineIsNoReading(t *testing.T) { if _, ok := Parse(strings.TrimSuffix(orphanOut, "ok\n")); ok { t.Fatal("a cut-off run was accepted as a reading") } } type fakeAPI struct { cfg proxmox.GuestConfig stores []proxmox.Storage } func (f *fakeAPI) GuestConfig(context.Context, int) (proxmox.GuestConfig, error) { return f.cfg, nil } func (f *fakeAPI) ListStorage(context.Context) ([]proxmox.Storage, error) { return f.stores, nil } func guest9201() *fakeAPI { extra := map[string]json.RawMessage{ "mp0": json.RawMessage(`"local-lvm:vm-9201-disk-1,mp=/var/lib/felhom,backup=1,size=20G"`), "mp8": json.RawMessage(`"/mnt/felhom-drives,mp=/mnt/felhom-drives"`), "mp9": json.RawMessage(`"local:9201/vm-9201-disk-2.raw,mp=/x"`), } return &fakeAPI{ cfg: proxmox.GuestConfig{RootFS: "local-lvm:vm-9201-disk-0,size=8G", Extra: extra}, stores: []proxmox.Storage{ {Storage: "local", Type: "dir", Path: "/var/lib/vz"}, {Storage: "local-lvm", Type: "lvmthin", VGName: "pve", ThinPool: "data"}, {Storage: "felhom-pbs", Type: "pbs"}, }, } } type seqRunner struct { outs []string errs []error calls [][]string } func (r *seqRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) { r.calls = append(r.calls, append([]string{name}, args...)) i := len(r.calls) - 1 var err error if i < len(r.errs) { err = r.errs[i] } if i < len(r.outs) { return []byte(r.outs[i]), nil, err } return nil, nil, err } func TestPoolsOf_RootfsAndVolumeMountpointsOnly(t *testing.T) { c := &Checker{API: guest9201(), Runner: &seqRunner{}, Logger: quiet()} pools, err := c.PoolsOf(context.Background(), 9201) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(pools, []Pool{{VG: "pve", Pool: "data"}}) { t.Fatalf("pools = %v, want [pve/data] once (bind mounts and dir volumes are not thin)", pools) } } // The consequence: an orphan seen on BOTH reads becomes a finding whose sentence names the pool and the id, and says // nothing was removed. The only command ever run is the read-only helper with . // RED-PROOF (P1/redproof-thincheck.txt): with Orphans() returning nil this test fails. func TestCheck_OrphanOnBothReads_IsNamed(t *testing.T) { rr := &seqRunner{outs: []string{orphanOut, orphanOut}} c := &Checker{API: guest9201(), Runner: rr, Pause: 1, Logger: quiet()} f := c.Check(context.Background(), 9201) if len(f) != 1 || f[0].Pool.String() != "pve/data" || !reflect.DeepEqual(f[0].Orphans, []int{4}) { t.Fatalf("findings = %+v", f) } want := [][]string{{HelperPath, "pve", "data"}, {HelperPath, "pve", "data"}} if !reflect.DeepEqual(rr.calls, want) { t.Fatalf("calls = %v, want only the read-only helper twice", rr.calls) } d := Describe(f) for _, s := range []string{"thin pool pve/data holds device 4 that LVM does not know", "Felhom did not remove anything", "whole-guest-backup-orphan-thin.md"} { if !strings.Contains(d, s) { t.Fatalf("Describe = %q, missing %q", d, s) } } } // An id that is gone on the second read (a volume mid-creation) is not reported; a healthy pool is read ONCE. func TestCheck_TransientOrHealthy_NothingReported(t *testing.T) { rr := &seqRunner{outs: []string{orphanOut, healthyOut}} if f := (&Checker{API: guest9201(), Runner: rr, Pause: 1, Logger: quiet()}).Check(context.Background(), 9201); len(f) != 0 { t.Fatalf("a transient id was reported: %+v", f) } rr = &seqRunner{outs: []string{healthyOut}} if f := (&Checker{API: guest9201(), Runner: rr, Logger: quiet()}).Check(context.Background(), 9201); len(f) != 0 || len(rr.calls) != 1 { t.Fatalf("healthy: findings %+v, calls %v", f, rr.calls) } if Describe(nil) != "" { t.Fatal("no finding must describe as empty") } } // The helper refusing (a metadata snapshot held by someone else, exit 3) or failing: no finding, no panic — the check // fails open; it explains a failure, it never stops a backup. func TestCheck_HelperRefuses_FailsOpen(t *testing.T) { rr := &seqRunner{errs: []error{errors.New("exit status 3")}} if f := (&Checker{API: guest9201(), Runner: rr, Logger: quiet()}).Check(context.Background(), 9201); len(f) != 0 { t.Fatalf("findings = %+v", f) } } // The root helper's own tests (configs/test_felhom_thin_check.py) run with the Go suite. func TestRootHelperPythonSuite(t *testing.T) { py, err := exec.LookPath("python3") if err != nil { t.Skip("python3 not found") } _, file, _, _ := runtime.Caller(0) script := filepath.Join(filepath.Dir(file), "..", "..", "configs", "test_felhom_thin_check.py") out, err := exec.Command(py, script).CombinedOutput() if err != nil { t.Fatalf("helper suite failed: %v\n%s", err, out) } }