phase: 33_gateway_route_switching

Phase 33 completion pass is done. Final report:

## Phase 33 — completion report

**All 8 task files complete** (`.agents/phases/complete/33_gateway_route_switching/`), full gate green, docs verified.

### Work in this pass

1. **`tests/test_routectl.py` added** (17 tests, all green) — exercises the *real* helper binary unprivileged against a fake tree (`--root`), the real agent parser, the captured gateway fixtures, and stubbed `nginx`/`haproxy`/`systemctl` on `PATH`. Pins the full protocol (locate → state → dry-run → temp-file/fsync/`os.replace` write → checker → restore on checker failure → reload-never-restart → re-read proof → sidecar record), all three route shapes (haproxy `server`, nginx `proxy_pass`, nginx stream `map` via its sink-value id), `drift` / unknown-id / `map-default` refusals, `list`/`verify`, and the `--serve` peer gate (`SO_PEERCRED` uid check, one JSON line per request, exact body shape).

2. **Real helper defect fixed** — `deploy/dashboards-routectl.py`: `main()`'s early-return paths returned `({"ok": False, …}, EXIT_*)` tuples, so `sys.exit()` printed a Python repr instead of a JSON line (usage errors, agent-import refusal). Now prints `json.dumps(…)` and returns only the code, keeping the one-JSON-line contract.

3. **Documentation consistency fixes** (task 08 criterion 6):
   - `README.md`: garbled verdict-colour sentence on the switch ("green and to the position") → "in the **on** position and tinted `--gate-on`" (the existence-pair contract).
   - `README.md`: audit sentence now names all three event kinds — `route-disabled` / `route-enabled` / **`route-denied`** (the code stores one per refused call; dry runs store none).
   - `page-gateway.js`: replaced the stale pre-33 module docstring ("the switch is not wired") with the two-arm `GATEWAY_ALLOW_WRITES` contract.
   - `AGENTS.md`: every phase-33 claim re-verified against code (the ten gates, the 35 s/30 s budget pair, `gateway.writes_enabled`, `/api/summary`'s `disabled`, the 1.4.0 agent bullet, the gap list) — no changes needed.

### Verification
- `uv run pytest --cov`: **1970 passed**, 18 skipped — coverage **98.74%** (floor 94%)
- `ruff` clean · `pyright` 0 errors · `bash .agents/validate.sh` **exit 0** (incl. `node --check` on every page script)
- Dev server running; all 10 sources `ok`; bundles exported

### Live infrastructure
**No live changes made.** The live drill remains unexecuted per `data/logs/NOTES-33-switching.md` (no operator approval; checker binaries unavailable in the podman proxy units). All routes stay enabled, config hashes unchanged, and the sandbox rehearsal + backup tree are in place for the operator's own `DANGER` call. All changes are in the working tree only.
This commit is contained in:
2026-11-01 11:50:51 -05:00
parent 0271387053
commit 86c8bf4992
91 changed files with 7568 additions and 519 deletions
+383 -1
View File
@@ -30,8 +30,10 @@ import importlib.util
import ipaddress
import json
import os
import pwd
import re
import shutil
import socket
import stat
import subprocess
import sys
@@ -367,7 +369,7 @@ def test_no_shell_function_is_named_after_a_coreutil_it_could_be_asked_to_run(sc
#: The commands of `install()` that would touch the machine running pytest. Each one is a
#: PATH stub that records its arguments and succeeds — the same trick `ScriptedNutClient` is
#: for the UPS wire: fake the privileged boundary, run the real logic.
ROOT_BOUNDARY_STUBS = ("id", "getent", "useradd", "usermod", "chown", "systemctl")
ROOT_BOUNDARY_STUBS = ("id", "getent", "useradd", "usermod", "chown", "systemctl", "sudo")
#: The three absolute destinations the installer writes to, rewritten into a temp directory
#: so the test exercises the body instead of the operator's `/usr` and `/etc`.
@@ -376,6 +378,16 @@ SANDBOX_REWRITES = {
'TOKEN_FILE="/etc/dashboards-agent.token"': 'TOKEN_FILE="{root}/token"',
'CERT_FILE="/etc/dashboards-agent.certs"': 'CERT_FILE="{root}/certs"',
'UNIT="/etc/systemd/system/$SERVICE.service"': 'UNIT="{root}/dashboards-agent.service"',
'ROUTECTL_TARGET="/usr/local/sbin/dashboards-routectl.py"':
'ROUTECTL_TARGET="{root}/sbin/dashboards-routectl.py"',
'ROUTECTL_SOCKET_UNIT="/etc/systemd/system/dashboards-routectl.socket"':
'ROUTECTL_SOCKET_UNIT="{root}/dashboards-routectl.socket"',
'ROUTECTL_SERVICE_UNIT="/etc/systemd/system/dashboards-routectl@.service"':
'ROUTECTL_SERVICE_UNIT="{root}/dashboards-routectl@.service"',
'ROUTECTL_SOCKET_PATH="/run/dashboards-routectl.sock"':
'ROUTECTL_SOCKET_PATH="{root}/run/dashboards-routectl.sock"',
'ROUTECTL_STATE_DIR="/var/lib/dashboards-routectl"':
'ROUTECTL_STATE_DIR="{root}/var-lib/dashboards-routectl"',
}
@@ -396,6 +408,11 @@ def _sandbox_installer(tmp_path: Path) -> tuple[Path, Path, dict[str, str]]:
script.write_text(text)
(tmp_path / "agent").mkdir()
(tmp_path / "agent" / "host_agent.py").write_text("# stub agent source\n", encoding="utf-8")
# The routectl sources resolve as `$SCRIPT_DIR/…`, so the real files land next to the
# copy: the guard's `[ -f "$_src" ]` check and the re-run `cmp` both need them.
for _name in ("dashboards-routectl.py", "dashboards-routectl.socket",
"dashboards-routectl@.service"):
shutil.copyfile(DEPLOY / _name, script.parent / _name)
stubs = tmp_path / "stubs"
stubs.mkdir()
@@ -2980,3 +2997,368 @@ def test_the_socket_capture_is_idempotent_so_a_re_run_does_not_churn(tmp_path: P
assert result.returncode == 0, result.stdout + result.stderr
assert _fixture_body(again[1] / "gateway_proc_net_tcp.txt") == _fixture_body(
once / "tests" / "fixtures" / "gateway_proc_net_tcp.txt")
# --- the route-switch helper: --with-routectl / --without-routectl (phase 33 task 02) --------
#: The install that adds the helper and its two units, at the sandbox's default bind/port.
ROUTECTL_FLAGS = ("--bind", "10.0.0.5", "--port", "9120", "--with-routectl")
def _target(tmp_path: Path, where: str = "") -> Path:
return tmp_path / where / "target"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_with_routectl_installs_helper_and_units_and_leaves_the_agent_unit_byte_identical(
tmp_path: Path
) -> None:
"""The phase's central safety claim, as a bytes comparison.
Two sandboxes, the same flags, one with the helper: the rendered `dashboards-agent.service`
must be byte-identical — the temptation to "help" the agent reach the socket by relaxing
its sandbox is exactly what this task exists to close, and the comparison is the pin. The
helper and both units land in the other sandbox, byte-identical to the deployed copies, and
the install ends with the socket's activation and the agent-uid reachability check.
"""
plain_script, _plain_log, plain_env = _sandbox_installer(tmp_path / "plain")
plain = _install(plain_script, plain_env, "--bind", "10.0.0.5", "--port", "9120")
assert plain.returncode == 0, f"plain install failed:\n{plain.stdout}\n{plain.stderr}"
rc_script, rc_log, rc_env = _sandbox_installer(tmp_path / "rc")
rc = _install(rc_script, rc_env, *ROUTECTL_FLAGS)
assert rc.returncode == 0, f"the routectl install failed:\n{rc.stdout}\n{rc.stderr}"
plain_unit = (
_target(tmp_path, "plain") / "dashboards-agent.service"
).read_text().replace(str(_target(tmp_path, "plain")), "<ROOT>")
rc_unit = (
_target(tmp_path, "rc") / "dashboards-agent.service"
).read_text().replace(str(_target(tmp_path, "rc")), "<ROOT>")
assert plain_unit == rc_unit, "the agent unit must be byte-identical with and without --with-routectl"
target = _target(tmp_path, "rc")
helper = target / "sbin" / "dashboards-routectl.py"
socket_unit = target / "dashboards-routectl.socket"
service_unit = target / "dashboards-routectl@.service"
assert helper.is_file() and socket_unit.is_file() and service_unit.is_file()
assert helper.read_text() == (DEPLOY / "dashboards-routectl.py").read_text(), (
"the installed helper is a copy, not a re-render"
)
assert socket_unit.read_text() == (DEPLOY / "dashboards-routectl.socket").read_text()
assert service_unit.read_text() == (DEPLOY / "dashboards-routectl@.service").read_text()
calls = rc_log.read_text()
assert "systemctl enable --now dashboards-routectl.socket" in calls
assert "sudo -u dashboards-agent python3" in calls, "the reachability check runs as the agent uid"
assert str(target / "run" / "dashboards-routectl.sock") in calls, (
"the check connects the sandbox's socket path, as the agent uid"
)
def _unit_lines(path: Path) -> list[str]:
return [line.rstrip() for line in path.read_text().splitlines() if line.strip()]
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_allow_writes_adds_only_the_flag_to_the_agent_unit(tmp_path: Path) -> None:
"""The write path's one unit change is one argument, and nothing else moves.
Task 07's guard says a re-run diff that shows `NoNewPrivileges`, `ProtectSystem`,
`PrivateDevices` or `SystemCallFilter` moving is a stop, not a `--force` — so this is
the same diff in the sandbox: with `--allow-writes`, the generated unit differs from
the plain install's exactly in the `ExecStart` line, and every security directive is
byte-identical. The flag lands on the unit's own ExecStart, because the agent (not
the installer) is the one that must refuse a write it was not started for.
"""
script, _log, env = _sandbox_installer(tmp_path)
plain = _install(script, env, "--bind", "10.0.0.5", "--port", "9120")
assert plain.returncode == 0
plain_unit = _target(tmp_path) / "dashboards-agent.service"
plain_root = str(_target(tmp_path))
script2, _log2, env2 = _sandbox_installer(tmp_path / "w")
writes = _install(script2, env2, "--bind", "10.0.0.5", "--port", "9120",
"--allow-writes", "--with-routectl")
assert writes.returncode == 0, f"--allow-writes install failed:\n{writes.stderr}"
writes_unit = _target(tmp_path, "w") / "dashboards-agent.service"
plain_lines = _unit_lines(plain_unit)
writes_lines = _unit_lines(writes_unit)
writes_root = str(_target(tmp_path, "w"))
plain_exec = [line_ for line_ in plain_lines if line_.startswith("ExecStart=")]
writes_exec = [line_ for line_ in writes_lines if line_.startswith("ExecStart=")]
assert len(plain_exec) == 1 and len(writes_exec) == 1
writes_exec[0] = writes_exec[0].replace(writes_root, "<ROOT>")
plain_exec[0] = plain_exec[0].replace(plain_root, "<ROOT>")
assert writes_exec[0] == plain_exec[0] + " --allow-writes", (
"the flag is appended to ExecStart, nothing else changes there"
)
assert len(plain_lines) == len(writes_lines), "no other line is added or removed"
for line_ in plain_lines:
if line_.startswith("ExecStart="):
continue
assert line_.replace(plain_root, "<ROOT>") in [
other.replace(writes_root, "<ROOT>") for other in writes_lines
], f"the write path moved a unit line: {line_}"
# The four directives task 07's guard names: they must be byte-identical, not moved.
# `PrivateDevices` is `false` by documented decision (the unit carries no device filter
# at all — see the template's comment), so the pin is on the value that is there.
for directive in ("NoNewPrivileges=true", "ProtectSystem=strict",
"PrivateDevices=false", "SystemCallFilter=@system-service"):
assert directive in writes_unit.read_text(), f"the write path moved {directive}"
assert directive in plain_unit.read_text(), f"the baseline unit lacks {directive}"
# And a flag without a helper is a legitimate host state: it installs, and the agent
# answers a write with 503 rather than a write — the installer has nothing to refuse.
script3, _log3, env3 = _sandbox_installer(tmp_path / "w2")
solo = _install(script3, env3, "--bind", "10.0.0.5", "--port", "9120", "--allow-writes")
assert solo.returncode == 0, f"--allow-writes without --with-routectl must install:\n{solo.stderr}"
assert " --allow-writes" in _target(tmp_path, "w2").joinpath(
"dashboards-agent.service").read_text()
# Non-install modes do not render a unit, so the flag refuses there before any write.
result = _install(script, env, "--status", "--allow-writes")
assert result.returncode == 1
assert "needs an install" in result.stderr
def _serve_on_socket(tmp_path: Path, *, allow_uid: str,
request: bytes) -> tuple[int, str]:
"""Run the real helper as a subprocess the unit does: a connected AF_UNIX socket on
fd 0/fd 1 (`Accept=yes` + `StandardInput=socket`), `--allow-uid`, one request, one
answer line. Returns `(returncode, answer line)` — the process's exit code is the
gate's verdict as much as the line is."""
child_side, parent_side = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
proc = subprocess.Popen(
[sys.executable, str(DEPLOY / "dashboards-routectl.py"), "--serve",
"--allow-uid", allow_uid,
"--agent-module", str(ROOT / "agent" / "host_agent.py"),
"--state", str(tmp_path / "serve-state.json")],
stdin=child_side.fileno(), stdout=child_side.fileno(),
stderr=subprocess.PIPE, close_fds=True)
parent_side.sendall(request)
answer = parent_side.recv(65536).decode("utf-8").strip()
parent_side.close()
proc.wait(timeout=60)
child_side.close()
err = proc.stderr.read().decode() if proc.stderr is not None else ""
if proc.returncode not in (0, 1):
raise AssertionError(
f"the helper exited {proc.returncode} instead of answering:\n"
f"{answer}\n{err}")
return proc.returncode, answer
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_serve_answers_one_request_on_the_socket_stdin(tmp_path: Path) -> None:
"""The live box's transport, pinned: with `Accept=yes` + `StandardInput=socket`,
the accepted connection lands on fd 0, and `serve()` must find it there (fd 3 first —
the classic contract — then fd 0), name its peer, and answer one request. The
first live install (phase 33 task 07) answered a real connection with
`peer: no connected socket on this transport` because only fd 3 was looked at;
the same task found the box's kernel delivers `SO_PEERCRED` reversed
(`{pid, uid, gid}`) and `read(2)` on a socket fd that does not wake on
buffered data (`recv(2)` does) — which is why the helper calibrates the uid
slot with a same-process probe and reads the socket path with `recv`."""
me = pwd.getpwuid(os.geteuid()).pw_name
code, answer = _serve_on_socket(
tmp_path, allow_uid=me,
request=b'{"id": "haproxy:80->127.0.0.1:443", "action": "show"}\n')
body = json.loads(answer)
assert isinstance(body, dict), (
f"the answer is one JSON object on the wire (one write, no double): {answer!r}")
assert body.get("ok") is not None, f"the named peer's request was not answered: {answer}"
assert "peer:" not in str(body.get("detail", "")), (
f"the named peer's request was refused by the gate: {answer}")
assert code in (0, 1)
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_serve_refuses_a_wrong_peer_and_a_transport_without_one(
tmp_path: Path) -> None:
"""The second look fails closed in both directions: a named peer that is not
`--allow-uid` is a refusal, and a transport whose caller the kernel will not name
(`/dev/null` on stdin) is a refusal too — the gate never opens on an unnamed caller."""
me = pwd.getpwuid(os.geteuid()).pw_name
_code, answer = _serve_on_socket(tmp_path, allow_uid="root",
request=b'{"verb": "list"}\n')
body = json.loads(answer)
assert body["ok"] is False and "peer:" in body["detail"], (
f"a foreign uid must not pass the gate: {answer}")
# the wire's own gate (a correctly-peered caller): a wider body is a
# refusal with the wire's own sentence — and the answer is a JSON
# *object*, not the (dict, code) tuple a caller-side inversion once
# serialised as a list (phase 33 task 07 caught it on the live chain).
_code, answer = _serve_on_socket(tmp_path, allow_uid=me,
request=b'{"verb": "list"}\n')
body = json.loads(answer)
assert body["ok"] is False, f"a wider body must be refused: {answer}"
assert body["detail"] == "body: exactly {id, action, dry_run}", (
f"the refusal must carry the wire's own sentence: {answer}")
proc = subprocess.Popen(
[sys.executable, str(DEPLOY / "dashboards-routectl.py"), "--serve",
"--allow-uid", me, "--agent-module", str(ROOT / "agent" / "host_agent.py"),
"--state", str(tmp_path / "pipe-state.json")],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
close_fds=True)
proc.wait(timeout=60)
out = proc.stdout.read().decode("utf-8").strip() if proc.stdout is not None else ""
assert proc.returncode == 1, (
f"an unnamed caller must exit refused (1), not answered: rc={proc.returncode} out={out!r}")
assert out == "", (
f"a pipe transport has no wire; the refusal is the exit code, not a line: {out!r}")
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_the_units_carry_the_grant_as_directives(tmp_path: Path) -> None:
"""The file-layer grant is a directive systemd enforces; the sandbox pins the lines.
`systemd-analyze verify` is not available in the sandbox, so the assertions are on the
directive lines: the socket is 0600 and agent-owned (the whole access control at the file
layer), the service is root with the socket on stdin and the one-state-directory
`StateDirectory`, and `ReadWritePaths` names every directory whose file the helper
replaces — `/etc/haproxy`, `/etc/nginx/conf.d`, `/etc/nginx/stream.d` — and nothing else
(the main `nginx.conf` is never written, so it must not be listed).
"""
script, _log, env = _sandbox_installer(tmp_path)
result = _install(script, env, *ROUTECTL_FLAGS)
assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}"
target = _target(tmp_path)
socket_unit = (target / "dashboards-routectl.socket").read_text()
# Without `Accept=yes` systemd would look for a `dashboards-routectl.service` that does
# not exist (a `Service=` line naming the template does not load on systemd 257) and
# refuse to listen — the live box's first install, phase 33 task 07.
assert _directive(socket_unit, "Accept") == ["Accept=yes"]
assert _directive(socket_unit, "ListenStream") == ["ListenStream=/run/dashboards-routectl.sock"]
assert _directive(socket_unit, "SocketMode") == ["SocketMode=0600"], (
"0600 + SocketUser is the whole access control at the file layer"
)
assert _directive(socket_unit, "SocketUser") == ["SocketUser=dashboards-agent"]
assert _directive(socket_unit, "SocketGroup") == ["SocketGroup=dashboards-agent"]
assert "WantedBy=sockets.target" in socket_unit
service = (target / "dashboards-routectl@.service").read_text()
assert _directive(service, "User") == ["User=root"]
assert "StandardInput=socket" in service and "StandardOutput=socket" in service, (
"one connection, one instance: the connected socket is stdin"
)
assert _directive(service, "StateDirectory") == ["StateDirectory=dashboards-routectl"]
assert _directive(service, "ProtectSystem") == ["ProtectSystem=read-only"]
assert _directive(service, "ReadWritePaths") == [
"ReadWritePaths=/etc/haproxy /etc/nginx/conf.d /etc/nginx/stream.d"
], "os.replace needs write on the directory: every one listed, and nothing the helper never writes"
assert _directive(service, "NoNewPrivileges") == ["NoNewPrivileges=true"], (
"the privilege comes from systemd starting it as root, not from escalation"
)
assert _directive(service, "SystemCallFilter") == ["SystemCallFilter=@system-service"]
assert _directive(service, "ProtectHome") == ["ProtectHome=read-only"]
assert _directive(service, "PrivateTmp") == ["PrivateTmp=yes"]
exec_lines = _directive(service, "ExecStart")
assert len(exec_lines) == 1 and exec_lines[0].startswith(
"ExecStart=/usr/local/sbin/dashboards-routectl.py --serve"
)
assert "--allow-uid dashboards-agent" in service, "the second look: the peer uid the helper accepts"
assert "--state /var/lib/dashboards-routectl/state.json" in service
# The on-disk modes the sandbox's real `install -m` applied.
assert stat.S_IMODE((target / "sbin" / "dashboards-routectl.py").stat().st_mode) == 0o755, (
"the helper is world-executable: the grant is the socket, not the file mode"
)
assert stat.S_IMODE((target / "dashboards-routectl.socket").stat().st_mode) == 0o644
assert stat.S_IMODE((target / "dashboards-routectl@.service").stat().st_mode) == 0o644
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_without_routectl_removes_the_units_and_not_the_config_or_the_state(tmp_path: Path) -> None:
"""The inverse keeps what the removal must keep.
The proxy configs and the helper's state directory are the operator's memory of what was
switched, so `--without-routectl` prints the still-off list first, removes the helper and
its two units, and leaves the state (and the configs) behind, ending with the reminder.
"""
script, call_log, env = _sandbox_installer(tmp_path)
assert _install(script, env, *ROUTECTL_FLAGS).returncode == 0
target = _target(tmp_path)
state_dir = target / "var-lib" / "dashboards-routectl"
state_dir.mkdir(parents=True)
state_dir.joinpath("state.json").write_text('{"nginx:443->127.0.0.1:44450": {}}', encoding="utf-8")
result = _install(script, env, "--without-routectl")
assert result.returncode == 0, f"--without-routectl failed:\n{result.stdout}\n{result.stderr}"
assert not (target / "dashboards-routectl.socket").exists(), "the socket unit must be removed"
assert not (target / "dashboards-routectl@.service").exists(), "the service unit must be removed"
assert not (target / "sbin" / "dashboards-routectl.py").exists(), "the helper must be removed"
assert (state_dir / "state.json").is_file(), "the state is the memory of what was switched; it stays"
calls = call_log.read_text()
assert "systemctl disable --now dashboards-routectl.socket" in calls
assert "daemon-reload" in calls
out = result.stdout
assert "routes the helper has switched off" in out, "the still-off list prints first"
assert "still off" in out, "the reminder ends the removal, not the file list"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_the_with_and_without_flags_do_not_mix(tmp_path: Path) -> None:
"""One flag each direction, before any write."""
script, call_log, env = _sandbox_installer(tmp_path)
result = _install(script, env, "--with-routectl", "--without-routectl")
assert result.returncode == 1
assert result.stdout == ""
assert "one flag each direction" in result.stderr
assert not call_log.exists(), "refused before a single privileged command"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_with_routectl_needs_an_install(tmp_path: Path) -> None:
"""`--status` probes a port; `--uninstall` deletes the agent. Neither installs files."""
script, _log, env = _sandbox_installer(tmp_path)
result = _install(script, env, "--status", "--with-routectl")
assert result.returncode == 1
assert "needs an install" in result.stderr
def test_no_new_sudoers_line_exists() -> None:
"""The grant is a file mode systemd enforces; the pin is the absence of a sudoers rule.
`deploy/agent-sudoers` is still exactly one line (the staged installer), neither of the
two new units names sudo, and the operator page says the sentence out loud.
"""
assert _grant_lines(SUDOERS.read_text()) == [CANONICAL_GRANT], (
"phase 33 adds a write without adding a sudoers line; a new rule is the regression"
)
for name in ("dashboards-routectl.socket", "dashboards-routectl@.service"):
text = (DEPLOY / name).read_text()
# `sudo\b` is the word sudo by itself: the units' own "not a sudoers line" comment
# (the negation that is the point) does not match, a grant would.
assert not re.search(r"sudo\b", text.lower()), f"{name} grants nothing by sudoers"
block = INSTALLER.read_text().split("routectl_off() {", 1)
assert len(block) == 2, "the --without-routectl path is a function"
assert "sudoers" not in block[1].split("\n}\n", 1)[0].lower(), (
"the removal path must not touch sudoers either"
)
readme = (DEPLOY / "gateway-routectl-README.md").read_text()
assert "No sudoers line is involved" in readme
assert "0600" in readme, "the operator page names the mode that is the grant"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_the_install_checks_the_agent_can_reach_the_socket(tmp_path: Path) -> None:
"""A socket the agent cannot connect to is a feature that fails at 03:00.
So the check is part of the install: with the check's `sudo -u` step failing, the install
must exit non-zero rather than report a success nobody can use, and the check must have
run (the stub log is the proof it connected as the agent uid, not just been skipped).
"""
script, call_log, env = _sandbox_installer(tmp_path)
sudo_stub = tmp_path / "stubs" / "sudo"
sudo_stub.write_text(
'#!/bin/sh\nprintf "%s %s\\n" "sudo" "$*" >> "$STUB_LOG"\nexit 1\n', encoding="utf-8"
)
sudo_stub.chmod(0o755)
result = _install(script, env, *ROUTECTL_FLAGS)
assert result.returncode == 1, "an unreachable socket is a failed install, not a silent success"
assert "cannot connect" in result.stderr
assert "sudo -u dashboards-agent python3" in call_log.read_text(), "the check ran as the agent uid"