hive-c0re: extract agent-socket-plumbing prose (#715 batch 5)
This commit is contained in:
parent
f8e0061f3f
commit
d2f1a9d291
3 changed files with 44 additions and 98 deletions
|
|
@ -1,11 +1,9 @@
|
|||
//! `/var/lib/hyperhive/agent-ports.json` writer (#15 / #740).
|
||||
//!
|
||||
//! The hive-gateway nginx container lives in the host's system config,
|
||||
//! not in the meta flake — its build can't be triggered by a
|
||||
//! meta-rebuild on every agent spawn / move / destroy. Instead it
|
||||
//! reads this JSON file at request-handling time to look up per-agent
|
||||
//! upstream ports, so the file IS the source of truth for "which
|
||||
//! agents exist and what's their web port" from the gateway's POV.
|
||||
//! `/var/lib/hyperhive/agent-ports.json` writer. Legacy TCP map for
|
||||
//! per-agent `/agent/<name>/` routing — the unix-socket replacement
|
||||
//! lives in `agent_sockets.rs`. The gateway reads this JSON at
|
||||
//! request-handling time rather than 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 → web port):
|
||||
//!
|
||||
|
|
@ -20,13 +18,9 @@
|
|||
//!
|
||||
//! Ports come from [`crate::lifecycle::agent_web_port`] — pure
|
||||
//! FNV-1a(name) hash so the value is reproducible from the name
|
||||
//! alone. The manager is intentionally excluded from the map: the
|
||||
//! gateway routes `/` straight to it via the c0re dashboard upstream
|
||||
//! (see `nix/modules/hive-gateway.nix`, atlas's #740) rather than a
|
||||
//! per-agent `/agent/<name>/` prefix. Post-#753 the manager's port
|
||||
//! is computed by the same hash as every other agent, but it still
|
||||
//! doesn't appear here — keeping the routing surface "sub-agents
|
||||
//! only" matches the gateway's current shape.
|
||||
//! alone. Manager is excluded from the map: the gateway routes `/`
|
||||
//! straight to it via the c0re dashboard upstream rather than a
|
||||
//! per-agent `/agent/<name>/` prefix.
|
||||
//!
|
||||
//! Atomicity: write to a sibling `.tmp` file + rename so a partial
|
||||
//! write never leaves an unparseable file in place. The gateway's
|
||||
|
|
@ -116,7 +110,7 @@ mod tests {
|
|||
// Use MANAGER_NAME in the input so the assert below actually
|
||||
// exercises the filter path — a literal `"hm1nd"` would pass
|
||||
// trivially if the constant ever changed and the filter
|
||||
// silently became a no-op (argus on PR #748).
|
||||
// silently became a no-op.
|
||||
let names: Vec<String> = ["iris", MANAGER_NAME, "argus"]
|
||||
.iter()
|
||||
.map(|s| (*s).to_owned())
|
||||
|
|
|
|||
|
|
@ -30,11 +30,12 @@ pub fn start(agent: &str, socket_path: &Path, coord: Arc<Coordinator>) -> Result
|
|||
let listener = UnixListener::bind(socket_path)
|
||||
.with_context(|| format!("bind agent socket {}", socket_path.display()))?;
|
||||
// The socket is bind-mounted into exactly one container as
|
||||
// `/run/hive/mcp.sock` (`lifecycle::set_nspawn_flags`); post-#658
|
||||
// the in-container harness connects as the per-agent unix user,
|
||||
// not root, so the default `tokio::net::UnixListener::bind` perms
|
||||
// (0755) lock it out. 0666 lets the agent user connect; the bind
|
||||
// source dir is per-agent on host so blast radius is unchanged.
|
||||
// `/run/hive/mcp.sock` (`lifecycle::set_nspawn_flags`); the
|
||||
// in-container harness connects as the per-agent unix user,
|
||||
// not root, so the default `tokio::net::UnixListener::bind`
|
||||
// perms (0755) lock it out. 0666 lets the agent user connect;
|
||||
// the bind source dir is per-agent on host so blast radius is
|
||||
// unchanged.
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o666))
|
||||
.with_context(|| format!("chmod agent socket {}", socket_path.display()))?;
|
||||
|
|
@ -230,7 +231,7 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
|
|||
}
|
||||
}
|
||||
AgentRequest::SetStatus { text } => {
|
||||
// #720: cap length + reject multi-line so a confused caller
|
||||
// Cap length + reject multi-line so a confused caller
|
||||
// can't dump a multi-paragraph session report into the
|
||||
// dashboard chip.
|
||||
if let Err(message) = crate::limits::check_status_text(text) {
|
||||
|
|
@ -261,10 +262,10 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
|
|||
}
|
||||
AgentRequest::GetAgentMeta { name } => {
|
||||
let target = name.as_deref().unwrap_or(agent);
|
||||
// #432: gate status on the target's running state so a
|
||||
// stopped container's stale on-disk status doesn't leak
|
||||
// through. Also surface `running` itself so callers can
|
||||
// tell (e.g. "iris is down" vs "iris has no status set").
|
||||
// Gate status on the target's running state so a stopped
|
||||
// container's stale on-disk status doesn't leak through.
|
||||
// Also surface `running` itself so callers can tell
|
||||
// (e.g. "iris is down" vs "iris has no status set").
|
||||
let (status_text, status_set_at, running) =
|
||||
crate::container_view::read_agent_status_live(target).await;
|
||||
let role = if target == hive_sh4re::MANAGER_AGENT {
|
||||
|
|
@ -339,8 +340,9 @@ fn handle_send(
|
|||
}
|
||||
// Resolve magic-recipient sentinels (currently `<parent>`) against
|
||||
// topology.json; no-op for ordinary names. Lets agents address
|
||||
// structural roles without learning the label — runtime reparenting
|
||||
// (#486) propagates for free (#692).
|
||||
// structural roles without learning the label — runtime
|
||||
// reparenting propagates for free. See `docs/conventions.md::
|
||||
// Recipient sentinels`.
|
||||
let resolved = crate::topology::resolve_recipient(agent, to);
|
||||
match coord.broker.send(&Message {
|
||||
from: agent.to_owned(),
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Reference in a new issue