installer v1.15.0: DR-tier-by-default sweep (F-7 pbs-apply, F-10 age, F-9 wg default-on, F-1 single version + hub const, F-2 dry-run auth, F-8 rotation pointer, ACL felhom-pbs kept) + hostinstall_gates.py

Claude-Session: https://claude.ai/code/session_01NptTCFtu7dz2Ru89qHRagN
This commit is contained in:
2026-07-12 19:52:56 +02:00
parent e205a2d6a1
commit 007946faf4
4 changed files with 268 additions and 24 deletions
+35
View File
@@ -1,5 +1,40 @@
# Felhom scripts — Changelog
## felhom-host-install v1.15.0 — DR-tier-by-default: uniform DR plumbing on every install (2026-07-12)
Sweeps the DRILL-day0-vm-2026-07-12 installer findings so the next Day-0 runs the drill's §5
sequence with zero fix-and-continue stops. Operator decisions 2026-07-12: capability is BAKED
uniformly; activation is a hub flag; WG is base infrastructure.
- **F-7** — ships `/usr/local/sbin/felhom-pbs-apply` (fetch `configs/felhom-pbs-apply` from the
agent repo, `bash -n`, install 0755 — the mkfs/selfupdate wrapper pattern). The FELHOM_PBSDR
sudoers alias already shipped; the 3 `pbsdr-*` capabilities are no longer born DEGRADED.
Uninstall removes it (disclosure↔uninstall parity kept, harness GL4-D green).
- **F-10** — installs the `age` package (escrow-ceremony identity-wrap dependency; a fresh-box
ceremony died on it live).
- **F-9 / decision 5** — the rendered agent.json now defaults `wg_tunnel.enabled: true` in BOTH
modes (WG is base infrastructure like cloudflared, also the future OOB transport; setdefault —
a deliberately-disabled preserved config is respected). The byo wg-off config assert is
RETIRED; byo consent = the typed disclosure ack (the WG tunnel + pbs-apply wrapper + age are
now on the disclosed list) + the tester-agreement disclosure line.
- **ACL** — the default `--acl-storages` set keeps `felhom-pbs` (drill-proven: narrowing it 403s
the PBS-DR apply-bridge at `/storage/felhom-pbs`); a missing felhom-pbs storage is now an
expected-pre-tier INFO, and no longer a byo refusal (the tier creates the storage later).
- **F-1** — single version source: `SCRIPT_VERSION` alone feeds `-h`, the run banners, and (via a
gate cross-check) the hub Setup-tab const `hostInstallVersion` (bumped 1.12.0→1.15.0 in the
same commit). The header no longer carries its own version literal.
- **F-2** — dry-run fetch lines print the REAL auth mode (`curl (anonymous …)` vs `curl -u <git>`)
instead of the hardcoded `-u <git>` lie on the anonymous branch.
- **F-8** — step 4b + the final summary now say out loud that root@pam was rotated + vaulted and
where to retrieve it (pointer only, never the credential).
- **New gate: `scripts/hostinstall_gates.py`** — mechanical grep-assertions (version single-source
+ hub-const sync, age, pbs-apply ship+uninstall, wg default-on + byo-assert retirement,
felhom-pbs in the default ACL). All red-proofed; the age assertion was hardened TWICE by its
red-proof (a prefix regex matched `agekit`, then a loose one matched the dry-run print line).
- F-3 (root-owned `guests/` parents) is fixed at its creator — the AGENT (v0.86.0); this pass
applied the drill's live-fix (chown the two parent dirs) to felhom-pve, which had the same
latent state. Peti's host: unreachable, check deferred.
## felhom-host-install v1.14.0 — deployment_mode from --mode (appliance self-heal gate) (2026-07-12)
- The agent-config writer now templates the top-level **`deployment_mode`** field from `--mode`
+114 -23
View File
@@ -1,6 +1,7 @@
#!/bin/bash
#===============================================================================
# felhom-host-install.sh v1.14.0
# felhom-host-install.sh — version: the SCRIPT_VERSION variable below (single source;
# `-h` and the run banner both read it — the v1.14.0/v1.13.0 header/banner drift was drill F-1).
# Day-0 host-bootstrap for a Felhom Proxmox host (operator-deploy model).
#
# Run by the operator on a FRESHLY-PVE-INSTALLED box (after a manual PVE install
@@ -19,6 +20,17 @@
# new credential. The checksum trust root is the HUB, not Gitea. This removes the
# old prerequisite "install the agent binary + unit manually".
#
# v1.15.0 (DR-tier-by-default, DRILL-day0-vm-2026-07-12 sweep): every install now deploys the FULL
# DR plumbing uniformly — capability is baked, activation is a hub flag. F-7: ships the
# `felhom-pbs-apply` wrapper (like mkfs/selfupdate — pbsdr capabilities no longer born DEGRADED).
# F-10: installs `age` (escrow-ceremony identity-wrap dependency). F-9: renders
# `wg_tunnel.enabled: true` (WG is BASE infrastructure like cloudflared, not a DR component —
# operator decision 2026-07-12; byo consent moves to the disclosure ack + tester agreement, the
# byo wg-off config assert is retired). The default --acl-storages set keeps `felhom-pbs`
# (drill-proven: narrowing it 403s the PBS-DR apply-bridge later); a missing felhom-pbs storage
# is expected pre-tier and no longer a byo refusal. F-1: single VERSION source (SCRIPT_VERSION).
# F-2: dry-run fetch lines show the real auth mode (anonymous vs -u). F-8: step 4b + the final
# summary now say OUT LOUD that root@pam was rotated + where to retrieve it.
# v1.14.0 (CAMPAIGN-3 Part 6): --mode now also templates the agent's top-level `deployment_mode`
# ("appliance" | "byo") — authoritative from --mode, written even over a --preserve-from config, so a
# byo box can never inherit an appliance host-service self-heal. Gates the agent's node self-heal
@@ -75,10 +87,12 @@
# touches root@pam (step 4b skipped), REQUIRES explicit
# --cores AND --memory, refuses --enable-oob and
# --rotate-recovery, dies on non-9.x PVE (unless --force)
# and on --acl-storages entries absent from this box,
# and on --acl-storages entries absent from this box
# (except felhom-pbs — the PBS-DR tier creates it later),
# prints the full host-mutation list and requires typing
# the host's short hostname, and asserts the agent config
# keeps lan_resolver/wg_tunnel/oob OFF.
# keeps lan_resolver/oob OFF (wg_tunnel is ON in BOTH
# modes since v1.15.0 — base infra, disclosed in the ack).
# (dr the 10D DR stub — documented seam, not implemented.)
#
# Options:
@@ -135,7 +149,7 @@
# Uninstall (local host teardown — no hub contact, no passphrase):
# --uninstall cleanly revert an install: destroy the Felhom guest, remove the agent
# (unit/sudoers/binary/state/config/user + runtime artifacts: shared-parent
# unit, mkfs wrapper, hook snippet, dnsmasq snippets), the pveum
# unit, mkfs + pbs-apply wrappers, hook snippet, dnsmasq snippets), the pveum
# role/user/token/ACL, and the install state file. Refuses a non-Felhom guest (no
# /etc/felhom-bootstrap mount) and skips host-level removal if OTHER Felhom
# guests remain (both overridable with --force). Typed vmid confirmation
@@ -168,7 +182,9 @@
set -euo pipefail
SCRIPT_VERSION="1.13.0" # keep in sync with the header line at the top of this file
SCRIPT_VERSION="1.15.0" # the SINGLE version source (F-1): -h, the run banners, and the hub
# Setup-tab copy (hub internal/web/configs.go hostInstallVersion —
# scripts/hostinstall_gates.py asserts the two stay equal) all follow it.
# Operator signing keys pinned at day-0 (GL-4; doc 04 §3 two-key model). EMPTY by default — the pin
# CEREMONY is an operator step: generate the real keypairs OFFLINE, then fill these four constants
@@ -291,9 +307,14 @@ ART_GOLDEN_SHA=""
#-------------------------------------------------------------------------------
# Helpers
#-------------------------------------------------------------------------------
# Print the header through the last option line (the "# State (" line is the end marker — keeps the
# range from drifting as the header grows; v1.9.1's numeric '2,95p' had already drifted).
usage() { sed -n '2,/^# State (/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0; }
# Print the header through the last option line. Markers, not line numbers (v1.9.1's numeric
# '2,95p' had drifted): start at the "# Day-0 host-bootstrap" line, end before "# State (".
# The version line is printed from SCRIPT_VERSION — the ONLY version source (F-1).
usage() {
echo "felhom-host-install.sh v${SCRIPT_VERSION}"
sed -n '/^# Day-0 host-bootstrap/,/^# State (/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'
exit 0
}
run() { # simple (no pipes/redirects) mutating command
if $DRY_RUN; then log_dry "$*"; else "$@"; fi
@@ -402,6 +423,12 @@ _git_auth_args() {
fi
}
# _dry_auth_hint — the dry-run print's auth stanza (drill F-2): the old hardcoded "curl -u <git>"
# lied on the anonymous-fetch branch. Requires resolve_git_creds to have run (both call sites do).
_dry_auth_hint() {
if [[ -n "$GIT_TOKEN" ]]; then echo "curl -u <git>"; else echo "curl (anonymous — no git credential)"; fi
}
# Fetch a Gitea generic-package URL to a dest (authed when a credential exists, else anonymous),
# then VERIFY its sha256 against the expected (hub-vouched) value. Aborts on any mismatch —
# verify-before-use; the sha is the integrity root either way. $1=url $2=dest $3=expected_sha
@@ -599,7 +626,7 @@ _uninstall_statement() {
if [[ "$scope" == "full" ]]; then
echo " - the felhom-agent: binary, unit, sudoers, config (+ its .bak backups), state dir, service user"
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"
echo " - break-glass watchdog + OOB artifacts (where present); guest-hook snippet; dnsmasq snippets; the mkfs + pbs-apply wrappers"
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
@@ -856,8 +883,9 @@ run_uninstall() {
fi
if [[ -d /mnt/felhom-drives ]]; then run rmdir /mnt/felhom-drives 2>/dev/null || true; fi
# 4d. Guarded-mkfs wrapper, guest-hook snippet, lan-resolver dnsmasq snippets (drill R3-R5).
# 4d. Guarded-mkfs + pbs-apply wrappers, guest-hook snippet, lan-resolver dnsmasq snippets (drill R3-R5).
if [[ -f /usr/local/sbin/felhom-mkfs-guarded ]]; then run rm -f /usr/local/sbin/felhom-mkfs-guarded; else log_skip " felhom-mkfs-guarded already absent"; fi
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 /var/lib/vz/snippets/felhom-guest-hook.sh ]]; then run rm -f /var/lib/vz/snippets/felhom-guest-hook.sh; fi
local dconf _dnsmasq_touched=false
for dconf in /etc/dnsmasq.d/felhom-*.conf; do
@@ -923,7 +951,7 @@ run_uninstall() {
# 8. Summary + the GL-4 kept-vs-wiped statement.
_uninstall_statement full
log_success "UNINSTALL complete — removed: guest $vmid, the felhom-agent (unit/sudoers/binary/state/config+baks/user + selfupdate-artifacts/shared-parent/mkfs-wrapper/hook-snippet/dnsmasq-snippets), the pveum role/user/token/ACL,$( $pool_removed && printf ' the %s pool,' "$PVE_POOL") and $STATE_FILE."
log_success "UNINSTALL complete — removed: guest $vmid, the felhom-agent (unit/sudoers/binary/state/config+baks/user + selfupdate-artifacts/shared-parent/mkfs-wrapper/pbs-apply-wrapper/hook-snippet/dnsmasq-snippets), the pveum role/user/token/ACL,$( $pool_removed && printf ' the %s pool,' "$PVE_POOL") and $STATE_FILE."
if $REMOVE_GOLDEN; then log_info " golden vzdump: removed."; else log_info " golden vzdump: left in place (--remove-golden to remove)."; fi
log_info " NOTE: the 'sudo' and 'dnsmasq' packages were left installed (system packages); the host record still exists in the hub — remove it there if desired."
$DRY_RUN && log_warn " DRY-RUN: nothing above was actually executed."
@@ -1239,9 +1267,14 @@ _byo_disclosure_ack() {
+ ${AGENT_UNIT} (+ felhom-agent-rollback.service + start-limit drop-in)
+ ${AGENT_SUDOERS} + state dir ${AGENT_STATE_DIR} + config ${AGENT_CONFIG} (0600)
host: /usr/local/sbin/felhom-mkfs-guarded + /usr/local/sbin/felhom-selfupdate-guarded
+ /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)
+ the 'sudo' package if absent + install state dir ${STATE_DIR}
+ 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
operator can disable it per-customer hub-side on request)
EOF
if $SKIP_PROVISION; then
echo " guest: none (--skip-provision)"
@@ -1250,7 +1283,7 @@ EOF
echo " + the golden vzdump imported onto storage '${ARCHIVE_STORAGE}'"
fi
echo " update: operator-signed self-update authority: ${RESOLVED_OP_ID:-NONE (self-update stays dormant)}"
echo " NOT touched in byo mode: root@pam (no break-glass), host DNS (:53), WireGuard tunnels, OOB sshd."
echo " NOT touched in byo mode: root@pam (no break-glass), host DNS (:53), OOB sshd."
echo ""
if $DRY_RUN; then
log_dry "would prompt: Type this host's short hostname ($(hostname -s)) to acknowledge the byo install"
@@ -1367,10 +1400,20 @@ step_preflight() {
# --acl-storages existence (GL-2): the scoped ACL grants Datastore.* at each of these paths — a
# grant on a storage this box doesn't have silently protects nothing (campaign-2 R2 class). byo
# refuses; appliance warns (the default set includes felhom-pbs, absent on most customer boxes).
# refuses; appliance warns. EXCEPTION (v1.15.0, drill retrofit-ACL lesson): `felhom-pbs` is
# EXPECTED absent on a fresh box — the PBS-DR tier apply creates it later, and the grant MUST
# be pre-positioned or the apply-bridge 403s (Datastore.Allocate on /storage/felhom-pbs — the
# drill hit exactly this after narrowing --acl-storages). Do NOT narrow the set to silence
# the info line.
local _missing_storages=() _acls
for _acls in "${PVE_STORAGES[@]}"; do
pvesm status --storage "$_acls" >/dev/null 2>&1 || _missing_storages+=("$_acls")
if ! pvesm status --storage "$_acls" >/dev/null 2>&1; then
if [[ "$_acls" == "felhom-pbs" ]]; then
log_info " acl storage 'felhom-pbs' not present yet — expected: the PBS-DR tier creates it; the grant is pre-positioned deliberately"
else
_missing_storages+=("$_acls")
fi
fi
done
if [[ ${#_missing_storages[@]} -gt 0 ]]; then
if [[ "$MODE" == "byo" ]]; then
@@ -1380,7 +1423,7 @@ step_preflight() {
log_warn " acl storage '$_acls' not found (pvesm status) — its grant will point at nothing"
done
elif [[ "$MODE" == "byo" ]]; then
log_info " acl storages all present: ${PVE_STORAGES[*]}"
log_info " acl storages all present or pre-positioned: ${PVE_STORAGES[*]}"
fi
# Host DNS :53 gate (byo only) — GL-8/F6. In byo the agent's own lan-resolver stays OFF (a config
@@ -1629,7 +1672,11 @@ step_break_glass() {
-H "Authorization: Bearer $HOST_API_KEY" -H 'Content-Type: application/json' -d @- 2>/dev/null)
newpw="" # scrub the plaintext from the shell var the moment it is vaulted
case "$code" in
200) log_success " root@pam password set + vaulted to the hub (retrieve via the operator /admin path; never logged here)" ;;
200) log_success " root@pam password set + vaulted to the hub (retrieve via the operator /admin path; never logged here)"
# Drill F-8: say this OUT LOUD — the operator was live-surprised by a 401 at the PVE
# GUI. Pointer only, never the credential.
log_warn " NOTE: the root@pam password just CHANGED — the old one now fails at the PVE web GUI (:8006)."
log_warn " Retrieve the new one at hub → host page (vaulted recovery credential)." ;;
401|403) die "recovery-credential vault rejected ($code) — host key/authorization problem" ;;
*) die "recovery-credential vault failed (HTTP $code)" ;;
esac
@@ -1661,7 +1708,7 @@ step_agent_install() {
local url="$GITEA_BASE/api/packages/$GITEA_OWNER/generic/felhom-agent/$ART_AGENT_VER/felhom-agent"
log_info " fetching agent binary v$ART_AGENT_VER from Gitea …"
if $DRY_RUN; then
log_dry "curl -u <git> -o /tmp/felhom-agent.new $url ; verify sha256=$ART_AGENT_SHA ; install -m0755 -> $AGENT_BIN"
log_dry "$(_dry_auth_hint) -o /tmp/felhom-agent.new $url ; verify sha256=$ART_AGENT_SHA ; install -m0755 -> $AGENT_BIN"
else
local tmp; tmp=$(mktemp -t felhom-agent.XXXXXX)
fetch_verify "$url" "$tmp" "$ART_AGENT_SHA"
@@ -1689,6 +1736,21 @@ step_agent_install() {
log_success " sudo installed ($(sudo --version 2>/dev/null | head -1))"
fi
fi
# `age` (drill F-10) — the escrow ceremony's identity-wrap dependency. NOT installed by PVE by
# default; a fresh-box ceremony died on it live (the demo host only had it from its spike-era
# install). DR-tier-by-default: baked on every install, both modes. Idempotent.
if ! command -v age >/dev/null 2>&1; then
if $DRY_RUN; then
log_dry "apt-get install -y age # escrow ceremony identity-wrap dependency (F-10)"
else
log_info " installing the 'age' package (escrow ceremony identity-wrap dependency) …"
DEBIAN_FRONTEND=noninteractive apt-get install -y -q age >/dev/null 2>&1 \
|| { apt-get update -q >/dev/null 2>&1; DEBIAN_FRONTEND=noninteractive apt-get install -y -q age >/dev/null 2>&1; } \
|| die "failed to install the 'age' package (the escrow ceremony needs it)"
log_success " age installed ($(age --version 2>/dev/null | head -1))"
fi
fi
# Resolve visudo by absolute path too (non-login SSH PATH can miss /usr/sbin).
local VISUDO; VISUDO=$(command -v visudo 2>/dev/null || echo /usr/sbin/visudo)
@@ -1790,6 +1852,22 @@ step_agent_install() {
log_success " installed /usr/local/sbin/felhom-selfupdate-guarded (0755, the guarded A/B binary-swap path)"
fi
# PBS-DR apply wrapper (drill F-7) — the ONLY pbs-apply path the FELHOM_PBSDR sudoers alias
# permits. The sudoers has shipped the alias since the PBS-DR slice; the binary itself did not
# ship, so the 3 pbsdr-* capabilities were born DEGRADED ("binary not found") on every fresh
# box. DR-tier-by-default (2026-07-12): capability is baked on EVERY install (activation is a
# hub flag). Install BEFORE the sudoers, 0755 root:root, bash -n first — the mkfs pattern.
if $DRY_RUN; then
log_dry "fetch configs/felhom-pbs-apply ; bash -n ; install 0755 -> /usr/local/sbin/felhom-pbs-apply"
else
local patmp; patmp=$(mktemp -t felhom-pbsapply.XXXXXX)
fetch_raw "configs/felhom-pbs-apply" "$patmp"
bash -n "$patmp" || { rm -f "$patmp"; die "fetched felhom-pbs-apply failed bash -n — refusing to install"; }
install -m 0755 -o root -g root "$patmp" /usr/local/sbin/felhom-pbs-apply
rm -f "$patmp"
log_success " installed /usr/local/sbin/felhom-pbs-apply (0755, the guarded PBS-DR apply path)"
fi
# Sudoers — fetch the canonical file, validate with visudo -cf BEFORE installing (0440 root:root).
if $DRY_RUN; then
log_dry "fetch configs/felhom-agent.sudoers ; visudo -cf ; install 0440 -> $AGENT_SUDOERS"
@@ -1983,8 +2061,8 @@ step_agent_config() {
fi
if [[ "$MODE" == "byo" ]]; then
log_dry "assert (byo) written config: lan_resolver.enable is false/absent"
log_dry "assert (byo) written config: wg_tunnel.enabled is false/absent"
log_dry "assert (byo) written config: oob.enabled is false/absent"
log_dry "(byo NOTE: wg_tunnel.enabled=true is now the DEFAULT both modes — WG is base infrastructure; consent = the typed disclosure ack)"
fi
log_dry "systemctl restart felhom-agent && felhom-agent --config $AGENT_CONFIG --selftest"
_state_mark agent_config; return 0
@@ -2036,6 +2114,13 @@ if os.environ.get('INSTALL_MODE') == 'byo':
base.setdefault('lan_resolver', {"enable": False})
else:
base.setdefault('lan_resolver', {"enable": True})
# WG tunnel (drill F-9; operator decision 2026-07-12 #5): WG is BASE infrastructure like
# cloudflared — always-on for every install, both modes (it is also the future OOB-management
# transport; the PBS-DR tier merely RIDES it). Registration is hands-free (the drill proved it:
# keygen -> hub peer 10.77.0.x/32 -> handshake, no vouch). setdefault, not override: a
# --preserve-from config that deliberately disabled it is respected (the per-customer opt-out
# lives hub-side; byo consent = the disclosure ack + the tester agreement).
base.setdefault('wg_tunnel', {"enabled": True})
# Day-0 overrides (always authoritative)
base['proxmox'] = {
"endpoint":"https://127.0.0.1:8006",
@@ -2079,20 +2164,22 @@ PY
# a customer/BYO box until their production endpoints exist. Parse the JUST-WRITTEN file (same
# interpreter pattern as the write above) and refuse to start the daemon on any true value —
# a --preserve-from carrying lan_resolver.enable=true is exactly what this catches.
# NOTE (v1.15.0 / decision 5): wg_tunnel.enabled was REMOVED from the byo-forbidden set — WG is
# base infrastructure (always-on both modes, like cloudflared); byo consent moved to the typed
# disclosure ack (the WG tunnel is now on that list) + the tester-agreement disclosure line.
if [[ "$MODE" == "byo" ]]; then
AGENT_CONFIG="$AGENT_CONFIG" python3 <<'PY' || die "byo config assert FAILED — see the keys above; a byo box must keep lan_resolver/wg_tunnel/oob off and deployment_mode=byo (fix the preserved config and re-run with --resume)"
AGENT_CONFIG="$AGENT_CONFIG" python3 <<'PY' || die "byo config assert FAILED — see the keys above; a byo box must keep lan_resolver/oob off and deployment_mode=byo (fix the preserved config and re-run with --resume)"
import json, os, sys
d = json.load(open(os.environ['AGENT_CONFIG']))
bad = []
if d.get('lan_resolver', {}).get('enable'): bad.append('lan_resolver.enable (takes over host DNS on :53)')
if d.get('wg_tunnel', {}).get('enabled'): bad.append('wg_tunnel.enabled (opens an outbound WG tunnel)')
if d.get('oob', {}).get('enabled'): bad.append('oob.enabled (starts a second operator sshd)')
if d.get('deployment_mode') == 'appliance': bad.append('deployment_mode=appliance (unlocks host-service self-heal on a host we do not own)')
if bad:
print('byo-forbidden config keys are TRUE: ' + '; '.join(bad), file=sys.stderr)
sys.exit(1)
PY
log_success " byo config asserts passed (lan_resolver.enable / wg_tunnel.enabled / oob.enabled off; deployment_mode=byo)"
log_success " byo config asserts passed (lan_resolver.enable / oob.enabled off; deployment_mode=byo; wg_tunnel intentionally ON — base infrastructure)"
fi
# health: read-only selftest (proxmox) must pass before provisioning
@@ -2144,7 +2231,7 @@ step_golden() {
local url="$GITEA_BASE/api/packages/$GITEA_OWNER/generic/felhom-golden/$ART_GOLDEN_VER/golden.tar.zst"
if $DRY_RUN; then
log_dry "curl -u <git> -o <dump>/vzdump-lxc-${GOLDEN_VMID}-<ts>.tar.zst $url ; verify sha256=$ART_GOLDEN_SHA ; set GOLDEN_VOLID"
log_dry "$(_dry_auth_hint) -o <dump>/vzdump-lxc-${GOLDEN_VMID}-<ts>.tar.zst $url ; verify sha256=$ART_GOLDEN_SHA ; set GOLDEN_VOLID"
GOLDEN_VOLID="${ARCHIVE_STORAGE}:backup/vzdump-lxc-${GOLDEN_VMID}-<dry-run>.tar.zst"
_state_mark golden; return 0
fi
@@ -2267,6 +2354,10 @@ step_verify() {
else
log_warn "Day-0 provision completed WITH WARNINGS — review the checks above"
fi
# Drill F-8: repeat the break-glass pointer in the FINAL summary (the 4b line scrolls away).
if _state_has break_glass; then
log_info "root@pam was rotated + vaulted at step 4b — retrieve at hub → host page (the old GUI password no longer works)."
fi
}
#-------------------------------------------------------------------------------
+117
View File
@@ -0,0 +1,117 @@
# -*- 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 Setup-tab const (hub internal/web/configs.go
hostInstallVersion) equals SCRIPT_VERSION (F-1 structural fix)
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")
# hub Setup-tab const must equal SCRIPT_VERSION (the copy the drill found at 1.12.0).
if os.path.exists(HUB_CONFIGS) and script_ver:
with io.open(HUB_CONFIGS, "r", encoding="utf-8") as f:
hub_src = f.read()
hm = re.search(r'hostInstallVersion\s*=\s*"(\d+\.\d+\.\d+)"', hub_src)
if not hm:
fail("hub hostInstallVersion const not found in internal/web/configs.go")
elif hm.group(1) != script_ver:
fail("hub Setup-tab hostInstallVersion=%s != SCRIPT_VERSION=%s (F-1: bump both together)"
% (hm.group(1), script_ver))
else:
ok("hub Setup-tab hostInstallVersion matches (%s)" % hm.group(1))
else:
if not os.path.exists(HUB_CONFIGS):
fail("hub/internal/web/configs.go not found — cannot cross-check the Setup-tab version")
# ── 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")
print()
if fails:
print("hostinstall gates: %d FAILURE(S)" % len(fails))
sys.exit(1)
print("hostinstall gates: ALL PASS")