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 actions;
|
||||||
pub mod agent_ports;
|
pub mod agent_ports;
|
||||||
pub mod agent_server;
|
pub mod agent_server;
|
||||||
|
pub mod agent_sockets;
|
||||||
pub mod approvals;
|
pub mod approvals;
|
||||||
pub mod auto_update;
|
pub mod auto_update;
|
||||||
pub mod broker;
|
pub mod broker;
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,16 @@ pub async fn sync_agents(
|
||||||
tracing::warn!(error = ?e, "agent_ports::write failed (non-fatal)");
|
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 {
|
if initial {
|
||||||
git(&dir, &["init", "--initial-branch=main"]).await?;
|
git(&dir, &["init", "--initial-branch=main"]).await?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue