diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index f401ff35..b5b43d9a 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -1101,6 +1101,19 @@ _remove_network_storage_units() { return 0 } +# _require_tty WHAT — refuse, in words, when no terminal can be opened (R-310). The uninstall's typed +# confirmation reads /dev/tty on purpose and --force does NOT bypass it: an irreversible destroy is not +# scriptable without a person. Without this, an unattended run died on +# "line N: /dev/tty: No such device or address", which reads as a crash, not as a refusal. +# Pinned by scripts/test_hostinstall.py (test_require_tty_*). +_require_tty() { + if ! { : < /dev/tty; } 2>/dev/null; then + die "$1 needs a terminal, and none is attached — nothing was destroyed. + This is deliberate: an irreversible teardown is never confirmed unattended (--force does not skip it). + Run it from an interactive shell (for example ssh -t root@ ...)." + fi +} + run_uninstall() { log_step "UNINSTALL — local host teardown" @@ -1151,6 +1164,7 @@ run_uninstall() { log_dry "would prompt: Type the vmid ($vmid) to confirm PERMANENT destruction" else local ans + _require_tty "the typed vmid confirmation" read -rp "Type the vmid ($vmid) to confirm PERMANENT destruction: " ans < /dev/tty [[ "$ans" == "$vmid" ]] || die "confirmation mismatch (got '$ans', expected '$vmid') — aborting, nothing destroyed" fi @@ -3268,8 +3282,13 @@ step_golden() { fi if $GOLDEN_VOLID_EXPLICIT; then # The operator named this archive. Never silently substitute a different one. + # R-310: name the vouched version once — the "it is controller X, but the vouched golden + # is Y" reason already says it. + local _vouched_line="" + [[ -n "$ART_GOLDEN_VER" && "$GOLDEN_CHECK_WHY" == *"$ART_GOLDEN_VER"* ]] \ + || _vouched_line="The vouched golden is ${ART_GOLDEN_VER:-}. " die "refusing the golden you named ($GOLDEN_VOLID): ${GOLDEN_CHECK_WHY}. - The vouched golden is ${ART_GOLDEN_VER:-}. Either pass the archive that matches it, + ${_vouched_line}Either pass the archive that matches it, or re-run with --force-gitea-golden to fetch the vouched one from Gitea." fi log_warn " ignoring the local golden $GOLDEN_VOLID — ${GOLDEN_CHECK_WHY}" diff --git a/scripts/test_hostinstall.py b/scripts/test_hostinstall.py index c947f830..fd05f305 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); R-306 (--preflight-only writes no state); R-130 (lvm minimum wording); R-180 (archive storage outside the ACL set); R-179 (NAS units). +Rows: R-275, R-276, R-881 (uninstall residue); R-306 (--preflight-only writes no state); R-130 (lvm minimum wording); R-180 (archive storage outside the ACL set); R-179 (NAS units); R-310 (golden refusal wording, the uninstall's terminal). Run: python3 scripts/test_hostinstall.py """ import os @@ -515,6 +515,79 @@ def test_net_unit_marker_matches_the_agent(): assert a and a.group(1) == m.group(1), "installer marker %r != agent netUnitMarker %r" % (m.group(1), a and a.group(1)) +# ── R-310: the golden refusal names the vouched version once; no terminal is a refusal, in words ── +GOLDEN_RUN = """ +golden_local_matches_manifest() { GOLDEN_CHECK_WHY="%s"; return 1; } +resolve_artifacts() { :; } +_state_mark() { :; } +GOLDEN_VOLID="local:backup/vzdump-lxc-9100-2026_08_03-07_33_00.tar.zst"; GOLDEN_VOLID_EXPLICIT=true +FORCE_GITEA_GOLDEN=false; ART_GOLDEN_VER="0.213.0" +step_golden +echo REACHED +""" + + +def _golden_refusal(why): + sb = Sandbox() + try: + rc, out = sb.run(prelude() + func("step_golden") + GOLDEN_RUN % why) + assert rc != 0 and "REACHED" not in out and "refusing the golden you named" in out, out + return out + finally: + sb.close() + + +def test_golden_refusal_names_the_vouched_version_once(): + out = _golden_refusal("it is controller 0.192.0, but the vouched golden is 0.213.0") + assert out.count("0.213.0") == 1, "the vouched version is stated %d times:\n%s" % (out.count("0.213.0"), out) + + +def test_golden_refusal_still_names_the_version_when_the_reason_does_not(): + out = _golden_refusal("the archive could not be resolved to a file on disk") + assert "The vouched golden is 0.213.0." in out, out + + +def _tty_run(with_tty): + import fcntl + import termios + script = prelude() + func("_require_tty") + '_require_tty "the typed vmid confirmation"\necho PASSED\n' + if not with_tty: + p = subprocess.run(["bash", "-c", script], capture_output=True, text=True, stdin=subprocess.DEVNULL, + start_new_session=True) + return p.returncode, p.stdout + p.stderr + import pty + master, slave = pty.openpty() + + def ctty(): + os.setsid() + fcntl.ioctl(slave, termios.TIOCSCTTY, 0) + try: + p = subprocess.run(["bash", "-c", script], capture_output=True, text=True, stdin=slave, preexec_fn=ctty) + return p.returncode, p.stdout + p.stderr + finally: + os.close(master); os.close(slave) + + +def test_require_tty_refuses_in_words_without_a_terminal(): + rc, out = _tty_run(False) + assert rc != 0 and "PASSED" not in out, out + assert "needs a terminal" in out and "nothing was destroyed" in out, out + assert "No such device" not in out, out + + +def test_require_tty_passes_with_a_terminal(): + # the control: with a controlling terminal the guard lets the prompt happen. + rc, out = _tty_run(True) + assert rc == 0 and "PASSED" in out, out + + +def test_require_tty_guards_the_uninstall_prompt(): + body = func("run_uninstall") + g = body.find('_require_tty "the typed vmid confirmation"') + r = body.find('read -rp "Type the vmid') + assert g > 0 and g < r, "the vmid confirmation is not guarded by _require_tty" + + def main(): tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)] fails = 0