From 17af3b65f6ae509ee8c387105aa51899817023c3 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 5 Oct 2026 21:16:02 +0200 Subject: [PATCH] R-306: --preflight-only writes no state _state_put and _state_mark are no-ops under --preflight-only as well as --dry-run, so the banner "no state written" is true and the dnsmasq ownership answer is recorded only by the real install's own preflight. The log says "would be recorded at install" on a preflight-only run. scripts/test_hostinstall.py: test_preflight_only_writes_no_state (+ its control and a no-other-writer check). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- scripts/felhom-host-install.sh | 16 ++++++++----- scripts/test_hostinstall.py | 44 +++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index 1d72cf05..3d7727bf 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -403,8 +403,12 @@ _state_has() { [[ -f "$STATE_FILE" ]] || return 1 STATE_FILE="$STATE_FILE" python3 -c "import json,os,sys;f=os.environ['STATE_FILE'];d=json.load(open(f));sys.exit(0 if sys.argv[1] in d.get('completed',[]) else 1)" "$1" 2>/dev/null } +# Both writers are no-ops under --dry-run AND --preflight-only (R-306): the preflight-only banner says +# "no state written", and a value recorded by a preflight-only run (dnsmasq ownership) would otherwise be +# baked in before the real install's own preflight. Pinned by scripts/test_hostinstall.py +# (test_preflight_only_writes_no_state). _state_mark() { - $DRY_RUN && return 0 + { $DRY_RUN || $PREFLIGHT_ONLY; } && return 0 mkdir -p "$STATE_DIR" STATE_FILE="$STATE_FILE" python3 -c "import json,os,sys;f=os.environ['STATE_FILE'];d=json.load(open(f)) if os.path.exists(f) else {'completed':[]};c=d.setdefault('completed',[]);(c.append(sys.argv[1]) if sys.argv[1] not in c else None);json.dump(d,open(f,'w'),indent=2)" "$1" } @@ -413,9 +417,9 @@ should_skip() { # returns 0 (skip) if --resume AND step already done return 1 } # _state_put KEY VALUE — set a top-level string key in state.json (creates the file if absent). -# Mirrors _state_mark: dry-run no-ops (writes nothing), robust JSON via python3. +# Mirrors _state_mark: dry-run and preflight-only no-op (write nothing), robust JSON via python3. _state_put() { - $DRY_RUN && return 0 + { $DRY_RUN || $PREFLIGHT_ONLY; } && return 0 mkdir -p "$STATE_DIR" STATE_FILE="$STATE_FILE" python3 -c "import json,os,sys;f=os.environ['STATE_FILE'];d=json.load(open(f)) if os.path.exists(f) else {'completed':[]};d[sys.argv[1]]=sys.argv[2];json.dump(d,open(f,'w'),indent=2)" "$1" "$2" } @@ -1965,14 +1969,14 @@ step_preflight() { # rule as before, applied one level finer. if command -v dpkg-query >/dev/null 2>&1 && dpkg-query -W -f='${Status}' dnsmasq 2>/dev/null | grep -q "install ok installed"; then _state_put dnsmasq_preexisting yes - log_info " dnsmasq: already installed BEFORE Felhom — recorded; uninstall will not touch it" + log_info " dnsmasq: already installed BEFORE Felhom — $($PREFLIGHT_ONLY && echo "would be recorded at install" || echo recorded); uninstall will not touch it" else _state_put dnsmasq_preexisting no - log_info " dnsmasq: not present before Felhom — recorded; uninstall will remove it again if we install it" + log_info " dnsmasq: not present before Felhom — $($PREFLIGHT_ONLY && echo "would be recorded at install" || echo recorded); uninstall will remove it again if we install it" fi if command -v dpkg-query >/dev/null 2>&1 && dpkg-query -W -f='${Status}' dnsmasq-base 2>/dev/null | grep -q "install ok installed"; then _state_put dnsmasq_base_preexisting yes - log_info " dnsmasq-base: already installed BEFORE Felhom — recorded; uninstall will leave it" + log_info " dnsmasq-base: already installed BEFORE Felhom — $($PREFLIGHT_ONLY && echo "would be recorded at install" || echo recorded); uninstall will leave it" else _state_put dnsmasq_base_preexisting no fi diff --git a/scripts/test_hostinstall.py b/scripts/test_hostinstall.py index 3d52f1e0..5bd76384 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). +Rows: R-275, R-276, R-881 (uninstall residue); R-306 (--preflight-only writes no state). Run: python3 scripts/test_hostinstall.py """ import os @@ -330,6 +330,48 @@ def test_bundle_list_is_current(): assert not new, "the agent bundle installs file(s) this test (and maybe the uninstall) does not know: %s" % new +# ── R-306: --preflight-only writes no state ────────────────────────────────────────────────────── +def _state_run(sb, preflight_only, dry=False): + return sb.run(prelude(dry=dry) + func("_state_mark") + func("_state_put") + func("_state_get") + + 'PREFLIGHT_ONLY=%s\nSTATE_DIR="$SB/state"; STATE_FILE="$SB/state/state.json"\n' + '_state_put dnsmasq_preexisting yes\n_state_mark preflight\necho "GOT=$(_state_get dnsmasq_preexisting)"\n' + % ("true" if preflight_only else "false")) + + +def test_preflight_only_writes_no_state(): + sb = Sandbox() + try: + rc, out = _state_run(sb, preflight_only=True) + assert rc == 0, out + assert not os.path.exists(sb.path("state", "state.json")), \ + "--preflight-only wrote state.json: %s" % open(sb.path("state", "state.json")).read() + assert "GOT=\n" in out + "\n", out + finally: + sb.close() + + +def test_install_preflight_does_write_state(): + # the control: the same helpers DO write on a real install, or the test above proves nothing. + sb = Sandbox() + try: + rc, out = _state_run(sb, preflight_only=False) + assert rc == 0, out + assert "GOT=yes" in out, out + assert '"preflight"' in open(sb.path("state", "state.json")).read() + finally: + sb.close() + + +def test_state_json_has_no_other_writer(): + # every write must go through the two guarded helpers; a direct write would bypass the guard. + bad = [l.strip() for l in SRC.splitlines() + if re.search(r'>\s*"?\$STATE_FILE|json\.dump\(d,open\(f', l) + and not re.match(r"\s*STATE_FILE=\"\$STATE_FILE\" python3 -c", l)] + assert not bad, "state.json written outside _state_mark/_state_put: %s" % bad + body = func("step_preflight") + assert "_state_put dnsmasq_preexisting" in body, "the ownership record no longer goes through _state_put" + + def main(): tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)] fails = 0