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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user