hive-c0re: extract agent-socket-plumbing prose (#715 batch 5)

This commit is contained in:
damocles 2026-05-31 17:29:38 +02:00 committed by mara
commit d2f1a9d291
3 changed files with 44 additions and 98 deletions

View file

@ -1,58 +1,12 @@
//! `/var/lib/hyperhive/agent-sockets.json` writer (#784 phase 2,
//! prerequisite to #14 container netns isolation).
//! `/var/lib/hyperhive/agent-sockets.json` writer. Sibling to
//! `agent_ports.rs`; same atomic `<path>.tmp` + `rename()` shape so
//! the gateway's nginx worker never reads a partial file. Manager
//! excluded from the map (manager UI routes via the dashboard
//! upstream, not per-agent `/agent/<name>/`).
//!
//! Sibling to `agent_ports.rs`. The gateway needs to know which unix
//! socket to `proxy_pass` to per agent once the per-agent web UI
//! flips off TCP and on to `UnixListener::bind` (#784 phase 1
//! landed via PR #800). This file is the source of truth for
//! "which agents exist + where to reach their web UI over a domain
//! socket" from the gateway's POV — read at request-handling time,
//! not at gateway build time, so a `nixos-container update` of the
//! gateway isn't needed every time an agent spawns / moves /
//! destroys.
//!
//! Shape (flat object keyed by logical agent name → socket path):
//!
//! ```json
//! {
//! "iris": "/run/hive-agent/iris.sock",
//! "atlas": "/run/hive-agent/atlas.sock",
//! "argus": "/run/hive-agent/argus.sock",
//! "damocles": "/run/hive-agent/damocles.sock"
//! }
//! ```
//!
//! Socket paths are deterministic from the agent name —
//! [`socket_path_for`] computes them, so a name alone resolves to a
//! reproducible path. Manager is intentionally excluded from the map
//! (same reasoning as `agent_ports.rs`: the gateway routes the
//! manager's UI at `/` straight to the dashboard upstream, not via
//! per-agent `/agent/<name>/`).
//!
//! Coexists with `agent-ports.json` during the #784 phase-3
//! transition: agents that haven't opted in to `HIVE_WEB_SOCKET` yet
//! still appear in both files; the gateway picks the socket upstream
//! when one exists, falls back to the TCP port otherwise. Step 4
//! drops the TCP path entirely once every agent's web UI has flipped.
//!
//! Atomicity: same `<path>.tmp` + `rename()` shape as `agent_ports.rs`
//! so the gateway's nginx worker never reads a partial file.
//!
//! ## Per-agent subdir layout
//!
//! `<sockets-root>/<name>/web.sock`, NOT `<sockets-root>/<name>.sock`.
//! Each agent's container bind-mounts the per-agent SUBDIR
//! (`/run/hive-agent/<name>/`), and the harness binds the socket
//! inside it. File-level bind-mounts don't survive the harness's
//! "unlink stale socket then `bind(2)` a new one" cycle — the unlink
//! drops the bind, the rebind happens in private container
//! namespace, host never sees the new inode. Bind-mounting the
//! parent dir keeps both sides looking at the same dir inode so the
//! socket appears on the host the moment the harness binds it.
//!
//! Per-agent dir isolation (one dir per agent rather than a shared
//! `/run/hive-agent/` bind) satisfies mara on #800: an agent's
//! container only sees its own dir + socket, never siblings'.
//! Full mechanism — per-agent subdir bind-mount, `.bound` marker
//! gate, gateway UDS upstream, transition vs `agent-ports.json`,
//! 10s poll loop: `docs/gateway.md::Per-agent unix-socket upstream`.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@ -66,8 +20,8 @@ const HOST_SOCKETS_PATH: &str = "/var/lib/hyperhive/agent-sockets.json";
/// Host-side parent directory holding per-agent socket subdirs. The
/// gateway container bind-mounts this whole tree (read-only) so it
/// can `proxy_pass` to any agent. Each agent's container bind-mounts
/// only its own `<name>/` subdir, scoping access per mara's #800
/// directive ("agents can only access their own sockets").
/// only its own `<name>/` subdir — agents can only access their own
/// sockets.
pub const AGENT_SOCKET_DIR: &str = "/run/hive-agent";
/// Socket filename inside each per-agent subdir. Fixed so the path
@ -79,10 +33,9 @@ pub const SOCKET_FILENAME: &str = "web.sock";
/// successful `bind_unix`. Presence = "this agent has opted in to
/// `hyperhive.web.useUnixSocket = true` and its harness has bound
/// the socket"; absence = "the harness is still on TCP, don't
/// publish the unix upstream for this agent yet". Atlas's gate on
/// PR #813 — without it, the gateway would `proxy_pass` to a
/// non-existent socket for every sub-agent that hasn't flipped the
/// option yet.
/// publish the unix upstream for this agent yet". Without this gate
/// the gateway would `proxy_pass` to a non-existent socket for every
/// sub-agent that hasn't flipped the option yet.
pub const READY_MARKER: &str = ".bound";
#[must_use]
@ -101,7 +54,7 @@ pub fn agent_dir_for(name: &str) -> PathBuf {
/// Compute the deterministic socket path for an agent. Pure function
/// of the agent name so the value matches whatever
/// [`agent_sockets::write`] writes for that agent, and whatever the
/// harness binds via `HIVE_WEB_SOCKET` post-#784 phase 1.
/// harness binds via `HIVE_WEB_SOCKET`.
#[must_use]
pub fn socket_path_for(name: &str) -> PathBuf {
agent_dir_for(name).join(SOCKET_FILENAME)
@ -115,10 +68,9 @@ pub fn socket_path_for(name: &str) -> PathBuf {
///
/// Also filters by `READY_MARKER` presence: only agents whose
/// harness has actually bound the unix socket (and dropped the
/// marker) appear in the map. Atlas's gate on #813 — without this,
/// the gateway would `proxy_pass` to a non-existent socket for
/// every sub-agent that hasn't yet flipped
/// `hyperhive.web.useUnixSocket = true`.
/// marker) appear in the map. Without this, the gateway would
/// `proxy_pass` to a non-existent socket for every sub-agent that
/// hasn't yet flipped `hyperhive.web.useUnixSocket = true`.
///
/// `BTreeMap` keeps the JSON output sorted by key so a re-emit
/// without churn produces byte-identical output — same idempotency
@ -270,8 +222,7 @@ mod tests {
// Use `MANAGER_NAME` in the input so the assert actually
// exercises the filter path — a literal `"hm1nd"` would pass
// trivially if the constant ever changed and the filter
// silently became a no-op (same pattern as #748 fix on
// agent_ports::build_map). All-ready predicate bypasses the
// silently became a no-op. All-ready predicate bypasses the
// marker check so we exercise the manager filter in isolation.
let names: Vec<String> = ["iris", MANAGER_NAME, "argus"]
.iter()
@ -320,9 +271,8 @@ mod tests {
.iter()
.map(|s| (*s).to_owned())
.collect();
// Pretend only `atlas` has flipped + bound (mara on PR #813:
// "agents can only access their own sockets" — the gate
// makes sure only opted-in agents get a UDS upstream).
// Pretend only `atlas` has flipped + bound — the gate makes
// sure only opted-in agents get a UDS upstream.
let map = build_map_with(&names, |name| name == "atlas");
assert!(map.contains_key("atlas"));
assert!(!map.contains_key("iris"));