Files
felhom.eu/scripts/test_hostinstall.py
T
admin 7f3944eff9 R-180: refuse in pre-flight an archive storage the agent's token will not be granted on
The step-8 restore reads the golden as the agent's token; a storage outside PVE_STORAGES (and not
the backup target, which step 6 grants since R-185) 403'd at step 8/8 — after the token was minted
and root@pam rotated. Pre-flight now refuses it with the two remedies (move the golden, or add the
storage to --acl-storages). scripts/test_hostinstall.py: test_archive_storage_* (4).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:25:21 +02:00

440 lines
20 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Behaviour tests for felhom-host-install.sh — the paths that need no Proxmox host.
HOW IT RUNS ANYWHERE. The installer runs as root on a Proxmox host; the CI runner is Alpine + BusyBox +
bash + python3 + git. So each test lifts the functions it needs out of felhom-host-install.sh VERBATIM
(by name, `^name() {` to the first `^}`), runs them under bash in a temp directory, and replaces the
host commands (`systemctl`, `chown`, …) with PATH stubs that record what they were asked. Paths the
functions act on are variables the test points into the temp directory. Nothing touches the real host.
Where behaviour cannot be isolated, a STATIC test checks the wiring (the function is CALLED, from the
right place) — a helper defined and never called is the seam-built-never-wired shape.
Rows: R-275, R-276, R-881 (uninstall residue); R-306 (--preflight-only writes no state); R-130 (lvm minimum wording); R-180 (archive storage outside the ACL set).
Run: python3 scripts/test_hostinstall.py
"""
import os
import re
import shutil
import stat
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.join(HERE, "felhom-host-install.sh")
AGENT_OS_APPLY = os.path.join(os.path.dirname(os.path.dirname(HERE)), "felhom-agent", "configs", "felhom-os-apply")
# The root-owned files the agent's config bundle installs (felhom-agent configs/felhom-os-apply
# BUNDLE_FILES, agent v0.147.0). Frozen here so CI (which has no sibling checkout) still checks it;
# where the sibling IS present, test_bundle_list_is_current fails when the bundle gains a file.
BUNDLE_DESTS = [
"/usr/local/sbin/felhom-mkfs-guarded",
"/usr/local/sbin/felhom-selfupdate-guarded",
"/usr/local/sbin/felhom-pbs-apply",
"/usr/local/sbin/felhom-backup-target-apply",
"/usr/local/sbin/felhom-os-apply",
"/usr/local/sbin/felhom-crash-guard",
"/usr/local/sbin/felhom-priv-apply",
"/var/lib/vz/snippets/felhom-guest-hook.sh",
"/usr/local/sbin/felhom-shared-parent.sh",
"/etc/systemd/system/felhom-shared-parent.service",
"/etc/systemd/system/felhom-crash-guard.service",
"/etc/systemd/system/felhom-crash-guard-check.service",
"/etc/systemd/system/felhom-crash-guard-check.timer",
"/etc/felhom/crash-guard.conf",
"/etc/systemd/system/felhom-agent.service",
"/etc/systemd/system/felhom-agent-rollback.service",
"/etc/systemd/system/felhom-agent.service.d/felhom-agent-limits.conf",
"/usr/local/sbin/felhom-mgmt-watchdog",
"/etc/tmpfiles.d/felhom-privsep.conf",
"/etc/systemd/system/felhom-mgmt-watchdog.service",
"/etc/systemd/system/felhom-mgmt-watchdog.timer",
"/etc/systemd/system/felhom-sshd.service",
"/etc/felhom-oob.nft",
"/etc/systemd/system/felhom-oob-nft.service",
"/etc/sudoers.d/felhom-op",
"/etc/sudoers.d/felhom-agent",
]
SRC = open(SCRIPT, encoding="utf-8").read()
def func(name):
m = re.search(r"^%s\(\) \{[^\n]*\n.*?^\}\n" % re.escape(name), SRC, re.S | re.M)
if not m:
raise AssertionError("%s() not found in felhom-host-install.sh" % name)
return m.group(0)
def one_liners():
"""The log_* helpers and die (one-line definitions)."""
out = [l for l in SRC.splitlines() if re.match(r"^(log_\w+|die)\(\)\s+\{.*\}\s*$", l)]
if len(out) < 8:
raise AssertionError("log helpers not found (%d)" % len(out))
return "RED=; GREEN=; YELLOW=; BLUE=; CYAN=; NC=\n" + "\n".join(out) + "\n"
def section(start_re, end_re):
s = re.search(start_re, SRC, re.M)
e = re.search(end_re, SRC, re.M)
if not s or not e or e.start() <= s.start():
raise AssertionError("section %r..%r not found" % (start_re, end_re))
return SRC[s.start():e.start()]
class Sandbox:
"""A temp dir with a stub bin/ first on PATH. Each stub appends its argv to calls.log."""
def __init__(self):
self.root = tempfile.mkdtemp(prefix="hostinstall-test-")
self.bin = os.path.join(self.root, "bin")
os.mkdir(self.bin)
self.calls = os.path.join(self.root, "calls.log")
open(self.calls, "w").close()
def stub(self, name, body="exit 0"):
p = os.path.join(self.bin, name)
with open(p, "w") as f:
f.write('#!/bin/sh\necho "%s $*" >> "%s"\n%s\n' % (name, self.calls, body))
os.chmod(p, 0o755)
def path(self, *parts):
return os.path.join(self.root, *parts)
def logged(self):
return open(self.calls).read()
def run(self, script):
env = dict(os.environ)
env["PATH"] = self.bin + os.pathsep + env.get("PATH", "")
env["SB"] = self.root
p = subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env)
return p.returncode, p.stdout + p.stderr
def close(self):
shutil.rmtree(self.root, ignore_errors=True)
def prelude(dry=False):
return one_liners() + ("DRY_RUN=%s\n" % ("true" if dry else "false")) + func("run")
# ── R-275: the agent config and every copy of it ─────────────────────────────────────────────────
# The names measured on demo-hp 2026-08-09; none but the last matched the old `.bak*` glob's intent,
# and the old glob missed even that one's siblings.
DEMO_HP_COPIES = ["agent.json.campaign8-before", "agent.json.campaign9-before", "agent.json.campaign9-prev",
"agent.json.pre-e-target-move", "agent.json.pre-prunegate.bak"]
def test_purge_default_dir_removes_every_copy():
sb = Sandbox()
try:
d = sb.path("etc-felhom-agent")
os.mkdir(d)
for n in ["agent.json", ".hidden-copy"] + DEMO_HP_COPIES:
open(os.path.join(d, n), "w").write("secret")
rc, out = sb.run(prelude() + func("_purge_agent_config") +
'AGENT_CFG_DIR_DEFAULT="$SB/etc-felhom-agent"\n_purge_agent_config "$SB/etc-felhom-agent/agent.json"\n')
assert rc == 0, out
left = os.listdir(d) if os.path.exists(d) else []
assert not os.path.exists(d), "agent config dir survived the uninstall with: %s" % sorted(left)
finally:
sb.close()
def test_purge_custom_path_keeps_foreign_files():
sb = Sandbox()
try:
d = sb.path("shared")
os.mkdir(d)
for n in ["agent.json", "other.conf"] + DEMO_HP_COPIES:
open(os.path.join(d, n), "w").write("x")
rc, out = sb.run(prelude() + func("_purge_agent_config") +
'AGENT_CFG_DIR_DEFAULT="/etc/felhom-agent"\n_purge_agent_config "$SB/shared/agent.json"\n')
assert rc == 0, out
left = sorted(os.listdir(d))
assert left == ["other.conf"], "custom-path purge left/removed the wrong files: %s" % left
finally:
sb.close()
def test_purge_dry_run_touches_nothing():
sb = Sandbox()
try:
d = sb.path("etc-felhom-agent")
os.mkdir(d)
open(os.path.join(d, "agent.json"), "w").write("x")
rc, out = sb.run(prelude(dry=True) + func("_purge_agent_config") +
'AGENT_CFG_DIR_DEFAULT="$SB/etc-felhom-agent"\n_purge_agent_config "$SB/etc-felhom-agent/agent.json"\n')
assert rc == 0, out
assert os.path.exists(os.path.join(d, "agent.json")), "dry-run removed the config"
assert "rm -rf" in out, "dry-run did not print the removal: %s" % out
finally:
sb.close()
def test_seal_makes_old_copies_root_only():
sb = Sandbox()
try:
sb.stub("chown")
d = sb.path("etc-felhom-agent")
os.mkdir(d)
for n in DEMO_HP_COPIES:
p = os.path.join(d, n)
open(p, "w").write("x")
os.chmod(p, 0o644)
rc, out = sb.run(prelude() + func("_seal_old_agent_config") + '_seal_old_agent_config "$SB/etc-felhom-agent"\n')
assert rc == 0, out
log = sb.logged()
for n in DEMO_HP_COPIES:
p = os.path.join(d, n)
assert "chown root:root %s" % p in log, "%s not given to root: %s" % (n, log)
assert stat.S_IMODE(os.stat(p).st_mode) == 0o600, "%s mode %o" % (n, stat.S_IMODE(os.stat(p).st_mode))
assert os.path.exists(p), "seal DELETED %s (it may be an operator's backup)" % n
assert "now root-only" in out
finally:
sb.close()
def test_seal_is_called_when_the_user_is_created():
body = func("step_agent_install")
m = re.search(r'useradd --system[^\n]*"\$AGENT_USER"\n(.*?)\n\s*fi\n', body, re.S)
assert m and '_seal_old_agent_config "$AGENT_CFG_DIR_DEFAULT"' in m.group(1), \
"_seal_old_agent_config is not called right after the service user is created"
def test_uninstall_wiring():
body = func("run_uninstall")
stop = body.find("run systemctl stop felhom-agent")
purge = body.find('_purge_agent_config "$agent_cfg"')
wg = body.find("_teardown_wg_tunnel")
assert purge > 0, "run_uninstall does not call _purge_agent_config"
assert wg > 0, "run_uninstall does not call _teardown_wg_tunnel (R-276)"
assert stop > 0 and stop < wg, "the WireGuard teardown must run after the agent is stopped"
assert '"${agent_cfg}".bak*' not in body, "the old .bak* glob is back"
assert re.search(r'for bak in "\$\{AGENT_SUDOERS\}"\.\*', body), "sudoers copies are not removed"
# ── R-276: the WireGuard tunnel ──────────────────────────────────────────────────────────────────
def _wg_sandbox(active, enabled, conf, sticky=False):
sb = Sandbox()
# systemctl stub: is-active/is-enabled read flag files; disable --now clears them (unless sticky).
sb.stub("systemctl", r'''
case "$1" in
is-active) [ -e "$SB/wg.active" ]; exit $? ;;
is-enabled) [ -e "$SB/wg.enabled" ]; exit $? ;;
disable) rm -f "$SB/wg.enabled"; %s exit 0 ;;
esac
exit 0''' % ("" if sticky else 'rm -f "$SB/wg.active";'))
if active:
open(sb.path("wg.active"), "w").close()
if enabled:
open(sb.path("wg.enabled"), "w").close()
if conf:
open(sb.path("wg-felhom.conf"), "w").write("[Interface]\nPrivateKey = x\n")
return sb
WG_RUN = ('WG_UNIT="wg-quick@wg-felhom"; WG_CONF="$SB/wg-felhom.conf"; _WG_TEARDOWN_NOTE=""; _WG_PRESENT=false\n'
'_teardown_wg_tunnel\necho "PRESENT=$_WG_PRESENT NOTE=$_WG_TEARDOWN_NOTE"\n')
def test_wg_teardown_brings_the_tunnel_down():
sb = _wg_sandbox(active=True, enabled=True, conf=True)
try:
rc, out = sb.run(prelude() + func("_teardown_wg_tunnel") + WG_RUN)
assert rc == 0, out
assert "systemctl disable --now wg-quick@wg-felhom" in sb.logged(), sb.logged()
assert not os.path.exists(sb.path("wg.active")), "tunnel still active"
assert not os.path.exists(sb.path("wg-felhom.conf")), "wg-felhom.conf survived"
assert "PRESENT=true NOTE=\n" in out + "\n", out
assert "is down" in out
finally:
sb.close()
def test_wg_still_active_is_reported_not_claimed_down():
sb = _wg_sandbox(active=True, enabled=True, conf=True, sticky=True)
try:
rc, out = sb.run(prelude() + func("_teardown_wg_tunnel") + WG_RUN)
assert rc == 0, out
assert "STILL active" in out and "is down" not in out, out
assert "NOTE=wg-quick@wg-felhom is STILL active" in out, out
finally:
sb.close()
def test_wg_absent_is_a_noop():
sb = _wg_sandbox(active=False, enabled=False, conf=False)
try:
rc, out = sb.run(prelude() + func("_teardown_wg_tunnel") + WG_RUN)
assert rc == 0, out
assert "disable" not in sb.logged(), sb.logged()
assert "PRESENT=false" in out, out
finally:
sb.close()
def test_wg_dry_run_changes_nothing():
sb = _wg_sandbox(active=True, enabled=True, conf=True)
try:
rc, out = sb.run(prelude(dry=True) + func("_teardown_wg_tunnel") + WG_RUN)
assert rc == 0, out
assert os.path.exists(sb.path("wg-felhom.conf")) and os.path.exists(sb.path("wg.active"))
assert "disable --now wg-quick@wg-felhom" in out, out
finally:
sb.close()
def test_statement_names_the_tunnel_and_the_peer():
sb = Sandbox()
try:
sb.stub("pvesm", "exit 1")
rc, out = sb.run(prelude() + func("_uninstall_statement") +
'vmid=9201; pool_removed=false; _busy_mounts=(); _had_break_glass=false; REMOVE_GOLDEN=false\n'
'PVE_POOL=felhom; ISLAND_BRIDGE=vmbr9; WG_UNIT="wg-quick@wg-felhom"; WG_CONF=/etc/wireguard/wg-felhom.conf\n'
'_WG_PRESENT=true; _WG_TEARDOWN_NOTE=""\n_uninstall_statement full\n')
assert rc == 0, out
wiped, kept = out.split("KEPT", 1)
assert "WireGuard tunnel to the Felhom off-site endpoint" in wiped, out
assert "WireGuard PEER" in kept, out
assert "priv-apply" in wiped, out
finally:
sb.close()
# ── R-881: every file the config bundle installs is removed by the uninstall ─────────────────────
def test_uninstall_removes_every_bundle_file():
usect = section(r"^_guest_drive_note\(\)", r"^# run_adopt_pool")
# the uninstall names some paths through these variables — expand them before searching.
for var, val in [("AGENT_UNIT", "/etc/systemd/system/felhom-agent.service"),
("AGENT_SUDOERS", "/etc/sudoers.d/felhom-agent"),
("AGENT_BIN", "/usr/local/bin/felhom-agent")]:
usect = usect.replace("${%s}" % var, val).replace("$%s" % var, val)
missing = [p for p in BUNDLE_DESTS if p not in usect]
assert not missing, "the uninstall does not remove bundle file(s): %s" % missing
def test_bundle_list_is_current():
if not os.path.exists(AGENT_OS_APPLY):
print(" note: %s absent (CI) — the frozen list is checked, not its currency" % AGENT_OS_APPLY)
return
text = open(AGENT_OS_APPLY, encoding="utf-8").read()
m = re.search(r"^BUNDLE_FILES = \[(.*?)^\]", text, re.S | re.M)
assert m, "BUNDLE_FILES not found in felhom-os-apply"
dests = re.findall(r'^\s*\("(/[^"]+)"', m.group(1), re.M)
assert len(dests) >= 20, "parsed only %d bundle entries" % len(dests)
new = sorted(set(dests) - set(BUNDLE_DESTS))
assert not new, "the agent bundle installs file(s) this test (and maybe the uninstall) does not know: %s" % new
# ── R-306: --preflight-only writes no state ──────────────────────────────────────────────────────
def _state_run(sb, preflight_only, dry=False):
return sb.run(prelude(dry=dry) + func("_state_mark") + func("_state_put") + func("_state_get") +
'PREFLIGHT_ONLY=%s\nSTATE_DIR="$SB/state"; STATE_FILE="$SB/state/state.json"\n'
'_state_put dnsmasq_preexisting yes\n_state_mark preflight\necho "GOT=$(_state_get dnsmasq_preexisting)"\n'
% ("true" if preflight_only else "false"))
def test_preflight_only_writes_no_state():
sb = Sandbox()
try:
rc, out = _state_run(sb, preflight_only=True)
assert rc == 0, out
assert not os.path.exists(sb.path("state", "state.json")), \
"--preflight-only wrote state.json: %s" % open(sb.path("state", "state.json")).read()
assert "GOT=\n" in out + "\n", out
finally:
sb.close()
def test_install_preflight_does_write_state():
# the control: the same helpers DO write on a real install, or the test above proves nothing.
sb = Sandbox()
try:
rc, out = _state_run(sb, preflight_only=False)
assert rc == 0, out
assert "GOT=yes" in out, out
assert '"preflight"' in open(sb.path("state", "state.json")).read()
finally:
sb.close()
def test_state_json_has_no_other_writer():
# every write must go through the two guarded helpers; a direct write would bypass the guard.
bad = [l.strip() for l in SRC.splitlines()
if re.search(r'>\s*"?\$STATE_FILE|json\.dump\(d,open\(f', l)
and not re.match(r"\s*STATE_FILE=\"\$STATE_FILE\" python3 -c", l)]
assert not bad, "state.json written outside _state_mark/_state_put: %s" % bad
body = func("step_preflight")
assert "_state_put dnsmasq_preexisting" in body, "the ownership record no longer goes through _state_put"
# ── R-130: the local-lvm minimum says what it does (it warns; the install continues) ────────────
def test_lvm_minimum_is_named_as_what_it_does():
assert not re.search(r"HARD_MIN_LVM|hard min", SRC), "a 'hard min' that only warns is back"
body = func("step_preflight")
m = re.search(r'^.*RECOMMENDED_MIN_LVM_GIB.*$', body, re.M)
assert m and "log_warn" in m.group(0) and "die" not in m.group(0), "the lvm check is not a warning: %s" % (m and m.group(0))
assert "recommended" in m.group(0) and "continues" in m.group(0), m.group(0)
# ── R-180: the archive storage must be one the agent's token is granted on ─────────────────────
def _granted(storages, archive, target="felhom-backup"):
sb = Sandbox()
try:
rc, out = sb.run(func("_archive_storage_granted") +
'PVE_STORAGES=(%s); ARCHIVE_STORAGE="%s"; BACKUP_TARGET_ID="%s"\n'
'if _archive_storage_granted; then echo GRANTED; else echo REFUSED; fi\n'
% (storages, archive, target))
assert rc == 0, out
return out.strip()
finally:
sb.close()
def test_archive_storage_in_the_acl_set_is_granted():
assert _granted("local local-lvm felhom-pbs", "local") == "GRANTED", "archive storage case ('local local-lvm felhom-pbs', 'local') -> %s, want GRANTED" % _granted("local local-lvm felhom-pbs", "local")
assert _granted("local nvme-scratch", "nvme-scratch") == "GRANTED", "archive storage case ('local nvme-scratch', 'nvme-scratch') -> %s, want GRANTED" % _granted("local nvme-scratch", "nvme-scratch") # --acl-storages adds it
def test_archive_storage_outside_the_acl_set_is_refused():
# the demo-hp 2026-08-03 shape with a storage that is not the backup target
assert _granted("local local-lvm felhom-pbs", "nvme-scratch") == "REFUSED", "archive storage case ('local local-lvm felhom-pbs', 'nvme-scratch') -> %s, want REFUSED" % _granted("local local-lvm felhom-pbs", "nvme-scratch")
assert _granted("local local-lvm felhom-pbs", "local-lvm2") == "REFUSED", "archive storage case ('local local-lvm felhom-pbs', 'local-lvm2') -> %s, want REFUSED" % _granted("local local-lvm felhom-pbs", "local-lvm2") # no prefix match
assert _granted("local local-lvm felhom-pbs", "felhom-backup", target="") == "REFUSED", "archive storage case ('local local-lvm felhom-pbs', 'felhom-backup', target='') -> %s, want REFUSED" % _granted("local local-lvm felhom-pbs", "felhom-backup", target="")
def test_archive_storage_on_the_backup_target_is_granted_in_step_6():
assert _granted("local local-lvm felhom-pbs", "felhom-backup") == "GRANTED", "archive storage case ('local local-lvm felhom-pbs', 'felhom-backup') -> %s, want GRANTED" % _granted("local local-lvm felhom-pbs", "felhom-backup")
def test_archive_storage_check_is_wired_into_preflight():
body = func("step_preflight")
m = re.search(r"archive storage '\$ARCHIVE_STORAGE' present.*?_archive_storage_granted[^\n]*\n[^\n]*\|\| die", body, re.S)
assert m, "step_preflight does not refuse when _archive_storage_granted fails"
# and before anything is minted: the call sits in step_preflight, which runs before step_token.
pre = [m.start() for m in re.finditer(r"^\s*step_preflight\s*$", SRC, re.M)]
tok = re.search(r"^step_token\s*$", SRC, re.M)
assert pre and tok and max(pre) < tok.start(), "preflight no longer runs before the token step"
def main():
tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
fails = 0
for name, f in tests:
try:
f()
print("PASS", name)
except AssertionError as e:
fails += 1
print("FAIL", name, "--", e)
print("%d passed, %d failed" % (len(tests) - fails, fails))
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())