Files
bullpen/tests/test_no_hardcoded_hosts.py
marfrit 05f06ed6a9 portability: fix the two bugs @reviewer found in the portability commit itself (#100)
1. bin/bullpen-up ROSTER collapsed silently. `{cfg.ROOM_HOST: …, cfg.COORD_HOST: …,
   cfg.GRIND_HOST: …}` is a dict literal with CONFIGURABLE keys — a small fleet putting
   two roles on one machine (the plausible target of #100) lost the earlier entry
   without a word. That is the same silent-no-op the commit claimed to fix, one level
   down. Now a list of roles merged per host AND per scope, so a combined
   room+coordinator box brings up both its system and its user units. Verified: split
   roles unchanged (8 system + 12 user), collapsed onto one host keeps all 20.
   Also renamed the local `cfg` in main(), which shadowed the config module.

2. bin/bullpen-selfimprove posted as `from=$BULLPEN_COORD_HOST` — a room NICK confused
   with a HOSTNAME. "noether" is a privileged nick that merely happens to equal the
   coordinator's hostname here; on any other fleet the run would post as an unknown
   nick and be rejected silently, which the R4 comment directly above explains. New
   cfg.POST_NICK.

Test gaps @reviewer named, both closed:
  * shell entrypoints were entirely outside the AST check — and bullpen-selfimprove is
    Bash and was one of the offenders that commit had to fix. Added a grep-based check.
  * `fritz.box` was missing from the host list, though it is the strongest fleet marker
    there is. Adding it immediately surfaced three more real offenders: a hardcoded
    mneme URL in bullpen_participant, another in the lurker, and the legitimate
    last-resort fallback in bullpen_mem (exempted, with the reason).

Documented rather than fixed: %h resolves to the service manager's home regardless of
User=, and a stray /root/.local/bin copy would shadow every system unit — both now in
the units-test docstring. cfg.FLEET_HOSTS lost the `pve\d`/`dcw\d` families the regex
had, so a future pve5 needs adding by hand; noted at the definition. `orca` added.

57/57 across both portability suites. bullpen-up --dry-run on noether unchanged;
selfimprove composes a brief with zero origin-fleet mentions under overridden hosts.
2026-08-02 00:40:10 +02:00

152 lines
6.4 KiB
Python

"""No fleet hostname may reach a RUNTIME string in the Python entrypoints (#100).
Prose is fine — docstrings and comments explain why things are the way they are, and
"runs on boltzmann" is useful history. What must not survive is a hostname in a string
the program actually *uses*: an sic target, a URL, or a line of advice posted back into
the room. The last kind is the sneaky one — @dispatcher used to answer every routing
question with `sic hertz room-ask …`, so a foreign fleet was told to talk to a machine
it does not have.
The check is AST-based rather than grep-based for exactly that reason: grep cannot tell
`# grinds on boltzmann` (fine) from `sic("boltzmann", …)` (not fine), and a grep-based
rule would either fail on honest comments or have to be so narrow it misses the strings
that matter. Docstrings are located by position (first statement of module/class/def)
and excluded; every other string constant, including the literal parts of f-strings, is
checked.
lib/bullpen_config.py and lib/bullpen_config.sh are exempt: they are where the origin
fleet's values legitimately live, as the last fallback behind env and config file.
Known limits, all deliberate (the target is an oversight, not an adversary):
* A docstring that is *printed* at runtime (`print(__doc__)`, as bin/room_tail does)
counts as prose here — a usage text naming a fleet host would pass.
* Concatenation (`"her" + "tz"`) and bytes literals are not folded, so they slip past.
* Shell entrypoints get the grep-based check below rather than an AST walk.
"""
import ast
import os
import pytest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Deliberately NOT the full cfg.FLEET_HOSTS list: "nc" and "data" are ordinary words that
# would false-positive on every substring match. `fritz.box` is in — it is the strongest
# fleet marker there is, and a URL like "http://ampere.fritz.box:9090/" would otherwise
# sail through (@reviewer, 2026-08-02).
HOSTS = ("hertz", "noether", "boltzmann", "orca", "dcw2", "hossenfelder", "fritz.box")
EXEMPT = {
"lib/bullpen_config.py",
# holds the same last-resort default in its `except ImportError` branch, for the
# case where the config module is not on the path at all — there is nothing to
# resolve it from there, so the literal is the fallback of last resort.
"lib/bullpen_mem.py",
}
def _py_files():
out = []
for sub in ("bin", "lib", "lurker"):
d = os.path.join(REPO, sub)
if not os.path.isdir(d):
continue
for name in sorted(os.listdir(d)):
path = os.path.join(d, name)
if not os.path.isfile(path) or name.endswith((".sh", ".service", ".timer", ".md")):
continue
rel = f"{sub}/{name}"
if rel in EXEMPT:
continue
try:
src = open(path, encoding="utf-8").read()
except (OSError, UnicodeDecodeError):
continue
if src.startswith("#!") and "python" not in src.splitlines()[0]:
continue
out.append((rel, path))
return out
FILES = _py_files()
def _docstring_nodes(tree):
"""Constant nodes that are docstrings — prose, not runtime values."""
found = set()
for node in ast.walk(tree):
if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
continue
body = getattr(node, "body", None)
if (body and isinstance(body[0], ast.Expr)
and isinstance(body[0].value, ast.Constant)
and isinstance(body[0].value.value, str)):
found.add(id(body[0].value))
return found
@pytest.mark.parametrize("rel,path", FILES, ids=[r for r, _ in FILES])
def test_no_fleet_host_in_runtime_string(rel, path):
tree = ast.parse(open(path, encoding="utf-8").read(), filename=path)
skip = _docstring_nodes(tree)
offenders = []
for node in ast.walk(tree):
if not isinstance(node, ast.Constant) or not isinstance(node.value, str):
continue
if id(node) in skip:
continue
for host in HOSTS:
if host in node.value:
offenders.append(f"line {node.lineno}: {node.value[:70]!r}")
break
assert not offenders, (
f"{rel} puts a fleet hostname in a runtime string — use bullpen_config "
f"(cfg.ROOM_HOST / cfg.GRIND_HOST / cfg.COORD_HOST / cfg.PROXY / cfg.OC_URL):\n "
+ "\n ".join(offenders))
def _sh_files():
"""Shell entrypoints: bin/* with a sh/bash shebang, plus lib/*.sh."""
out = []
for sub in ("bin", "lib", "deploy"):
d = os.path.join(REPO, sub)
if not os.path.isdir(d):
continue
for name in sorted(os.listdir(d)):
path = os.path.join(d, name)
rel = f"{sub}/{name}"
if not os.path.isfile(path) or rel in {"lib/bullpen_config.sh"}:
continue
try:
first = open(path, encoding="utf-8").readline()
except (OSError, UnicodeDecodeError):
continue
if name.endswith(".sh") or ("sh" in first and first.startswith("#!")
and "python" not in first):
out.append((rel, path))
return out
SH_FILES = _sh_files()
@pytest.mark.parametrize("rel,path", SH_FILES, ids=[r for r, _ in SH_FILES] or ["none"])
def test_shell_entrypoint_has_no_fleet_host(rel, path):
"""The AST walk above cannot see shell. bin/bullpen-selfimprove is Bash and was one of
the offenders the config migration had to fix, so without this the regression is
unguarded there (@reviewer, 2026-08-02).
Comments are stripped crudely (a leading `#`), which is enough: the point is to catch a
hostname in a command or a message, and those do not live in comment lines."""
offenders = []
for n, ln in enumerate(open(path, encoding="utf-8").read().splitlines(), 1):
stripped = ln.strip()
if not stripped or stripped.startswith("#"):
continue
for host in HOSTS:
if host in ln:
offenders.append(f"line {n}: {ln.strip()[:70]}")
break
assert not offenders, (
f"{rel} names a fleet host outside a comment — source lib/bullpen_config.sh and use "
f"$BULLPEN_ROOM_HOST / $BULLPEN_GRIND_HOST / $BULLPEN_COORD_HOST:\n "
+ "\n ".join(offenders))