diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index 6c45a80b..5761ee3c 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -667,6 +667,17 @@ eligible_backup_drive() { return 1 } +# _archive_storage_granted — true when the agent's token will hold the Store role on ARCHIVE_STORAGE by +# step 8 (R-180): it is in PVE_STORAGES (granted in step 4/5), or it is the backup target, which +# configure_backup_target grants in step 6 whenever it exists (R-185) — and it exists, because the +# preflight has just found it. Pure: reads the two variables only. Pinned by scripts/test_hostinstall.py +# (test_archive_storage_*). +_archive_storage_granted() { + local s + for s in "${PVE_STORAGES[@]}"; do [[ "$s" == "$ARCHIVE_STORAGE" ]] && return 0; done + [[ -n "$BACKUP_TARGET_ID" && "$ARCHIVE_STORAGE" == "$BACKUP_TARGET_ID" ]] +} + # configure_backup_target — Case A/B. Idempotent and SCENARIO-F SAFE. configure_backup_target() { # SCENARIO F, the hard rule: never touch a box that already has a target. The agent.json write @@ -1926,6 +1937,13 @@ step_preflight() { else die "archive storage '$ARCHIVE_STORAGE' not found (pvesm status). Pass --archive-storage NAME." fi + # R-180: the agent restores the golden FROM this storage in step 8, as its token — so the token + # must be granted there. Checked HERE, because by step 8 the token is minted, root@pam rotated + # and the agent installed (demo-hp 2026-08-03: HTTP 403 at /storage/felhom-backup at step 8/8). + _archive_storage_granted \ + || die "archive storage '$ARCHIVE_STORAGE' is not one the agent's token will be granted on (${PVE_STORAGES[*]}${BACKUP_TARGET_ID:+, and $BACKUP_TARGET_ID via the backup target}). + The restore in step 8 would fail with HTTP 403 — after the token is minted and (appliance) root@pam is rotated. + Move the golden to one of those storages (default: local), or pass --acl-storages with '$ARCHIVE_STORAGE' added." fi # --acl-storages existence (GL-2): the scoped ACL grants Datastore.* at each of these paths — a diff --git a/scripts/test_hostinstall.py b/scripts/test_hostinstall.py index bdf7e5f1..0384f742 100644 --- a/scripts/test_hostinstall.py +++ b/scripts/test_hostinstall.py @@ -11,7 +11,7 @@ functions act on are variables the test points into the temp directory. Nothing 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). +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 @@ -381,6 +381,46 @@ def test_lvm_minimum_is_named_as_what_it_does(): 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