e15d1d30ea
gates / gates (push) Successful in 47s
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
119 lines
5.4 KiB
Python
119 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for configs/felhom-thin-check (R-1012): it lists the thin pool's device ids next to LVM's and NEVER removes
|
|
anything. Fake `lvs`, `dmsetup` and `thin_ls` record every call; nothing real is touched. Each refusal has a positive
|
|
control (the healthy read), so a test cannot pass because the helper refuses everything.
|
|
Run: python3 configs/test_felhom_thin_check.py (also run by internal/thincheck's Go test)
|
|
"""
|
|
import os, pathlib, re, subprocess, tempfile, unittest
|
|
|
|
HELPER = pathlib.Path(__file__).resolve().parent / "felhom-thin-check"
|
|
|
|
|
|
class Box:
|
|
def __init__(self, pool_ids=(1, 2), lvm=(("vm-9201-disk-0", 1), ("vm-9201-disk-1", 2)), attr="twi-aotz--",
|
|
held="-", reserve_rc=0, thin_ls_rc=0):
|
|
self.t = tempfile.TemporaryDirectory()
|
|
r = pathlib.Path(self.t.name)
|
|
self.log = r / "calls"
|
|
self.log.write_text("")
|
|
lvm_lines = "\\n".join(f" {n},{i}" for n, i in lvm)
|
|
pool_lines = "\\n".join(str(i) for i in pool_ids)
|
|
self.bins = {}
|
|
for name, body in {
|
|
"lvs": f'''case "$*" in *lv_attr*) echo " {attr}";; *thin_id*) printf "{lvm_lines}\\n";; esac''',
|
|
"dmsetup": f'''case "$1" in
|
|
status) echo "0 112975872 thin-pool 585 6860/262144 517080/882624 {held} rw discard_passdown queue_if_no_space - 1024";;
|
|
message) [ "$4" = reserve_metadata_snap ] && exit {reserve_rc}; exit 0;;
|
|
esac''',
|
|
"thin_ls": f'''[ {thin_ls_rc} -ne 0 ] && exit {thin_ls_rc}; printf "{pool_lines}\\n"''',
|
|
}.items():
|
|
p = r / name
|
|
p.write_text(f'#!/bin/bash\necho "{name} $*" >> "{self.log}"\n{body}\n')
|
|
p.chmod(0o755)
|
|
self.bins[name] = p
|
|
self.lock = r / "lock"
|
|
|
|
def run(self, *args):
|
|
script = (f'source "{HELPER}"; LVS="{self.bins["lvs"]}"; DMSETUP="{self.bins["dmsetup"]}"; '
|
|
f'THIN_LS="{self.bins["thin_ls"]}"; LOCK="{self.lock}"; main "$@"')
|
|
r = subprocess.run(["bash", "-c", script, "x", *args], capture_output=True, text=True)
|
|
return r.returncode, r.stdout, r.stderr
|
|
|
|
def calls(self):
|
|
return self.log.read_text().splitlines()
|
|
|
|
|
|
def messages(calls):
|
|
return [c.split()[-1] for c in calls if c.startswith("dmsetup message")]
|
|
|
|
|
|
class Reads(unittest.TestCase):
|
|
def test_healthy_pool_lists_both_sides_and_releases(self):
|
|
b = Box()
|
|
rc, out, err = b.run("pve", "data")
|
|
self.assertEqual(rc, 0, err)
|
|
self.assertEqual(out.splitlines(), ["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"])
|
|
self.assertEqual(messages(b.calls()), ["reserve_metadata_snap", "release_metadata_snap"])
|
|
self.assertIn("thin_ls --metadata-snap --no-headers -o DEV /dev/mapper/pve-data_tmeta", b.calls())
|
|
|
|
def test_the_2026_10_14_orphan_is_listed(self):
|
|
rc, out, _ = Box(pool_ids=(1, 2, 4)).run("pve", "data")
|
|
self.assertEqual(rc, 0)
|
|
self.assertIn("pool 4", out.splitlines())
|
|
|
|
def test_dashed_names_map_to_the_device_mapper_name(self):
|
|
b = Box()
|
|
rc, _, err = b.run("felhom-scratch", "thin-pool")
|
|
self.assertEqual(rc, 0, err)
|
|
self.assertIn("dmsetup status felhom--scratch-thin--pool-tpool", b.calls())
|
|
|
|
|
|
class Refuses(unittest.TestCase):
|
|
def refused(self, box, rc_want, *args):
|
|
rc, out, err = box.run(*args)
|
|
self.assertEqual(rc, rc_want, (out, err))
|
|
self.assertNotIn("ok", out.splitlines())
|
|
return box.calls()
|
|
|
|
def test_arguments(self):
|
|
for args in (("pve",), ("pve", "data", "x"), ("pve;id", "data"), ("-pve", "data"), ("pve", "../data"),
|
|
("pve", "da ta")):
|
|
calls = self.refused(Box(), 1, *args)
|
|
self.assertEqual(calls, [], args)
|
|
|
|
def test_not_a_thin_pool(self):
|
|
calls = self.refused(Box(attr="Vwi-aotz--"), 1, "pve", "vm-9201-disk-1")
|
|
self.assertEqual(messages(calls), [])
|
|
|
|
def test_a_snapshot_someone_else_holds_is_never_touched(self):
|
|
b = Box(held="1234")
|
|
rc, _, err = b.run("pve", "data")
|
|
self.assertEqual(rc, 3)
|
|
self.assertEqual(messages(b.calls()), []) # neither reserve nor release
|
|
# the reason line ends with the reason, not the exit code (found live on demo-hp 2026-10-17)
|
|
self.assertTrue(err.strip().endswith("not touched"), err)
|
|
|
|
def test_reserve_failure_does_not_release(self):
|
|
calls = self.refused(Box(reserve_rc=1), 3, "pve", "data")
|
|
self.assertEqual(messages(calls), ["reserve_metadata_snap"])
|
|
|
|
def test_thin_ls_failure_still_releases(self):
|
|
calls = self.refused(Box(thin_ls_rc=1), 4, "pve", "data")
|
|
self.assertEqual(messages(calls), ["reserve_metadata_snap", "release_metadata_snap"])
|
|
|
|
|
|
class NeverDeletes(unittest.TestCase):
|
|
def test_the_only_pool_messages_are_reserve_and_release(self):
|
|
src = HELPER.read_text()
|
|
sent = re.findall(r'"\$DMSETUP" message "\$TPOOL" 0 (\S+)', src)
|
|
self.assertEqual(sorted(set(sent)), ["release_metadata_snap", "reserve_metadata_snap"])
|
|
for word in ("delete", "lvremove", "thin_repair", "create_thin", "create_snap", " rm "):
|
|
code = "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#"))
|
|
self.assertNotIn(word, code, word)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|