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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 21:16:02 +02:00
parent 85de3f9b87
commit 17af3b65f6
2 changed files with 53 additions and 7 deletions
+10 -6
View File
@@ -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
+43 -1
View File
@@ -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