hyperhive/hive-c0re/src/topology.rs

168 lines
5.9 KiB
Rust

//! Agent topology storage — single source of truth for parent/child
//! relations in the hive. Lives in the hive-c0re-owned meta repo at
//! `/var/lib/hyperhive/meta/topology.json`, alongside `flake.nix`, so
//! topology changes thread through the same git commit log as deploys.
//!
//! Why meta, not per-agent: an agent shouldn't be able to claim a
//! parent without that parent's consent, and an operator-driven
//! re-parenting shouldn't require touching the moved agent's own
//! config. Topology IS a system-level concern; meta is where
//! system-level facts live.
//!
//! Format — flat JSON map keyed by agent name, values are the parent
//! agent's name or `null` for root:
//!
//! ```json
//! {
//! "manager": null,
//! "alice": "manager",
//! "bob": "alice"
//! }
//! ```
//!
//! Agents present in `nixos-container list` but absent from the file
//! default to root-level (`parent = None`). This file is operator/
//! manager-managed via approval-gated writes (write API lands in a
//! follow-up PR on the #361 milestone); for the bootstrap commit
//! `meta::sync_agents` seeds it with the existing implicit topology
//! (manager as root, all current sub-agents as direct children).
use std::collections::BTreeMap;
use std::path::PathBuf;
const TOPOLOGY_FILE: &str = "topology.json";
#[must_use]
pub fn topology_path() -> PathBuf {
crate::meta::meta_dir().join(TOPOLOGY_FILE)
}
/// Snapshot of the topology map. Read on every `container_view::build_all`
/// and every `render_flake` call. The file is small (one line per agent),
/// so we re-read rather than caching — keeps the source of truth on disk.
///
/// Returns an empty map when the file is absent or unparsable; callers
/// treat that as "no recorded parents", which falls back to every agent
/// being root-level. Safe degradation for fresh installs that haven't
/// run through `meta::sync_agents` yet.
#[must_use]
pub fn read() -> BTreeMap<String, Option<String>> {
let path = topology_path();
let Ok(raw) = std::fs::read_to_string(&path) else {
return BTreeMap::new();
};
serde_json::from_str(&raw).unwrap_or_default()
}
/// Look up one agent's parent. Returns `None` when the agent is root
/// or absent from the file. Cheap convenience over `read()` for
/// callers that want a single entry.
#[must_use]
pub fn parent_of(name: &str) -> Option<String> {
read().get(name).cloned().flatten()
}
/// Persist the topology map. Sorted JSON output (BTreeMap is sorted by
/// key) keeps git diffs minimal across re-writes. Best-effort —
/// returns an `io::Error` so callers can decide whether a failure
/// should abort their op (sync_agents, RequestSetParent) or just log.
pub fn write(topology: &BTreeMap<String, Option<String>>) -> std::io::Result<()> {
let path = topology_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = serde_json::to_string_pretty(topology)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&path, format!("{text}\n"))
}
/// Compute the default topology for a fresh install: every non-manager
/// agent has the manager as parent; manager itself is root. Used by
/// `meta::sync_agents` on first call to seed `topology.json`.
///
/// As soon as an explicit write lands (#361 follow-up: dashboard /
/// `RequestSetParent` API), this seeding stops touching pre-existing
/// entries — `sync_agents` only adds rows for newly-spawned agents
/// against whatever the operator has configured.
#[must_use]
pub fn default_seed(agent_names: &[String]) -> BTreeMap<String, Option<String>> {
let mut out = BTreeMap::new();
for name in agent_names {
if name == crate::lifecycle::MANAGER_NAME {
out.insert(name.clone(), None);
} else {
out.insert(name.clone(), Some(crate::lifecycle::MANAGER_NAME.to_owned()));
}
}
out
}
/// Reconcile `topology.json` against the current agent set. Adds an
/// entry (default: parent = manager, manager itself = root) for any
/// agent missing from the file; removes entries for agents no longer
/// present. Existing entries are preserved as-is — operator/manager
/// choices stick across regenerations. Returns true when the file
/// changed and should be re-committed by the caller.
pub fn reconcile(agent_names: &[String]) -> std::io::Result<bool> {
let mut current = read();
let mut changed = false;
// Add missing agents at their default position.
for name in agent_names {
if !current.contains_key(name) {
let parent = if name == crate::lifecycle::MANAGER_NAME {
None
} else {
Some(crate::lifecycle::MANAGER_NAME.to_owned())
};
current.insert(name.clone(), parent);
changed = true;
}
}
// Drop entries for agents that no longer exist.
let known: std::collections::HashSet<_> = agent_names.iter().collect();
current.retain(|name, _| {
let keep = known.contains(name);
if !keep {
changed = true;
}
keep
});
if changed {
write(&current)?;
}
Ok(changed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_seed_makes_manager_root_others_children() {
let agents = vec![
"alice".to_owned(),
crate::lifecycle::MANAGER_NAME.to_owned(),
"bob".to_owned(),
];
let seed = default_seed(&agents);
assert_eq!(
seed.get(crate::lifecycle::MANAGER_NAME),
Some(&None),
"manager should be root"
);
assert_eq!(
seed.get("alice"),
Some(&Some(crate::lifecycle::MANAGER_NAME.to_owned()))
);
assert_eq!(
seed.get("bob"),
Some(&Some(crate::lifecycle::MANAGER_NAME.to_owned()))
);
}
#[test]
fn default_seed_handles_empty_input() {
let seed = default_seed(&[]);
assert!(seed.is_empty());
}
}