R-275, R-276, R-881: the uninstall removes every copy of the agent config, the WireGuard tunnel and felhom-priv-apply
- R-275: the agent's own config dir is purged as a directory (the .bak* glob missed all five demo-hp copies); a custom config path loses agent.json + every agent.json.* sibling only. Sudoers dotted copies go too. At install, a freshly created service user cannot read what an old install left in /etc/felhom-agent (sealed root 0600, named, never deleted). vmbr9 and the ISO first-boot files are now NAMED under KEPT. - R-276: wg-quick@wg-felhom is disabled --now and its conf removed, then observed down; the hub-side peer is named under KEPT (removing it is the hub's job). - R-881: /usr/local/sbin/felhom-priv-apply is removed; the disclosure says it and the guest hook come from the config bundle. - scripts/test_hostinstall.py: lifts the functions verbatim and runs them with PATH stubs (13 tests, BusyBox-safe); harness GL4-D here-string (SIGPIPE false miss), GL8-F1 follows the new purge. - SCRIPT_VERSION 1.32.0 (not published; no tag). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -184,7 +184,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_VERSION="1.31.0" # the SINGLE version source (F-1): -h and the run banners follow it.
|
||||
SCRIPT_VERSION="1.32.0" # the SINGLE version source (F-1): -h and the run banners follow it.
|
||||
# The hub used to carry a copy for its Setup tab; R-94 DELETED it
|
||||
# (2026-08-02) because the hub cannot know which version a box runs —
|
||||
# the Setup command fetches this script at run time. scripts/
|
||||
@@ -811,9 +811,18 @@ _uninstall_statement() {
|
||||
echo " WIPED (this run):"
|
||||
echo " - guest $vmid (container + its OS/Docker/user-data volumes)"
|
||||
if [[ "$scope" == "full" ]]; then
|
||||
echo " - the felhom-agent: binary, unit, sudoers, config (+ its .bak backups), state dir, service user"
|
||||
echo " - the felhom-agent: binary, unit, sudoers (+ its copies), state dir, service user, and its config"
|
||||
echo " directory with every copy of the config in it"
|
||||
if $_WG_PRESENT; then
|
||||
if [[ -n "$_WG_TEARDOWN_NOTE" ]]; then
|
||||
echo " - the WireGuard tunnel to the Felhom off-site endpoint: NOT shown down — $_WG_TEARDOWN_NOTE"
|
||||
else
|
||||
echo " - the WireGuard tunnel to the Felhom off-site endpoint (${WG_UNIT} disabled, ${WG_CONF} removed)"
|
||||
fi
|
||||
fi
|
||||
echo " - self-update artifacts: guarded wrapper, A/B slots (.prev/.new.*), rollback unit, start-limit drop-in"
|
||||
echo " - break-glass watchdog + OOB artifacts (where present); guest-hook snippet; dnsmasq snippets; the mkfs + pbs-apply wrappers"
|
||||
echo " - break-glass watchdog + OOB artifacts (where present); guest-hook snippet; dnsmasq snippets; the mkfs, pbs-apply,"
|
||||
echo " backup-target-apply, os-apply and priv-apply wrappers; the crash guard; the config-bundle record"
|
||||
echo " - pveum: the Felhom roles/user/token/scoped ACL$( $pool_removed && printf '; the emptied %s pool' "$PVE_POOL")"
|
||||
echo " - the install state file"
|
||||
if $REMOVE_GOLDEN; then echo " - the golden vzdump (--remove-golden)"; fi
|
||||
@@ -835,6 +844,21 @@ _uninstall_statement() {
|
||||
echo " - the PBS backups + this customer's namespace on the PBS side — delete there if wanted"
|
||||
fi
|
||||
echo " - the hub host/customer record + report history (operator UI / DB)"
|
||||
if [[ "$scope" == "full" ]] && $_WG_PRESENT; then
|
||||
echo " - this host's WireGuard PEER on the hub and the off-site endpoint (its /32 and public key) — the"
|
||||
echo " tunnel is down from this side; delete the peer in the hub (operator) if the customer is leaving"
|
||||
fi
|
||||
if [[ "$scope" == "full" ]]; then
|
||||
# R-275: named, not removed — a host network change and the ISO's own first-boot files.
|
||||
if grep -qE "^[[:space:]]*iface[[:space:]]+${ISLAND_BRIDGE}[[:space:]]" /etc/network/interfaces 2>/dev/null; then
|
||||
echo " - the ${ISLAND_BRIDGE} island bridge stanza in /etc/network/interfaces (host-internal, no port; a reinstall"
|
||||
echo " reuses it) — to remove it: delete the stanza, then ifreload -a"
|
||||
fi
|
||||
if [[ -e /etc/felhom/.bootstrap-done || -e /etc/felhom/appliance-pairing-code ]]; then
|
||||
echo " - the appliance ISO's first-boot files: /etc/felhom/.bootstrap-done, /etc/felhom/appliance-pairing-code,"
|
||||
echo " felhom-bootstrap.service (disabled, fired once) — not this script's; remove by hand if wanted"
|
||||
fi
|
||||
fi
|
||||
echo " - the escrow blob in the hub, if one exists (operator UI)"
|
||||
if $_had_break_glass; then
|
||||
echo " - the hub-vaulted root@pam recovery credential — the box KEEPS the password step 4b set; rotate it if the box leaves Felhom management"
|
||||
@@ -931,6 +955,90 @@ _dnsmasq_purge_owned() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# _purge_agent_config CFG — remove the agent config AND every copy of it (R-275).
|
||||
#
|
||||
# The config holds the per-host hub api_key and the Proxmox token. Copies of it are made by hand and
|
||||
# by tools, under names nobody can predict (`agent.json.campaign9-before`, `agent.json.pre-prunegate.bak`
|
||||
# were measured on demo-hp 2026-08-09). The old `${cfg}.bak*` glob missed all five of them, and the
|
||||
# reinstall then handed them to the new service account (same uid). So: the agent's OWN directory is
|
||||
# purged as a directory. A config at a custom path (operator-chosen, maybe a shared dir) is never
|
||||
# purged by directory — there the config and every `${cfg}.*` sibling go, then an empty dir.
|
||||
# Pinned by scripts/test_hostinstall.py (test_purge_*).
|
||||
AGENT_CFG_DIR_DEFAULT="/etc/felhom-agent"
|
||||
_purge_agent_config() {
|
||||
local cfg="$1" dir f
|
||||
dir=$(dirname "$cfg")
|
||||
if [[ "$dir" == "$AGENT_CFG_DIR_DEFAULT" ]]; then
|
||||
if [[ -d "$dir" ]]; then
|
||||
run rm -rf "$dir"
|
||||
log_success " removed $dir (the agent config and every copy of it)"
|
||||
else
|
||||
log_skip " $dir already absent"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$cfg" ]]; then run rm -f "$cfg"; else log_skip " $cfg already absent"; fi
|
||||
for f in "${cfg}".*; do [[ -e "$f" ]] && run rm -f "$f"; done
|
||||
run rmdir "$dir" 2>/dev/null || true
|
||||
return 0
|
||||
}
|
||||
|
||||
# _seal_old_agent_config DIR — at install, when the service user was JUST created (R-275, second half).
|
||||
# A new system account can get the uid the deleted one had, so files a previous install left in the
|
||||
# config dir would become readable by the new account. They are made root-only (0600 root:root) and
|
||||
# named — never deleted (they may be an operator's own backup). Step 6 rewrites agent.json and gives
|
||||
# it to the agent again, so only the old copies stay sealed.
|
||||
_seal_old_agent_config() {
|
||||
local dir="$1" f found=false
|
||||
[[ -d "$dir" ]] || return 0
|
||||
for f in "$dir"/* "$dir"/.[!.]*; do
|
||||
[[ -f "$f" ]] || continue
|
||||
found=true
|
||||
run chown root:root "$f"
|
||||
run chmod 0600 "$f"
|
||||
log_warn " left by a previous install, now root-only: $f"
|
||||
done
|
||||
$found && log_warn " remove these by hand if you do not need them (they may hold an old hub key and Proxmox token)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# _teardown_wg_tunnel — stop the WireGuard tunnel to the Felhom off-site endpoint (R-276).
|
||||
#
|
||||
# The agent creates it at run time (wg_tunnel.enabled is the default, decision 5) and the uninstall
|
||||
# used to leave it enabled and handshaking: a box told to leave Felhom kept a live network path into
|
||||
# Felhom's endpoint. Stop + disable the unit, remove its conf, then OBSERVE that it is down. The
|
||||
# peer on the hub/endpoint side is not this script's to remove — the closing statement names it.
|
||||
# Sets _WG_TEARDOWN_NOTE when the tunnel could not be shown down. Pinned by
|
||||
# scripts/test_hostinstall.py (test_wg_*).
|
||||
WG_UNIT="wg-quick@wg-felhom"
|
||||
WG_CONF="/etc/wireguard/wg-felhom.conf"
|
||||
_WG_TEARDOWN_NOTE=""
|
||||
_WG_PRESENT=false
|
||||
_teardown_wg_tunnel() {
|
||||
local active=false enabled=false
|
||||
systemctl is-active --quiet "$WG_UNIT" 2>/dev/null && active=true
|
||||
systemctl is-enabled --quiet "$WG_UNIT" 2>/dev/null && enabled=true
|
||||
if $active || $enabled || [[ -e "$WG_CONF" ]]; then _WG_PRESENT=true; fi
|
||||
if ! $_WG_PRESENT; then
|
||||
log_skip " WireGuard tunnel ($WG_UNIT) not present"
|
||||
return 0
|
||||
fi
|
||||
if $active || $enabled; then
|
||||
run systemctl disable --now "$WG_UNIT" || log_warn " systemctl disable --now $WG_UNIT returned non-zero"
|
||||
fi
|
||||
run systemctl reset-failed "$WG_UNIT" 2>/dev/null || true
|
||||
if [[ -e "$WG_CONF" ]]; then run rm -f "$WG_CONF"; fi
|
||||
$DRY_RUN && return 0
|
||||
# Positive observable: the unit must now read inactive (an exit code is not an observation).
|
||||
if systemctl is-active --quiet "$WG_UNIT" 2>/dev/null; then
|
||||
_WG_TEARDOWN_NOTE="$WG_UNIT is STILL active after disable --now"
|
||||
log_warn " $_WG_TEARDOWN_NOTE — stop it by hand: systemctl disable --now $WG_UNIT"
|
||||
else
|
||||
log_success " WireGuard tunnel to the Felhom off-site endpoint is down ($WG_UNIT disabled, conf removed)"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
run_uninstall() {
|
||||
log_step "UNINSTALL — local host teardown"
|
||||
|
||||
@@ -1045,6 +1153,8 @@ run_uninstall() {
|
||||
for bak in "${AGENT_UNIT}".bak-*; do [[ -e "$bak" ]] && run rm -f "$bak"; done
|
||||
run systemctl daemon-reload
|
||||
if [[ -f "$AGENT_SUDOERS" ]]; then run rm -f "$AGENT_SUDOERS"; else log_skip " $AGENT_SUDOERS already absent"; fi
|
||||
# R-275: dotted copies (`felhom-agent.bak-pre-e2a`) are inert for sudo but are still a copy of it.
|
||||
for bak in "${AGENT_SUDOERS}".*; do [[ -e "$bak" ]] && run rm -f "$bak"; done
|
||||
if [[ -f "$AGENT_BIN" ]]; then run rm -f "$AGENT_BIN"; else log_skip " $AGENT_BIN already absent"; fi
|
||||
for bak in "${AGENT_BIN}".bak-*; do [[ -e "$bak" ]] && run rm -f "$bak"; done
|
||||
if [[ -d "$AGENT_STATE_DIR" ]]; then run rm -rf "$AGENT_STATE_DIR"; else log_skip " $AGENT_STATE_DIR already absent"; fi
|
||||
@@ -1054,10 +1164,13 @@ run_uninstall() {
|
||||
# drill R1 / GL-6 F1). The config write leaves `${agent_cfg}.bak*` siblings (e.g. .bak-<ver>,
|
||||
# .bak-ceremony-*, .bak-pre064) — one GL-6 residue still held a LIVE hub api_key. Remove the
|
||||
# config AND every `.bak*` sibling, then the (now-empty) dir. Paths logged, contents never.
|
||||
if [[ -f "$agent_cfg" ]]; then run rm -f "$agent_cfg"; else log_skip " $agent_cfg already absent"; fi
|
||||
local _cfgbak
|
||||
for _cfgbak in "${agent_cfg}".bak*; do [[ -e "$_cfgbak" ]] && run rm -f "$_cfgbak"; done
|
||||
run rmdir "$(dirname "$agent_cfg")" 2>/dev/null || true
|
||||
# R-275: the `.bak*` glob missed every hand-made copy (`agent.json.campaign9-before` …), so the
|
||||
# agent's own directory is now purged as a directory — see _purge_agent_config.
|
||||
_purge_agent_config "$agent_cfg"
|
||||
|
||||
# 4b0. The WireGuard tunnel to the Felhom off-site endpoint (R-276). The agent is stopped above, so
|
||||
# nothing re-enables it while it goes.
|
||||
_teardown_wg_tunnel
|
||||
|
||||
# 4b2. Management-plane break-glass (TASK G1): timer+oneshot+script+tmpfiles. Stop/disable the
|
||||
# timer, remove all four artifacts + the runtime heal-marker. We do NOT `rmdir /run/sshd` —
|
||||
@@ -1161,6 +1274,8 @@ run_uninstall() {
|
||||
if [[ -f /usr/local/sbin/felhom-pbs-apply ]]; then run rm -f /usr/local/sbin/felhom-pbs-apply; else log_skip " felhom-pbs-apply already absent"; fi
|
||||
if [[ -f /usr/local/sbin/felhom-backup-target-apply ]]; then run rm -f /usr/local/sbin/felhom-backup-target-apply; else log_skip " felhom-backup-target-apply already absent"; fi
|
||||
if [[ -f /usr/local/sbin/felhom-os-apply ]]; then run rm -f /usr/local/sbin/felhom-os-apply; else log_skip " felhom-os-apply already absent"; fi
|
||||
# R-881: the content checker the config bundle installs since agent v0.146.1 (R-861).
|
||||
if [[ -f /usr/local/sbin/felhom-priv-apply ]]; then run rm -f /usr/local/sbin/felhom-priv-apply; else log_skip " felhom-priv-apply already absent"; fi
|
||||
# 1.30.0: the crash guard (kernel.panic goes back to the kernel default 0 at the next boot) and the root-owned
|
||||
# slow-lane trust files.
|
||||
if systemctl list-unit-files felhom-crash-guard.service >/dev/null 2>&1; then
|
||||
@@ -1676,7 +1791,8 @@ _byo_disclosure_ack() {
|
||||
+ /usr/local/sbin/felhom-pbs-apply (PBS-DR apply wrapper — DR capability is baked
|
||||
on every install; ACTIVATION stays a hub flag, off = zero effect on this host)
|
||||
+ felhom-mgmt-watchdog service+timer+script + /etc/tmpfiles.d/felhom-privsep.conf
|
||||
+ guest-hook snippet under /var/lib/vz/snippets/ (agent-installed at runtime)
|
||||
+ /usr/local/sbin/felhom-priv-apply + the guest-hook snippet under /var/lib/vz/snippets/
|
||||
(both from the agent's config bundle)
|
||||
+ the 'sudo' and 'age' packages if absent + install state dir ${STATE_DIR}
|
||||
wg: an OUTBOUND WireGuard tunnel to the Felhom hub (wg_tunnel.enabled=true — base
|
||||
infrastructure like the cloudflared tunnel; hands-free peer registration; the
|
||||
@@ -2270,6 +2386,10 @@ step_agent_install() {
|
||||
else
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin "$AGENT_USER"
|
||||
log_success " created service user $AGENT_USER"
|
||||
# R-275: the new account may get the uid of a deleted one — make what an old install left
|
||||
# in the agent's own config dir root-only. Only that dir: a custom config path may share a
|
||||
# directory with files that are not ours.
|
||||
_seal_old_agent_config "$AGENT_CFG_DIR_DEFAULT"
|
||||
fi
|
||||
|
||||
# systemd-journal group: the NAS verify pipeline (agent v0.81.0) classifies mount failures from
|
||||
|
||||
@@ -256,8 +256,11 @@ if [[ -n "$ustart" && -n "$uend" && "$ustart" -lt "$uend" ]]; then
|
||||
for tok in 'felhom-selfupdate-guarded' 'felhom-agent-rollback.service' 'felhom-agent-limits.conf' \
|
||||
'.prev' 'felhom-mgmt-watchdog' 'felhom-privsep.conf' 'felhom-mkfs-guarded' \
|
||||
'felhom-guest-hook' '/mnt/felhom-drives' 'AGENT_SUDOERS' 'AGENT_STATE_DIR' \
|
||||
'remove_scoped_acl' 'pveum user token remove' 'pveum pool delete' 'STATE_FILE'; do
|
||||
echo "$usect" | grep -qF "$tok" || d_missing+="$tok "
|
||||
'remove_scoped_acl' 'pveum user token remove' 'pveum pool delete' 'STATE_FILE' \
|
||||
'felhom-priv-apply' '_teardown_wg_tunnel' '_purge_agent_config'; do
|
||||
# a here-string, not `echo | grep -q`: under pipefail grep -q's early exit SIGPIPEs the echo
|
||||
# and a token that IS present reads as missing (AGENT_SUDOERS did, 2026-10-05).
|
||||
grep -qF -- "$tok" <<<"$usect" || d_missing+="$tok "
|
||||
done
|
||||
if [[ -z "$d_missing" ]]; then
|
||||
verdict PASS "GL4-D disclosure↔uninstall parity (all artifact tokens covered)"
|
||||
@@ -326,25 +329,13 @@ else
|
||||
verdict FAIL "GL8-F6 byo :53 gate refuses+instructs, never mutates the owner's resolver"
|
||||
fi
|
||||
|
||||
# GL8-F1(static): uninstall removes the agent config's .bak* siblings (not just agent.json).
|
||||
if grep -q '"${agent_cfg}".bak\*' "$SCRIPT"; then
|
||||
verdict PASS "GL8-F1 uninstall removes \${agent_cfg}.bak* (secret-bearing backups)"
|
||||
# GL8-F1 / R-275: the agent config and EVERY copy of it go — the agent's own config dir is purged as a
|
||||
# directory (the old `.bak*` glob missed `agent.json.campaign9-before` & co.). Behaviour is pinned by
|
||||
# scripts/test_hostinstall.py (test_purge_*, run in CI); here only the call site.
|
||||
if grep -q '^ _purge_agent_config "\$agent_cfg"' "$SCRIPT" && ! grep -q '"${agent_cfg}".bak\*' "$SCRIPT"; then
|
||||
verdict PASS "GL8-F1 uninstall purges the agent config dir (every copy, R-275)"
|
||||
else
|
||||
verdict FAIL "GL8-F1 uninstall removes \${agent_cfg}.bak* (secret-bearing backups)"
|
||||
fi
|
||||
|
||||
# GL8-F1(behavioural): the exact glob-removal pattern the script uses, exercised in a temp dir —
|
||||
# both agent.json AND its .bak* siblings must go (a plain `rm -f agent.json` would leave the .bak).
|
||||
f1dir="$WORK/etc-felhom-agent"; mkdir -p "$f1dir"
|
||||
: > "$f1dir/agent.json"; : > "$f1dir/agent.json.bak-0.75.0"; : > "$f1dir/agent.json.bak-ceremony-2026-07-08"; : > "$f1dir/agent.json.bak-pre064"
|
||||
agent_cfg="$f1dir/agent.json"
|
||||
rm -f "$agent_cfg"
|
||||
for _cfgbak in "${agent_cfg}".bak*; do [[ -e "$_cfgbak" ]] && rm -f "$_cfgbak"; done
|
||||
rmdir "$f1dir" 2>/dev/null || true
|
||||
if [[ ! -e "$f1dir" ]]; then
|
||||
verdict PASS "GL8-F1b glob removal clears agent.json + every .bak* + the empty dir"
|
||||
else
|
||||
verdict FAIL "GL8-F1b glob removal clears agent.json + every .bak* + the empty dir" "residue: $(ls -A "$f1dir" 2>/dev/null | tr '\n' ' ')"
|
||||
verdict FAIL "GL8-F1 uninstall purges the agent config dir (every copy, R-275)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
#!/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).
|
||||
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
|
||||
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user