Files
felhom-agent/internal/thincheck/thincheck_test.go
T
admin e15d1d30ea
gates / gates (push) Successful in 47s
R-1124 + R-1012: forget a decommissioned drive that is gone; name an orphan thin device in a backup failure
R-1124: ForgetRetiredDrives (start-up + the 20 s tick) disables and removes the host mount unit of a
DECOMMISSIONED drive whose disk is absent and unmounted; the agent then no longer lists it. A present
decommissioned drive keeps its unit (one-click re-enroll). The file goes only through the new
felhom-priv-apply verb `unit-forget`, which re-checks marker, disabled, unmounted and absent as root.

R-1012: the read-only root helper felhom-thin-check <vg> <pool> (bundle) lists the pool's thin ids
(metadata snapshot reserved and released) next to LVM's before and after. BackupRunner.SetThinCheck runs
it before a snapshot-mode backup (log) and again after a failure, appending the finding in plain words to
the error the controller mails to the operator. Two reads, intersected; fails open; never removes.

Red-proofs: felhom.eu audits/agent-allowlist-rotation-2026-10-17/P1/. Code only; the CHANGELOG entry
follows the release.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 15:20:34 +02:00

185 lines
6.2 KiB
Go

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 <vg> <pool>.
// 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)
}
}