diff --git a/hive-c0re/src/agent_sockets.rs b/hive-c0re/src/agent_sockets.rs new file mode 100644 index 00000000..05a8cc25 --- /dev/null +++ b/hive-c0re/src/agent_sockets.rs @@ -0,0 +1,226 @@ +//! `/var/lib/hyperhive/agent-sockets.json` writer (#784 phase 2, +//! prerequisite to #14 container netns isolation). +//! +//! 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//`). +//! +//! 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 `.tmp` + `rename()` shape as `agent_ports.rs` +//! so the gateway's nginx worker never reads a partial file. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::lifecycle::MANAGER_NAME; + +const HOST_SOCKETS_PATH: &str = "/var/lib/hyperhive/agent-sockets.json"; + +/// Host-side directory that holds per-agent unix sockets. Each +/// agent's container bind-mounts only its own `.sock` from +/// this dir, scoping access per mara's #800 directive ("agents can +/// only access their own sockets"). The gateway container gets the +/// whole dir mounted read-only so it can proxy to every agent. +pub const AGENT_SOCKET_DIR: &str = "/run/hive-agent"; + +#[must_use] +pub fn host_sockets_path() -> PathBuf { + PathBuf::from(HOST_SOCKETS_PATH) +} + +/// 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. +#[must_use] +pub fn socket_path_for(name: &str) -> PathBuf { + Path::new(AGENT_SOCKET_DIR).join(format!("{name}.sock")) +} + +/// Compute the agent-socket map for the given logical agent names. +/// Sub-agents only — manager is filtered out at the call boundary +/// for the same reason it's filtered from `agent_ports::build_map` +/// (manager UI is routed via the c0re dashboard upstream, not via +/// `/agent//`). +/// +/// `BTreeMap` keeps the JSON output sorted by key so a re-emit +/// without churn produces byte-identical output — same idempotency +/// shape `agent_ports::write` relies on. +#[must_use] +pub fn build_map(names: &[String]) -> BTreeMap { + names + .iter() + .filter(|n| n.as_str() != MANAGER_NAME) + .map(|n| (n.clone(), socket_path_for(n))) + .collect() +} + +/// Render the map as pretty-printed JSON. Pretty so a human peek at +/// `cat /var/lib/hyperhive/agent-sockets.json` shows one row per agent +/// — keeps the file readable without a separate jq step (mirrors +/// `agent_ports::render`). +fn render(map: &BTreeMap) -> String { + // Serialize as strings (PathBuf → JSON string via the Display + // impl). BTreeMap → serde_json::to_string_pretty preserves key + // order, so the output is deterministic across calls with the + // same agent set. + let stringly: BTreeMap<&String, String> = map + .iter() + .map(|(k, v)| (k, v.display().to_string())) + .collect(); + serde_json::to_string_pretty(&stringly) + .expect("BTreeMap<&String, String> is always serialisable") +} + +/// Atomically write the JSON for `names` to +/// `/var/lib/hyperhive/agent-sockets.json`. Writes via a sibling +/// `.tmp` + rename so a crashing process never leaves a +/// partial file behind that the gateway worker would fail to parse. +/// +/// Idempotent — if the rendered content matches what's already on +/// disk, the write + rename are skipped so the file's mtime stays +/// stable and inotify watchers in the gateway (or any future +/// watchers) don't fire spurious reload events. Mirrors the +/// `agent_ports::write` shape — keep them in lockstep. +pub fn write(names: &[String]) -> Result<()> { + let map = build_map(names); + let body = render(&map); + let path = host_sockets_path(); + if std::fs::read_to_string(&path).ok().as_deref() == Some(&body) { + return Ok(()); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create {}", parent.display()))?; + } + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &body) + .with_context(|| format!("write {}", tmp.display()))?; + std::fs::rename(&tmp, &path).with_context(|| { + format!( + "rename {} -> {} (atomic publish)", + tmp.display(), + path.display() + ) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn socket_path_for_uses_agent_dir_constant() { + // The path is derived from `AGENT_SOCKET_DIR` — pin both ends + // so a future move (e.g. to `/run/hyperhive/sockets/`) + // requires updating both the constant and the consumers. + let p = socket_path_for("iris"); + assert_eq!(p, Path::new("/run/hive-agent/iris.sock")); + } + + #[test] + fn build_map_filters_manager() { + // 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). + let names: Vec = ["iris", MANAGER_NAME, "argus"] + .iter() + .map(|s| (*s).to_owned()) + .collect(); + let map = build_map(&names); + assert!(!map.contains_key(MANAGER_NAME)); + assert!(map.contains_key("iris")); + assert!(map.contains_key("argus")); + } + + #[test] + fn build_map_uses_socket_path_for() { + // Map values agree with the helper so callers can use either + // (build_map for the bulk write, socket_path_for for one-off + // lookups) without divergence. + let names = vec!["iris".to_owned()]; + let map = build_map(&names); + assert_eq!(map.get("iris"), Some(&socket_path_for("iris"))); + } + + #[test] + fn build_map_handles_empty_input() { + let map = build_map(&[]); + assert!(map.is_empty()); + } + + #[test] + fn build_map_dedupes_via_btreemap_key_collision() { + // Duplicate inputs collapse via the map; no callsite passes + // dups today, but guarding the invariant here means a future + // bug doesn't surface as a corrupt JSON doc (two `"iris":` + // keys). Mirrors agent_ports test. + let names = vec!["iris".to_owned(), "iris".to_owned()]; + let map = build_map(&names); + assert_eq!(map.len(), 1); + } + + #[test] + fn render_is_pretty_and_sorted() { + let mut map = BTreeMap::new(); + map.insert("zeta".to_owned(), PathBuf::from("/run/hive-agent/zeta.sock")); + map.insert("alpha".to_owned(), PathBuf::from("/run/hive-agent/alpha.sock")); + let body = render(&map); + // Pretty-print = newlines between keys + indentation. + assert!(body.contains('\n')); + // BTreeMap sorts → alpha before zeta in output. + let alpha_pos = body.find("alpha").expect("alpha in output"); + let zeta_pos = body.find("zeta").expect("zeta in output"); + assert!( + alpha_pos < zeta_pos, + "sorted order broken:\n{body}" + ); + } + + #[test] + fn render_emits_paths_as_strings() { + // PathBuf-valued map serialises as plain JSON strings (not + // some {"inner": "..."} wrapper). Pin the shape so the + // gateway-side reader can deserialise into String values + // without nested struct logic. + let mut map = BTreeMap::new(); + map.insert("iris".to_owned(), PathBuf::from("/run/hive-agent/iris.sock")); + let body = render(&map); + assert!(body.contains("\"iris\"")); + assert!(body.contains("\"/run/hive-agent/iris.sock\"")); + } +} diff --git a/hive-c0re/src/lib.rs b/hive-c0re/src/lib.rs index f74d50ec..c6161b02 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -15,6 +15,7 @@ pub mod actions; pub mod agent_ports; pub mod agent_server; +pub mod agent_sockets; pub mod approvals; pub mod auto_update; pub mod broker; diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 5ab45df2..5d5d3c44 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -115,6 +115,16 @@ pub async fn sync_agents( tracing::warn!(error = ?e, "agent_ports::write failed (non-fatal)"); } + // Refresh /var/lib/hyperhive/agent-sockets.json — sibling to the + // ports map, drives the gateway's unix-socket upstreams once + // agents opt in to `HIVE_WEB_SOCKET` (PR #800 / #784 phase 1). + // Coexists with the TCP-port map during the transition: the + // gateway picks the socket upstream when one exists, falls back + // to the TCP port otherwise. Same best-effort + non-fatal shape. + if let Err(e) = crate::agent_sockets::write(&agent_names) { + tracing::warn!(error = ?e, "agent_sockets::write failed (non-fatal)"); + } + if initial { git(&dir, &["init", "--initial-branch=main"]).await?; }