c0re: emit /var/lib/hyperhive/agent-ports.json on meta sync for gateway (#15)
This commit is contained in:
parent
c43ff5d80b
commit
e197efd3a6
3 changed files with 178 additions and 0 deletions
163
hive-c0re/src/agent_ports.rs
Normal file
163
hive-c0re/src/agent_ports.rs
Normal file
|
|
@ -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<String, u16> {
|
||||
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, u16>) -> 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<String, u16> is always serialisable")
|
||||
}
|
||||
|
||||
/// Atomically write the JSON for `names` to
|
||||
/// `/var/lib/hyperhive/agent-ports.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 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<String> = ["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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue