//! `/var/lib/hyperhive/agent-ports.json` writer — flat map of //! agent name → TCP web port. Written alongside `agents.conf` on //! every topology change. JSON shape, port derivation (FNV-1a hash), //! atomicity, and manager exclusion: `docs/gateway.md::Agent port map`. 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 `"root"` would pass // trivially if the constant ever changed and the filter // silently became a no-op. 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}"); } }