R-1124 + R-1012: forget a decommissioned drive that is gone; name an orphan thin device in a backup failure
gates / gates (push) Successful in 47s
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
This commit is contained in:
1 parent
344c4f9030
commit
e15d1d30ea
15 files changed
+1116
-5
No files matched your search
@@ -25,6 +25,7 @@
|
||||
|
||||
Cmnd_Alias FELHOM_MOUNT = \
|
||||
/usr/local/sbin/felhom-priv-apply ^unit mnt-[A-Za-z0-9_.\\-]+\.(mount|automount)$, \
|
||||
/usr/local/sbin/felhom-priv-apply ^unit-forget mnt-[A-Za-z0-9_.\\-]+\.mount$, \
|
||||
/usr/bin/systemctl daemon-reload, \
|
||||
/usr/bin/systemctl ^enable --now -- mnt-[A-Za-z0-9_.\\-]+\.mount$, \
|
||||
/usr/bin/systemctl ^disable -- mnt-[A-Za-z0-9_.\\-]+\.mount$, \
|
||||
@@ -101,6 +102,11 @@ Cmnd_Alias FELHOM_VZPARTIAL = \
|
||||
# ruled size rule and formats ONLY that volume, ONLY when it carries no signature. The agent calls it from the signed
|
||||
# `add_internal_place` job (an old box), always with 0 GiB planned: its guest's volumes already exist. `plan` changes
|
||||
# nothing. The installer runs the helper as root itself (a new box).
|
||||
# R-1012 (agent v0.159.0): the READ-ONLY orphan-thin check before and after a whole-guest backup — lists the pool's thin
|
||||
# device ids (through a metadata snapshot it reserves and releases) next to LVM's. It sends no other pool message; the
|
||||
# repair stays a human step. Pinned by configs/test_felhom_thin_check.py.
|
||||
Cmnd_Alias FELHOM_THINCHECK = \
|
||||
/usr/local/sbin/felhom-thin-check ^[A-Za-z0-9_+][A-Za-z0-9_.+-]{0\,63} [A-Za-z0-9_+][A-Za-z0-9_.+-]{0\,63}$
|
||||
Cmnd_Alias FELHOM_INTERNALPLACE = \
|
||||
/usr/local/sbin/felhom-internal-place ^(plan|create) 0$
|
||||
|
||||
@@ -336,4 +342,4 @@ Cmnd_Alias FELHOM_GUESTNET = \
|
||||
/usr/sbin/pct ^exec [0-9]+ -- pgrep -x dhclient$, \
|
||||
/usr/sbin/pct ^exec [0-9]+ -- dhclient -pf /run/dhclient\.eth0\.pid -lf /var/lib/dhcp/dhclient\.eth0\.leases eth0$
|
||||
|
||||
felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP, FELHOM_STALELOCK, FELHOM_FSTRIM, FELHOM_NETMOUNT, FELHOM_WG, FELHOM_SELFUPDATE, FELHOM_SSHD, FELHOM_OOB, FELHOM_PBSDR, FELHOM_BACKUPTARGET, FELHOM_SELFHEAL, FELHOM_ESCROW, FELHOM_GUESTNET, FELHOM_SCRATCH_TEARDOWN, FELHOM_OSAPPLY, FELHOM_POWEROFF, FELHOM_VZPARTIAL, FELHOM_INTERNALPLACE
|
||||
felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP, FELHOM_STALELOCK, FELHOM_FSTRIM, FELHOM_NETMOUNT, FELHOM_WG, FELHOM_SELFUPDATE, FELHOM_SSHD, FELHOM_OOB, FELHOM_PBSDR, FELHOM_BACKUPTARGET, FELHOM_SELFHEAL, FELHOM_ESCROW, FELHOM_GUESTNET, FELHOM_SCRATCH_TEARDOWN, FELHOM_OSAPPLY, FELHOM_POWEROFF, FELHOM_VZPARTIAL, FELHOM_INTERNALPLACE, FELHOM_THINCHECK
|
||||
@@ -191,6 +191,8 @@ BUNDLE_FILES = [
|
||||
("/usr/local/sbin/felhom-backup-target-apply", "felhom-backup-target-apply", 0o755, "bash", "replace"),
|
||||
# R-1006 (agent v0.156.0): the only delete of backup files — a dead half-written vzdump, re-checked as root.
|
||||
("/usr/local/sbin/felhom-vzdump-partial-clean", "felhom-vzdump-partial-clean", 0o755, "bash", "replace"),
|
||||
# R-1012 (agent v0.159.0): the read-only orphan-thin check around a whole-guest backup (names an orphan, never removes).
|
||||
("/usr/local/sbin/felhom-thin-check", "felhom-thin-check", 0o755, "bash", "replace"),
|
||||
# felhom.eu architecture/16 §5 (agent v0.158.0): the internal data place — the only lvcreate/mkfs of a system-disk volume.
|
||||
("/usr/local/sbin/felhom-internal-place", "felhom-internal-place", 0o755, "bash", "replace"),
|
||||
("/usr/local/sbin/felhom-os-apply", "felhom-os-apply", 0o755, "python", "replace"),
|
||||
|
||||
@@ -13,6 +13,8 @@ longer name the destination: each verb has a fixed source and a fixed (or strict
|
||||
|
||||
Verbs (each one sudoers line, exact-match pattern):
|
||||
unit <name> /var/lib/felhom-agent/units/<name> -> /etc/systemd/system/<name> (.mount | .automount)
|
||||
unit-forget <name> REMOVES /etc/systemd/system/<name> (R-1124): only a felhom by-UUID drive .mount unit that is
|
||||
disabled, not mounted, and whose drive is absent (its /dev/disk/by-uuid link is gone)
|
||||
dnsmasq <tmp> <name> /tmp/felhom-resolver-<digits>.conf -> /etc/dnsmasq.d/felhom-<...>.conf
|
||||
wg /var/lib/felhom-agent/wg/wg-felhom.conf -> /etc/wireguard/wg-felhom.conf (0600)
|
||||
sshd-config /var/lib/felhom-agent/felhom-sshd/sshd_config -> /etc/felhom-sshd/sshd_config
|
||||
@@ -41,7 +43,7 @@ WG_SRC, WG_DEST = STATE + "/wg/wg-felhom.conf", "/etc/wireguard/wg-felhom.conf"
|
||||
SSHD_SRC, SSHD_DEST = STATE + "/felhom-sshd/sshd_config", "/etc/felhom-sshd/sshd_config"
|
||||
KEY_SRC, KEY_DEST = STATE + "/felhom-sshd/authorized_keys.felhom-op", "/etc/felhom-sshd/authorized_keys/felhom-op"
|
||||
MAX_BYTES = 64 * 1024
|
||||
VERBS = ("unit", "dnsmasq", "wg", "sshd-config", "sshd-key", "controller-image")
|
||||
VERBS = ("unit", "unit-forget", "dnsmasq", "wg", "sshd-config", "sshd-key", "controller-image")
|
||||
# R-861 (a) A1 (`09` §3 decision 165): the SAME pattern as the agent's controllerImageRe (internal/localapi/
|
||||
# controllerswap.go) — a compromised agent cannot hand the guest's bootstrap any other image. Pinned by
|
||||
# configs/test_felhom_priv_apply.py ControllerImage.
|
||||
@@ -51,6 +53,11 @@ CONTROLLER_IMAGE_MAX = 256
|
||||
VMID_RE = re.compile(r"^[0-9]{1,9}$")
|
||||
|
||||
UNIT_NAME_RE = re.compile(r"^mnt-[A-Za-z0-9_.\\-]+\.(mount|automount)$")
|
||||
DRIVE_UNIT_NAME_RE = re.compile(r"^mnt-[A-Za-z0-9_.\\-]+\.mount$")
|
||||
DRIVE_UNIT_MARKER = "Managed by felhom-agent"
|
||||
NET_UNIT_MARKER = "felhom network storage"
|
||||
WANTS_DIR = UNIT_DIR + "/multi-user.target.wants"
|
||||
BY_UUID_DIR = "/dev/disk/by-uuid"
|
||||
DNSMASQ_TMP_RE = re.compile(r"^/tmp/felhom-resolver-[0-9]+\.conf$")
|
||||
DNSMASQ_NAME_RE = re.compile(r"^felhom-[a-z0-9][a-z0-9._-]*\.conf$")
|
||||
SEG = r"[A-Za-z0-9_-][A-Za-z0-9_.-]*"
|
||||
@@ -135,6 +142,36 @@ class Host:
|
||||
def read_stdin(self, limit):
|
||||
return sys.stdin.buffer.read(limit + 1)
|
||||
|
||||
# ---- unit-forget (R-1124) ----
|
||||
def read_installed(self, path):
|
||||
"""The installed unit: None when absent; Refused when it is not a regular file (a symlink is never followed)."""
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as e:
|
||||
raise Refused("F1", f"cannot open {path}: {e.strerror}")
|
||||
with os.fdopen(fd, "rb") as f:
|
||||
if not stat.S_ISREG(os.fstat(f.fileno()).st_mode):
|
||||
raise Refused("F1", f"{path} is not a regular file")
|
||||
return f.read(MAX_BYTES + 1)
|
||||
|
||||
def lexists(self, path):
|
||||
return os.path.lexists(path)
|
||||
|
||||
def mountpoints(self):
|
||||
"""Every mount point in /proc/self/mountinfo (field 5, octal escapes decoded)."""
|
||||
out = set()
|
||||
with open("/proc/self/mountinfo", "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
parts = line.split(" ")
|
||||
if len(parts) > 4:
|
||||
out.add(re.sub(r"\\([0-7]{3})", lambda m: chr(int(m.group(1), 8)), parts[4]))
|
||||
return out
|
||||
|
||||
def remove(self, path):
|
||||
os.remove(path)
|
||||
|
||||
def write_guest_image(self, vmid, data):
|
||||
"""As root: `pct exec <vmid> -- tee <the fixed file>` with the checked ref on stdin (no shell)."""
|
||||
r = subprocess.run(["/usr/sbin/pct", "exec", str(vmid), "--", "tee", CONTROLLER_IMAGE_FILE],
|
||||
@@ -424,6 +461,45 @@ def controller_image(rest, host):
|
||||
return 0
|
||||
|
||||
|
||||
def unit_forget(rest, host):
|
||||
"""R-1124: remove ONE felhom drive mount unit that the agent has already disabled, whose drive is gone. A drive that
|
||||
is still present or still mounted keeps its unit — this verb can only forget, never unmount or take a drive away."""
|
||||
try:
|
||||
if len(rest) != 1 or not DRIVE_UNIT_NAME_RE.match(rest[0]) or "/" in rest[0]:
|
||||
raise Refused("A1", "usage: felhom-priv-apply unit-forget mnt-<escaped path>.mount")
|
||||
name = rest[0]
|
||||
path = os.path.join(UNIT_DIR, name)
|
||||
data = host.read_installed(path)
|
||||
if data is None:
|
||||
host.log(f"felhom-priv-apply: ABSENT unit-forget {path} (nothing to remove)")
|
||||
return 0
|
||||
text = text_of(data, path)
|
||||
if DRIVE_UNIT_MARKER not in text or NET_UNIT_MARKER in text:
|
||||
raise Refused("F2", f"{name} is not a felhom drive unit")
|
||||
check_unit(name, text)
|
||||
s = parse_ini(text, name)
|
||||
what, where = s["Mount"].get("What", ""), s["Mount"]["Where"]
|
||||
mu = re.match(r"^/dev/disk/by-uuid/(.+)$", what)
|
||||
if not mu:
|
||||
raise Refused("F2", f"{name} is not a by-UUID drive unit")
|
||||
if host.lexists(os.path.join(WANTS_DIR, name)):
|
||||
raise Refused("F3", f"{name} is still enabled (disable it first)")
|
||||
if where in host.mountpoints():
|
||||
raise Refused("F4", f"{where} is mounted")
|
||||
if host.lexists(os.path.join(BY_UUID_DIR, mu.group(1))):
|
||||
raise Refused("F5", f"the drive of {name} is present (filesystem UUID {mu.group(1)})")
|
||||
except Refused as e:
|
||||
host.log(f"felhom-priv-apply: REFUSED [{e.rule}] unit-forget {' '.join(rest)[:120]}: {e.reason}")
|
||||
return 2 if e.rule == "A1" else 3
|
||||
try:
|
||||
host.remove(path)
|
||||
except OSError as e:
|
||||
host.log(f"felhom-priv-apply: FAILED unit-forget {path}: {e}")
|
||||
return 4
|
||||
host.log(f"felhom-priv-apply: REMOVED unit-forget {path} (where={where})")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv, host=None):
|
||||
host = host or Host()
|
||||
if argv == ["--self-check"]:
|
||||
@@ -445,6 +521,8 @@ def main(argv, host=None):
|
||||
return 0
|
||||
if argv and argv[0] == "controller-image":
|
||||
return controller_image(argv[1:], host)
|
||||
if argv and argv[0] == "unit-forget":
|
||||
return unit_forget(argv[1:], host)
|
||||
try:
|
||||
verb, src, dest, mode, checker = plan(argv)
|
||||
data = host.read_source(src)
|
||||
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
# felhom-thin-check — list the thin device ids a thin pool holds next to the ones LVM knows (R-1012). READ-ONLY: it
|
||||
# names an orphan, it never removes one. Installed as /usr/local/sbin/felhom-thin-check (config bundle).
|
||||
#
|
||||
# felhom-thin-check <vg> <pool>
|
||||
#
|
||||
# A full host root while vzdump takes its snapshot can leave a thin snapshot inside the pool that LVM no longer knows
|
||||
# (demo-hp 2026-10-14: `thin_ls` device 4, LVM only 1 and 2); from then on every snapshot backup of that guest fails
|
||||
# („Creation of new snapshot 4 of device 2 failed") and nothing said why. The repair (`dmsetup message … delete <id>`)
|
||||
# stays a HUMAN step, decided by the operator — this helper only reads.
|
||||
#
|
||||
# Reading the live pool's device list needs a metadata snapshot: the kernel's documented way to read metadata while
|
||||
# the pool is in use (`reserve_metadata_snap`, `thin_ls --metadata-snap`, `release_metadata_snap`). It changes no data
|
||||
# and no mapping; it is held for the length of one `thin_ls -o DEV` (well under a second on these pools). Rules:
|
||||
# 1. the arguments are an LVM name pair; <vg>/<pool> is a thin pool (lv_attr starts with `t`);
|
||||
# 2. only one run at a time (a lock file); a metadata snapshot that someone ELSE holds is never touched — the run
|
||||
# refuses (exit 3) and does not release it;
|
||||
# 3. LVM's list is read before AND after the pool's, so a volume created in between is not reported as an orphan
|
||||
# (the caller treats an id as an orphan only when it is in the pool and in neither LVM list).
|
||||
# Output, one record per line: lvm-before <id> <lv> | pool <id> | lvm-after <id> <lv> | ok
|
||||
# Exit: 0 read, 1 refused (arguments / not a thin pool), 3 a metadata snapshot is already held or cannot be reserved,
|
||||
# 4 the pool could not be read. Pinned by configs/test_felhom_thin_check.py.
|
||||
set -u
|
||||
LVS=/usr/sbin/lvs
|
||||
DMSETUP=/usr/sbin/dmsetup
|
||||
THIN_LS=/usr/sbin/thin_ls
|
||||
LOCK=/run/lock/felhom-thin-check.lock
|
||||
NAME_RE='^[A-Za-z0-9_+][A-Za-z0-9_.+-]{0,63}$'
|
||||
|
||||
die() { echo "felhom-thin-check: REFUSED: $1" >&2; exit "${2:-1}"; } # $1 the reason, $2 the exit code
|
||||
|
||||
lvm_ids() { # $1 tag → "<tag> <id> <lv>" for every thin volume of this pool
|
||||
"$LVS" --noheadings --separator , -o lv_name,thin_id --select "pool_lv=$POOL" -- "$VG" 2>/dev/null |
|
||||
awk -F, -v tag="$1" '{ gsub(/ /, ""); if ($2 ~ /^[0-9]+$/) print tag, $2, $1 }'
|
||||
}
|
||||
|
||||
release() { "$DMSETUP" message "$TPOOL" 0 release_metadata_snap >/dev/null 2>&1; }
|
||||
|
||||
main() {
|
||||
[[ $# -eq 2 ]] || die "usage: felhom-thin-check <vg> <pool>"
|
||||
VG=$1 POOL=$2
|
||||
local attr held ids
|
||||
[[ "$VG" =~ $NAME_RE && "$POOL" =~ $NAME_RE ]] || die "not an LVM name pair"
|
||||
|
||||
attr=$("$LVS" --noheadings -o lv_attr -- "$VG/$POOL" 2>/dev/null | tr -d ' ')
|
||||
[[ "$attr" == t* ]] || die "$VG/$POOL is not a thin pool"
|
||||
|
||||
DM="${VG//-/--}-${POOL//-/--}"
|
||||
TPOOL="$DM-tpool"
|
||||
TMETA="/dev/mapper/${DM}_tmeta"
|
||||
|
||||
exec 9>"$LOCK" || die "cannot open the lock file" 4
|
||||
flock -w 30 9 || die "another check holds the lock" 3
|
||||
|
||||
# Field 7 of the thin-pool status is the held metadata root: "-" when no metadata snapshot exists.
|
||||
held=$("$DMSETUP" status "$TPOOL" 2>/dev/null | awk '$3 == "thin-pool" { print $7 }')
|
||||
[[ -n "$held" ]] || die "cannot read the status of $TPOOL" 4
|
||||
[[ "$held" == "-" ]] || die "a metadata snapshot of $TPOOL is already held (by someone else) — not touched" 3
|
||||
|
||||
lvm_ids lvm-before
|
||||
"$DMSETUP" message "$TPOOL" 0 reserve_metadata_snap >/dev/null 2>&1 || die "could not reserve a metadata snapshot of $TPOOL" 3
|
||||
trap release EXIT
|
||||
trap 'release; trap - EXIT; exit 4' INT TERM HUP
|
||||
if ! ids=$("$THIN_LS" --metadata-snap --no-headers -o DEV "$TMETA" 2>/dev/null); then
|
||||
die "thin_ls could not read $TMETA" 4
|
||||
fi
|
||||
release
|
||||
trap - EXIT INT TERM HUP
|
||||
awk '$1 ~ /^[0-9]+$/ { print "pool", $1 }' <<<"$ids"
|
||||
lvm_ids lvm-after
|
||||
echo ok
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@"; fi
|
||||
@@ -376,5 +376,67 @@ class ControllerImage(unittest.TestCase):
|
||||
self.assertIn("controller-image", buf.getvalue())
|
||||
|
||||
|
||||
class UnitForget(unittest.TestCase):
|
||||
"""R-1124: the agent forgets a decommissioned drive whose disk is gone — the root half removes its unit file, and
|
||||
ONLY a felhom by-UUID drive unit that is disabled, not mounted, and whose /dev/disk/by-uuid link is absent.
|
||||
RED-PROOF: on the pre-R-1124 wrapper `unit-forget` is not a verb (A1 usage, rc 2) — the removal case fails."""
|
||||
NAME = "mnt-hdd_1.mount"
|
||||
PATH = "/etc/systemd/system/mnt-hdd_1.mount"
|
||||
UUID_LINK = "/dev/disk/by-uuid/91d2dc2d-2d28-4929-9bdd-3e11fa2f41ae"
|
||||
|
||||
def host(self, text=LOCAL_UNIT, kind="file", links=(), mounted=()):
|
||||
h = FakeHost()
|
||||
h.installed = {} if text is None else {self.PATH: (kind, text.encode())}
|
||||
h.links, h.mounted, h.removed = set(links), set(mounted), []
|
||||
h.read_installed = lambda p: (None if p not in h.installed else
|
||||
(_ for _ in ()).throw(pa.Refused("F1", f"{p} is not a regular file"))
|
||||
if h.installed[p][0] != "file" else h.installed[p][1])
|
||||
h.lexists = lambda p: p in h.links
|
||||
h.mountpoints = lambda: set(h.mounted)
|
||||
h.remove = lambda p: h.removed.append(p)
|
||||
return h
|
||||
|
||||
def test_an_absent_disabled_unmounted_drive_unit_is_removed(self):
|
||||
h = self.host()
|
||||
self.assertEqual(run(h, "unit-forget", self.NAME), 0, h.logs)
|
||||
self.assertEqual(h.removed, [self.PATH])
|
||||
|
||||
def test_already_gone_is_fine(self):
|
||||
h = self.host(text=None)
|
||||
self.assertEqual(run(h, "unit-forget", self.NAME), 0, h.logs)
|
||||
self.assertEqual(h.removed, [])
|
||||
|
||||
def refused(self, h, rule, *argv):
|
||||
rc = run(h, *(argv or ("unit-forget", self.NAME)))
|
||||
self.assertIn(rc, (2, 3), h.logs)
|
||||
self.assertEqual(h.removed, [], h.logs)
|
||||
self.assertTrue(any(f"[{rule}]" in l for l in h.logs), h.logs)
|
||||
|
||||
def test_F5_a_present_drive_keeps_its_unit(self):
|
||||
self.refused(self.host(links=[self.UUID_LINK]), "F5")
|
||||
|
||||
def test_F4_a_mounted_drive_keeps_its_unit(self):
|
||||
self.refused(self.host(mounted=["/mnt/hdd_1"]), "F4")
|
||||
|
||||
def test_F3_an_enabled_unit_is_not_removed(self):
|
||||
self.refused(self.host(links=["/etc/systemd/system/multi-user.target.wants/" + self.NAME]), "F3")
|
||||
|
||||
def test_F2_a_network_unit_or_a_foreign_unit_is_not_removed(self):
|
||||
self.refused(self.host(text=LOCAL_UNIT.replace("Managed by felhom-agent", "Written by someone")), "F2")
|
||||
net = LOCAL_UNIT.replace("# Managed by felhom-agent", "# Managed by felhom-agent felhom network storage")
|
||||
self.refused(self.host(text=net), "F2")
|
||||
|
||||
def test_F1_a_symlinked_unit_is_not_followed(self):
|
||||
self.refused(self.host(kind="symlink"), "F1")
|
||||
|
||||
def test_A1_names(self):
|
||||
for argv in (("unit-forget", "mnt-hdd_1.automount"), ("unit-forget", "sshd.service"),
|
||||
("unit-forget", "../mnt-hdd_1.mount"), ("unit-forget",), ("unit-forget", self.NAME, "x")):
|
||||
self.refused(self.host(), "A1", *argv)
|
||||
|
||||
def test_U3_a_unit_whose_name_lies_about_where_is_not_removed(self):
|
||||
self.refused(self.host(text=LOCAL_UNIT.replace("Where=/mnt/hdd_1", "Where=/mnt/other")), "U3")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/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)
|
||||
Reference in new issue
Block a user