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:
@@ -42,7 +42,7 @@ def sign_in(client: TestClient, password: str = ADMIN_PASSWORD,
|
||||
response = client.post("/api/auth/login", json={"password": password, **payload})
|
||||
assert response.status_code == 200, f"sign-in failed: {response.status_code} {response.text}"
|
||||
body: dict[str, Any] = response.json()
|
||||
# On the client, not per request: the ~200 existing HTTP assertions in test_api.py post to
|
||||
# the one write endpoint, and a token the helper did not attach would 403 every one of them.
|
||||
# On the client, not per request: the existing HTTP assertions in test_api.py post to the
|
||||
# upstream write endpoints, and a token the helper did not attach would 403 every one of them.
|
||||
client.headers[auth.CSRF_HEADER] = str(body.get("csrf", ""))
|
||||
return body
|
||||
|
||||
+62
-1
@@ -34,6 +34,14 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
||||
help="run tests marked `store_perf`: the phase-14 timing measurements against the "
|
||||
"seeded production-shaped store (they skip otherwise — they cost minutes, not ms)",
|
||||
)
|
||||
parser.addoption(
|
||||
"--allow-live-writes",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="allow the one opt-in live write test (phase 33's route round-trip on "
|
||||
"haproxy:5201->web.reeselink.com:5201) to switch its named route; without it "
|
||||
"the test skips and the default live suite stays read-only",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
||||
@@ -257,7 +265,8 @@ GATEWAY_TREE = {
|
||||
"proc/net/tcp6": "gateway_proc_net_tcp6.txt",
|
||||
}
|
||||
|
||||
GATEWAY_MODES = ("full", "resolved", "predates", "no_haproxy", "no_nginx", "sockets_silent")
|
||||
GATEWAY_MODES = ("full", "resolved", "predates", "no_haproxy", "no_nginx", "sockets_silent",
|
||||
"marked", "hand_edited")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -287,6 +296,16 @@ def gateway_snapshot(host_agent_module: Any, tmp_path: Path) -> Any:
|
||||
agent's own **not found** line, the other half of the page untouched
|
||||
* `sockets_silent` — neither `/proc` table readable: `available: null`, so every live count
|
||||
must come back `None` and never `0`
|
||||
* `marked` / `hand_edited` — the same captured tree with the route switch's three edits
|
||||
applied (phase 33 task 04): `marked` is the helper's work — one haproxy `server` line,
|
||||
one vhost `proxy_pass` line and one stream `map` entry (value rewritten to the sink
|
||||
address) each carrying the agent's `#off <name>` trailing comment (the whole comment, one
|
||||
name, nothing else — `split_marker`'s grammar), plus the helper's sink `server` block in
|
||||
a `stream.d` file; `hand_edited` is the same three shapes with the markers removed — the
|
||||
`server` line deleted, the `proxy_pass` line plain, the map entry's value deleted and
|
||||
no sink file. Both are the **captured** config plus an edit, never a toy file: the
|
||||
marker-awareness tests need the fleet's real backends and map, and the suppression has to
|
||||
be marker-caused, not shape-caused.
|
||||
"""
|
||||
def build(mode: str = "full") -> dict[str, Any]:
|
||||
if mode not in GATEWAY_MODES:
|
||||
@@ -307,6 +326,48 @@ def gateway_snapshot(host_agent_module: Any, tmp_path: Path) -> Any:
|
||||
if mode == "sockets_silent":
|
||||
(root / "proc/net/tcp").unlink()
|
||||
(root / "proc/net/tcp6").unlink()
|
||||
if mode in ("marked", "hand_edited"):
|
||||
# The one haproxy line the switch turns off: the helper marks the `server` line
|
||||
# itself (the agent's `split_marker` reads the trailing comment; the name is one
|
||||
# word and the whole comment) and the line stays in the document. `hand_edited`
|
||||
# is the same shape an operator left by deleting the line.
|
||||
haproxy_cfg = root / "etc/haproxy/haproxy.cfg"
|
||||
text = haproxy_cfg.read_text()
|
||||
line = " server gitea reeseapps-gitea.reeselink.com:2222 check inter 5s"
|
||||
assert line in text, "the capture's gitea server line moved; update the injection"
|
||||
if mode == "marked":
|
||||
haproxy_cfg.write_text(text.replace(line, f"{line} #off gitea"))
|
||||
else:
|
||||
haproxy_cfg.write_text(text.replace(line + "\n", ""))
|
||||
# The one vhost line: the marker goes after the `;` (the tokenizer attaches the
|
||||
# end-of-line comment to the statement that closed it). `hand_edited` keeps the
|
||||
# line — removing a marker leaves the route declared and on.
|
||||
vhost = root / "etc/nginx/conf.d/http.conf"
|
||||
text = vhost.read_text()
|
||||
line = " proxy_pass http://nextcloud.reeselink.com:11000;"
|
||||
assert line in text, "the capture's nextcloud vhost line moved; update the injection"
|
||||
if mode == "marked":
|
||||
vhost.write_text(text.replace(line, f"{line} #off nextcloud-11000"))
|
||||
# The one map entry: the helper rewrites the value to the sink address and marks
|
||||
# the line; `hand_edited` is the value deleted and nothing else left behind.
|
||||
stream = root / "etc/nginx/stream.d/stream.conf"
|
||||
text = stream.read_text()
|
||||
line = (" brain.reeseapps.com "
|
||||
"reeseapps-brain-of-reese.reeselink.com:443;")
|
||||
assert line in text, "the capture's brain map entry moved; update the injection"
|
||||
if mode == "marked":
|
||||
stream.write_text(text.replace(line, " brain.reeseapps.com "
|
||||
"127.0.0.1:44450; #off brain.reeseapps.com"))
|
||||
else:
|
||||
stream.write_text(text.replace(line, " brain.reeseapps.com ;"))
|
||||
if mode == "marked":
|
||||
# The helper's sink file, created on first need by the write path: a stream
|
||||
# `server` that listens on loopback, names no `server_name` and proxies nowhere
|
||||
# (its whole job is to answer a switched-off route). It is the block, not the
|
||||
# port, that `sink_endpoint()` recognises, and the glob include picks it up.
|
||||
(root / "etc/nginx/stream.d/00-dashboards-sink.conf").write_text(
|
||||
"# managed by dashboards-routectl — do not edit\n"
|
||||
"server {\n listen 127.0.0.1:44450;\n}\n")
|
||||
|
||||
errors: list[str] = []
|
||||
collector = host_agent_module.Collector(root)
|
||||
|
||||
+593
-41
@@ -27,6 +27,7 @@ import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
@@ -838,8 +839,10 @@ def test_the_captured_haproxy_shape_parses_into_frontends_and_backends() -> None
|
||||
[{"addr": "::", "port": port}] for port in (22, 2000, 5201, 8080, 25565, 25566)]
|
||||
assert [frontend["default_backend"] for frontend in parsed["frontends"]] == [
|
||||
"svc-2222", "svc-2000", "svc-5201", "svc-8080", "svc-25565", "svc-25566"]
|
||||
# no line carries the helper's marker, so none is off (the flag is present, not inferred)
|
||||
assert [backend["servers"] for backend in parsed["backends"]] == [
|
||||
[{"name": f"svc{port}", "target": f"svc{port}.example.com", "port": port}]
|
||||
[{"name": f"svc{port}", "target": f"svc{port}.example.com", "port": port,
|
||||
"disabled": False}]
|
||||
for port in (2222, 2000, 5201, 8080, 25565, 25566)]
|
||||
# routing is the names themselves, and the config joins: every frontend's default_backend is
|
||||
# one of the backends listed beside it (phase 29 draws its pipes from exactly this)
|
||||
@@ -865,9 +868,10 @@ def test_the_recorded_gateway_haproxy_config_parses_too() -> None:
|
||||
"testcraft-25565", "nimcraft-25566"]
|
||||
assert [(backend["name"], backend["servers"]) for backend in parsed["backends"]][:2] == [
|
||||
("gitea-2222", [{"name": "gitea", "target": "reeseapps-gitea.reeselink.com",
|
||||
"port": 2222}]),
|
||||
("sec-gitea-2000", [{"name": "sec-gitea", "target": "stackexpected-gitea.reeselink.com",
|
||||
"port": 2222}]),
|
||||
"port": 2222, "disabled": False}]),
|
||||
("sec-gitea-2000", [{"name": "sec-gitea",
|
||||
"target": "stackexpected-gitea.reeselink.com",
|
||||
"port": 2222, "disabled": False}]),
|
||||
]
|
||||
# every frontend's target exists beside it — the join phase 29 draws its pipes from
|
||||
names = {backend["name"] for backend in parsed["backends"]}
|
||||
@@ -912,9 +916,10 @@ def test_a_server_that_names_no_port_keeps_null_and_unknown_lines_are_ignored()
|
||||
" http-request deny deny_status 403\n"
|
||||
)
|
||||
assert parsed["backends"][0]["servers"] == [
|
||||
{"name": "withport", "target": "10.1.0.9", "port": 8080},
|
||||
{"name": "noport", "target": "10.1.0.10", "port": None},
|
||||
{"name": "servicename", "target": "host.example.com:http", "port": None},
|
||||
{"name": "withport", "target": "10.1.0.9", "port": 8080, "disabled": False},
|
||||
{"name": "noport", "target": "10.1.0.10", "port": None, "disabled": False},
|
||||
{"name": "servicename", "target": "host.example.com:http", "port": None,
|
||||
"disabled": False},
|
||||
]
|
||||
# a `backend` body never grows frontend fields, however many directives it holds
|
||||
assert "binds" not in parsed["backends"][0]
|
||||
@@ -933,7 +938,7 @@ def test_a_listen_section_is_reported_as_the_pair_haproxy_says_it_is() -> None:
|
||||
"default_backend": "stats", "backends": ["stats"]}]
|
||||
assert parsed["backends"] == [{"name": "stats",
|
||||
"servers": [{"name": "own", "target": "127.0.0.1",
|
||||
"port": 8405}]}]
|
||||
"port": 8405, "disabled": False}]}]
|
||||
|
||||
|
||||
def test_a_backend_computed_per_request_contributes_no_name() -> None:
|
||||
@@ -1249,27 +1254,30 @@ def test_the_captured_nginx_shape_parses_into_servers_maps_and_targets() -> None
|
||||
assert [server["proxy_passes"] for server in parsed["servers"]] == [
|
||||
[],
|
||||
# the stream form: no `location` exists, and the target is a variable, not an address
|
||||
[{"location": None, "scheme": None, "target": None, "variable": "targetBackend"}],
|
||||
[{"location": None, "scheme": None, "target": None, "variable": "targetBackend",
|
||||
"disabled": False}],
|
||||
[{"location": "/", "scheme": "http", "variable": None,
|
||||
"target": {"host": "nextcloud.internal.test", "port": 11000}}],
|
||||
"target": {"host": "nextcloud.internal.test", "port": 11000}, "disabled": False}],
|
||||
[{"location": "/", "scheme": "http", "variable": None,
|
||||
"target": {"host": "127.0.0.1", "port": 8081}}],
|
||||
"target": {"host": "127.0.0.1", "port": 8081}, "disabled": False}],
|
||||
]
|
||||
maps = by_variable(parsed["maps"])
|
||||
assert set(maps) == {"connection_upgrade", "targetBackend"}
|
||||
# the variable name is spelled one way on both sides of the join, and the `$` is nginx's
|
||||
# syntax rather than part of the name — so it is dropped here once, not in the page
|
||||
assert maps["targetBackend"]["entries"] == [
|
||||
{"key": "default", "target": {"host": "127.0.0.1", "port": 4443}},
|
||||
{"key": "default", "target": {"host": "127.0.0.1", "port": 4443},
|
||||
"disabled": False},
|
||||
{"key": "brain.example.com", "target": {"host": "brain-of-reese.internal.test",
|
||||
"port": 443}},
|
||||
"port": 443}, "disabled": False},
|
||||
]
|
||||
# …and the `proxy_pass $targetBackend` above still carries `target: null`: the map decides
|
||||
# per connection, so merging it here would be a number the wire never published
|
||||
assert parsed["servers"][1]["proxy_passes"][0]["target"] is None
|
||||
# `$connection_upgrade`'s values are header words, not hosts: keys reported, targets null
|
||||
assert maps["connection_upgrade"]["entries"] == [
|
||||
{"key": "default", "target": None}, {"key": "", "target": None}]
|
||||
{"key": "default", "target": None, "disabled": False},
|
||||
{"key": "", "target": None, "disabled": False}]
|
||||
# the include lines, as written, in document order — including the one file this tree has no
|
||||
# opinion about because it is not a config (`mime.types`)
|
||||
assert parsed["includes"] == ["/etc/nginx/stream.d/*.conf", "/etc/nginx/mime.types",
|
||||
@@ -1325,15 +1333,18 @@ def test_a_proxy_pass_reports_the_address_the_variable_or_neither() -> None:
|
||||
assert parsed["servers"][0]["proxy_passes"] == [
|
||||
# the trailing path is dropped the way haproxy's `check inter 5s` is: it is not a place
|
||||
{"location": "/a/", "scheme": "http", "target": {"host": "h1.test", "port": 8080},
|
||||
"variable": None},
|
||||
"variable": None, "disabled": False},
|
||||
# an `upstream` name is a host with no port — the name is the fact, the port is not
|
||||
{"location": "/b/", "scheme": "http", "target": {"host": "pool_name", "port": None},
|
||||
"variable": None},
|
||||
{"location": "/c/", "scheme": None, "target": None, "variable": "backend"},
|
||||
"variable": None, "disabled": False},
|
||||
{"location": "/c/", "scheme": None, "target": None, "variable": "backend",
|
||||
"disabled": False},
|
||||
# a unix socket is neither a host nor a variable: the line is reported with nulls
|
||||
{"location": "/d/", "scheme": None, "target": None, "variable": None},
|
||||
{"location": "/d/", "scheme": None, "target": None, "variable": None,
|
||||
"disabled": False},
|
||||
# a colon that is not a port either: the scheme is a fact, the host would be a guess
|
||||
{"location": "/e/", "scheme": "http", "target": None, "variable": None},
|
||||
{"location": "/e/", "scheme": "http", "target": None, "variable": None,
|
||||
"disabled": False},
|
||||
]
|
||||
|
||||
|
||||
@@ -1371,9 +1382,9 @@ def test_an_upstream_block_keeps_its_server_lines_as_targets() -> None:
|
||||
"http { server { listen 80; } }"
|
||||
)
|
||||
assert parsed["upstreams"] == [{"name": "pool_name", "servers": [
|
||||
{"host": "10.1.0.9", "port": 8080},
|
||||
{"host": "backend.test", "port": None},
|
||||
{"host": "::1", "port": 8080},
|
||||
{"host": "10.1.0.9", "port": 8080, "disabled": False},
|
||||
{"host": "backend.test", "port": None, "disabled": False},
|
||||
{"host": "::1", "port": 8080, "disabled": False},
|
||||
]}]
|
||||
# an upstream's `server` lines are not server blocks, however many of them there are
|
||||
assert len(parsed["servers"]) == 1
|
||||
@@ -2179,8 +2190,11 @@ def agent_server():
|
||||
host_agent.Handler.collector = host_agent.Collector("/")
|
||||
host_agent.Handler.token = token
|
||||
# Cleared on purpose: `Handler` is process-wide state, so a token-mode server started
|
||||
# after a TLS test in this file must not inherit that test's bundle.
|
||||
# after a TLS test in this file must not inherit that test's bundle — or the write
|
||||
# path's gate, which the bundle check is the first line of.
|
||||
host_agent.Handler.bundle = None
|
||||
host_agent.Handler.allow_writes = False
|
||||
host_agent.Handler.routectl_socket = None
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler)
|
||||
servers.append(httpd)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
@@ -2253,15 +2267,6 @@ def test_token_is_required_when_configured_but_health_stays_open(agent_server) -
|
||||
assert _get(base + "/health")[0] == 200
|
||||
|
||||
|
||||
def test_agent_has_no_write_path(agent_server) -> None:
|
||||
"""No do_POST/do_PUT exists: read-only is structural, not a policy toggle."""
|
||||
base, _port = agent_server()
|
||||
request = urllib.request.Request(base + "/snapshot", data=b"{}", method="POST")
|
||||
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||
urllib.request.urlopen(request, timeout=5)
|
||||
assert excinfo.value.code in (405, 501)
|
||||
|
||||
|
||||
def test_refuses_to_bind_off_loopback_without_a_token(monkeypatch) -> None:
|
||||
"""0.0.0.0 with no token would hand host inventory to the whole LAN."""
|
||||
monkeypatch.delenv("AGENT_TOKEN", raising=False)
|
||||
@@ -2452,6 +2457,10 @@ def tls_agent_server():
|
||||
host_agent.Handler.collector = host_agent.Collector("/", smart=False)
|
||||
host_agent.Handler.token = token
|
||||
host_agent.Handler.bundle = bundle
|
||||
# Cleared on purpose, like `bundle` above: a writes-off server started after a
|
||||
# `POST /route` test must not inherit that test's write gate or helper socket.
|
||||
host_agent.Handler.allow_writes = False
|
||||
host_agent.Handler.routectl_socket = None
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler)
|
||||
httpd.socket = bundle.context.wrap_socket(httpd.socket, server_side=True)
|
||||
servers.append(httpd)
|
||||
@@ -2463,6 +2472,8 @@ def tls_agent_server():
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
host_agent.Handler.bundle = None
|
||||
host_agent.Handler.allow_writes = False
|
||||
host_agent.Handler.routectl_socket = None
|
||||
|
||||
|
||||
def _json_or_raw(body: bytes) -> dict:
|
||||
@@ -2757,14 +2768,19 @@ def test_a_bundle_allows_the_off_loopback_bind_without_a_token(
|
||||
tls_fleet: SimpleNamespace, tmp_path: Path, monkeypatch) -> None:
|
||||
"""The client certificate *is* the protection, so the exit-2 refusal does not apply."""
|
||||
served: list[tuple] = []
|
||||
seen_kwargs: list[dict] = []
|
||||
monkeypatch.setattr(host_agent, "serve",
|
||||
lambda *args: served.append(args))
|
||||
lambda *args, **kwargs: served.append(args) or seen_kwargs.append(kwargs))
|
||||
path = write_bundle(tmp_path, tls_fleet.bundle)
|
||||
assert host_agent.main(["--host", "0.0.0.0", "--port", "0", "--cert-bundle", str(path)]) == 0
|
||||
assert served, "a bundle-configured agent must start"
|
||||
host_arg, _port, _collector, token_arg, bundle_arg = served[0]
|
||||
assert (host_arg, token_arg) == ("0.0.0.0", None)
|
||||
assert isinstance(bundle_arg, host_agent.TlsBundle)
|
||||
# 1.4.0's defaults: the write path is off, and the helper's home is the standard socket
|
||||
# path unless the flag names another one (an empty value is the 503 arm: no helper here).
|
||||
assert seen_kwargs[0]["allow_writes"] is False
|
||||
assert seen_kwargs[0]["routectl_socket"] == "/run/dashboards-routectl.sock"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extra", [[], ["--cert-bundle", ""]])
|
||||
@@ -2819,22 +2835,157 @@ def test_the_schema_names_the_three_proxy_sections() -> None:
|
||||
assert "--no-proxy" in doc and "--haproxy-config" in doc and "--nginx-config" in doc
|
||||
|
||||
|
||||
def test_the_agent_version_is_1_3_0_and_the_schema_names_the_resolver_section() -> None:
|
||||
"""1.3.0 is the version that resolves the names the configs declare.
|
||||
def test_the_agent_version_is_1_4_0_and_the_schema_documents_the_route_marker() -> None:
|
||||
"""1.4.0 is the version that reads the route switch's marker off the route line.
|
||||
|
||||
The bump is how the fleet tells an agent that publishes `resolver` from one that does not —
|
||||
`--status`, `/health` and this test all read the same constant, and `sources/gateway.py`'s
|
||||
"the agent is too old" arm keys on the sections the version belongs to. The SCHEMA block is
|
||||
`GET /schema`'s body, so it names the section, its three-way `available`, and `--no-resolve`,
|
||||
the flag that removes it in silence.
|
||||
The bump is how the fleet tells an agent that publishes `disabled` from one that does not —
|
||||
`--status`, `/health` and this test all read the same constant. The SCHEMA block is
|
||||
`GET /schema`'s body, so it documents the flag on both config sections and the marker's
|
||||
spelling, and `--no-resolve` stays named: the flag that removes the resolver section in
|
||||
silence.
|
||||
"""
|
||||
assert host_agent.AGENT_VERSION == "1.3.0"
|
||||
assert host_agent.AGENT_VERSION == "1.4.0"
|
||||
doc = host_agent.__doc__ or ""
|
||||
assert schema_documents("resolver"), "resolver is not documented in the SCHEMA block"
|
||||
block = doc.split(" resolver ", 1)[-1]
|
||||
assert "available" in block, "the resolver block does not document its three-way answer"
|
||||
assert "--no-resolve" in doc, "the flag that removes the section is undocumented"
|
||||
assert "libc" in block, "how the addresses were obtained is not published in the schema"
|
||||
assert "disabled" in doc, "the marker read is not documented in the SCHEMA block"
|
||||
assert "#off" in doc, "the marker's spelling is not documented in the schema"
|
||||
|
||||
|
||||
def test_the_marker_grammar_is_one_constant_shared_by_writer_and_reader() -> None:
|
||||
"""`split_marker` is the read side of the switch and `marker_for` the write side; both
|
||||
spell the same grammar, which is the whole of the read side (phase 33 task 04).
|
||||
|
||||
A marked line keeps its text and gains a name; anything that is not exactly `#off <name>`
|
||||
— an extra word, a bare `#off`, or the operator's own comment — is not a switch, and a line
|
||||
without a comment is a line.
|
||||
"""
|
||||
assert host_agent.ROUTE_MARKER == "#off"
|
||||
line, name = host_agent.split_marker("server s1 10.1.1.9:443 #off s1")
|
||||
assert line == "server s1 10.1.1.9:443" and name == "s1"
|
||||
# trailing whitespace after the name is the operator's formatting, not part of the name
|
||||
assert host_agent.split_marker("server s1 10.1.1.9:443 #off s1 ") == (
|
||||
"server s1 10.1.1.9:443", "s1")
|
||||
line, name = host_agent.split_marker("server s1 10.1.1.9:443 #off")
|
||||
assert name is None, "a bare #off with no name is a comment, not a switch"
|
||||
line, name = host_agent.split_marker("server s1 10.1.1.9:443 #off s1 extra")
|
||||
assert name is None, "two words after the marker is a comment, not a switch"
|
||||
line, name = host_agent.split_marker("server s1 10.1.1.9:443 # off s1")
|
||||
assert name is None, "a comment that merely mentions the word is not a switch"
|
||||
line, name = host_agent.split_marker("server s1 10.1.1.9:443")
|
||||
assert name is None
|
||||
# the write side spells what the read side parses: the round trip is exact
|
||||
for name_ in ("s1", "pool:8080", "web.example.com"):
|
||||
marked, marker = host_agent.split_marker(f"line {host_agent.marker_for(name_)}")
|
||||
assert marker == name_ and marked == "line"
|
||||
|
||||
|
||||
def test_config_line_index_finds_the_line_the_marker_names() -> None:
|
||||
"""The helper edits by line number, and the line is found by the marker, never the route's
|
||||
text (a target string appears in many lines; the marker names exactly one of them)."""
|
||||
text = "backend be\n server s1 10.1.1.9:443\n server s2 10.1.1.10:443 #off s1\n"
|
||||
assert host_agent.config_line_index(text, "s1") == 3
|
||||
assert host_agent.config_line_index(text, "s2") == -1
|
||||
# a commented-out line is not a route: its marker is part of the comment, not a switch
|
||||
text = "# server s3 10.1.1.11:443 #off s3\n"
|
||||
assert host_agent.config_line_index(text, "s3") == -1
|
||||
|
||||
|
||||
def test_the_haproxy_parser_reports_a_marked_server_line_disabled() -> None:
|
||||
"""The read side of the switch: a `server` line carrying the helper's marker is
|
||||
`disabled: true` and every other line `disabled: false` — and the marked line is still a
|
||||
line: its target is parsed and its text kept, because the helper rewrites the value, not
|
||||
the line, and a parser that dropped it would lose the route the switch is about."""
|
||||
parsed = host_agent.parse_haproxy_config(
|
||||
"backend be\n"
|
||||
" server on 10.1.1.9:443\n"
|
||||
" server off 10.1.1.10:443 #off off\n"
|
||||
" server note 10.1.1.11:443 # a comment that is not the switch\n"
|
||||
)
|
||||
assert parsed["backends"][0]["servers"] == [
|
||||
{"name": "on", "target": "10.1.1.9", "port": 443, "disabled": False},
|
||||
{"name": "off", "target": "10.1.1.10", "port": 443, "disabled": True},
|
||||
{"name": "note", "target": "10.1.1.11", "port": 443, "disabled": False},
|
||||
]
|
||||
|
||||
|
||||
def test_the_nginx_parser_reports_a_marked_line_disabled_in_all_three_shapes() -> None:
|
||||
"""One marker, three route lines: a `proxy_pass`, an `upstream` member, a `map` entry.
|
||||
|
||||
The stream form is the fleet's own: a `proxy_pass $var` with no location, a `map` whose
|
||||
entries are the SNI fan-out, and an `upstream` the http vhosts name. Each line's marker is
|
||||
its own line's `disabled`, and an unmarked line beside a marked one is still on."""
|
||||
parsed = host_agent.parse_nginx_config(
|
||||
"stream {\n"
|
||||
" server { listen 443; proxy_pass $targetBackend; }\n"
|
||||
" server { listen 127.0.0.1:44450; return 502; }\n"
|
||||
" map $ssl_preread_server_name $targetBackend {\n"
|
||||
" default 10.1.0.1:8008;\n"
|
||||
" on.example.com 10.1.0.1:8008;\n"
|
||||
" off.example.com 127.0.0.1:44450; #off off.example.com\n"
|
||||
" }\n"
|
||||
"}"
|
||||
"http {\n"
|
||||
" upstream pool {\n"
|
||||
" server 10.1.0.9:8080; #off 10.1.0.9:8080\n"
|
||||
" server 10.1.0.10:8080;\n"
|
||||
" }\n"
|
||||
" server { listen 80; location / { proxy_pass http://pool; } }\n"
|
||||
"}"
|
||||
)
|
||||
assert parsed["servers"][0]["proxy_passes"] == [
|
||||
{"location": None, "scheme": None, "target": None, "variable": "targetBackend",
|
||||
"disabled": False}]
|
||||
assert parsed["servers"][1]["proxy_passes"] == [], "a `return` block proxies nowhere"
|
||||
entries = parsed["maps"][0]["entries"]
|
||||
assert [entry["key"] for entry in entries] == ["default", "on.example.com", "off.example.com"]
|
||||
assert [entry["disabled"] for entry in entries] == [False, False, True]
|
||||
assert entries[2]["target"] == {"host": "127.0.0.1", "port": 44450}, \
|
||||
"the marked entry keeps its value: the state is the flag, not a rewritten line"
|
||||
assert parsed["upstreams"] == [{"name": "pool", "servers": [
|
||||
{"host": "10.1.0.9", "port": 8080, "disabled": True},
|
||||
{"host": "10.1.0.10", "port": 8080, "disabled": False},
|
||||
]}]
|
||||
|
||||
|
||||
def test_the_nginx_section_exposes_the_marker_flag_on_the_wire(tmp_path: Path) -> None:
|
||||
"""The section the dashboard reads is where the flag must land: a marked line in the file
|
||||
is a `disabled: true` row in the `haproxy` and `nginx` sections, and an unmarked config
|
||||
reports `false`, never a missing key (the dashboard's `enabled: false` is the flag's
|
||||
absence of absence, and a row that could not answer the question would read as on)."""
|
||||
write_file(tmp_path / "root", "opt/haproxy/etc/haproxy.cfg",
|
||||
"backend be\n server on 10.1.1.9:443\n"
|
||||
" server off 10.1.1.10:443 #off off\n")
|
||||
write_file(tmp_path / "root", "opt/nginx/conf/nginx.conf",
|
||||
"stream {\n"
|
||||
" server {\n"
|
||||
" listen 443;\n"
|
||||
" proxy_pass $v; #off v\n"
|
||||
" }\n"
|
||||
" map $x $v {\n"
|
||||
" default 10.1.0.1:8008;\n"
|
||||
" a.test 10.1.0.1:8008;\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"http {\n"
|
||||
" upstream p {\n"
|
||||
" server 10.1.0.9:8080; #off 10.1.0.9:8080\n"
|
||||
" }\n"
|
||||
"}\n")
|
||||
errors: list[str] = []
|
||||
collector = host_agent.Collector(
|
||||
str(tmp_path / "root"), haproxy_config="/opt/haproxy/etc/haproxy.cfg",
|
||||
nginx_config="/opt/nginx/conf/nginx.conf")
|
||||
haproxy = collector.haproxy(errors)
|
||||
assert [row["disabled"] for row in haproxy["backends"][0]["servers"]] == [False, True]
|
||||
nginx = collector.nginx(errors)
|
||||
assert nginx["servers"][0]["proxy_passes"][0]["disabled"] is True
|
||||
assert [row["disabled"] for row in nginx["upstreams"][0]["servers"]] == [True]
|
||||
assert [row["disabled"] for row in nginx["maps"][0]["entries"]] == [False, False]
|
||||
assert errors == []
|
||||
|
||||
|
||||
# ---- the recorded gateway: the captures themselves (phase 28 task 04) -------------------- #
|
||||
@@ -3732,3 +3883,404 @@ def test_the_target_list_is_the_two_sections_and_survives_a_section_that_is_not_
|
||||
("vhost.test", 443), ("map.test", 443)]
|
||||
assert host_agent.target_names(None, None) == []
|
||||
assert host_agent.target_names({"backends": "nonsense"}, {"maps": [7]}) == []
|
||||
|
||||
|
||||
# ---- POST /route (the one write, agent 1.4.0) -------------------------------- #
|
||||
|
||||
class _Peer:
|
||||
"""A routectl stub: an AF_UNIX socket that counts dials, reads one request line and
|
||||
answers one line. `hang` holds the connection open without answering, which is what
|
||||
makes the agent's 30 s budget (patched down in the test) fire. A dial it never sees
|
||||
is a refusal that happened before the socket, so the counters are the pins."""
|
||||
|
||||
def __init__(self, tmp_path: Path, reply: str | None = None, *, hang: bool = False) -> None:
|
||||
self.path = str(tmp_path / "peer.sock")
|
||||
self.reply = reply
|
||||
self.hang = hang
|
||||
self.requests: list[dict[str, Any]] = []
|
||||
self.connections = 0
|
||||
self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self._server.bind(self.path)
|
||||
self._server.listen(8)
|
||||
|
||||
def serve(self, window: float) -> None:
|
||||
deadline = time.monotonic() + window
|
||||
self._server.settimeout(0.05)
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
conn, _addr = self._server.accept()
|
||||
except TimeoutError:
|
||||
continue
|
||||
except OSError:
|
||||
return # stop() closed the server; the window is over
|
||||
self.connections += 1
|
||||
data = b""
|
||||
try:
|
||||
while b"\n" not in data:
|
||||
chunk = conn.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
except OSError:
|
||||
pass
|
||||
if data:
|
||||
self.requests.append(json.loads(data.decode()))
|
||||
if self.hang:
|
||||
continue # never answer; the agent's own timeout fires
|
||||
if self.reply is not None:
|
||||
with suppress(OSError):
|
||||
conn.sendall(self.reply.encode())
|
||||
conn.close()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._server.close()
|
||||
|
||||
|
||||
def _start_peer(tmp_path: Path, **kwargs: Any) -> _Peer:
|
||||
peer = _Peer(tmp_path, **kwargs)
|
||||
threading.Thread(target=peer.serve, args=(6.0,), daemon=True).start()
|
||||
return peer
|
||||
|
||||
|
||||
def _https_post(url: str, context: ssl.SSLContext, body: bytes) -> tuple[int, dict]:
|
||||
request = urllib.request.Request(url, data=body, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15, context=context) as response:
|
||||
return response.status, _json_or_raw(response.read())
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, _json_or_raw(error.read())
|
||||
|
||||
|
||||
def _post(url: str, body: bytes, headers: dict[str, str] | None = None) -> tuple[int, dict]:
|
||||
request = urllib.request.Request(url, data=body, headers=headers or {}, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return response.status, json.loads(response.read())
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, json.loads(error.read())
|
||||
|
||||
|
||||
def _route_body() -> bytes:
|
||||
return json.dumps({"id": "nginx:443->brain.test:443", "action": "disable",
|
||||
"dry_run": True}).encode()
|
||||
|
||||
|
||||
def test_the_agent_is_1_4_0_and_the_write_path_is_a_documented_flag() -> None:
|
||||
"""The version moves with the write; the two flags are on the command line, not in
|
||||
config, and the status page and the docstring say the write exists and is gated."""
|
||||
assert host_agent.AGENT_VERSION == "1.4.0"
|
||||
assert host_agent.ROUTE_TIMEOUT_SECONDS == 30
|
||||
help_text = subprocess.run([sys.executable, str(AGENT_PATH), "--help"],
|
||||
capture_output=True, text=True, timeout=30).stdout
|
||||
for flag in ("--allow-writes", "--routectl-socket", "--once"):
|
||||
assert flag in help_text, flag
|
||||
assert "POST /route" in (host_agent.__doc__ or "")
|
||||
|
||||
|
||||
def test_a_post_without_a_client_certificate_is_refused(
|
||||
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
|
||||
"""The token is not a write credential: a probe that holds only the CA (the same
|
||||
identity `install-agent.sh --status` uses) gets the certificate refusal, on a POST too."""
|
||||
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
|
||||
status, body = _https_post(base + "/route", client_context(tls_fleet.root_cert), _route_body())
|
||||
assert status == 403
|
||||
assert body["detail"] == "client certificate required"
|
||||
|
||||
|
||||
def test_a_revoked_serial_is_refused_on_the_write_path_too(
|
||||
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
|
||||
"""A serial on the denylist is refused on every path, including the write — the
|
||||
revoke in `/certs` bites a `POST /route` no later than it bites a `GET`."""
|
||||
text = tls_fleet.bundle + f"{host_agent.REVOKED_LINE}{tls_fleet.stolen.serial}\n"
|
||||
base = tls_agent_server(load_bundle(tmp_path, text))
|
||||
status, body = _https_post(base + "/route",
|
||||
client_context(tls_fleet.root_cert, str(tls_fleet.stolen.cert),
|
||||
str(tls_fleet.stolen.key)),
|
||||
_route_body())
|
||||
assert status == 403
|
||||
assert body["detail"] == "client certificate revoked"
|
||||
|
||||
|
||||
def test_token_mode_cannot_write_and_token_is_not_a_write_credential(
|
||||
agent_server, tmp_path: Path) -> None:
|
||||
"""A token-mode agent (no bundle) answers the write 403 even when the bearer token is
|
||||
right: `Authorization` is a read credential, and the write's gate is the certificate.
|
||||
The 405 arm is the same handler: a POST to any other path is refused before anything
|
||||
else is checked."""
|
||||
base, _port = agent_server("k8s-ops-token")
|
||||
bearer = {"Authorization": "Bearer k8s-ops-token"}
|
||||
status, body = _post(base + "/route", _route_body(), bearer)
|
||||
assert status == 403
|
||||
assert body["detail"] == "writes require a certificate"
|
||||
for path in ("/health", "/snapshot", "/info", "/schema", "/"):
|
||||
status, body = _post(base + path, b"{}", bearer)
|
||||
assert status == 405, path
|
||||
assert body["detail"] == "no such write path", path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body, detail", [
|
||||
('{"id": "x", "action": "disable", "dry_run": true, "extra": 1}',
|
||||
"body carries a key the write path does not take"),
|
||||
('{"id": "x", "action": "restart", "dry_run": true}',
|
||||
"action must be disable or enable"),
|
||||
(json.dumps({"id": "y" * 257, "action": "disable"}),
|
||||
"id must be a string of at most 256 characters"),
|
||||
("[1, 2]", "body is not a JSON object"),
|
||||
("not json at all", "body is not a JSON object"),
|
||||
])
|
||||
def test_a_body_outside_the_vocabulary_is_refused_before_the_peer_is_touched(
|
||||
body: str, detail: str, tls_fleet: SimpleNamespace, tls_agent_server,
|
||||
tmp_path: Path) -> None:
|
||||
"""The helper decides what a route id means; the agent decides what a body is. A body
|
||||
outside `{id, action, dry_run}` is refused before any socket opens, so a bad body can
|
||||
never reach a proxy config through this door."""
|
||||
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
|
||||
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
|
||||
host_agent.Handler.allow_writes = True
|
||||
host_agent.Handler.routectl_socket = peer.path
|
||||
status, answer = _https_post(base + "/route",
|
||||
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
|
||||
tls_fleet.client_key),
|
||||
body.encode())
|
||||
assert status == 422
|
||||
assert answer["detail"] == detail
|
||||
peer.stop()
|
||||
assert peer.connections == 0
|
||||
|
||||
|
||||
def test_an_oversized_body_is_refused_before_the_peer_is_touched(
|
||||
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
|
||||
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
|
||||
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
|
||||
host_agent.Handler.allow_writes = True
|
||||
host_agent.Handler.routectl_socket = peer.path
|
||||
oversized = json.dumps({"id": "x" * 5000, "action": "disable"})
|
||||
assert len(oversized) > host_agent._ROUTE_MAX_BODY
|
||||
status, answer = _https_post(base + "/route",
|
||||
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
|
||||
tls_fleet.client_key),
|
||||
oversized.encode())
|
||||
assert status == 422
|
||||
assert answer["detail"] == "body longer than the write path accepts"
|
||||
peer.stop()
|
||||
assert peer.connections == 0
|
||||
|
||||
|
||||
def test_writes_off_refuses_before_the_peer_is_touched(
|
||||
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
|
||||
"""The certificate verifies and the helper is listening, but the agent was started
|
||||
without `--allow-writes`: the refusal is the agent's own, and the dial never happens —
|
||||
a host that is not armed must not be reachable by an armed dashboard."""
|
||||
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
|
||||
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
|
||||
# allow_writes stays False (the fixture's default)
|
||||
host_agent.Handler.routectl_socket = peer.path
|
||||
status, answer = _https_post(base + "/route",
|
||||
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
|
||||
tls_fleet.client_key),
|
||||
_route_body())
|
||||
assert status == 403
|
||||
assert answer["detail"] == "writes are not enabled on this agent"
|
||||
peer.stop()
|
||||
assert peer.connections == 0
|
||||
|
||||
|
||||
def test_a_host_without_the_helper_answers_503_not_a_write(
|
||||
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
|
||||
"""`--routectl-socket` empty (the flag's documented arm): armed host, no helper unit.
|
||||
The answer is 503, not a write and not a 200 — a dashboard can tell "not installed"
|
||||
from "refused" without guessing."""
|
||||
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
|
||||
host_agent.Handler.allow_writes = True
|
||||
# routectl_socket stays None (the fixture's default)
|
||||
status, answer = _https_post(base + "/route",
|
||||
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
|
||||
tls_fleet.client_key),
|
||||
_route_body())
|
||||
assert status == 503
|
||||
assert answer["detail"] == "no route helper on this host"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action, dry_run", [("disable", True), ("enable", False)])
|
||||
def test_a_good_request_reaches_the_peer_once_and_the_answer_is_passed_through(
|
||||
action: str, dry_run: bool, tls_fleet: SimpleNamespace, tls_agent_server,
|
||||
tmp_path: Path) -> None:
|
||||
"""The agent is a forward: the body that reaches the helper is exactly `{id, action,
|
||||
dry_run}` — no header, no token, no invented fields — and the helper's answer (a
|
||||
refusal here, `applied` stays false) is the response, verbatim. One dial, one request:
|
||||
a retry would be a second reload."""
|
||||
peer = _start_peer(tmp_path, reply='{"ok": false, "detail": "ambiguous: whatever"}')
|
||||
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
|
||||
host_agent.Handler.allow_writes = True
|
||||
host_agent.Handler.routectl_socket = peer.path
|
||||
payload = {"id": "nginx:443->brain.test:443", "action": action, "dry_run": dry_run}
|
||||
status, answer = _https_post(base + "/route",
|
||||
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
|
||||
tls_fleet.client_key),
|
||||
json.dumps(payload).encode())
|
||||
assert status == 200
|
||||
assert answer == {"ok": False, "detail": "ambiguous: whatever"}
|
||||
assert peer.connections == 1
|
||||
assert peer.requests == [payload]
|
||||
peer.stop()
|
||||
|
||||
|
||||
def test_the_peer_is_called_once_even_when_it_times_out(
|
||||
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The helper hangs: the agent's own 30 s budget (patched to 0.3 s here) fires and the
|
||||
answer is 502 with the reason, never a hang and never a second dial — a retry would be
|
||||
a second reload of the fleet's only ingress."""
|
||||
peer = _start_peer(tmp_path, hang=True)
|
||||
monkeypatch.setattr(host_agent, "ROUTE_TIMEOUT_SECONDS", 0.3)
|
||||
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
|
||||
host_agent.Handler.allow_writes = True
|
||||
host_agent.Handler.routectl_socket = peer.path
|
||||
status, answer = _https_post(base + "/route",
|
||||
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
|
||||
tls_fleet.client_key),
|
||||
_route_body())
|
||||
assert status == 502
|
||||
assert answer["detail"] == "route helper timed out"
|
||||
assert peer.connections == 1
|
||||
assert len(peer.requests) == 1
|
||||
peer.stop()
|
||||
|
||||
|
||||
def test_an_unreadable_peer_answer_is_502_and_the_peer_is_not_retried(
|
||||
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
|
||||
"""A helper that answers bytes that are not a JSON object is a broken helper: 502 with
|
||||
the reason, one dial. The agent invents no success and no detail of its own."""
|
||||
peer = _start_peer(tmp_path, reply="nonsense is not json")
|
||||
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
|
||||
host_agent.Handler.allow_writes = True
|
||||
host_agent.Handler.routectl_socket = peer.path
|
||||
status, answer = _https_post(base + "/route",
|
||||
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
|
||||
tls_fleet.client_key),
|
||||
_route_body())
|
||||
assert status == 502
|
||||
assert answer["detail"] == "route helper answered something unreadable"
|
||||
assert peer.connections == 1
|
||||
peer.stop()
|
||||
|
||||
|
||||
def test_every_other_path_is_405_on_the_write_path_and_a_query_string_is_not_a_second_path(
|
||||
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
|
||||
"""The write path is `/route` and nothing else, on a TLS host with writes on: every
|
||||
other path is 405 before the certificate, the flag or the body are even looked at. A
|
||||
query string on `/route` is the same path — the helper decides what the id means."""
|
||||
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
|
||||
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
|
||||
host_agent.Handler.allow_writes = True
|
||||
host_agent.Handler.routectl_socket = peer.path
|
||||
context = client_context(tls_fleet.root_cert, tls_fleet.client_cert, tls_fleet.client_key)
|
||||
for path in ("/health", "/snapshot", "/info", "/schema", "/", "/nope"):
|
||||
status, answer = _https_post(base + path, context, b"{}")
|
||||
assert status == 405, path
|
||||
assert answer["detail"] == "no such write path", path
|
||||
status, answer = _https_post(base + "/route?from=drill", context, _route_body())
|
||||
assert status == 200
|
||||
assert peer.connections == 1
|
||||
peer.stop()
|
||||
|
||||
|
||||
# ---- the `disabled` field (the read side of the switch) ---------------------- #
|
||||
|
||||
def _haproxy_server(snapshot: dict[str, Any], name: str) -> dict[str, Any] | None:
|
||||
for backend in snapshot["haproxy"]["backends"]:
|
||||
for server in backend["servers"]:
|
||||
if server["name"] == name:
|
||||
return server
|
||||
return None
|
||||
|
||||
|
||||
def _nginx_proxy_pass(snapshot: dict[str, Any], host: str, port: int) -> dict[str, Any] | None:
|
||||
for server in snapshot["nginx"]["servers"]:
|
||||
for entry in server["proxy_passes"]:
|
||||
target = entry.get("target")
|
||||
if target is not None and target["host"] == host and target["port"] == port:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _nginx_map_entry(snapshot: dict[str, Any], key: str) -> dict[str, Any] | None:
|
||||
for mapped in snapshot["nginx"]["maps"]:
|
||||
for entry in mapped["entries"]:
|
||||
if entry["key"] == key:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def test_a_marked_haproxy_server_is_disabled_and_stays_in_the_document(gateway_snapshot) -> None:
|
||||
"""`#off <name>` is a trailing comment, not a deletion: the marked line stays in the
|
||||
config (the enable half needs it) and the agent says `disabled: true` on the node.
|
||||
The unmarked neighbours keep `disabled: false` — the field is the marker, nothing else."""
|
||||
snap = gateway_snapshot("marked")
|
||||
gitea = _haproxy_server(snap, "gitea")
|
||||
assert gitea is not None, "the marked line must stay in the document"
|
||||
assert gitea["disabled"] is True
|
||||
assert gitea["target"] == "reeseapps-gitea.reeselink.com" and gitea["port"] == 2222
|
||||
others = [server for backend in snap["haproxy"]["backends"]
|
||||
for server in backend["servers"] if server["name"] != "gitea"]
|
||||
assert others, "the fixture's other servers must still be parsed"
|
||||
assert all(server["disabled"] is False for server in others)
|
||||
|
||||
|
||||
def test_an_unmarked_config_has_disabled_false_everywhere(gateway_snapshot) -> None:
|
||||
snap = gateway_snapshot("full")
|
||||
for backend in snap["haproxy"]["backends"]:
|
||||
for server in backend["servers"]:
|
||||
assert server["disabled"] is False, server["name"]
|
||||
for server in snap["nginx"]["servers"]:
|
||||
for entry in server["proxy_passes"]:
|
||||
assert entry["disabled"] is False
|
||||
for pool in snap["nginx"]["upstreams"]:
|
||||
for member in pool["servers"]:
|
||||
assert member["disabled"] is False
|
||||
for mapped in snap["nginx"]["maps"]:
|
||||
for entry in mapped["entries"]:
|
||||
assert entry["disabled"] is False
|
||||
|
||||
|
||||
def test_a_marked_vhost_proxy_pass_is_disabled(gateway_snapshot) -> None:
|
||||
snap = gateway_snapshot("marked")
|
||||
entry = _nginx_proxy_pass(snap, "nextcloud.reeselink.com", 11000)
|
||||
assert entry is not None, "the marked proxy_pass must stay in the document"
|
||||
assert entry["disabled"] is True
|
||||
plain = gateway_snapshot("full")
|
||||
plain_entry = _nginx_proxy_pass(plain, "nextcloud.reeselink.com", 11000)
|
||||
assert plain_entry is not None
|
||||
assert plain_entry["disabled"] is False
|
||||
|
||||
|
||||
def test_a_marked_map_entry_is_disabled_and_its_value_is_the_sink(gateway_snapshot) -> None:
|
||||
"""The helper's third arm: the map entry's value is rewritten to the sink address and
|
||||
the line marked. `disabled` is the marker (true even though the target is a loopback),
|
||||
and the unmarked capture keeps the real target with `disabled: false`."""
|
||||
snap = gateway_snapshot("marked")
|
||||
entry = _nginx_map_entry(snap, "brain.reeseapps.com")
|
||||
assert entry is not None
|
||||
assert entry["disabled"] is True
|
||||
assert entry["target"] == {"host": "127.0.0.1", "port": 44450}
|
||||
plain = gateway_snapshot("full")
|
||||
plain_entry = _nginx_map_entry(plain, "brain.reeseapps.com")
|
||||
assert plain_entry is not None
|
||||
target = plain_entry["target"]
|
||||
assert target is not None and target["host"] == "reeseapps-brain-of-reese.reeselink.com"
|
||||
assert target["port"] == 443
|
||||
|
||||
|
||||
def test_a_hand_edit_is_an_absence_not_a_marker(gateway_snapshot) -> None:
|
||||
"""`hand_edited` is the operator's other half of the switch: the haproxy line is gone
|
||||
(the route is off, nothing to mark), the vhost line is plain (on, `disabled: false`),
|
||||
and the map value is deleted (the entry is there, its target is null, not marked)."""
|
||||
snap = gateway_snapshot("hand_edited")
|
||||
assert _haproxy_server(snap, "gitea") is None, "the deleted line is gone"
|
||||
entry = _nginx_proxy_pass(snap, "nextcloud.reeselink.com", 11000)
|
||||
assert entry is not None
|
||||
assert entry["disabled"] is False
|
||||
mapped = _nginx_map_entry(snap, "brain.reeseapps.com")
|
||||
assert mapped is not None
|
||||
assert mapped["target"] is None
|
||||
assert mapped["disabled"] is False
|
||||
|
||||
+1012
-106
File diff suppressed because it is too large
Load Diff
@@ -59,8 +59,10 @@ PROTECTED_API = (
|
||||
"/api/disks/history",
|
||||
# Phase 29: the ingress's pipes. `…/snapshot` carries the gateway's whole routing map — every
|
||||
# domain it serves and every address it dials — so it is listed here for the same reason the
|
||||
# bundle download is, whatever the payload happens to hold today.
|
||||
"/api/gateway/snapshot", "/api/gateway/history",
|
||||
# bundle download is, whatever the payload happens to hold today. Phase 33 task 05 adds the
|
||||
# surface's one write, `…/route`, beside them: it is the app's second upstream exception
|
||||
# (invariant 1) and the gate is what keeps it from answering anonymously.
|
||||
"/api/gateway/snapshot", "/api/gateway/history", "/api/gateway/route",
|
||||
"/api/llm/snapshot", "/api/llm/history", "/api/ups/snapshot",
|
||||
"/api/ups/history", "/api/ups/commands", "/api/auth/status",
|
||||
# Phase 15: the certificate authority's own surface. `/api/certs/<name>/bundle` is every
|
||||
@@ -80,7 +82,7 @@ PROTECTED_PAGES = ("/", "/system", "/disks", "/llm", "/ups", "/gateway", "/setti
|
||||
"/certs")
|
||||
|
||||
|
||||
#: The one write this app has, with the fields the route needs (the UPS is unconfigured, so a
|
||||
#: The write this file exercises, with the fields the route needs (the UPS is unconfigured, so a
|
||||
#: request that reaches the route answers 404 — that is the point).
|
||||
WRITE = {"ups": "apc", "command": "beeper.disable", "confirm": "yes"}
|
||||
|
||||
@@ -162,8 +164,9 @@ def app(store: SampleStore) -> Any:
|
||||
"""An app with **no** sources configured, so a route that runs is distinguishable.
|
||||
|
||||
No targets, no engines, no NUT: every protected answer that got as far as its handler says
|
||||
`404 {"configured": false}`, and nothing this file does can touch the fleet. The login POST
|
||||
and the UPS POST are the two writes on the app, and only one of them dials anything.
|
||||
`404 {"configured": false}`, and nothing this file does can touch the fleet. The login POST,
|
||||
the UPS POST, and the gateway route POST are the writes on the app, and only two of them
|
||||
dial anything.
|
||||
"""
|
||||
return create_app(settings_for(store.path), store)
|
||||
|
||||
@@ -1643,7 +1646,7 @@ def test_every_route_that_is_not_public_requires_a_session(anon: TestClient) ->
|
||||
|
||||
gated = [path for path in paths if not is_public(path)]
|
||||
assert len(gated) == len(paths) - 4, "the public surface grew without this test being read"
|
||||
assert {"/api/ups/command", "/api/auth/logout"} <= { # the two writes, both gated
|
||||
assert {"/api/ups/command", "/api/gateway/route", "/api/auth/logout"} <= { # the three writes, all gated
|
||||
path for path, methods in served if "POST" in methods}
|
||||
for path in gated:
|
||||
page = anon.get(path, headers=BROWSER_HEADERS)
|
||||
|
||||
@@ -124,6 +124,39 @@ def test_the_local_testing_password_is_prose_and_never_an_assignment(template: P
|
||||
)
|
||||
|
||||
|
||||
#: The write gate phase 33 added. The pin is the **declaration** — a comment that names
|
||||
#: the variable (with its default and what enabling it authorises) — never an assignment:
|
||||
#: a value in a tracked template is a decision made for everyone who copies the file, and
|
||||
#: the configuration table refuses that trap (the two writes are two decisions, and one
|
||||
#: flag does not carry the other).
|
||||
WRITE_GATES = ("GATEWAY_ALLOW_WRITES",)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
|
||||
def test_every_write_gate_is_declared_and_never_assigned_in_the_template(template: Path) -> None:
|
||||
"""A write gate no template names is a gate nobody finds.
|
||||
|
||||
The declaration is a comment (a commented-out line, or prose naming the variable with
|
||||
its default and what enabling it authorises). An *active* assignment in a tracked
|
||||
template is the gate armed by default for everyone who copies the file —
|
||||
`GATEWAY_ALLOW_WRITES=1` in a template is a route switch waiting in a fresh
|
||||
deployment.
|
||||
"""
|
||||
text = template.read_text()
|
||||
for name in WRITE_GATES:
|
||||
commented = [ln for ln in text.splitlines()
|
||||
if name in ln and ln.lstrip().startswith("#")]
|
||||
assert commented, (
|
||||
f"{template.name} never names {name} in a comment — a write gate nobody finds "
|
||||
"is a gate whose default nobody can see"
|
||||
)
|
||||
active = re.search(rf"^[ \t]*(?:export[ \t]+)?{name}[ \t]*=(?!=)", text, re.MULTILINE)
|
||||
assert active is None, (
|
||||
f"{template.name} actively assigns {name} — a value in a tracked template is a "
|
||||
"decision for everyone who copies it; the pin is the declaration, never a value"
|
||||
)
|
||||
|
||||
|
||||
def test_the_dev_template_points_at_where_the_local_password_lives() -> None:
|
||||
"""A dev copying the template needs to know what to type at the first login prompt."""
|
||||
text = ENV_DEV_EXAMPLE.read_text().lower()
|
||||
|
||||
@@ -33,6 +33,7 @@ from dashboards.collectors import (
|
||||
fetch_snapshot,
|
||||
gateway_job,
|
||||
host_jobs,
|
||||
post_route,
|
||||
)
|
||||
from dashboards.config import Settings, Target
|
||||
from dashboards.poller import Result
|
||||
@@ -891,6 +892,206 @@ async def test_a_token_mode_snapshot_gains_no_tls_fields() -> None:
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# post_route (the app's second upstream write, phase 33 task 05)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def recorded_post(handler: Any) -> tuple[httpx.AsyncClient, list[httpx.Request]]:
|
||||
"""The `post_route` shape of `client_for`: a scripted answer, every request kept."""
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def recording(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return handler(request)
|
||||
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(recording)), seen
|
||||
|
||||
|
||||
async def test_post_route_posts_the_wire_body_and_returns_the_helpers_object_verbatim() -> None:
|
||||
"""The wire is task 01's `--serve` contract: `id`, `action`, `dry_run`, and nothing else.
|
||||
|
||||
`danger` is the dashboard's typed confirmation, not a wire key — the agent's writer takes a
|
||||
body with no `danger`, and a header or field smuggled in is a contract break the agent would
|
||||
answer with its own 422. The answer comes back untouched: an `applied: false` must not
|
||||
become a success on the way over (rule 2), so the object is handed over, not rebuilt.
|
||||
"""
|
||||
client, seen = recorded_post(lambda request: httpx.Response(
|
||||
200, json={"applied": True, "changed": True, "reloaded": True}))
|
||||
async with client:
|
||||
answer = await post_route(client, Target("gateway", "http://agent.test"),
|
||||
{"id": "brain.reeseapps.com", "action": "disable",
|
||||
"dry_run": False},
|
||||
timeout=35.0)
|
||||
assert answer == {"applied": True, "changed": True, "reloaded": True}
|
||||
assert len(seen) == 1
|
||||
request = seen[0]
|
||||
assert request.method == "POST" and request.url.path == "/route"
|
||||
assert json.loads(request.content) == {"id": "brain.reeseapps.com",
|
||||
"action": "disable", "dry_run": False}
|
||||
assert "authorization" not in {key.lower() for key in request.headers}, (
|
||||
"the plain path carries only the target's own headers")
|
||||
|
||||
|
||||
async def test_post_route_sends_the_token_on_the_token_path() -> None:
|
||||
"""A host nobody has bundled dials the compatibility way: the header, exactly as the read sends it.
|
||||
|
||||
`parse_targets` is what moves the userinfo token into the header; a directly built target
|
||||
hands `post_route` the same shape, so the pin is the header arriving on the wire.
|
||||
"""
|
||||
client, seen = recorded_post(lambda request: httpx.Response(
|
||||
200, json={"applied": False, "changed": False}))
|
||||
target = Target("gateway", "http://agent.test",
|
||||
headers={"Authorization": "Bearer SEKRIT-AGENT-TOKEN"})
|
||||
async with client:
|
||||
await post_route(client, target, {"id": "r", "action": "enable", "dry_run": True},
|
||||
timeout=35.0)
|
||||
assert seen[0].headers["Authorization"] == "Bearer SEKRIT-AGENT-TOKEN"
|
||||
|
||||
|
||||
async def test_post_route_sends_no_authorization_on_the_certificate_path() -> None:
|
||||
"""The bundle is the credential on a `https://` target, and the write is stricter than the read.
|
||||
|
||||
`parse_targets` has already moved any token into the header, so a bundled host's target has a
|
||||
clean URL and no header — and the write must add nothing of its own: a header the read would
|
||||
have sent (a compatibility host's token) is a second opinion the write refuses, so the
|
||||
certificate path sends the body and the certificate and nothing else.
|
||||
"""
|
||||
seen: list[list[tuple[bytes, bytes]]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request.headers.raw)
|
||||
return httpx.Response(200, json={"applied": False, "dry_run": True, "changed": True})
|
||||
|
||||
tls = AgentTls(verify="/unused/root.pem", cert=("/unused/cert.pem", "/unused/key.pem"),
|
||||
status=lambda: certs.ACTIVE, transport=httpx.MockTransport(handler))
|
||||
async with client_for(lambda request: httpx.Response(200, json={})) as shared:
|
||||
answer = await post_route(shared, Target("gateway", "https://agent.test"),
|
||||
{"id": "r", "action": "disable", "dry_run": True},
|
||||
timeout=35.0, tls=tls)
|
||||
assert answer["dry_run"] is True
|
||||
assert len(seen) == 1
|
||||
assert not any(key == b"authorization" for key, _ in seen[0]), (
|
||||
"a token next to a certificate is the second opinion the write refuses")
|
||||
|
||||
|
||||
class PostTimeoutRecorder(TimeoutRecordingClient):
|
||||
"""The same seam as `TimeoutRecordingClient`, for the write's own budget argument."""
|
||||
|
||||
async def post(self, url: Any, **kwargs: Any) -> httpx.Response: # type: ignore[override]
|
||||
self.timeouts.append(kwargs.get("timeout", "<unset>"))
|
||||
return await super().post(url, **kwargs)
|
||||
|
||||
|
||||
async def test_post_route_carries_the_write_budget_and_never_a_bare_none() -> None:
|
||||
"""`GATEWAY_WRITE_TIMEOUT_SECONDS` is the write's own 35 s, passed per request.
|
||||
|
||||
A `None` timeout is *no timeout at all* to httpx: a helper stuck on a wedged `systemctl
|
||||
reload` would hold a request open for good. The sentinel keeps the client default, a number
|
||||
keeps the write's own budget.
|
||||
"""
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"applied": False, "changed": False})
|
||||
|
||||
client = PostTimeoutRecorder(transport=httpx.MockTransport(handler),
|
||||
timeout=10.0, base_url="http://agent.test")
|
||||
async with client:
|
||||
await post_route(client, Target("gw", "http://agent.test"),
|
||||
{"id": "r", "action": "disable", "dry_run": False}, timeout=35.0)
|
||||
await post_route(client, Target("gw", "http://agent.test"),
|
||||
{"id": "r", "action": "disable", "dry_run": False})
|
||||
assert client.timeouts[-2:] == [35.0, httpx.USE_CLIENT_DEFAULT]
|
||||
|
||||
|
||||
async def test_post_route_timeout_is_a_504_that_names_the_redacted_url() -> None:
|
||||
"""The message lands in the event table and the browser reads it (invariant 6): the URL is
|
||||
the redacted one and the status is the 504 a timeout is, so a slow helper is not a 502.
|
||||
"""
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ReadTimeout("read timed out")
|
||||
|
||||
target = Target("gw", "http://SEKRIT-AGENT-TOKEN@agent.test")
|
||||
async with client_for(handler) as client:
|
||||
with pytest.raises(UpstreamError) as caught:
|
||||
await post_route(client, target, {"id": "r", "action": "disable", "dry_run": False})
|
||||
assert caught.value.status == 504
|
||||
assert "SEKRIT-AGENT-TOKEN" not in caught.value.message
|
||||
assert "agent.test/route" in caught.value.message
|
||||
assert "timed out" in caught.value.message
|
||||
|
||||
|
||||
async def test_post_route_unreachable_is_a_502_with_the_redacted_url() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("no route to host")
|
||||
|
||||
target = Target("gw", "https://SEKRIT-AGENT-TOKEN@agent.test")
|
||||
tls = AgentTls(verify="/unused/root.pem", cert=("/unused/cert.pem", "/unused/key.pem"),
|
||||
status=lambda: certs.ACTIVE, transport=httpx.MockTransport(handler))
|
||||
async with client_for(lambda request: httpx.Response(200, json={})) as client:
|
||||
with pytest.raises(UpstreamError) as caught:
|
||||
await post_route(client, target, {"id": "r", "action": "enable", "dry_run": False},
|
||||
tls=tls)
|
||||
assert caught.value.status == 502
|
||||
assert "SEKRIT-AGENT-TOKEN" not in caught.value.message
|
||||
|
||||
|
||||
async def test_post_route_401_on_the_token_path_is_about_the_token() -> None:
|
||||
"""A 401 the token path gets is the rotated-token case: the sentence says so, and the URL
|
||||
is still the redacted one."""
|
||||
async with client_for(lambda request: httpx.Response(401, json={"detail": "bad token"})) as client:
|
||||
with pytest.raises(UpstreamError) as caught:
|
||||
await post_route(client, Target("gw", "http://SEKRIT-AGENT-TOKEN@agent.test"),
|
||||
{"id": "r", "action": "disable", "dry_run": False})
|
||||
assert "rejected the token (401)" in caught.value.message
|
||||
assert "SEKRIT-AGENT-TOKEN" not in caught.value.message
|
||||
|
||||
|
||||
async def test_post_route_refusal_carries_the_helpers_own_word() -> None:
|
||||
"""A `>= 400` answer is the agent's own refusal — a `403` here is the write gate's own word
|
||||
(`writes are not enabled on this agent`), and paraphrasing it would be the dashboard
|
||||
claiming to know more than the peer said."""
|
||||
async with client_for(lambda request: httpx.Response(
|
||||
409, json={"detail": "no such id: brain.reeseapps.com"})) as client:
|
||||
with pytest.raises(UpstreamError) as caught:
|
||||
await post_route(client, Target("gateway", "http://agent.test"),
|
||||
{"id": "brain.reeseapps.com", "action": "disable", "dry_run": False})
|
||||
assert caught.value.status == 502
|
||||
assert "HTTP 409" in caught.value.message
|
||||
assert "no such id: brain.reeseapps.com" in caught.value.message
|
||||
|
||||
|
||||
async def test_post_route_an_unreadable_answer_is_an_error_with_a_bounded_slice() -> None:
|
||||
async with client_for(lambda request: httpx.Response(
|
||||
200, text="not json at all " + "x" * 200)) as client:
|
||||
with pytest.raises(UpstreamError, match="unreadable") as caught:
|
||||
await post_route(client, Target("gateway", "http://agent.test"),
|
||||
{"id": "r", "action": "disable", "dry_run": False})
|
||||
assert "x" * 200 not in caught.value.message, "the slice is bounded, not the whole body"
|
||||
async with client_for(lambda request: httpx.Response(200, json=[1, 2])) as client:
|
||||
with pytest.raises(UpstreamError, match="non-object"):
|
||||
await post_route(client, Target("gateway", "http://agent.test"),
|
||||
{"id": "r", "action": "disable", "dry_run": False})
|
||||
|
||||
|
||||
async def test_post_route_refuses_a_revoked_agent_before_any_dial() -> None:
|
||||
"""The revocation gate is in front of the network, for the write as for the read.
|
||||
|
||||
The scripted transport records what it receives, and a `status` callable that answers
|
||||
`REVOKED` is what `certs.agent_status` would answer after the operator's click: the
|
||||
refusal must come before the transport sees a request, not after.
|
||||
"""
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(200, json={"applied": True})
|
||||
|
||||
tls = AgentTls(verify="/unused/root.pem", cert=("/unused/cert.pem", "/unused/key.pem"),
|
||||
status=lambda: certs.REVOKED, transport=httpx.MockTransport(handler))
|
||||
async with client_for(lambda request: httpx.Response(200, json={})) as client:
|
||||
with pytest.raises(UpstreamError, match="agent certificate revoked") as caught:
|
||||
await post_route(client, Target("gateway", "https://agent.test"),
|
||||
{"id": "r", "action": "disable", "dry_run": False}, tls=tls)
|
||||
assert seen == [], "a revoked host must not be written"
|
||||
assert "revoked" in caught.value.message
|
||||
|
||||
# LLM engines
|
||||
# --------------------------------------------------------------------------- #
|
||||
GUFO = (FIXTURES / "metrics_gufo.txt").read_text()
|
||||
|
||||
@@ -135,6 +135,45 @@ def test_the_gateway_settings_come_from_the_environment(monkeypatch: pytest.Monk
|
||||
importlib.reload(config)
|
||||
|
||||
|
||||
def test_the_gateway_write_is_off_by_default_and_budgets_are_their_own() -> None:
|
||||
"""Phase 33 task 05: the flag is a *decision*, and its default is the safe one.
|
||||
|
||||
`GATEWAY_ALLOW_WRITES` is `NUT_ALLOW_ACTIONS`'s sibling: the same truthy rule, the same
|
||||
default, and its own budget. The read's 10 s is the read's alone; the write's 35 s default
|
||||
is the write's own, and the two never share a number.
|
||||
"""
|
||||
settings = Settings()
|
||||
assert settings.gateway_allow_writes is False
|
||||
assert settings.gateway_write_timeout == 35.0
|
||||
assert settings.gateway_timeout == 10.0
|
||||
|
||||
|
||||
def test_the_gateway_write_settings_come_from_the_environment(
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""`GATEWAY_ALLOW_WRITES=1` is the only way the flag turns on, the same rule as the NUT one."""
|
||||
monkeypatch.setenv("GATEWAY_ALLOW_WRITES", "1")
|
||||
monkeypatch.setenv("GATEWAY_WRITE_TIMEOUT_SECONDS", "12.5")
|
||||
reloaded = importlib.reload(config)
|
||||
try:
|
||||
assert reloaded.SETTINGS.gateway_allow_writes is True
|
||||
assert reloaded.SETTINGS.gateway_write_timeout == 12.5
|
||||
finally:
|
||||
for name in ("GATEWAY_ALLOW_WRITES", "GATEWAY_WRITE_TIMEOUT_SECONDS"):
|
||||
monkeypatch.delenv(name)
|
||||
importlib.reload(config)
|
||||
|
||||
|
||||
def test_the_gateway_flag_uses_the_same_truthy_rule_as_every_other_flag(
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The rule is `_flag`'s: the two writes are two decisions, the same shape (task 05)."""
|
||||
for truthy in ("1", "true", "TRUE", "yes", "on"):
|
||||
monkeypatch.setenv("GATEWAY_ALLOW_WRITES", truthy)
|
||||
assert config._flag("GATEWAY_ALLOW_WRITES") is True, truthy
|
||||
for falsey in ("0", "false", "", "no"):
|
||||
monkeypatch.setenv("GATEWAY_ALLOW_WRITES", falsey)
|
||||
assert config._flag("GATEWAY_ALLOW_WRITES") is False, falsey
|
||||
|
||||
|
||||
def test_the_disk_source_defaults_to_a_five_minute_cadence() -> None:
|
||||
"""SMART is not a per-second signal (phase 09), and the fetch waits for the agent's
|
||||
own 20 s smartctl budget. Both are env-only, like every other interval."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1696,7 +1696,7 @@ def test_no_count_carries_a_verdict_or_a_window(gateway_snapshot: Any) -> None:
|
||||
`sources/host.py` fails here — the first would be a judgement `severity.py` never made, the
|
||||
second would claim a measurement this source does not take.
|
||||
"""
|
||||
for mode in ("full", "predates", "sockets_silent"):
|
||||
for mode in ("full", "predates", "sockets_silent", "marked", "hand_edited"):
|
||||
keys = walk_keys(gateway.parse(gateway_snapshot(mode), "gateway"))
|
||||
assert "severity" not in keys, mode
|
||||
assert not {key for key in keys if "window" in key or key.startswith("Δ")}, mode
|
||||
@@ -2132,3 +2132,370 @@ def test_warnings_travel_in_the_payload_and_the_stub_share_the_key(gateway_snaps
|
||||
assert gateway.stub("gateway")["warnings"] == []
|
||||
assert gateway.parse(gateway_snapshot("predates"), "gateway")["warnings"] == []
|
||||
assert set(gateway.stub("gateway")) - {"unreachable"} <= set(gateway.parse(snap, "gateway"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# phase 33 task 04: `enabled` on every line, and the warnings learn the marker
|
||||
#
|
||||
# The helper's `#off <name>` marker leaves a switched-off line **in the config**; the agent
|
||||
# reads it and publishes `disabled`, and this module draws the line either way — an off route
|
||||
# is a claim the config made (a different fact from "never declared"), so it is drawn and
|
||||
# counted, flagged. An arm that never read the config draws no lines at all, which is what
|
||||
# keeps a `false` out of a payload nobody answered. The warning arms learn the marker the same
|
||||
# way: a shape the operator's own switch caused is not a fault, and the same shape a hand edit
|
||||
# left is.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def marked(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""A line the helper marked off: the agent's own `disabled: true` beside the intact line."""
|
||||
return {**row, "disabled": True}
|
||||
|
||||
|
||||
def test_a_marked_haproxy_server_line_is_an_off_line_with_a_distinct_id() -> None:
|
||||
"""On and off are both drawn; the id's `#off` suffix is what keeps them distinct rows."""
|
||||
section = haproxy_section(
|
||||
[frontend("haproxy-22", [2222], default="be")],
|
||||
[backend("be", [server("s1", "10.9.9.9", 80),
|
||||
marked(server("s2", "10.9.9.9", 80))])])
|
||||
payload = gateway.parse(snapshot(haproxy=section, agent_version="1.4.0"), "gateway")
|
||||
by_enabled = {row["enabled"]: row for row in payload["connections"]}
|
||||
assert set(by_enabled) == {True, False}, "one of these selectors is off is two facts"
|
||||
on, off = by_enabled[True], by_enabled[False]
|
||||
assert on["id"] == "haproxy:2222->10.9.9.9:80"
|
||||
assert off["id"] == "haproxy:2222->10.9.9.9:80#off", (
|
||||
"the suffix is the write path's key — the two halves of the split must not collide")
|
||||
assert on["out"]["selectors"] == off["out"]["selectors"] == ["be"], (
|
||||
"the off line keeps the config's own word for the route — switched off is not deleted")
|
||||
# the flag is absence-tolerant: a row the agent did not report it on (an older agent's
|
||||
# document) is on, because absence is "not switched off", never "broken"
|
||||
older = haproxy_section([frontend("haproxy-22", [2222], default="be")],
|
||||
[backend("be", [server("s1", "10.9.9.9", 80)])])
|
||||
payload = gateway.parse(snapshot(haproxy=older), "gateway")
|
||||
assert [row["enabled"] for row in payload["connections"]] == [True]
|
||||
|
||||
|
||||
def test_a_backend_whose_only_server_is_switched_off_is_not_an_empty_warning() -> None:
|
||||
"""The marker decision, pinned both ways: the line the helper left behind keeps the backend
|
||||
non-empty (no warning — its state is the line's `enabled: false`), and the same shape the
|
||||
operator left by **deleting** the line is the fault."""
|
||||
switched_off = haproxy_section([frontend("haproxy-22", [2222], default="be")],
|
||||
[backend("be", [marked(server("s1", "10.9.9.9", 80))])])
|
||||
assert gateway.warnings(switched_off, None) == [], (
|
||||
"the line is still in the document, so the backend declares a server — the off state "
|
||||
"is the line's flag, not an empty-backend fault")
|
||||
|
||||
hand_deleted = haproxy_section([frontend("haproxy-22", [2222], default="be")],
|
||||
[backend("be", [])])
|
||||
assert kinds(gateway.warnings(hand_deleted, None)) == [gateway.WARN_BACKEND_EMPTY], (
|
||||
"the config no longer says anything — that is the fault the same shape warns about")
|
||||
|
||||
|
||||
def test_a_marked_proxy_pass_is_an_off_line_and_the_block_is_still_drawn() -> None:
|
||||
off = {**proxy_pass(("10.9.9.9", 80), location="/off"), "disabled": True}
|
||||
section = nginx_section(
|
||||
[nginx_server([8443], ["x.example"], [proxy_pass(("10.9.9.9", 80)), off])])
|
||||
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
|
||||
by_enabled = {row["enabled"]: row for row in payload["connections"]}
|
||||
assert set(by_enabled) == {True, False}
|
||||
assert by_enabled[False]["id"] == "nginx:8443->10.9.9.9:80#off"
|
||||
assert by_enabled[False]["out"]["selectors"] == ["x.example"]
|
||||
|
||||
|
||||
def test_a_marked_upstream_member_is_off_and_its_siblings_stay_on() -> None:
|
||||
pool = {"name": "pool",
|
||||
"servers": [{"host": "10.9.9.1", "port": 80, "disabled": False},
|
||||
{"host": "10.9.9.2", "port": 80, "disabled": True}]}
|
||||
section = nginx_section([nginx_server([8443], ["x.example"], [pool_pass("pool")])],
|
||||
upstreams=[pool])
|
||||
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
|
||||
by_host = {row["out"]["host"]: row["enabled"] for row in payload["connections"]}
|
||||
assert by_host == {"10.9.9.1": True, "10.9.9.2": False}, (
|
||||
"the member line's own flag is its state — the pool's other members are not switched "
|
||||
"with it")
|
||||
|
||||
|
||||
def test_a_map_entry_is_off_by_marker_or_by_the_sink_block_and_only_those() -> None:
|
||||
"""Three off-lookalikes, one of which is routing: the marked line, the line whose value is
|
||||
the helper's sink block, and a plain loopback target with no such block. Only the first two
|
||||
are the switch — an address alone, without the block, is not off (a vhost that legitimately
|
||||
lands on loopback is routing, not a switch)."""
|
||||
sink_block = {"listen": [{"addr": "127.0.0.1", "port": 19999}],
|
||||
"server_names": [], "proxy_passes": []}
|
||||
entries = [("web.example", ("10.9.9.9", 80)),
|
||||
("off.example", ("10.8.8.8", 80)),
|
||||
("sink.example", ("127.0.0.1", 19999))]
|
||||
maps = [mapping_block("$var", entries)]
|
||||
maps[0]["entries"][1] = {**maps[0]["entries"][1], "disabled": True} # `off.example`, marked
|
||||
section = nginx_section(
|
||||
[sink_block,
|
||||
nginx_server([443], (), [proxy_pass(None, variable="$var")])],
|
||||
maps=maps)
|
||||
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
|
||||
by_selector = {row["out"]["selectors"][0]: row["enabled"]
|
||||
for row in payload["connections"] if row["out"]["selectors"]}
|
||||
assert by_selector == {"web.example": True, "off.example": False,
|
||||
"sink.example": False}, (
|
||||
"the marked line and the line whose value is the sink block are the switch; "
|
||||
"a plain target is not")
|
||||
|
||||
# the control target: a loopback address the config routes to, with no such block
|
||||
maps[0]["entries"].append({"key": "plain.example",
|
||||
"target": {"host": "127.0.0.2", "port": 29999},
|
||||
"disabled": False})
|
||||
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
|
||||
plain = [row for row in payload["connections"]
|
||||
if row["out"]["selectors"] == ["plain.example"]]
|
||||
assert plain and plain[0]["enabled"] is True, (
|
||||
"an address alone, without the block, is not off")
|
||||
|
||||
# and the refusal the other way: the same config with the sink block absent — the value is
|
||||
# just an address again, and the line is on
|
||||
no_sink = dict(section, servers=[s for s in section["servers"]
|
||||
if s is not sink_block])
|
||||
payload = gateway.parse(snapshot(nginx=no_sink, agent_version="1.4.0"), "gateway")
|
||||
sink_row = [row for row in payload["connections"]
|
||||
if row["out"]["selectors"] == ["sink.example"]]
|
||||
assert sink_row and sink_row[0]["enabled"] is True
|
||||
|
||||
|
||||
def test_a_map_entry_with_no_value_warns_unless_the_line_carries_the_marker() -> None:
|
||||
"""The same shape, two causes: the value hand-deleted is the fault the warning exists for,
|
||||
the value the marker's line lost is the operator's own state and stays quiet. A marked entry
|
||||
with no value has no endpoint to draw either, so it is a warning-less absence — the off
|
||||
state lives in the line's flag, and the line is the config's."""
|
||||
entries = [{"key": "gone.example", "target": None, "disabled": False},
|
||||
{"key": "off.example", "target": None, "disabled": True}]
|
||||
section = nginx_section(
|
||||
[nginx_server([443], (), [proxy_pass(None, variable="$var")])],
|
||||
maps=[{"variable": "$var", "entries": entries}])
|
||||
assert kinds(gateway.warnings(None, section)) == [gateway.WARN_MAP_ROUTE_MISSING]
|
||||
found = gateway.warnings(None, section)[0]
|
||||
assert found["subject"] == "var:gone.example"
|
||||
# the marked entry draws no line (it has no endpoint) and warns nothing
|
||||
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
|
||||
assert all(row["out"]["selectors"] != ["off.example"] for row in payload["connections"])
|
||||
|
||||
|
||||
def test_two_marked_map_entries_to_one_endpoint_merge_into_one_off_line() -> None:
|
||||
"""Same state, same endpoint, two selector words: one line, the union of both keys — the
|
||||
merge rule with `enabled` in the key, in the direction that unions instead of splits."""
|
||||
entries = [{"key": "a.example", "target": {"host": "10.9.9.9", "port": 80},
|
||||
"disabled": True},
|
||||
{"key": "b.example", "target": {"host": "10.9.9.9", "port": 80},
|
||||
"disabled": True}]
|
||||
section = nginx_section(
|
||||
[nginx_server([443], (), [proxy_pass(None, variable="$var")])],
|
||||
maps=[{"variable": "$var", "entries": entries}])
|
||||
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
|
||||
assert len(payload["connections"]) == 1
|
||||
row = payload["connections"][0]
|
||||
assert row["enabled"] is False and row["id"] == "nginx:443->10.9.9.9:80#off"
|
||||
assert row["out"]["selectors"] == ["a.example", "b.example"]
|
||||
|
||||
|
||||
def test_the_stub_and_the_predates_arm_have_no_lines_and_so_no_false(
|
||||
gateway_snapshot: Any) -> None:
|
||||
"""`false` is always a claim the config made, so an arm that never read the config carries
|
||||
no `enabled` at all — not a row of `null`s (that would be a guess) and not `false` (that
|
||||
would be a claim). The stub and the predates arm are that arm, both pinned."""
|
||||
assert gateway.stub("gateway")["connections"] == []
|
||||
assert "enabled" not in walk_keys(gateway.stub("gateway"))
|
||||
assert "disabled" not in walk_keys(gateway.stub("gateway"))
|
||||
|
||||
predates = gateway.parse(gateway_snapshot("predates"), "gateway")
|
||||
assert predates["supported"] is False and predates["connections"] == []
|
||||
assert "enabled" not in walk_keys(predates)
|
||||
|
||||
|
||||
def test_the_captured_configs_carry_no_marker_and_their_lines_are_on(gateway_snapshot: Any) -> None:
|
||||
"""The recorded ingress, through the real agent: the flag is on every markable line and no
|
||||
line of it is marked, so every line of the captured graph is `enabled: true` — the pin that
|
||||
a re-capture in which the operator switched a route off is visible here rather than silently
|
||||
redrawn as on."""
|
||||
snap = gateway_snapshot("full")
|
||||
for block in snap["haproxy"]["backends"]:
|
||||
assert all(row["disabled"] is False for row in block["servers"])
|
||||
for server in snap["nginx"]["servers"]:
|
||||
assert all(p["disabled"] is False for p in server["proxy_passes"])
|
||||
for block in snap["nginx"]["upstreams"]:
|
||||
assert all(m["disabled"] is False for m in block["servers"])
|
||||
for block in snap["nginx"]["maps"]:
|
||||
assert all(e["disabled"] is False for e in block["entries"])
|
||||
|
||||
payload = gateway.parse(snap, "gateway")
|
||||
assert payload["connections"], "the capture has routes"
|
||||
assert all(row["enabled"] is True for row in payload["connections"])
|
||||
assert not any(row["id"].endswith("#off") for row in payload["connections"])
|
||||
|
||||
|
||||
def test_a_marker_off_route_is_declared_and_disabled(gateway_snapshot: Any) -> None:
|
||||
"""Three of the fleet's own routes, switched off in the `marked` fixture (one haproxy
|
||||
`server`, one vhost `proxy_pass`, one map entry pointing at the sink), come back as three
|
||||
drawn rows reading `enabled: false` — selector and endpoint intact, every other row on, and
|
||||
the marker-caused shapes warn nothing. The row is *declared and off*, which is a different
|
||||
fact from never declared (absent) and from on, and the `#off` id suffix is the state's
|
||||
spelling the write path keys on.
|
||||
"""
|
||||
payload = gateway.parse(gateway_snapshot("marked"), "gateway")
|
||||
off = [row for row in payload["connections"] if row["enabled"] is False]
|
||||
on = [row for row in payload["connections"] if row["enabled"] is True]
|
||||
assert len(off) == 3, "three markers, three off rows — no merge stole one, none is missing"
|
||||
assert on, "the other routes of the capture are still declared and on"
|
||||
assert all(row["enabled"] is True for row in on), ("every non-marker row is on")
|
||||
|
||||
by_selector = {tuple(row["out"]["selectors"]): row for row in off}
|
||||
# the selectors are the config's own words, verbatim — switching a route off is not deleting
|
||||
# its name, and the endpoint is what the config (or the helper's rewrite) wrote, intact
|
||||
assert by_selector[("gitea-2222",)]["out"]["host"] == "reeseapps-gitea.reeselink.com"
|
||||
assert by_selector[("gitea-2222",)]["out"]["port"] == 2222
|
||||
assert by_selector[("nextcloud.reeseapps.com",)]["out"]["host"] == "nextcloud.reeselink.com"
|
||||
assert by_selector[("nextcloud.reeseapps.com",)]["out"]["port"] == 11000
|
||||
assert by_selector[("brain.reeseapps.com",)]["out"]["host"] == "127.0.0.1"
|
||||
assert by_selector[("brain.reeseapps.com",)]["out"]["port"] == 44450
|
||||
for row in off:
|
||||
assert row["id"].endswith("#off"), "the off state is the id's spelling, on every off row"
|
||||
# the two marker-caused shapes are present in the raw sections, so `warnings == []` below is a
|
||||
# suppression, not a vacuous absence: the gitea backend's only server is marked, and the
|
||||
# brain entry's value is the sink
|
||||
snap = gateway_snapshot("marked")
|
||||
gitea = next(b for b in snap["haproxy"]["backends"] if b["name"] == "gitea-2222")
|
||||
assert gitea["servers"] and gitea["servers"][0]["disabled"] is True
|
||||
stream_map = next(b for b in snap["nginx"]["maps"] if b["variable"] == "targetBackend")
|
||||
brain = next(e for e in stream_map["entries"] if e["key"] == "brain.reeseapps.com")
|
||||
assert brain["disabled"] is True
|
||||
assert payload["warnings"] == [], ("a marker-caused empty backend and a sink-pointed map "
|
||||
"entry are states, not config-integrity faults")
|
||||
|
||||
|
||||
def test_the_source_never_calls_a_route_broken_because_the_operator_disabled_it(
|
||||
gateway_snapshot: Any) -> None:
|
||||
"""The positive direction of both marker-aware kinds, over the captured config with the
|
||||
markers injected (never a toy file): a backend whose only `server` the operator turned off
|
||||
is not `haproxy_backend_empty` — the line is still in the document, and its state is the
|
||||
row's `enabled: false` — and a map entry the helper pointed at its own sink is not a
|
||||
missing route. Neither shape may earn a warning while the switch is what made it.
|
||||
"""
|
||||
snap = gateway_snapshot("marked")
|
||||
# the shapes are present in the raw document, so the silence below is a decision
|
||||
gitea = next(b for b in snap["haproxy"]["backends"] if b["name"] == "gitea-2222")
|
||||
assert len(gitea["servers"]) == 1 and gitea["servers"][0]["disabled"] is True
|
||||
stream_map = next(b for b in snap["nginx"]["maps"] if b["variable"] == "targetBackend")
|
||||
brain = next(e for e in stream_map["entries"] if e["key"] == "brain.reeseapps.com")
|
||||
assert brain["disabled"] is True and brain["target"] == {"host": "127.0.0.1", "port": 44450}
|
||||
payload = gateway.parse(snap, "gateway")
|
||||
assert kinds(payload["warnings"]) == [], (
|
||||
"the operator's own switch is a state the page draws, not a fault the page alarms about")
|
||||
|
||||
|
||||
def test_the_same_shape_without_a_marker_is_still_a_warning(gateway_snapshot: Any) -> None:
|
||||
"""The negative direction, the same two shapes in the `hand_edited` fixture: the `server`
|
||||
line deleted by hand is the fault `haproxy_backend_empty` exists for, and the map entry
|
||||
whose value was hand-deleted (no marker, no sink file) is the fault
|
||||
`nginx_map_route_missing` exists for. Suppression is marker-caused, not shape-caused —
|
||||
together with `test_the_source_never_calls_a_route_broken_because_the_operator_disabled_it`
|
||||
this is the pair that says so.
|
||||
"""
|
||||
snap = gateway_snapshot("hand_edited")
|
||||
# the shapes are present and unmarked, so the warnings below are the checks working
|
||||
gitea = next(b for b in snap["haproxy"]["backends"] if b["name"] == "gitea-2222")
|
||||
assert gitea["servers"] == [], "the line is gone — the backend declares no server in the document"
|
||||
stream_map = next(b for b in snap["nginx"]["maps"] if b["variable"] == "targetBackend")
|
||||
brain = next(e for e in stream_map["entries"] if e["key"] == "brain.reeseapps.com")
|
||||
assert brain["target"] is None and brain["disabled"] is False
|
||||
payload = gateway.parse(snap, "gateway")
|
||||
assert kinds(payload["warnings"]) == [gateway.WARN_BACKEND_EMPTY,
|
||||
gateway.WARN_MAP_ROUTE_MISSING]
|
||||
found = {row["kind"]: row for row in payload["warnings"]}
|
||||
assert found[gateway.WARN_BACKEND_EMPTY]["subject"] == "gitea-2222"
|
||||
assert found[gateway.WARN_MAP_ROUTE_MISSING]["subject"] == "targetBackend:brain.reeseapps.com"
|
||||
# and the off state itself is gone with the marker: no `#off` row, nothing to draw off
|
||||
assert not any(row["id"].endswith("#off") for row in payload["connections"])
|
||||
assert all(row["enabled"] is True for row in payload["connections"])
|
||||
|
||||
|
||||
def test_the_sink_address_is_recognised_not_hard_coded() -> None:
|
||||
"""The sink is the *block* — loopback listen, no `server_name`, no `proxy_pass` — and the
|
||||
port is the helper's to choose. A config that writes a different sink port still reads as
|
||||
switched-off from the payload's own text, and a loopback target that is not the block's
|
||||
port is routing, not a switch (a vhost that legitimately lands on loopback is on)."""
|
||||
other_port = 45999
|
||||
sink_block = {"listen": [{"addr": "127.0.0.1", "port": other_port}],
|
||||
"server_names": [], "proxy_passes": []}
|
||||
section = nginx_section(
|
||||
[sink_block, nginx_server([443], (), [proxy_pass(None, variable="$var")])],
|
||||
maps=[mapping_block("$var", [("sink.example", ("127.0.0.1", other_port)),
|
||||
("plain.example", ("127.0.0.2", 29999))])])
|
||||
assert gateway.sink_endpoint(section) == ("127.0.0.1", other_port), (
|
||||
"the block, not the port constant, is what the source looks for")
|
||||
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
|
||||
by_selector = {row["out"]["selectors"][0]: row["enabled"]
|
||||
for row in payload["connections"] if row["out"]["selectors"]}
|
||||
assert by_selector == {"sink.example": False, "plain.example": True}, (
|
||||
"the entry whose value is the block's listen is off; the other loopback target is not")
|
||||
|
||||
|
||||
def test_a_disabled_line_still_publishes_only_a_measured_count() -> None:
|
||||
"""Nothing stamps `0`: an off row's `connections` is the join's answer, and the join says
|
||||
`0` only when the table answered (and genuinely had none) and `null` when it did not —
|
||||
with no sample in the latter case. `0` measured is a legitimate measurement; the
|
||||
distinction is *measured*, never *assumed*.
|
||||
"""
|
||||
haproxy = haproxy_section([frontend("f", [2222], default="be")],
|
||||
[backend("be", [marked(server("s1", "10.9.9.9", 80))])])
|
||||
sink_block = {"listen": [{"addr": "127.0.0.1", "port": 44450}],
|
||||
"server_names": [], "proxy_passes": []}
|
||||
section = nginx_section(
|
||||
[sink_block, nginx_server([443], (), [proxy_pass(None, variable="$var")])],
|
||||
maps=[mapping_block("$var", [("brain.example", ("127.0.0.1", 44450)),
|
||||
("web.example", ("10.9.9.1", 80))])])
|
||||
section["maps"][0]["entries"][0] = {**section["maps"][0]["entries"][0], "disabled": True}
|
||||
|
||||
# the table answered (and had nothing to book): the off rows are `0`, and `0` measured
|
||||
# publishes, because the endpoint is one the config wrote as an address
|
||||
answered = gateway.parse(
|
||||
snapshot(haproxy=haproxy, nginx=section, sockets=sockets_section(),
|
||||
agent_version="1.4.0"), "gateway")
|
||||
off = [row for row in answered["connections"] if row["enabled"] is False]
|
||||
assert len(off) == 2 and all(row["connections"] == 0 for row in off)
|
||||
by_selector = {row["out"]["selectors"][0]: row for row in answered["connections"]}
|
||||
assert by_selector["web.example"]["connections"] == 0 # the on row, the same measured zero
|
||||
off_metrics = {row["metric"] for row in off}
|
||||
assert off_metrics, "a joinable off endpoint carries a gauge, like its on counterpart"
|
||||
samples = {s.metric: s.value for s in gateway.samples(answered, 1.0)}
|
||||
for metric in off_metrics:
|
||||
assert samples.get(metric) == 0.0, "the off row's sample is the measured zero, not a stamp"
|
||||
|
||||
# the table did not answer: every row stays `null` and the off rows publish nothing at all
|
||||
silent = gateway.parse(
|
||||
snapshot(haproxy=haproxy, nginx=section,
|
||||
sockets=sockets_section(available=None), agent_version="1.4.0"), "gateway")
|
||||
assert all(row["connections"] is None for row in silent["connections"]), (
|
||||
"no table, no count — the off state is not a zero")
|
||||
assert gateway.samples(silent, 1.0) == []
|
||||
|
||||
|
||||
def test_the_stub_and_the_answer_share_one_key_set(gateway_snapshot: Any) -> None:
|
||||
"""The stub's `enabled` is `None` in the sense nobody read the config — and a stub has no
|
||||
rows, so the honest unknown is the *absence of the claim*: no rows, hence no `enabled` key
|
||||
anywhere in the document (a row of `null`s would be a guess, and a `false` would be a claim
|
||||
the config never made). The answer is the other half of the contract: every row it draws
|
||||
carries the state as a real boolean, never a `None` and never a missing key, so a page
|
||||
indexing `connections[].enabled` tests one shape in both arms.
|
||||
"""
|
||||
answer = gateway.parse(gateway_snapshot("full"), "gateway")
|
||||
assert answer["connections"], "the capture has routes to pin the answer half on"
|
||||
for row in answer["connections"]:
|
||||
assert row["enabled"] is True or row["enabled"] is False, (
|
||||
"an answered row's state is a claim the config made — never null, never absent")
|
||||
|
||||
stub = gateway.stub("gateway")
|
||||
assert stub["connections"] == []
|
||||
assert "enabled" not in walk_keys(stub), (
|
||||
"an arm that never read the config carries no `enabled` — its `None` is the absence")
|
||||
assert "disabled" not in walk_keys(stub)
|
||||
|
||||
# top-level parity: the page indexes the same keys on both arms; the only keys the stub
|
||||
# alone or the answer alone carries are the ones that *are* the difference
|
||||
shared = set(stub) | set(answer)
|
||||
assert shared - set(stub) == {"agent_version"}, "the answer's extra keys are the snapshot's"
|
||||
assert shared - set(answer) == {"unreachable"}, "the stub's extra key is the reachability one"
|
||||
|
||||
+125
-3
@@ -14,9 +14,11 @@ addresses the deployed dashboard dials, nothing forwarded:
|
||||
Each test skips when its source is not configured, so running the whole set with half a
|
||||
fleet reachable is a normal outcome, not a failure.
|
||||
|
||||
**Nothing in here may write.** The UPS test asserts the command gating is honest; it never
|
||||
sends an instant command — a battery test on hardware someone else is plugged into is not
|
||||
a unit test.
|
||||
**Nothing in here may write — with exactly one documented exception.** The UPS test asserts
|
||||
the command gating is honest; it never sends an instant command — a battery test on
|
||||
hardware someone else is plugged into is not a unit test. The one write is the phase-33
|
||||
round-trip test, gated by its own flag (`--allow-live-writes`) on top of `--live`: a
|
||||
default live run never switches a route, and the skip reason names the route it would.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -562,3 +564,123 @@ def test_target_config_is_clean_and_leak_free(settings: Settings) -> None:
|
||||
assert "@" not in target.display_url, f"{target.name}: display_url leaks credentials"
|
||||
for problem in settings.host_target_problems:
|
||||
assert isinstance(problem, str) and problem, f"empty problem: {problem!r}"
|
||||
|
||||
|
||||
#: The one write target of the opt-in round trip (phase 33 task 07): a benchmark service
|
||||
#: with no human dependency. Never the auth, git, alerts or file routes — task 07's
|
||||
#: precondition names them, and a test that would touch one of them is a test that does not
|
||||
#: run.
|
||||
SWITCH_DRILL_ROUTE = "haproxy:5201->web.reeselink.com:5201"
|
||||
|
||||
|
||||
async def test_the_switch_round_trip_on_one_named_route(
|
||||
request: pytest.FixtureRequest, settings: Settings,
|
||||
agent_tls: dict[str, collectors.AgentTls]) -> None:
|
||||
"""Phase 33's one live write: the named route off and back on, through the app's own
|
||||
`POST /route` dial.
|
||||
|
||||
Gated twice so a default live run stays read-only: the file's `live` marker (skip
|
||||
without `--live`) **and** the separate `--allow-live-writes` flag, whose skip reason
|
||||
names the route it would touch. When it does run — the operator's drill, task 07 step
|
||||
5 — it dials `collectors.post_route` (the app's second and last upstream write, through
|
||||
the stored mTLS authority, no hand-rolled client) and proves the round trip on the
|
||||
production read (`fetch_snapshot` → `gateway.parse`): the helper's own words for the
|
||||
write, the row's `enabled` flipping both ways, the agent's own section carrying the
|
||||
marked line in between, and the haproxy section the agent serves byte-identical between
|
||||
the pre-drill and post-drill reads. The restore lives in a `finally`: even a failed
|
||||
proof turns the route back on before the test ends, and a re-enable that itself fails
|
||||
propagates the helper's own word — a marked line left behind must be the operator's
|
||||
first line, never a silent one.
|
||||
"""
|
||||
if not bool(request.config.getoption("--allow-live-writes")):
|
||||
pytest.skip(f"opt-in live write: skip without --allow-live-writes (this test would "
|
||||
f"switch {SWITCH_DRILL_ROUTE} off and back on — the default live suite "
|
||||
"stays read-only")
|
||||
target = next((item for item in settings.host_targets if item.name == settings.gateway_host),
|
||||
None)
|
||||
if target is None:
|
||||
pytest.skip(f"GATEWAY_HOST={settings.gateway_host!r} is not in HOST_TARGETS")
|
||||
|
||||
async def read_ingress(client: httpx.AsyncClient) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
snapshot = await collectors.fetch_snapshot(client, target,
|
||||
timeout=settings.gateway_timeout,
|
||||
tls=agent_tls.get(target.name))
|
||||
return snapshot, gateway.parse(snapshot, target.name)
|
||||
|
||||
async def post(body: dict[str, Any]) -> dict[str, Any]:
|
||||
# One client per call: one write, one connection — the same discipline the agent
|
||||
# keeps for its one helper dial (a retry of a write is a second reload).
|
||||
async with httpx.AsyncClient(timeout=settings.gateway_timeout) as client:
|
||||
return await collectors.post_route(client, target, body,
|
||||
timeout=settings.gateway_timeout,
|
||||
tls=agent_tls.get(target.name))
|
||||
|
||||
disabled = False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.gateway_timeout) as client:
|
||||
try:
|
||||
before, payload = await read_ingress(client)
|
||||
except collectors.UpstreamError as exc:
|
||||
pytest.skip(f"{target.name} did not answer: {exc}")
|
||||
# Precondition 1: the agent on this host is the one with the write path.
|
||||
version = str(before.get("agent_version") or "")
|
||||
if not version.startswith("1.4."):
|
||||
pytest.skip(f"{target.name} runs agent {version or 'unknown'} — POST /route is "
|
||||
"1.4.0's; re-stage with deploy/install-agent.sh --allow-writes "
|
||||
"--with-routectl")
|
||||
# Precondition 2: a clean pre-state — the route is on and nothing else is off;
|
||||
# a half-finished earlier attempt is the state task 07 step 4 refuses to drill.
|
||||
route = next((row for row in payload["connections"]
|
||||
if row["id"] == SWITCH_DRILL_ROUTE), None)
|
||||
if route is None:
|
||||
pytest.skip(f"{SWITCH_DRILL_ROUTE} is not on the ingress the agent published — "
|
||||
"the drill target moved; update this test's target and re-verify")
|
||||
already_off = [row["id"] for row in payload["connections"]
|
||||
if row["enabled"] is False]
|
||||
assert not already_off, (
|
||||
f"a route is already disabled before the drill {already_off} — a previous "
|
||||
"attempt left a marked line; restore it before re-running (task 07 step 6)")
|
||||
# 1. dry_run: the diff, and no write (task 07 step 5.1).
|
||||
preview = await post({"id": SWITCH_DRILL_ROUTE, "action": "disable",
|
||||
"dry_run": True})
|
||||
assert preview.get("ok") is True and preview.get("dry_run") is True, preview
|
||||
assert preview.get("applied") is False and preview.get("changed") is True, preview
|
||||
assert any("#off" in str(line) for line in preview.get("diff") or []), (
|
||||
f"the dry-run diff must show the marker landing: {preview.get('diff')!r}")
|
||||
# 2. apply: the helper's own word for the write (step 5.2). A checker that refuses
|
||||
# (e.g. no `nginx -t` on the host's PATH) answers `applied: false, restored: true`
|
||||
# and this test fails on the helper's word instead of printing a false green.
|
||||
applied = await post({"id": SWITCH_DRILL_ROUTE, "action": "disable"})
|
||||
assert applied.get("ok") is True and applied.get("applied") is True, (
|
||||
f"the helper refused the write instead of applying it: {applied!r}")
|
||||
assert applied.get("validated") is True and applied.get("reloaded") is True, applied
|
||||
disabled = True
|
||||
# 3. the off state, proven on the production read (step 5.3/5.4): the row and the
|
||||
# agent's own section both report the marker.
|
||||
async with httpx.AsyncClient(timeout=settings.gateway_timeout) as client:
|
||||
off_snapshot, off_payload = await read_ingress(client)
|
||||
off_route = next(row for row in off_payload["connections"]
|
||||
if row["id"] == SWITCH_DRILL_ROUTE)
|
||||
assert off_route["enabled"] is False, "the row must report disabled within one read"
|
||||
marked = [server for backend in (off_snapshot.get("haproxy") or {}).get("backends", ())
|
||||
for server in backend.get("servers", ()) if server.get("disabled") is True]
|
||||
assert marked, "the agent's haproxy section shows no marked server line"
|
||||
finally:
|
||||
# The restore lives here, not after the assertions: a failed proof still leaves the
|
||||
# route on, and a failed re-enable propagates the helper's own word.
|
||||
if disabled:
|
||||
await post({"id": SWITCH_DRILL_ROUTE, "action": "enable"})
|
||||
# 4. the round trip closed: the row is on, nothing is off, and the haproxy section the
|
||||
# agent serves is byte-identical to the pre-drill read (step 5.5/5.6).
|
||||
async with httpx.AsyncClient(timeout=settings.gateway_timeout) as client:
|
||||
after, after_payload = await read_ingress(client)
|
||||
after_route = next(row for row in after_payload["connections"]
|
||||
if row["id"] == SWITCH_DRILL_ROUTE)
|
||||
assert after_route["enabled"] is True, (
|
||||
f"the route is still off after the re-enable — the drill left a marked line: "
|
||||
f"{after_route!r}")
|
||||
left_off = [row["id"] for row in after_payload["connections"]
|
||||
if row["enabled"] is False]
|
||||
assert not left_off, f"marked lines left behind after the round trip: {left_off}"
|
||||
assert json.dumps(after.get("haproxy")) == json.dumps(before.get("haproxy")), (
|
||||
"the haproxy section is not byte-identical after the round trip")
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
"""The route-switch root helper, tested unprivileged against a fake tree.
|
||||
|
||||
`deploy/dashboards-routectl.py` is the phase 33 writer: the dashboard reaches a
|
||||
gateway config file only through it (through the agent's `POST /route`, through the
|
||||
systemd-activated socket). Its safety property is the write protocol's order — locate,
|
||||
state, edit, validate, reload, prove, record — and the refusals that end it before a
|
||||
byte moves. So the tests run the **real helper** (`--root` pointed at a fake tree),
|
||||
against the **captured** gateway configs (`tests/fixtures/gateway_*.cfg|.conf`, the
|
||||
fleet's real ingress bytes, never a toy file), with `nginx` / `haproxy` / `systemctl`
|
||||
faked on `PATH` as scripts that record their argv and fail on order. The assertions
|
||||
are on the bytes: one line changed and nothing else, a byte-identical restore, a
|
||||
refusal that wrote nothing, the state sidecar's mode at birth, the sink's header and
|
||||
its removal rule, and the socket's peer gate. The helper's own `--serve` is driven the
|
||||
way the unit does (the connected socket on stdin), and the structural pins (no
|
||||
`shell=True`, no path from the wire, the one `systemctl` verb, the Python 3.10 floor)
|
||||
are greps over the file, the `auth.py`/`certs.py` precedent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DEPLOY = ROOT / "deploy"
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
HELPER = DEPLOY / "dashboards-routectl.py"
|
||||
AGENT = ROOT / "agent" / "host_agent.py"
|
||||
|
||||
#: The captured gateway tree, the same mapping `tests/conftest.py` lays out — the
|
||||
#: helper's `--root` reads the file the real ingress runs.
|
||||
GATEWAY_TREE = {
|
||||
"etc/haproxy/haproxy.cfg": "gateway_haproxy.cfg",
|
||||
"etc/nginx/nginx.conf": "gateway_nginx_main.conf",
|
||||
"etc/nginx/conf.d/http.conf": "gateway_nginx_conf_d_http.conf",
|
||||
"etc/nginx/stream.d/stream.conf": "gateway_nginx_stream_d_stream.conf",
|
||||
}
|
||||
|
||||
#: The ids the dashboard publishes for these lines (phase 30's vocabulary), and the
|
||||
#: two refusals that are not ids at all.
|
||||
HAPROXY_ROUTE = "haproxy:5201->web.reeselink.com:5201" # the iperf3 server line
|
||||
VHOST_ROUTE = "nginx:4443->nextcloud.reeselink.com:11000" # the 4443 vhost's proxy_pass
|
||||
MAP_ROUTE = "nginx:443->reeseapps-brain-of-reese.reeselink.com:443" # brain.reeseapps.com
|
||||
MAP_SINK_ID = "nginx:443->127.0.0.1:44450" # while off, the map entry is addressed by its sink value
|
||||
IMMICH_ROUTE = "nginx:443->reeseapps-immich.reeselink.com:443"
|
||||
MAP_DEFAULT = "nginx:443->127.0.0.1:4443" # the map's default: a fallback, not a service
|
||||
SINK_HEADER = "# managed by dashboards-routectl — do not edit"
|
||||
IMMICH_ORIGINAL = " immich.reeseapps.com reeseapps-immich.reeselink.com:443;"
|
||||
|
||||
|
||||
def _tree(tmp_path: Path) -> Path:
|
||||
"""The captured configs, laid into a fake `--root` tree byte for byte."""
|
||||
root = tmp_path / "root"
|
||||
for relative, fixture in GATEWAY_TREE.items():
|
||||
target = root / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes((FIXTURES / fixture).read_bytes())
|
||||
return root
|
||||
|
||||
|
||||
def _stubs(tmp_path: Path) -> tuple[Path, Path]:
|
||||
"""Fake `nginx` / `haproxy` / `systemctl` on `PATH`: they record their argv in
|
||||
`$FAKE_CALLS_LOG` and fail on order — `FAKE_CHECK_FAIL` makes the checker exit 1
|
||||
with its own first line, `FAKE_RELOAD_FAIL` makes `reload` fail and `status`
|
||||
print the unit's line."""
|
||||
stubs = tmp_path / "stubs"
|
||||
stubs.mkdir()
|
||||
log = tmp_path / "calls.log"
|
||||
for name in ("nginx", "haproxy", "systemctl"):
|
||||
script = stubs / name
|
||||
script.write_text(
|
||||
"#!/bin/sh\n"
|
||||
f'printf "%s %s\\n" "{name}" "$*" >> "$FAKE_CALLS_LOG"\n'
|
||||
"case \"$1\" in\n"
|
||||
" -t|-c)\n"
|
||||
" if [ -n \"$FAKE_CHECK_FAIL\" ]; then\n"
|
||||
f' echo "fake {name}: configuration file test failed"\n'
|
||||
" exit 1\n"
|
||||
" fi\n"
|
||||
" ;;\n"
|
||||
" reload)\n"
|
||||
" if [ -n \"$FAKE_RELOAD_FAIL\" ]; then exit 1; fi\n"
|
||||
" ;;\n"
|
||||
" status)\n"
|
||||
' if [ -n "$FAKE_RELOAD_FAIL" ]; then echo "fake unit: reload failed"; fi\n'
|
||||
" ;;\n"
|
||||
"esac\n"
|
||||
"exit 0\n",
|
||||
encoding="utf-8")
|
||||
script.chmod(0o755)
|
||||
return stubs, log
|
||||
|
||||
|
||||
def _run(tmp_path: Path, stubs: Path, log: Path, args: list[str], *,
|
||||
check_fail: bool = False, reload_fail: bool = False,
|
||||
agent_module: str | None = None
|
||||
) -> tuple[subprocess.CompletedProcess[str], dict, list[str]]:
|
||||
"""One helper invocation the way the operator runs it by hand: the real binary,
|
||||
the fake tree, the fake proxy commands. Returns the process, the one JSON answer
|
||||
line, and the recorded calls so far (the log is appended across invocations)."""
|
||||
argv = [sys.executable, str(HELPER),
|
||||
"--root", str(tmp_path / "root"),
|
||||
"--agent-module", str(AGENT) if agent_module is None else agent_module,
|
||||
"--state", str(tmp_path / "state.json"),
|
||||
"--sink-conf", "/etc/nginx/stream.d/00-dashboards-sink.conf",
|
||||
"--sink-address", "127.0.0.1:44450",
|
||||
"--nginx-unit", "nginx", "--haproxy-unit", "haproxy", *args]
|
||||
env = {
|
||||
"PATH": f"{stubs}{os.pathsep}{os.environ.get('PATH', '')}",
|
||||
"FAKE_CALLS_LOG": str(log),
|
||||
"FAKE_CHECK_FAIL": "1" if check_fail else "",
|
||||
"FAKE_RELOAD_FAIL": "1" if reload_fail else "",
|
||||
"HOME": str(tmp_path),
|
||||
}
|
||||
proc = subprocess.run(argv, capture_output=True, text=True, env=env, timeout=120)
|
||||
lines = [line for line in proc.stdout.splitlines() if line.strip()]
|
||||
answer: dict = json.loads(lines[-1]) if lines else {}
|
||||
calls = log.read_text().splitlines() if log.exists() else []
|
||||
return proc, answer, calls
|
||||
|
||||
|
||||
def _serve(tmp_path: Path, tree: Path, allow_uid: str, request: bytes) -> tuple[int, str]:
|
||||
"""`--serve` the way the unit does: the connected socket on fd 0/fd 1, one
|
||||
request, one answer line. (The `tests/test_deploy_scripts.py` transport pin,
|
||||
re-used for the wire-body gates the overview demands in this file.)"""
|
||||
child, parent = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(HELPER), "--serve",
|
||||
"--allow-uid", allow_uid,
|
||||
"--agent-module", str(AGENT),
|
||||
"--root", str(tree),
|
||||
"--state", str(tmp_path / "serve-state.json"),
|
||||
"--sink-conf", "/etc/nginx/stream.d/00-dashboards-sink.conf",
|
||||
"--sink-address", "127.0.0.1:44450",
|
||||
"--nginx-unit", "nginx", "--haproxy-unit", "haproxy"],
|
||||
stdin=child.fileno(), stdout=child.fileno(),
|
||||
stderr=subprocess.PIPE, close_fds=True)
|
||||
parent.sendall(request)
|
||||
answer = parent.recv(65536).decode("utf-8").strip()
|
||||
parent.close()
|
||||
err = proc.stderr.read().decode() if proc.stderr is not None else ""
|
||||
proc.wait(timeout=60)
|
||||
child.close()
|
||||
if proc.returncode not in (0, 1):
|
||||
raise AssertionError(
|
||||
f"the helper exited {proc.returncode} instead of answering: {answer!r} {err}")
|
||||
return proc.returncode, answer
|
||||
|
||||
|
||||
def _load(name: str, path: Path) -> types.ModuleType:
|
||||
"""Import a stdlib module by file path, the way the helper imports the agent."""
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec is not None and spec.loader is not None, f"no loader for {path}"
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _snapshot(tree: Path) -> dict[Path, bytes]:
|
||||
return {path: path.read_bytes() for path in sorted(tree.rglob("*")) if path.is_file()}
|
||||
|
||||
|
||||
def _sink(tree: Path) -> Path:
|
||||
return tree / "etc/nginx/stream.d/00-dashboards-sink.conf"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree(tmp_path: Path) -> Path:
|
||||
return _tree(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stubs(tmp_path: Path) -> tuple[Path, Path]:
|
||||
return _stubs(tmp_path)
|
||||
|
||||
|
||||
def test_disabling_one_route_changes_exactly_one_line(tree: Path, stubs: tuple[Path, Path],
|
||||
tmp_path: Path) -> None:
|
||||
"""The whole write, against the fakes: one hunk, one line, every other byte
|
||||
identical — asserted on the bytes, not on a count. The reload is the one verb
|
||||
the file executes, and the state sidecar names the write."""
|
||||
stubs_dir, log = stubs
|
||||
hap = tree / "etc/haproxy/haproxy.cfg"
|
||||
before_files = _snapshot(tree)
|
||||
before = hap.read_bytes()
|
||||
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", HAPROXY_ROUTE, "--apply"])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer == {"ok": True, "applied": True, "validated": True,
|
||||
"reloaded": True, "status": "", "state": "off"}, answer
|
||||
|
||||
after = hap.read_bytes()
|
||||
b, a = before.decode("utf-8").splitlines(), after.decode("utf-8").splitlines()
|
||||
assert len(b) == len(a), "the edit is in place: one line, never an add or a remove"
|
||||
changed = [i for i in range(len(b)) if b[i] != a[i]]
|
||||
assert len(changed) == 1, f"exactly one line changed byte for byte, nothing else: {changed}"
|
||||
index = changed[0]
|
||||
assert b[index] == " server iperf3 web.reeselink.com:5201 check inter 5s"
|
||||
assert a[index] == b[index] + " #off iperf3", (
|
||||
"the marker is the whole trailing comment; the original stands verbatim before it")
|
||||
for path, blob in before_files.items():
|
||||
if path != hap:
|
||||
assert path.read_bytes() == blob, f"{path} moved when one haproxy line was marked"
|
||||
assert "systemctl reload haproxy" in calls, "the one verb, for the one unit"
|
||||
assert "systemctl reload nginx" not in calls
|
||||
assert not [c for c in calls if "status" in c], (
|
||||
f"a successful reload never asks the unit for its status: {calls}")
|
||||
|
||||
state = json.loads((tmp_path / "state.json").read_text(encoding="utf-8"))
|
||||
assert set(state) == {HAPROXY_ROUTE}
|
||||
row = state[HAPROXY_ROUTE]
|
||||
assert row["action"] == "disable"
|
||||
assert row["file"] == "/etc/haproxy/haproxy.cfg"
|
||||
assert row["written"] == b[index] + " #off iperf3"
|
||||
assert row["original"] == b[index]
|
||||
assert row["sha256"] == hashlib.sha256(after).hexdigest()
|
||||
|
||||
|
||||
def test_the_vhost_arm_comments_the_proxy_pass_line_and_restores(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""The second arm: the vhost's `proxy_pass` is commented the same way, the
|
||||
sibling vhost on another port is untouched, and the re-enable is byte-identical."""
|
||||
stubs_dir, log = stubs
|
||||
http = tree / "etc/nginx/conf.d/http.conf"
|
||||
before = http.read_bytes()
|
||||
|
||||
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", VHOST_ROUTE, "--apply"])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer["state"] == "off"
|
||||
lines = http.read_text().splitlines()
|
||||
assert " proxy_pass http://nextcloud.reeselink.com:11000; #off nextcloud-11000" in lines
|
||||
assert " proxy_pass http://nextcloud.reeselink.com:11001;" in lines, (
|
||||
"the 8443 vhost's own line is a different fact; it does not move")
|
||||
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["enable", "--id", VHOST_ROUTE, "--apply"])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer["state"] == "on"
|
||||
assert http.read_bytes() == before, "the re-enable restores the file byte for byte"
|
||||
assert "systemctl reload nginx" in calls
|
||||
assert "systemctl reload haproxy" not in calls
|
||||
|
||||
|
||||
def test_a_disabled_map_entry_points_at_the_sink_and_keeps_its_original(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""The third arm: the map entry is **rewritten to the sink** (commenting it would
|
||||
fall through to the map's `default` and mis-route the domain), the sink file is
|
||||
created on first need with its header, and the re-enable — addressed by the sink
|
||||
value the entry carries while off — restores the file byte-identical and removes
|
||||
the sink the helper wrote. A `dry_run` writes nothing at all, not even the sink."""
|
||||
stubs_dir, log = stubs
|
||||
stream = tree / "etc/nginx/stream.d/stream.conf"
|
||||
sink = _sink(tree)
|
||||
original_line = " brain.reeseapps.com reeseapps-brain-of-reese.reeselink.com:443;"
|
||||
before = stream.read_bytes()
|
||||
|
||||
# the dry run: the unified diff and nothing else — no file, no checker, no reload
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log, ["disable", "--id", MAP_ROUTE])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer["ok"] is True and answer["applied"] is False
|
||||
assert answer["changed"] is True and answer["dry_run"] is True
|
||||
assert f"-{original_line}" in answer["diff"]
|
||||
assert "+ brain.reeseapps.com 127.0.0.1:44450; #off brain.reeseapps.com" in answer["diff"]
|
||||
assert stream.read_bytes() == before, "a dry run writes nothing"
|
||||
assert not sink.exists(), "a dry run writes no file at all (not even the sink's)"
|
||||
assert calls == [], f"a dry run neither validates nor reloads: {calls}"
|
||||
assert not (tmp_path / "state.json").exists()
|
||||
|
||||
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", MAP_ROUTE, "--apply"])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer["state"] == "off"
|
||||
assert sink.is_file(), "the sink is created on first need, before the checker runs"
|
||||
sink_text = sink.read_text(encoding="utf-8")
|
||||
assert sink_text.splitlines()[0] == SINK_HEADER
|
||||
assert "listen 127.0.0.1:44450;" in sink_text
|
||||
assert "proxy_pass" not in sink_text, "the block, not a vhost, is what the page's `sink_endpoint` recognises"
|
||||
lines = stream.read_text().splitlines()
|
||||
assert " brain.reeseapps.com 127.0.0.1:44450; #off brain.reeseapps.com" in lines
|
||||
assert original_line not in lines
|
||||
|
||||
# the enable is addressed by the sink value the entry carries while off
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["enable", "--id", MAP_SINK_ID, "--apply"])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer["state"] == "on"
|
||||
assert stream.read_bytes() == before, "the re-enable restores the file byte-identical to the pre-drill capture"
|
||||
assert not sink.exists(), "the helper removes the sink it wrote once nothing points at it"
|
||||
assert json.loads((tmp_path / "state.json").read_text(encoding="utf-8")) == {}
|
||||
assert "systemctl reload nginx" in calls
|
||||
|
||||
|
||||
def test_the_map_default_is_refused_and_nothing_is_written(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""The map's `default` is the fallback for every unmapped SNI, not a service:
|
||||
the id that addresses it is refused at the wire, before a byte moves or a
|
||||
checker runs."""
|
||||
stubs_dir, log = stubs
|
||||
before = _snapshot(tree)
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", MAP_DEFAULT, "--apply"])
|
||||
assert proc.returncode == 1
|
||||
assert answer == {"ok": False, "applied": False, "refused": "map-default"}
|
||||
assert _snapshot(tree) == before, "a refused write writes nothing"
|
||||
assert calls == [], f"a wire-level refusal reaches neither the checker nor the unit: {calls}"
|
||||
|
||||
|
||||
def test_an_unknown_id_is_refused(tree: Path, stubs: tuple[Path, Path],
|
||||
tmp_path: Path) -> None:
|
||||
stubs_dir, log = stubs
|
||||
before = _snapshot(tree)
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", "haproxy:9999->10.0.0.9:9999", "--apply"])
|
||||
assert proc.returncode == 1
|
||||
assert answer == {"ok": False, "detail": "not-found"}
|
||||
assert _snapshot(tree) == before
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_disabling_a_disabled_route_changes_nothing_and_reloads_nothing(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""Step 2: a route that is already off is an answer, not a write — no file, no
|
||||
checker, no reload. The fake `systemctl` log stays empty on the second call."""
|
||||
stubs_dir, log = stubs
|
||||
hap = tree / "etc/haproxy/haproxy.cfg"
|
||||
_proc, _answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", HAPROXY_ROUTE, "--apply"])
|
||||
after_first = hap.read_bytes()
|
||||
seen = len(calls)
|
||||
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", HAPROXY_ROUTE, "--apply"])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer == {"ok": True, "applied": False, "changed": False,
|
||||
"id": HAPROXY_ROUTE, "state": "off"}
|
||||
assert hap.read_bytes() == after_first
|
||||
assert len(calls) == seen, f"an already-off route changes nothing and reloads nothing: {calls[seen:]}"
|
||||
|
||||
|
||||
def test_a_failing_validator_restores_the_previous_bytes(tree: Path, stubs: tuple[Path, Path],
|
||||
tmp_path: Path) -> None:
|
||||
"""Step 5: the checker says no, the previous bytes go back byte-identical,
|
||||
nothing reloads, and no state is recorded — the answer carries the checker's
|
||||
own first line and the non-zero exit says it out."""
|
||||
stubs_dir, log = stubs
|
||||
hap = tree / "etc/haproxy/haproxy.cfg"
|
||||
before = hap.read_bytes()
|
||||
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", HAPROXY_ROUTE, "--apply"],
|
||||
check_fail=True)
|
||||
assert proc.returncode == 1
|
||||
assert answer["ok"] is False and answer["applied"] is False
|
||||
assert answer["validated"] is False and answer["restored"] is True
|
||||
assert answer["detail"] == "fake haproxy: configuration file test failed"
|
||||
assert hap.read_bytes() == before, "the restore is byte-identical"
|
||||
assert [c for c in calls if c.startswith("haproxy -c")] == [
|
||||
f"haproxy -c -f {tmp_path / 'root' / 'etc/haproxy/haproxy.cfg'}"], calls
|
||||
assert not [c for c in calls if c.startswith("systemctl")], (
|
||||
f"a failed validator reloads nothing: {calls}")
|
||||
assert not (tmp_path / "state.json").exists(), "a failed write records no state"
|
||||
|
||||
|
||||
def test_a_failing_reload_says_so_and_leaves_the_file_applied(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""Step 6: the config is correct and the reload is not — the file stays
|
||||
applied, the answer says so with the unit's own status line, and the state is
|
||||
still recorded (the write happened; the reload is the operator's next action)."""
|
||||
stubs_dir, log = stubs
|
||||
hap = tree / "etc/haproxy/haproxy.cfg"
|
||||
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", HAPROXY_ROUTE, "--apply"],
|
||||
reload_fail=True)
|
||||
assert proc.returncode == 0, "a reload failure is an answered call, not a crash"
|
||||
assert answer["ok"] is True and answer["applied"] is True
|
||||
assert answer["validated"] is True and answer["reloaded"] is False
|
||||
assert answer["status"] == "fake unit: reload failed"
|
||||
assert " #off iperf3" in hap.read_text(), "the applied file stays applied"
|
||||
assert "systemctl reload haproxy" in calls
|
||||
assert "systemctl status haproxy" in calls, "the answer carries the unit's own line"
|
||||
state = json.loads((tmp_path / "state.json").read_text(encoding="utf-8"))
|
||||
assert HAPROXY_ROUTE in state, "the write happened; the state says so"
|
||||
|
||||
|
||||
def test_reload_is_never_a_restart() -> None:
|
||||
"""The only verb this file executes is `systemctl reload <unit>`: a restart
|
||||
severs every live connection through the fleet's only ingress. Pinned at the
|
||||
call position (the docstring's negation is backticked, never an argv)."""
|
||||
src = HELPER.read_text(encoding="utf-8")
|
||||
verbs = re.findall(r'\["systemctl", "(\w+)"', src)
|
||||
assert verbs == ["reload", "status"], f"the only systemctl verbs: {verbs}"
|
||||
assert not re.search(r'["\']restart["\']', src), (
|
||||
"no quoted `restart` anywhere: the docstring refuses it in words, the argv never carries it")
|
||||
|
||||
|
||||
def test_the_helper_imports_the_agents_parser_and_refuses_without_it(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""The parser is the agent's, imported by file path: a helper that cannot
|
||||
import it refuses to run at all, names the path, and writes nothing — two
|
||||
parsers reading one config are two opinions about the same file."""
|
||||
stubs_dir, log = stubs
|
||||
before = _snapshot(tree)
|
||||
missing = tmp_path / "no-such-agent.py"
|
||||
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", HAPROXY_ROUTE, "--apply"],
|
||||
agent_module=str(missing))
|
||||
assert proc.returncode == 1
|
||||
assert answer == {"ok": False,
|
||||
"detail": f"cannot import the agent module {missing}: not a file"}
|
||||
assert _snapshot(tree) == before
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_state_is_0600_and_drift_is_refused(tree: Path, stubs: tuple[Path, Path],
|
||||
tmp_path: Path) -> None:
|
||||
"""The sidecar's mode is its birth mode, and `enable` restores only what this
|
||||
helper wrote: a hand-edited line is `drift` — a refusal with no byte change,
|
||||
never a guess. The other arm is a line this helper never wrote (no state row)."""
|
||||
stubs_dir, log = stubs
|
||||
hap = tree / "etc/haproxy/haproxy.cfg"
|
||||
_proc, _answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", HAPROXY_ROUTE, "--apply"])
|
||||
state_path = tmp_path / "state.json"
|
||||
assert stat.S_IMODE(state_path.stat().st_mode) == 0o600, "the mode is the birth mode"
|
||||
|
||||
off_line = " server iperf3 web.reeselink.com:5201 check inter 5s #off iperf3"
|
||||
assert off_line in hap.read_text(encoding="utf-8")
|
||||
edited = hap.read_text(encoding="utf-8").replace(
|
||||
off_line, " server iperf3 web.reeselink.com:5201 check inter 5s backup")
|
||||
hap.write_text(edited, encoding="utf-8")
|
||||
|
||||
seen = len(_calls)
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["enable", "--id", HAPROXY_ROUTE, "--apply"])
|
||||
assert proc.returncode == 1
|
||||
assert answer["ok"] is False and answer["applied"] is False and answer["drift"] is True
|
||||
assert answer["detail"] == "the line was edited after the helper wrote it"
|
||||
assert hap.read_text(encoding="utf-8") == edited, "drift is refused, not guessed at: no byte moves"
|
||||
assert len(calls) == seen, f"a drift refusal touches neither the checker nor the unit: {calls[seen:]}"
|
||||
|
||||
# the other arm: a line this helper never wrote (no state row at all)
|
||||
(tmp_path / "state.json").unlink()
|
||||
vhost = tree / "etc/nginx/conf.d/http.conf"
|
||||
before_vhost = vhost.read_bytes()
|
||||
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["enable", "--id", VHOST_ROUTE, "--apply"])
|
||||
assert proc.returncode == 1
|
||||
assert answer["ok"] is False and answer["drift"] is True
|
||||
assert answer["detail"] == "no state row: this helper did not write the line"
|
||||
assert vhost.read_bytes() == before_vhost
|
||||
|
||||
|
||||
def test_serve_refuses_a_peer_that_is_not_the_agent_uid(tree: Path, stubs: tuple[Path, Path],
|
||||
tmp_path: Path) -> None:
|
||||
"""The second look, in the fake-peer test: a connected caller whose uid is not
|
||||
`--allow-uid` is refused before the body is read — the gate never opens for a
|
||||
caller it was not given."""
|
||||
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
||||
pytest.skip("running as root: the foreign-uid arm needs a foreign uid")
|
||||
code, answer = _serve(tmp_path, tree, "root",
|
||||
(json.dumps({"id": HAPROXY_ROUTE, "action": "show"}) + "\n").encode())
|
||||
assert code == 1
|
||||
body = json.loads(answer)
|
||||
assert body["ok"] is False
|
||||
# the refusal names the caller's own uid and the user it was not: the gate
|
||||
# opened for no one, and the dialer gets the reason, not a timeout
|
||||
assert body["detail"] == f"peer: uid {os.geteuid()} is not root", (
|
||||
f"the refusal names the uid and the user: {body}")
|
||||
|
||||
|
||||
def test_serve_accepts_exactly_id_action_dry_run(tree: Path, stubs: tuple[Path, Path],
|
||||
tmp_path: Path) -> None:
|
||||
"""The wire's gate, from the accepted side: a named peer's `show` is answered,
|
||||
a wider body is refused with the wire's own sentence, and a `dry_run` write
|
||||
answers the diff without writing anything (not even the sink's file)."""
|
||||
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
||||
pytest.skip("running as root: the accept arm needs the caller to be the allowed uid")
|
||||
me = pwd.getpwuid(os.geteuid()).pw_name
|
||||
|
||||
code, answer = _serve(tmp_path, tree, me,
|
||||
(json.dumps({"id": HAPROXY_ROUTE, "action": "show"}) + "\n").encode())
|
||||
assert code == 0
|
||||
body = json.loads(answer)
|
||||
assert body == {"ok": True, "id": HAPROXY_ROUTE, "state": "on",
|
||||
"file": "/etc/haproxy/haproxy.cfg"}, body
|
||||
|
||||
code, answer = _serve(tmp_path, tree, me,
|
||||
(json.dumps({"id": HAPROXY_ROUTE, "action": "show",
|
||||
"command": "reboot"}) + "\n").encode())
|
||||
assert code == 1
|
||||
body = json.loads(answer)
|
||||
assert body == {"ok": False, "detail": "body: exactly {id, action, dry_run}"}
|
||||
|
||||
hap = tree / "etc/haproxy/haproxy.cfg"
|
||||
before = hap.read_bytes()
|
||||
code, answer = _serve(tmp_path, tree, me,
|
||||
(json.dumps({"id": HAPROXY_ROUTE, "action": "disable",
|
||||
"dry_run": True}) + "\n").encode())
|
||||
assert code == 0
|
||||
body = json.loads(answer)
|
||||
assert body["ok"] is True and body["applied"] is False and body["dry_run"] is True
|
||||
assert "diff" in body
|
||||
assert hap.read_bytes() == before, "a dry run over the wire writes nothing"
|
||||
assert not _sink(tree).exists()
|
||||
|
||||
|
||||
def test_no_shell_and_no_path_from_the_wire() -> None:
|
||||
"""The structural pins (the `auth.py`/`certs.py` precedent): every subprocess
|
||||
call is a fixed argv list — no `shell=True` — and the wire takes an id, never a
|
||||
path, a line number or a pattern; `--serve` accepts exactly `{id, action,
|
||||
dry_run}` and every other key is a refusal."""
|
||||
src = HELPER.read_text(encoding="utf-8")
|
||||
assert not re.search(r"subprocess\.\w+\([^)]*shell\s*=", src, re.S), (
|
||||
"no `shell=` at a call position (the docstring's negation is backticked; "
|
||||
"every call is a fixed argv list — `shell=True` included)")
|
||||
flags = re.findall(r'add_argument\(\s*"([^"]+)"', src)
|
||||
assert flags, "the flags must be there for the pin to mean anything"
|
||||
assert not {flag for flag in flags if flag in ("--file", "--line", "--pattern")}, (
|
||||
f"the wire takes an id, never a path/line/pattern; the flags are {flags}")
|
||||
assert 'set(body) - {"id", "action", "dry_run"}' in src, (
|
||||
"the wire's gate: anything wider than {id, action, dry_run} is a refusal")
|
||||
assert '"body: exactly {id, action, dry_run}"' in src
|
||||
|
||||
|
||||
def test_the_sink_file_is_left_alone_when_someone_elses_wrote_it(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""An operator's file at the sink's path is not the helper's to overwrite:
|
||||
the `map` disable refuses before the config write, and the file is untouched."""
|
||||
stubs_dir, log = stubs
|
||||
sink = _sink(tree)
|
||||
operator_file = "# an operator's file\nserver {\n listen 127.0.0.1:44451;\n}\n"
|
||||
sink.write_text(operator_file, encoding="utf-8")
|
||||
before = _snapshot(tree)
|
||||
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", MAP_ROUTE, "--apply"])
|
||||
assert proc.returncode == 1
|
||||
assert answer == {"ok": False,
|
||||
"detail": "sink-file-not-ours: /etc/nginx/stream.d/00-dashboards-sink.conf"}
|
||||
assert _snapshot(tree) == before, "the refusal writes nothing (the operator's file is byte-identical)"
|
||||
assert calls == [], f"the refusal happens before the checker and the unit: {calls}"
|
||||
|
||||
|
||||
def test_the_sink_is_removed_only_when_it_is_empty_of_our_routes(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""Two routes off: two lines carry the sink value, and enabling by that id is
|
||||
`ambiguous` — two facts, never a guess, because no line number crosses the
|
||||
wire. After the operator restores one line by hand, the enable succeeds and the
|
||||
sink goes with it. The remains-arm is pinned on the helper's own function: a
|
||||
map entry still pointing at the sink keeps the file (the wire cannot reach that
|
||||
arm — the ambiguity above is why)."""
|
||||
stubs_dir, log = stubs
|
||||
stream = tree / "etc/nginx/stream.d/stream.conf"
|
||||
sink = _sink(tree)
|
||||
|
||||
_proc, _answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", MAP_ROUTE, "--apply"])
|
||||
_proc, _answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", IMMICH_ROUTE, "--apply"])
|
||||
assert sink.is_file()
|
||||
|
||||
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["enable", "--id", MAP_SINK_ID, "--apply"])
|
||||
assert proc.returncode == 1
|
||||
assert answer["ok"] is False
|
||||
assert "ambiguous" in answer["detail"], f"two lines at the sink are two facts: {answer}"
|
||||
|
||||
text = stream.read_text(encoding="utf-8")
|
||||
text = text.replace(" immich.reeseapps.com 127.0.0.1:44450; #off immich.reeseapps.com",
|
||||
IMMICH_ORIGINAL)
|
||||
stream.write_text(text, encoding="utf-8")
|
||||
|
||||
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["enable", "--id", MAP_SINK_ID, "--apply"])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer["state"] == "on"
|
||||
assert not sink.exists(), "the helper removes the sink it wrote once no route still points at it"
|
||||
|
||||
# the remains-arm, on the helper's own function: a route still points at the sink
|
||||
helper = _load("routectl_under_test", HELPER)
|
||||
agent = _load("agent_under_test", AGENT)
|
||||
ctx = argparse.Namespace(root=str(tree),
|
||||
haproxy_config="/etc/haproxy/haproxy.cfg",
|
||||
nginx_config="/etc/nginx/nginx.conf",
|
||||
state_file=str(tmp_path / "sink-state.json"),
|
||||
sink_conf="/etc/nginx/stream.d/00-dashboards-sink.conf",
|
||||
sink_address="127.0.0.1:44450",
|
||||
nginx_unit="nginx", haproxy_unit="haproxy",
|
||||
actor="manual", allow_uid="nobody")
|
||||
write = helper.Write(ctx, agent, "nginx", 443, "127.0.0.1", 44450, MAP_SINK_ID)
|
||||
sink.write_text(f"{SINK_HEADER}\nserver {{\n listen 127.0.0.1:44450;\n}}\n",
|
||||
encoding="utf-8")
|
||||
documents = {"/etc/nginx/stream.d/stream.conf":
|
||||
"map $ssl_preread_server_name $targetBackend {\n"
|
||||
" default 127.0.0.1:4443;\n"
|
||||
" a.reeseapps.com 127.0.0.1:44450;\n"
|
||||
"}\n"}
|
||||
write.remove_sink_if_empty(documents)
|
||||
assert sink.is_file(), "a route still points at the sink: it stays"
|
||||
|
||||
|
||||
def test_the_helper_parses_on_python_3_10() -> None:
|
||||
"""The helper is installed beside the agent and must run on a host's Python
|
||||
3.10: the floor is in the header, and the file carries no 3.11+ syntax."""
|
||||
src = HELPER.read_text(encoding="utf-8")
|
||||
assert "# requires: python >=3.10" in src
|
||||
ast.parse(src, feature_version=(3, 10))
|
||||
Reference in New Issue
Block a user