scripts/iso R-59+R-60: the first-boot network gate — refuse loudly, sweep the NICs, never silently unreachable (v1.24.0 train, part 2/3)
network_gate() runs before mode dispatch. Hub reachable -> return with ZERO new behavior (asserted). Unreachable -> diagnose (physical-NIC table with raise-first carrier read; installer 192.168.100.2-fallback signature named when present), then — ONLY while no install state file exists, doubly confined by the unit's ConditionPathExists=!done-flag — sweep: per candidate re-point vmbr0's bridge-ports (atomic tmp+mv, always derived from the pristine copy) -> ifreload -> bounded dhclient (20s) -> hub probe (any HTTP status = reachable; the hub answers 302). First success persists winner + DHCP addressing (original kept as interfaces.felhom-bak) and proceeds; total failure restores the config BYTE-IDENTICALLY, paints a calm Hungarian console screen (spec copy, print_pairing_banner pattern) and retries every 60s with the unit held in activating — waiting is not failing (v1.21.0). Trigger is always hub-unreachable, never the fallback signature (spike F-P6: the truth-changed-after-install variant has no signature). Design inputs: SPIKE-firstboot-nic-sweep-2026-07-22 F-P1..F-P8. Harness: +4 scenarios (G1 zero-behavior in D incl. zero-sleeps + fixture-untouched, G2 sweep-success/persist/proceed, G3 sweep-fail byte-identical + screen content, G4 state-file-present = never sweep), gate seams (FELHOM_NET_SYS/INTERFACES_FILE/CONSOLE_DEV), ip/ifreload/ dhclient fakes, hub-probe oracle incl. follow:<nic> mode. Red-proofs run and restored: (A) restore-on-failure dropped -> G3 byte-identical FAILs; (B) state gate removed -> G4 zero-call FAILs; (C) early return removed -> all five G1 invariants FAIL. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UuFPHmHNrCJj1VhY6QdDMU
This commit is contained in:
@@ -9,6 +9,15 @@
|
||||
# invocation (fake sleep counts the waits; the script never exits between polls),
|
||||
# then consumes the 200 delivery, writes the direct env and runs host-install.
|
||||
# 410: a consumed-delivery poll exits non-zero (the crash-window hand-back to systemd).
|
||||
# G1 (in D+P): hub reachable -> the network gate returns with ZERO new behavior — no ip/
|
||||
# ifreload/dhclient fake is ever touched (B'-style invariant).
|
||||
# G2 sweep-ok: hub unreachable + no state.json -> the sweep re-points vmbr0 to the NIC that
|
||||
# reaches the hub, persists atomically (winner + inet dhcp; original saved as
|
||||
# interfaces.felhom-bak), logs "network self-heal", and PROCEEDS to pairing.
|
||||
# G3 sweep-fail: no NIC reaches the hub -> console screen painted AND the interfaces file is
|
||||
# BYTE-IDENTICAL (a failed sweep must never leave a half-rewritten config).
|
||||
# G4 state-file: state.json present -> screen + retry only; the sweep is NEVER invoked
|
||||
# (ifreload/dhclient call-count zero), interfaces untouched.
|
||||
# (Updated for v1.21.0/R-33: the old one-poll-per-invocation scenarios expected a non-zero exit on
|
||||
# 204, which the in-script wait deliberately no longer does — waiting is not failing.)
|
||||
set -uo pipefail
|
||||
@@ -20,17 +29,41 @@ fail=0
|
||||
say() { echo "TEST: $*"; }
|
||||
check() { if eval "$2"; then echo " ok: $1"; else echo " FAIL: $1"; fail=1; fi; }
|
||||
|
||||
# --- fake sleep: counts waits; flips the poll to 200 after 3, hard-aborts a runaway loop ------------
|
||||
# --- fake sleep: counts waits; flips the poll to 200 after 3, hard-aborts a runaway loop. The gate
|
||||
# scenarios (G3/G4) use /work/sleep.abort to end the deliberately-infinite screen+retry loop. ----
|
||||
cat > "$FAKE/sleep" <<'SLEEP'
|
||||
#!/bin/bash
|
||||
n=$(cat /work/sleep.count 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > /work/sleep.count
|
||||
flip=$(cat /work/sleep.flip 2>/dev/null || echo "")
|
||||
[ -n "$flip" ] && [ "$n" -ge "$flip" ] && echo 200 > /work/poll-mode
|
||||
if [ "$n" -gt 25 ]; then echo "RUNAWAY: fake sleep hit $n calls — killing the loop" >&2; kill -TERM $PPID; fi
|
||||
abort=$(cat /work/sleep.abort 2>/dev/null || echo 25)
|
||||
# abort only on WAIT-length sleeps (>=30s: the poll/retry waits) — never the gate's 2s diag settle,
|
||||
# which runs inside a command substitution: killing that subshell would blank the last screen paint.
|
||||
if [ "$n" -gt "$abort" ] && [ "${1:-0}" -ge 30 ]; then
|
||||
echo "ABORT: fake sleep hit $n calls — killing the loop" >&2; kill -TERM $PPID
|
||||
fi
|
||||
exit 0
|
||||
SLEEP
|
||||
chmod +x "$FAKE/sleep"
|
||||
|
||||
# --- gate fakes: ip / ifreload / dhclient log their calls; dhclient rc comes from /work/dhcp-mode ---
|
||||
cat > "$FAKE/ip" <<'IP'
|
||||
#!/bin/bash
|
||||
echo "$*" >> /work/ip.log
|
||||
exit 0
|
||||
IP
|
||||
cat > "$FAKE/ifreload" <<'IFR'
|
||||
#!/bin/bash
|
||||
echo "$*" >> /work/ifreload.log
|
||||
exit 0
|
||||
IFR
|
||||
cat > "$FAKE/dhclient" <<'DH'
|
||||
#!/bin/bash
|
||||
echo "$*" >> /work/dhclient.log
|
||||
exit "$(cat /work/dhcp-mode 2>/dev/null || echo 0)"
|
||||
DH
|
||||
chmod +x "$FAKE/ip" "$FAKE/ifreload" "$FAKE/dhclient"
|
||||
|
||||
# --- fake curl: logs every invocation's URL; emulates -o (fetch), --data (register), -w code (poll) --
|
||||
cat > "$FAKE/curl" <<'CURL'
|
||||
#!/bin/bash
|
||||
@@ -43,6 +76,17 @@ for a in "$@"; do
|
||||
done
|
||||
echo "$url" >> /work/curl.log
|
||||
mode=$(cat /work/poll-mode 2>/dev/null || echo 204)
|
||||
case "$url" in
|
||||
*"hub.example/")
|
||||
# the network gate's reachability probe (-o /dev/null -w '%{http_code}').
|
||||
# /work/hub-mode: absent|up -> 302; down -> 000/exit 7; follow:<nic> -> up only when the
|
||||
# interfaces fixture has bridge-ports <nic> (the sweep's "this NIC reaches the hub" oracle).
|
||||
hm=$(cat /work/hub-mode 2>/dev/null || echo up)
|
||||
case "$hm" in
|
||||
follow:*) if grep -q "bridge-ports ${hm#follow:}\$" /work/interfaces 2>/dev/null; then hm=up; else hm=down; fi ;;
|
||||
esac
|
||||
if [ "$hm" = "up" ]; then printf '302'; exit 0; else printf '000'; exit 7; fi ;;
|
||||
esac
|
||||
case "$url" in
|
||||
*"/felhom-host-install.sh")
|
||||
# write a stub host-install to the -o target
|
||||
@@ -74,11 +118,43 @@ printf '#!/bin/bash\nexit 0\n' > "$FAKE/systemctl"; chmod +x "$FAKE/systemctl"
|
||||
|
||||
reset_state() {
|
||||
rm -rf /etc/felhom /run/felhom-bootstrap-pass /var/lib/felhom-install "$CALLS" /work/hostinstall.log \
|
||||
/work/poll-mode /work/sleep.count /work/sleep.flip
|
||||
/work/poll-mode /work/sleep.count /work/sleep.flip /work/sleep.abort \
|
||||
/work/ip.log /work/ifreload.log /work/dhclient.log /work/hub-mode /work/dhcp-mode \
|
||||
/work/sys /work/interfaces /work/interfaces.felhom-bak /work/console.out \
|
||||
/run/felhom-interfaces.orig /work/run.log
|
||||
mkdir -p /etc/felhom
|
||||
}
|
||||
|
||||
# gate fixture: two "physical" NICs (nicA = the configured bridge-port, nicB = the other one) and an
|
||||
# interfaces file in the installer-fallback shape (static 192.168.100.2 on vmbr0 -> nicA).
|
||||
make_gate_fixture() {
|
||||
mkdir -p /work/sys/nicA /work/sys/nicB /work/sys/vmbr0 /work/sys/lo
|
||||
touch /work/sys/nicA/device /work/sys/nicB/device # only these two count as physical
|
||||
echo "aa:aa:aa:aa:aa:0a" > /work/sys/nicA/address; echo 1 > /work/sys/nicA/carrier; echo 1000 > /work/sys/nicA/speed
|
||||
echo "bb:bb:bb:bb:bb:0b" > /work/sys/nicB/address; echo 1 > /work/sys/nicB/carrier; echo -1 > /work/sys/nicB/speed
|
||||
cat > /work/interfaces <<'IFACES'
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
auto vmbr0
|
||||
iface vmbr0 inet static
|
||||
address 192.168.100.2/24
|
||||
bridge-ports nicA
|
||||
bridge-stp off
|
||||
bridge-fd 0
|
||||
|
||||
iface nicA inet manual
|
||||
|
||||
iface nicB inet manual
|
||||
|
||||
source /etc/network/interfaces.d/*
|
||||
IFACES
|
||||
}
|
||||
GATE_ENV="FELHOM_NET_SYS=/work/sys FELHOM_INTERFACES_FILE=/work/interfaces FELHOM_CONSOLE_DEV=/work/console.out"
|
||||
|
||||
# ============================ Scenario D — direct mode, zero appliance calls =========================
|
||||
# Runs WITH the gate fixture (NICs + fallback-shaped interfaces) and the hub reachable: the G1
|
||||
# invariant below is only meaningful if the gate HAD candidates to touch and touched none.
|
||||
say "D: direct env -> run_direct, NO appliance calls"
|
||||
reset_state
|
||||
cat > /etc/felhom/bootstrap.env <<'ENV'
|
||||
@@ -88,13 +164,21 @@ FELHOM_RETRIEVAL_PASSPHRASE=direct-pass
|
||||
FELHOM_HUB_URL=https://hub.example
|
||||
ENV
|
||||
chmod 0600 /etc/felhom/bootstrap.env
|
||||
bash "$BSTRAP"; rc=$?
|
||||
make_gate_fixture
|
||||
env $GATE_ENV bash "$BSTRAP"; rc=$?
|
||||
check "run_direct exited 0 (host-install stub succeeded)" "[ $rc -eq 0 ]"
|
||||
check "host-install was invoked" "[ -f /work/hostinstall.log ]"
|
||||
check "ZERO /appliance/register calls" "! grep -q '/appliance/register' $CALLS"
|
||||
check "ZERO /appliance/poll calls" "! grep -q '/appliance/poll' $CALLS"
|
||||
check "done-flag written" "[ -f /etc/felhom/.bootstrap-done ]"
|
||||
check "env shredded on success" "[ ! -f /etc/felhom/bootstrap.env ]"
|
||||
# G1 (zero-new-behavior invariant): with the hub reachable, the gate returned before ANY network
|
||||
# tooling was touched — no ip, no ifreload, no dhclient call exists.
|
||||
check "G1: gate made zero ip calls" "[ ! -f /work/ip.log ]"
|
||||
check "G1: gate made zero ifreload calls" "[ ! -f /work/ifreload.log ]"
|
||||
check "G1: gate made zero dhclient calls" "[ ! -f /work/dhclient.log ]"
|
||||
check "G1: gate consumed zero sleeps" "[ ! -f /work/sleep.count ]"
|
||||
check "G1: interfaces fixture untouched" "grep -q 'bridge-ports nicA' /work/interfaces && grep -q '192.168.100.2' /work/interfaces"
|
||||
|
||||
# ============ P: pairing loop (v1.21.0) — register, wait unbound INSIDE one invocation, deliver =====
|
||||
say "P: pairing env -> register + in-script 204 wait -> 200 delivery -> host-install, ONE invocation"
|
||||
@@ -126,6 +210,64 @@ bash "$BSTRAP"; rc=$?
|
||||
check "410 poll exits non-zero" "[ $rc -ne 0 ]"
|
||||
check "host-install NOT run" "[ ! -f /work/hostinstall.log ]"
|
||||
|
||||
# ============ G2: sweep-success — re-point to the hub-reaching NIC, persist, proceed ================
|
||||
say "G2: hub down + no state.json -> sweep finds nicB, persists (bak kept), proceeds to pairing"
|
||||
reset_state
|
||||
cat > /etc/felhom/bootstrap.env <<'ENV'
|
||||
FELHOM_HUB_URL=https://hub.example
|
||||
ENV
|
||||
make_gate_fixture
|
||||
echo "follow:nicB" > /work/hub-mode # hub reachable ONLY once vmbr0 points at nicB
|
||||
echo 410 > /work/poll-mode # after the gate, exit the pairing loop promptly
|
||||
env $GATE_ENV bash "$BSTRAP" > /work/run.log 2>&1; rc=$?
|
||||
check "sweep re-pointed vmbr0 to nicB" "grep -q 'bridge-ports nicB' /work/interfaces"
|
||||
check "winner persisted with DHCP addressing" "grep -q 'iface vmbr0 inet dhcp' /work/interfaces"
|
||||
check "fallback static address dropped" "! grep -q '192.168.100.2' /work/interfaces"
|
||||
check "original saved as interfaces.felhom-bak" "[ -f /work/interfaces.felhom-bak ] && grep -q 'bridge-ports nicA' /work/interfaces.felhom-bak"
|
||||
check "self-heal logged loudly" "grep -q 'network self-heal: vmbr0 -> nicB' /work/run.log"
|
||||
check "proceeded to pairing after the heal" "grep -q '/appliance/register' $CALLS"
|
||||
check "sweep exercised ifreload" "[ -f /work/ifreload.log ]"
|
||||
|
||||
# ============ G3: sweep-fail — screen painted, interfaces BYTE-IDENTICAL ============================
|
||||
say "G3: hub down everywhere -> console screen painted, interfaces byte-identical, no bak"
|
||||
reset_state
|
||||
cat > /etc/felhom/bootstrap.env <<'ENV'
|
||||
FELHOM_HUB_URL=https://hub.example
|
||||
ENV
|
||||
make_gate_fixture
|
||||
echo down > /work/hub-mode
|
||||
echo 1 > /work/dhcp-mode # no NIC leases either
|
||||
echo 8 > /work/sleep.abort # end the deliberate screen+retry loop after a few cycles
|
||||
PRE_SHA=$(sha256sum /work/interfaces | awk '{print $1}')
|
||||
env $GATE_ENV bash "$BSTRAP" > /work/run.log 2>&1; rc=$?
|
||||
POST_SHA=$(sha256sum /work/interfaces | awk '{print $1}')
|
||||
check "console screen painted (header)" "grep -q 'Nincs hálózati kapcsolat' /work/console.out"
|
||||
check "screen lists the NICs" "grep -q 'nicA' /work/console.out && grep -q 'nicB' /work/console.out"
|
||||
check "screen names the fallback signature" "grep -q '192.168.100.2' /work/console.out"
|
||||
check "screen carries the remedy line" "grep -q 'percenként újra próbálkozik' /work/console.out"
|
||||
check "interfaces BYTE-IDENTICAL after failed sweep" "[ \"$PRE_SHA\" = \"$POST_SHA\" ]"
|
||||
check "no .felhom-bak on failure (success-only persist)" "[ ! -f /work/interfaces.felhom-bak ]"
|
||||
check "host-install never ran" "[ ! -f /work/hostinstall.log ]"
|
||||
|
||||
# ============ G4: state.json present — screen only, the sweep is NEVER invoked ======================
|
||||
say "G4: hub down + state.json present -> screen + retry, sweep NEVER invoked, interfaces untouched"
|
||||
reset_state
|
||||
cat > /etc/felhom/bootstrap.env <<'ENV'
|
||||
FELHOM_HUB_URL=https://hub.example
|
||||
ENV
|
||||
make_gate_fixture
|
||||
mkdir -p /var/lib/felhom-install && touch /var/lib/felhom-install/state.json
|
||||
echo down > /work/hub-mode
|
||||
echo 8 > /work/sleep.abort
|
||||
PRE_SHA=$(sha256sum /work/interfaces | awk '{print $1}')
|
||||
env $GATE_ENV bash "$BSTRAP" > /work/run.log 2>&1; rc=$?
|
||||
POST_SHA=$(sha256sum /work/interfaces | awk '{print $1}')
|
||||
check "sweep NEVER invoked: zero ifreload calls" "[ ! -f /work/ifreload.log ]"
|
||||
check "sweep NEVER invoked: zero dhclient calls" "[ ! -f /work/dhclient.log ]"
|
||||
check "interfaces untouched" "[ \"$PRE_SHA\" = \"$POST_SHA\" ]"
|
||||
check "console screen still painted" "grep -q 'Nincs hálózati kapcsolat' /work/console.out"
|
||||
check "no-sweep branch logged" "grep -q 'no sweep, interfaces untouched' /work/run.log"
|
||||
|
||||
echo "=================================================="
|
||||
if [ $fail -eq 0 ]; then echo "ALL BOOTSTRAP-MODE TESTS PASSED"; else echo "SOME TESTS FAILED"; fi
|
||||
exit $fail
|
||||
|
||||
Reference in New Issue
Block a user