R-310: the golden refusal states the vouched version once; an uninstall with no terminal refuses in words

- The R-297 named-golden refusal no longer repeats "The vouched golden is X." when its reason
  already says it (kept when the reason does not name the version).
- --uninstall checks it can open /dev/tty before the typed vmid confirmation and, if not, refuses
  with a sentence (deliberate; --force does not skip it) instead of "/dev/tty: No such device".
scripts/test_hostinstall.py: test_golden_refusal_* (2), test_require_tty_* (3, with a pty control).
The runbook line naming the pty requirement is a documentation edit for the lead.

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:23:28 +02:00
parent c11d4fbf2e
commit b43705ac7b
2 changed files with 94 additions and 2 deletions
+20 -1
View File
@@ -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@<host> ...)."
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:-<unknown>}. "
die "refusing the golden you named ($GOLDEN_VOLID): ${GOLDEN_CHECK_WHY}.
The vouched golden is ${ART_GOLDEN_VER:-<unknown>}. 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}"
+74 -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); 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