The compute proof of concept had never been run with the agent as umbra-poc. Running it that way found three defects, all the same shape: a check that passed because its mechanism did nothing. The demonstration never reached the agent at all. It held the pid it spawned, which under `sudo -n -u` is the sudo wrapper - sudo forks the agent before exec'ing. Signalling that killed the wrapper and left the worker running, renewing its lease and holding the job indefinitely, under a printed note saying "exactly like a power cut". An unprivileged process cannot signal another account's process at all, and kill(2) on a process group succeeds as soon as one member is signalled, so the wrapper's death read as the agent's. Every run leaked an agent. Lease recovery was a diagnosis rather than a mechanism. sweep_expired_leases documented itself as "called by the controller's own timer" and there was no timer; a killed node's job came back only when somebody noticed a stalled job and posted to an administrative route by hand. The proof of concept swept by hand and recorded the result as automatic. app.py now runs the loop for the life of the application, building the compute repository at startup so a restart mid-job still recovers it. The power cut is now the agent's own: os._exit(137) the instant it claims a job, before running it, because a process that does not own the agent cannot signal it. A clean stop goes through a stop_file the agent polls, for the same reason. Liveness is measured by finding the worker's own pid in /proc matched on its config path, sampled repeatedly and accumulated - the wrapper and the worker are not simultaneous, so a single sample finds only the wrapper. The node key travels by `sudo --preserve-env` rather than as VAR=value in argv, because argv is world-readable through /proc/<pid>/cmdline and the environment is not. The demonstration checks whether the key will survive before starting and falls back to a pipe, which node_agent.py --node-key-stdin accepts. Neither a file nor argv ever holds it. Eight tests, each verified to fail against a mutation of the code it covers: three for the sweeper (it must return an expired lease, must survive a failing round, must not invent a repository it has no database for) and five driving a real agent process against a stub controller (stops on the stop file, exits 137 holding an uncompleted job, takes the key from both routes and neither, reports an empty pipe as an empty pipe). 448 pass with a database, 421 without. The deployment database is untouched: users=2 orgs=3 projects=0 audit=43, no compute tables.
303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""The node agent, driven as the process it actually is.
|
|
|
|
Every test here starts a real ``node_agent.py`` subprocess and points it at a stub
|
|
controller over real HTTP. That is heavier than calling the agent's methods, and
|
|
the weight is the point: the two behaviours worth testing here - stopping without
|
|
a signal, and dying without releasing anything - are about *processes*. ``os._exit``
|
|
cannot be observed in-process at all, and a stop file has no meaning without
|
|
something polling for it in a loop. A test that called ``NodeAgent.run`` in-process
|
|
would have had to mock both away and would have proved only that a mock was called.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
AGENT = Path(__file__).resolve().parents[1] / "node_agent.py"
|
|
NODE_KEY = "test-key-never-sent-anywhere-real"
|
|
|
|
|
|
class _StubController:
|
|
"""Just enough of ``/api/node`` to let an agent run.
|
|
|
|
Counts what it was asked for, because that is how the tests tell a stopped
|
|
agent from a running one and a dead one from a working one. A test that
|
|
asserted on a log line would pass on a log line written by an agent that had
|
|
already stopped.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.lock = threading.Lock()
|
|
self.heartbeats = 0
|
|
self.claims = 0
|
|
self.renews = 0
|
|
self.completes = 0
|
|
self.keys: set[str] = set()
|
|
self.offers_job = True
|
|
self.server: ThreadingHTTPServer | None = None
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
assert self.server is not None
|
|
return f"http://127.0.0.1:{self.server.server_address[1]}"
|
|
|
|
def __enter__(self) -> "_StubController":
|
|
controller = self
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def log_message(self, *args: Any) -> None: # noqa: A003 - stdlib name
|
|
pass
|
|
|
|
def _read(self) -> dict[str, Any]:
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
if not length:
|
|
return {}
|
|
return json.loads(self.rfile.read(length) or b"{}")
|
|
|
|
def _send(self, payload: Any) -> None:
|
|
body = b"" if payload is None else json.dumps(payload).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_POST(self) -> None: # noqa: N802 - stdlib name
|
|
self._read()
|
|
authorization = self.headers.get("Authorization") or ""
|
|
with controller.lock:
|
|
controller.keys.add(authorization)
|
|
if self.path.startswith("/api/node/heartbeat"):
|
|
controller.heartbeats += 1
|
|
self._send({"status": "online", "schedulable": True})
|
|
elif self.path.startswith("/api/node/jobs/claim"):
|
|
controller.claims += 1
|
|
if not controller.offers_job:
|
|
self._send(None)
|
|
return
|
|
self._send(
|
|
{
|
|
"job": {
|
|
"id": "job-1",
|
|
"kind": "sleep",
|
|
"payload": {"seconds": 0},
|
|
},
|
|
"placement": {
|
|
"node_name": "stub",
|
|
"device_kind": "cpu",
|
|
"reason": "test",
|
|
},
|
|
"lease_expires_at": "2026-01-01T00:00:00+00:00",
|
|
}
|
|
)
|
|
elif "/renew" in self.path:
|
|
controller.renews += 1
|
|
self._send({"ok": True})
|
|
elif self.path.endswith("/complete"):
|
|
controller.completes += 1
|
|
self._send({"ok": True})
|
|
else:
|
|
self._send({})
|
|
|
|
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
|
return self
|
|
|
|
def __exit__(self, *_: Any) -> None:
|
|
assert self.server is not None
|
|
self.server.shutdown()
|
|
self.server.server_close()
|
|
|
|
def snapshot(self) -> tuple[int, int, int]:
|
|
with self.lock:
|
|
return self.heartbeats, self.claims, self.completes
|
|
|
|
|
|
def _write_config(path: Path, controller: _StubController, **extra: Any) -> Path:
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"controller_url": controller.url,
|
|
"node_name": "stub-node",
|
|
"kinds": [],
|
|
"lease_seconds": 30,
|
|
"poll_interval_seconds": 0.05,
|
|
"node_key": "",
|
|
**extra,
|
|
}
|
|
)
|
|
)
|
|
return path
|
|
|
|
|
|
def _agent_environment(*, with_key: bool = True) -> dict[str, str]:
|
|
"""A database-free environment, because the agent never had one.
|
|
|
|
The key is here unless a test is specifically about how it arrives, since that
|
|
is the arrangement a node actually runs under.
|
|
"""
|
|
|
|
environment = {k: v for k, v in os.environ.items() if not k.startswith("UMBRATRANS")}
|
|
if with_key:
|
|
environment["UMBRA_NODE_KEY"] = NODE_KEY
|
|
else:
|
|
environment.pop("UMBRA_NODE_KEY", None)
|
|
return environment
|
|
|
|
|
|
def _run_agent(config: Path, *args: str, timeout: float = 20.0, with_key: bool = True):
|
|
environment = _agent_environment(with_key=with_key)
|
|
return subprocess.run(
|
|
[sys.executable, str(AGENT), "--config", str(config), "--log-level", "INFO", *args],
|
|
capture_output=True,
|
|
text=True,
|
|
env=environment,
|
|
timeout=timeout,
|
|
)
|
|
|
|
|
|
def test_a_stop_file_stops_an_agent_that_cannot_be_signalled(tmp_path: Path) -> None:
|
|
"""The agent stops when a file appears, with no signal involved.
|
|
|
|
A node agent normally runs as a service account, so its supervisor is not
|
|
allowed to signal it - kill(2) across accounts fails. Stopping it therefore has
|
|
to work without a signal, or every stop leaks a running agent. That is not
|
|
hypothetical: the proof of concept leaked one on every run, because the only
|
|
thing it could signal was the ``sudo`` wrapper it had itself spawned.
|
|
"""
|
|
|
|
with _StubController() as controller:
|
|
stop_file = tmp_path / "node.stop"
|
|
config = _write_config(tmp_path / "node.json", controller, stop_file=str(stop_file))
|
|
|
|
started = subprocess.Popen(
|
|
[sys.executable, str(AGENT), "--config", str(config), "--log-level", "INFO"],
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
env=_agent_environment(),
|
|
)
|
|
try:
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline and controller.snapshot()[0] == 0:
|
|
time.sleep(0.05)
|
|
before = controller.snapshot()
|
|
assert before[0] > 0, "the agent never reached the controller, so the stop " \
|
|
"would not prove anything"
|
|
|
|
stop_file.write_text("stop\n")
|
|
assert started.wait(timeout=15) == 0, "the agent did not stop cleanly"
|
|
|
|
# And it really did stop: no further traffic at all after the file.
|
|
settled = controller.snapshot()
|
|
time.sleep(0.6)
|
|
assert controller.snapshot() == settled, "traffic continued after the stop file"
|
|
finally:
|
|
if started.poll() is None:
|
|
started.kill()
|
|
started.wait(timeout=5)
|
|
|
|
|
|
def test_fault_injection_leaves_a_job_unclaimed_by_anything_that_finished_it(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""The agent vanishes holding a lease, having done nothing.
|
|
|
|
Exit code 137 and no completion call. The absence of the completion is the
|
|
assertion that matters: an agent that ran the job and then died would also
|
|
exit abruptly, and the lease would already be released by the completion, so
|
|
the recovery path would never see a stranded job at all.
|
|
"""
|
|
|
|
with _StubController() as controller:
|
|
config = _write_config(tmp_path / "node.json", controller)
|
|
finished = _run_agent(
|
|
config, "--fault-exit-after-claim", "1", "--max-iterations", "400"
|
|
)
|
|
|
|
assert finished.returncode == 137, (
|
|
f"expected the fault exit (137), got {finished.returncode}: "
|
|
f"{finished.stderr[-400:]}"
|
|
)
|
|
assert "without running it" in finished.stderr, finished.stderr[-400:]
|
|
assert controller.claims >= 1, "it never claimed anything, so nothing was stranded"
|
|
assert controller.completes == 0, "the job was completed, so no lease was stranded"
|
|
|
|
|
|
def test_the_node_key_is_never_in_the_config_file_it_is_written_beside(tmp_path: Path) -> None:
|
|
"""A config file holding a secret is a secret that ends up in a backup.
|
|
|
|
The key arrives by environment or by stdin, never from the file, even when the
|
|
file is the only thing that could plausibly supply it.
|
|
"""
|
|
|
|
with _StubController() as controller:
|
|
config = _write_config(tmp_path / "node.json", controller)
|
|
assert NODE_KEY not in config.read_text()
|
|
|
|
by_env = _run_agent(config, "--max-iterations", "1")
|
|
assert by_env.returncode == 0, by_env.stderr[-400:]
|
|
|
|
by_stdin = _run_agent_stdin(config)
|
|
assert by_stdin.returncode == 0, by_stdin.stderr[-400:]
|
|
|
|
# The stub recorded the header the agent actually sent.
|
|
assert f"Bearer {NODE_KEY}" in controller.keys
|
|
|
|
|
|
def _run_agent_stdin(config: Path):
|
|
return subprocess.run(
|
|
[sys.executable, str(AGENT), "--config", str(config), "--log-level", "INFO",
|
|
"--node-key-stdin", "--max-iterations", "1"],
|
|
input=NODE_KEY + "\n",
|
|
capture_output=True,
|
|
text=True,
|
|
env=_agent_environment(),
|
|
timeout=20,
|
|
)
|
|
|
|
|
|
def test_the_key_must_arrive_by_one_of_the_two_ways(tmp_path: Path) -> None:
|
|
"""Neither route supplied means a clear message naming the sudo cause.
|
|
|
|
The bare message this replaces said only "no node_key". Reaching it in
|
|
practice means sudo reset the environment, which is a fact about the host's
|
|
sudoers policy and not about the agent, so the agent says so.
|
|
"""
|
|
|
|
with _StubController() as controller:
|
|
config = _write_config(tmp_path / "node.json", controller)
|
|
finished = _run_agent(config, "--max-iterations", "1", with_key=False)
|
|
assert finished.returncode != 0
|
|
assert "UMBRA_NODE_KEY is not set" in finished.stderr, finished.stderr[-300:]
|
|
assert "sudo" in finished.stderr, (
|
|
"the failure under sudo is the environment being reset; the message should "
|
|
f"say so, got: {finished.stderr[-300:]}"
|
|
)
|
|
|
|
|
|
def test_an_empty_pipe_is_reported_as_an_empty_pipe(tmp_path: Path) -> None:
|
|
"""A closed stdin with nothing written is not a key that happens to be blank."""
|
|
|
|
with _StubController() as controller:
|
|
config = _write_config(tmp_path / "node.json", controller)
|
|
finished = subprocess.run(
|
|
[sys.executable, str(AGENT), "--config", str(config), "--node-key-stdin",
|
|
"--max-iterations", "1"],
|
|
input="", capture_output=True, text=True,
|
|
env=_agent_environment(with_key=False), timeout=20,
|
|
)
|
|
assert finished.returncode != 0
|
|
assert "stdin was empty" in finished.stderr, finished.stderr[-300:]
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
sys.exit(pytest.main([__file__, "-v"])) |