From e197efd3a6bfe8d8c77743cbbbe6794fcf3ea7e4 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 31 May 2026 12:58:40 +0200 Subject: [PATCH] c0re: emit /var/lib/hyperhive/agent-ports.json on meta sync for gateway (#15) --- hive-c0re/src/agent_ports.rs | 163 +++++++++++++++++++++++++++++++++++ hive-c0re/src/lib.rs | 1 + hive-c0re/src/meta.rs | 14 +++ 3 files changed, 178 insertions(+) create mode 100644 hive-c0re/src/agent_ports.rs diff --git a/hive-c0re/src/agent_ports.rs b/hive-c0re/src/agent_ports.rs new file mode 100644 index 00000000..ae9880f8 --- /dev/null +++ b/hive-c0re/src/agent_ports.rs @@ -0,0 +1,163 @@ +//! `/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. +//! +//! Shape (flat object keyed by logical agent name → web port): +//! +//! ```json +//! { +//! "iris": 8178, +//! "atlas": 8304, +//! "argus": 8267, +//! "damocles": 8549 +//! } +//! ``` +//! +//! 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: it sits at the +//! fixed `MANAGER_PORT` (8000) and the gateway routes `/` straight +//! to it without a per-agent prefix (see `nix/modules/hive-gateway.nix` +//! upstream config, atlas's #740). +//! +//! Atomicity: write to a sibling `.tmp` file + rename so a partial +//! write never leaves an unparseable file in place. The gateway's +//! `nginx` worker can read mid-write and Just Work because `rename()` +//! is atomic on the same filesystem. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +use crate::lifecycle::{self, MANAGER_NAME}; + +const HOST_PORTS_PATH: &str = "/var/lib/hyperhive/agent-ports.json"; + +#[must_use] +pub fn host_ports_path() -> PathBuf { + PathBuf::from(HOST_PORTS_PATH) +} + +/// Compute the agent-port map for the given logical agent names. +/// Sub-agents only — manager is filtered out at the call boundary +/// because the gateway doesn't surface per-agent routing for it. +/// +/// `BTreeMap` keeps the JSON output sorted by key so a re-emit +/// without churn produces byte-identical output (helpful when the +/// operator inspects the file by hand or diffs deploys). +#[must_use] +pub fn build_map(names: &[String]) -> BTreeMap { + names + .iter() + .filter(|n| n.as_str() != MANAGER_NAME) + .map(|n| (n.clone(), lifecycle::agent_web_port(n))) + .collect() +} + +/// Render the map as pretty-printed JSON. Pretty so a human peek at +/// `cat /var/lib/hyperhive/agent-ports.json` shows one row per agent +/// — keeps the file readable without a separate jq step. +fn render(map: &BTreeMap) -> String { + // BTreeMap → serde_json::to_string_pretty preserves key order, + // so the output is deterministic across calls with the same + // agent set. + serde_json::to_string_pretty(map) + .expect("BTreeMap is always serialisable") +} + +/// Atomically write the JSON for `names` to +/// `/var/lib/hyperhive/agent-ports.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 nginx (or any future watchers) +/// don't fire spurious reload events. +pub fn write(names: &[String]) -> Result<()> { + let map = build_map(names); + let body = render(&map); + let path = host_ports_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 build_map_filters_manager() { + let names: Vec = ["iris", "hm1nd", "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_agent_web_port() { + let names = vec!["iris".to_owned()]; + let map = build_map(&names); + assert_eq!(map.get("iris"), Some(&lifecycle::agent_web_port("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; the last value wins. + // 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). + 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(), 9000u16); + map.insert("alpha".to_owned(), 8000u16); + 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}" + ); + } +} diff --git a/hive-c0re/src/lib.rs b/hive-c0re/src/lib.rs index 0ba297c9..f74d50ec 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -13,6 +13,7 @@ //! surface beyond "this is where the modules live". pub mod actions; +pub mod agent_ports; pub mod agent_server; pub mod approvals; pub mod auto_update; diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 531d356e..5ab45df2 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -101,6 +101,20 @@ pub async fn sync_agents( let topology_changed = crate::topology::reconcile(&agent_names) .with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?; + // Refresh /var/lib/hyperhive/agent-ports.json so the hive-gateway + // nginx sees the new agent set (#15 / #740). The file is the + // single source of truth for which agents the gateway proxies to, + // since the gateway container lives in system config and can't be + // rebuilt from meta-flake events. Atomic write (tmp + rename) so + // a partial write never trips the gateway's read. + if let Err(e) = crate::agent_ports::write(&agent_names) { + // Best-effort: a failed write doesn't block the meta-flake + // regen + container ops that follow. The gateway falls back + // to whatever map is currently on disk (or an empty map on + // first boot, meaning no per-agent routing yet). + tracing::warn!(error = ?e, "agent_ports::write failed (non-fatal)"); + } + if initial { git(&dir, &["init", "--initial-branch=main"]).await?; }