diff --git a/hive-c0re/src/agent_ports.rs b/hive-c0re/src/agent_ports.rs deleted file mode 100644 index 6b38049a..00000000 --- a/hive-c0re/src/agent_ports.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! `/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() { - // Use MANAGER_NAME in the input so the assert below actually - // exercises the filter path — a literal `"hm1nd"` would pass - // trivially if the constant ever changed and the filter - // silently became a no-op (argus on PR #748). - 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_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 f74d50ec..0ba297c9 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -13,7 +13,6 @@ //! 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 5ab45df2..531d356e 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -101,20 +101,6 @@ 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?; }