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:
parent
dc8c71c687
commit
c7a8cec2b5
3 changed files with 237 additions and 0 deletions
226
hive-c0re/src/agent_sockets.rs
Normal file
226
hive-c0re/src/agent_sockets.rs
Normal 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\""));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue