Files
felhom.eu/scripts/hostinstall_gates.py
T
admin bee6848458
gates / gates (push) Successful in 8s
installer v1.23.0 — publishing becomes an act, not a side-effect (R-110, R-183)
Two channels moved off main in the same change, because either one left behind
makes the other cosmetic.

Channel 1 — the served script. webpage.yaml git-synced /scripts/ from
--branch=main every 30s and nginx served that tree, so pushing this file WAS
publishing it: within half a minute it was what every new machine downloaded and
ran as root, with no staging and no rollback but another push. The sync is now
SPLIT: the website keeps tracking main at the same cadence (a copy edit must
never need a release) and /scripts/ tracks the tag installer-v<SCRIPT_VERSION>.

PROVEN before the manifest was touched: git-sync v4.4.0 follows a tag AND
notices a MOVED one — measured on a throwaway sync against this repo,
"update required ... local:<old> remote:<new>" -> "updated successfully",
within one period. The moved-tag half is what the publish model rests on.

Channel 2 — the sixteen files fetched at run time. fetch_raw pulled from
$AGENT_REPO/raw/branch/main; it now pulls raw/tag/v$ART_AGENT_VER. That is a
correctness fix, not only a channel one (R-183): a fresh install fetched the
vouched agent BINARY while taking its unit file, sudoers and guarded wrappers
from whatever main held. Two refs, one install, nothing compared them. Their
correct ref was never SCRIPT_VERSION — they do not live in this repo.

No fallback to a branch: a vouched version whose tag is missing fails loudly
rather than quietly serving main.

Channel 3 — the URL — needed no change, recorded rather than left silent:
https://felhom.eu/scripts/felhom-host-install.sh never carried a ref, so both
producers follow the tag with no edit. No hub change, no hub version bump.

Gate 6 in hostinstall_gates.py pins all three structurally with no network, so
it stays in --fast and runs in CI. It deliberately does NOT assert "a tag exists
for the current SCRIPT_VERSION": that would go red on the very push that bumps
the version, before publishing — and publishing being separate is the ruling.
2026-08-03 12:08:37 +02:00

210 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""DR-tier-by-default installer gates — mechanical grep-assertions against
felhom-host-install.sh's known failure mode: a Day-0 that silently regresses one of the
drill-swept findings (DRILL-day0-vm-2026-07-12 F-1/F-7/F-9/F-10 + the ACL-narrowing 403).
Run from the repo root: python scripts/hostinstall_gates.py
Gates (all must pass; non-zero exit on any failure):
1. version — exactly ONE version source: SCRIPT_VERSION exists, the header line carries no
version literal, and **the hub carries no host-install version literal at all**.
The third assertion inverted on 2026-08-02 (R-94): it used to require the hub's
`hostInstallVersion` const to EQUAL SCRIPT_VERSION, which is unachievable
honestly — the Option-1 install command downloads felhom-host-install.sh from
the website at RUN TIME and the website git-syncs `main` every 30 seconds
(R-110), so the hub cannot know which version a given box will run. A
build-time literal there is a guess with a version number's authority, and the
real one drifted to 1.19.0-vs-1.22.0 and stayed wrong for 19 days. The label was
deleted rather than derived; this gate now pins its absence (F-1 structural fix,
second form).
2. age — the `age` package is installed by the agent-install step (F-10)
3. pbs-apply — configs/felhom-pbs-apply is fetched + installed to
/usr/local/sbin/felhom-pbs-apply (F-7), and the uninstall removes it
4. wg — the rendered agent.json defaults wg_tunnel enabled=true (F-9 / decision 5),
and the byo assert no longer forbids it
5. acl — the default PVE_STORAGES set still contains felhom-pbs (narrowing it is the
drill's apply-bridge 403)
"""
import io, os, re, sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPT = os.path.join(ROOT, "scripts", "felhom-host-install.sh")
HUB_CONFIGS = os.path.join(ROOT, "hub", "internal", "web", "configs.go")
fails = []
def fail(msg):
fails.append(msg)
print("FAIL:", msg)
def ok(msg):
print(" ok:", msg)
with io.open(SCRIPT, "r", encoding="utf-8") as f:
src = f.read()
lines = src.splitlines()
# ── 1. version single-source (F-1) ──────────────────────────────────────────────
m = re.search(r'^SCRIPT_VERSION="(\d+\.\d+\.\d+)"', src, re.M)
if not m:
fail("SCRIPT_VERSION=\"x.y.z\" not found — the single version source is gone")
script_ver = None
else:
script_ver = m.group(1)
ok("SCRIPT_VERSION=%s" % script_ver)
# header (first 10 lines) must NOT carry its own version literal — that is the F-1 drift.
header = "\n".join(lines[:10])
if re.search(r'felhom-host-install\.sh\s+v\d+\.\d+\.\d+', header):
fail("header line carries a hardcoded version — SCRIPT_VERSION is the only source (F-1)")
else:
ok("header has no version literal")
# The hub must carry NO host-install version literal at all (R-94, 2026-08-02). It cannot know
# which version a box will run — the Option-1 command fetches the script from the website at run
# time and the website git-syncs `main` every 30s. The const this replaced said 1.19.0 while the
# served script was 1.22.0, and had been wrong since 2026-07-14.
#
# Matched in CODE SHAPES, never as bare prose: the deleted declarations, the struct field, the
# assignment and the template action, plus a rename-proof generic form of each. Comments are
# deliberately NOT stripped (a `//` inside a URL string literal would truncate the scan and turn
# this gate blind); a comment that merely NAMES the identifier is allowed, and configs.go carries
# exactly such a note explaining the absence.
BANNED = [
(r'\bconst\s+hostInstallVersion\b', "const hostInstallVersion"),
(r'\bhostInstallVersion\s*=', "hostInstallVersion assignment"),
(r'(?i)\bconst\s+\w*hostinstall\w*version\b', "a renamed host-install version const"),
(r'\bScriptVersion\s+string\b', "ScriptVersion struct field"),
(r'\bScriptVersion\s*:', "ScriptVersion struct assignment"),
(r'\{\{\s*\.ScriptVersion\s*\}\}', "{{.ScriptVersion}} template action"),
]
HUB_DIR = os.path.join(ROOT, "hub")
if not os.path.isdir(HUB_DIR):
fail("hub/ not found at %s — cannot assert the absence of a host-install version literal" % HUB_DIR)
else:
scanned, hits = 0, 0
for dirpath, dirs, files in os.walk(HUB_DIR):
dirs[:] = [d for d in dirs if d not in (".git", "vendor", "node_modules")]
for fn in files:
if not (fn.endswith(".go") or fn.endswith(".html")):
continue
fp = os.path.join(dirpath, fn)
scanned += 1
with io.open(fp, "r", encoding="utf-8") as f:
for lineno, line in enumerate(f, 1):
for pat, what in BANNED:
if re.search(pat, line):
hits += 1
fail("%s:%d carries %s — the hub must render NO host-install version "
"(R-94: the served script is fetched at run time, so no build-time "
"value can be true). Single source: scripts/felhom-host-install.sh "
"SCRIPT_VERSION. Line: %s"
% (os.path.relpath(fp, ROOT), lineno, what, line.strip()[:120]))
if not hits:
ok("hub carries no host-install version literal (%d .go/.html files scanned, %d shapes checked)"
% (scanned, len(BANNED)))
# ── 2. age package (F-10) ───────────────────────────────────────────────────────
# must match the REAL install invocation, not the log_dry echo (red-proof-hardened twice:
# a prefix regex matched "agekit", then a loose one matched the dry-run print line).
if re.search(r'DEBIAN_FRONTEND=noninteractive apt-get install -y -q age\b', src):
ok("age is in the installed package set")
else:
fail("`age` install not found (F-10 — the fresh-box escrow ceremony dies without it)")
# ── 3. pbs-apply wrapper shipped + removed (F-7) ────────────────────────────────
if 'fetch_raw "configs/felhom-pbs-apply"' in src:
ok("felhom-pbs-apply is fetched from the agent repo")
else:
fail("configs/felhom-pbs-apply fetch not found (F-7 — pbsdr capabilities born DEGRADED)")
if re.search(r'install -m 0755 -o root -g root "\$patmp" /usr/local/sbin/felhom-pbs-apply', src):
ok("felhom-pbs-apply installed 0755 to /usr/local/sbin")
else:
fail("felhom-pbs-apply install line not found (F-7)")
if re.search(r'rm -f /usr/local/sbin/felhom-pbs-apply', src):
ok("uninstall removes felhom-pbs-apply")
else:
fail("uninstall does not remove /usr/local/sbin/felhom-pbs-apply")
# ── 4. wg_tunnel default-on (F-9 / decision 5) ──────────────────────────────────
if re.search(r"base\.setdefault\('wg_tunnel',\s*\{\"enabled\":\s*True\}\)", src):
ok("rendered agent.json defaults wg_tunnel.enabled=true")
else:
fail("wg_tunnel enabled-by-default missing from the agent.json render (F-9)")
# the byo assert must NOT forbid wg_tunnel any more (decision 5: WG is base infrastructure).
byo_assert = re.search(r"byo-forbidden config keys.*?sys\.exit\(1\)", src, re.S)
if byo_assert and "wg_tunnel" in byo_assert.group(0):
fail("the byo config assert still forbids wg_tunnel.enabled (decision 5 retired that)")
else:
ok("byo assert no longer forbids wg_tunnel")
# ── 5. default ACL keeps felhom-pbs (the drill 403) ─────────────────────────────
if re.search(r'^PVE_STORAGES=\([^)]*felhom-pbs[^)]*\)', src, re.M):
ok("PVE_STORAGES default contains felhom-pbs")
else:
fail("felhom-pbs missing from the default PVE_STORAGES — narrowing it 403s the PBS-DR apply-bridge")
# ── 6. the publish channel is pinned, not floating (R-110 / R-183) ──────────────
#
# WHAT THIS ASSERTS, AND WHAT IT DELIBERATELY DOES NOT.
#
# It does NOT assert "a tag exists for the current SCRIPT_VERSION". That gate would fail the very
# push that bumps SCRIPT_VERSION, before publishing has happened — and publishing being a SEPARATE
# deliberate act is the whole point of R-110's ruling. A gate that goes red on the normal path is a
# gate people learn to ignore, which is the reasoning the task's own §8.4 applies to the agent-side
# gate; it applies here identically. "Is the vouched version actually downloadable" is a real
# invariant and it lives where a missing artifact genuinely breaks day-0 — `felhom-agent`'s
# `agent_gates.py`, which has the network access to answer it.
#
# What it asserts instead are the two STRUCTURAL regressions that would silently return the
# installer to a floating channel, both answerable by reading files (no network, so this stays in
# `--fast` and therefore runs in CI on every push):
#
# 6a. no `raw/branch/` ref anywhere in the installer — one of the sixteen agent-config fetches
# slipping back to `main` is exactly how a channel stays floating unnoticed, and it is
# invisible in a diff that touches one line.
# 6b. `fetch_raw` still pins to the resolved agent version — the positive form, so the mechanism
# cannot be quietly deleted rather than regressed.
# 6c. the website manifest still syncs `/scripts/` from a TAG ref and the website from `main` —
# the split is the deploy-side half of the same channel, and reverting it is one word.
branch_refs = [l for l in lines if "raw/branch/" in l and not l.lstrip().startswith("#")]
if branch_refs:
fail("installer still fetches from a BRANCH ref — the run-time channel is floating again "
"(R-110/R-183). Offending line(s): %s" % "; ".join(l.strip()[:90] for l in branch_refs))
else:
ok("no raw/branch/ ref in the installer — every run-time fetch is pinned")
if re.search(r'raw/tag/v\$ART_AGENT_VER/', src):
ok("fetch_raw pins the agent configs to the vouched agent version")
else:
fail("fetch_raw no longer pins to $ART_AGENT_VER — the agent's configs and its binary can "
"again come from different refs in one install (R-183)")
WEBPAGE = os.path.join(ROOT, "manifests", "webpage.yaml")
try:
with io.open(WEBPAGE, "r", encoding="utf-8") as f:
wp = f.read()
except IOError as e:
fail("cannot read manifests/webpage.yaml to check the publish channel: %s" % e)
wp = None
if wp is not None:
# The scripts sync must name a tag ref; the website sync must still track main.
if re.search(r'--ref=installer-v', wp):
ok("manifest: /scripts/ syncs from an installer tag")
else:
fail("manifests/webpage.yaml has no `--ref=installer-v…` sync — /scripts/ is not served "
"from a tag, so pushing the installer publishes it again (R-110)")
if re.search(r'--(branch|ref)=main', wp):
ok("manifest: the website still tracks main (a copy edit must not need a release)")
else:
fail("manifests/webpage.yaml no longer tracks main for the website — pinning the SITE to "
"the installer tag turns every copy edit into a release")
print()
if fails:
print("hostinstall gates: %d FAILURE(S)" % len(fails))
sys.exit(1)
print("hostinstall gates: ALL PASS")