Add the deployment path: agent systemd unit and container image

The README referenced `deploy/install-agent.sh` and a `Containerfile` that
did not exist, so a fresh clone had no way to actually run this anywhere.

* agent: `--token-file` (and AGENT_TOKEN_FILE). The token guards hostname,
  mounts, disk and GPU models and process names, and argv is world-readable
  through `ps`, so a file has to win over the flag. Missing or empty token
  files are fatal — falling back to "no token" would expose the host.
* deploy/install-agent.sh: creates a system user, generates the token once
  (re-runs reuse it, --rotate-token for a new one), stores it 0600
  root-owned, and runs the agent under ProtectSystem=strict with no
  capabilities; --status and --uninstall included.
* Containerfile: two-stage, uv.lock resolved in the builder and slim Python
  in the runtime. Verified with podman — the image serves /api/health as an
  unprivileged user and writes its history under /var/lib/dashboards.
  UV_PROJECT_ENVIRONMENT and --no-editable are what make the venv survive
  being copied into the runtime stage.
* .containerignore keeps .git, .venv, data/ and .env out of the build
  context.

258 tests, 94% coverage; ruff and pyright clean.

LLM-Generated: true
This commit is contained in:
2026-10-16 17:33:24 -04:00
parent c4bb374ccc
commit 426682a999
7 changed files with 331 additions and 11 deletions
+25 -4
View File
@@ -941,6 +941,20 @@ def serve(host: str, port: int, collector: Collector, token: str | None) -> None
httpd.server_close()
def resolve_token(token: str, token_file: str) -> tuple[str | None, str | None]:
"""(token, error). A file wins over a flag because `ps` shows argv to every user on the
box, and this token guards hostname, mounts, GPU model and process names."""
if not token_file:
return (token or None), None
try:
value = Path(token_file).read_text(encoding="utf-8").strip()
except OSError as exc:
return None, f"cannot read --token-file {token_file}: {exc}"
if not value:
return None, f"--token-file {token_file} is empty"
return value, None
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0],
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
@@ -949,6 +963,9 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--port", type=int, default=int(os.environ.get("AGENT_PORT", "9120")))
parser.add_argument("--token", default=os.environ.get("AGENT_TOKEN", ""),
help="bearer token required on every request except /health")
parser.add_argument("--token-file", default=os.environ.get("AGENT_TOKEN_FILE", ""),
help="read the bearer token from a file (keeps it out of ps); wins "
"over --token")
parser.add_argument("--root", default="/", help="filesystem root to read (for tests)")
parser.add_argument("--top", type=int, default=5, help="processes per top-N list")
parser.add_argument("--net-all", action="store_true", help="include veth/bridge interfaces")
@@ -959,11 +976,15 @@ def main(argv: list[str] | None = None) -> int:
if args.once:
print(json.dumps({"ok": True, **collector.snapshot()}, indent=2, default=str))
return 0
if args.host not in ("127.0.0.1", "localhost", "::1") and not args.token:
print("[agent] refusing to bind off-loopback without --token (host metrics are "
"sensitive); pass --token or set AGENT_TOKEN", flush=True)
token, error = resolve_token(args.token, args.token_file)
if error:
print(f"[agent] {error}", flush=True)
return 2
serve(args.host, args.port, collector, args.token or None)
if args.host not in ("127.0.0.1", "localhost", "::1") and not token:
print("[agent] refusing to bind off-loopback without --token (host metrics are "
"sensitive); pass --token, --token-file or set AGENT_TOKEN", flush=True)
return 2
serve(args.host, args.port, collector, token)
return 0