hive-c0re: agent_sockets.json writer (#784 phase 2, step 2a)

sibling to agent_ports.rs (#748 / #15). Writes
/var/lib/hyperhive/agent-sockets.json mapping agent name → unix
socket path under /run/hive-agent/<name>.sock — the source of truth
for the gateway's eventual unix-socket upstreams once it flips off
TCP per #784's transition plan.

shape mirrors agent_ports.rs:
- BTreeMap for deterministic JSON output
- atomic <path>.tmp + rename for partial-write safety
- idempotent (skip rewrite when content unchanged)
- MANAGER_NAME filter (gateway routes manager via dashboard upstream,
  not /agent/<n>/)

socket_path_for(name) is pure-deterministic so a name alone resolves
to the same path the c0re writes + the harness binds via
HIVE_WEB_SOCKET (post-PR #800).

hooked into meta::sync_agents alongside the existing
agent_ports::write call (same best-effort + non-fatal shape).

next: step 2b (lifecycle bind-mount /run/hive-agent/ into agent +
gateway containers, with per-agent isolation per mara's #800 nudge
'agents can only access their own sockets'). then atlas's step 3
(gateway proxy_pass http://unix:...). then step 4 drops TCP.
This commit is contained in:
damocles 2026-05-31 15:26:23 +02:00 committed by mara
commit c7a8cec2b5
3 changed files with 237 additions and 0 deletions

View file

@ -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/<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.
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 `<name>.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/<name>/`).
///
/// `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<String, PathBuf> {
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, PathBuf>) -> 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
/// `<path>.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<String> = ["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\""));
}
}

View file

@ -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;

View file

@ -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?;
}