hyperhive/hive-c0re/src/topology.rs

456 lines
17 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()
}
/// Resolve a magic recipient sentinel (currently just
/// [`hive_sh4re::PARENT_RECIPIENT`]) to a real broker recipient at
/// send time. Lets agents address structural roles
/// (`send(to: "<parent>", ...)`) without learning their parent's
/// label — runtime reparenting (#486) propagates for free.
///
/// Resolution rules:
/// - `<parent>` → `topology.json`'s parent for `sender` if some,
/// else [`hive_sh4re::OPERATOR_RECIPIENT`] (the "no parent → tell
/// mara" fallback from #692 / #8592).
/// - Anything else: returned unchanged.
///
/// Returns an owned `String` for the rewritten recipient so callers
/// can plug it straight into [`crate::broker::Broker::send`] without
/// borrow-juggling around the temporary lookup.
///
/// Fast path: ordinary recipient names (the overwhelming majority of
/// sends) short-circuit before touching the disk — only `<parent>`
/// triggers the `read()` of `topology.json`. Sentinel-free traffic
/// pays a single string compare; the disk read is amortised across
/// every `<parent>` send (per argus on #703#issuecomment-8750).
#[must_use]
pub fn resolve_recipient(sender: &str, to: &str) -> String {
// Early exit: only sentinel recipients need topology lookup. This
// keeps the cost of a normal `send` at one string comparison.
if to != hive_sh4re::PARENT_RECIPIENT {
return to.to_owned();
}
resolve_recipient_in(&read(), sender, to)
}
/// Pure form of [`resolve_recipient`] taking the topology map
/// explicitly. Split out so unit tests can exercise the sentinel
/// rules without writing a `topology.json` to disk.
#[must_use]
pub fn resolve_recipient_in(
topo: &BTreeMap<String, Option<String>>,
sender: &str,
to: &str,
) -> String {
if to == hive_sh4re::PARENT_RECIPIENT {
topo.get(sender)
.cloned()
.flatten()
.unwrap_or_else(|| hive_sh4re::OPERATOR_RECIPIENT.to_owned())
} else {
to.to_owned()
}
}
/// True when `candidate` is `ancestor` or any descendant of
/// `ancestor` per the current topology. Walks parents from
/// `candidate` upward; the walk terminates at root or on a cycle
/// (cycle defence: bounded to 32 hops, more than any plausible
/// hive depth). Used by the cancel-authorization check in
/// `manager_server::handle_cancel_schedule` to enforce
/// "managers can cancel anything their subtree owns."
#[must_use]
pub fn is_descendant_of(candidate: &str, ancestor: &str) -> bool {
if candidate == ancestor {
return true;
}
let topo = read();
let mut cur = candidate.to_owned();
for _ in 0..32 {
let Some(parent) = topo.get(&cur).cloned().flatten() else {
return false;
};
if parent == ancestor {
return true;
}
cur = parent;
}
false
}
/// 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]
#[allow(
dead_code,
reason = "kept for the dashboard / RequestSetParent write API landing in \
#361 follow-ups; `sync_agents` does its own seeding today"
)]
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
}
/// Pure validation + apply for [`set_parent`]. Splits off so tests
/// can exercise the rules (cycle / unknown / manager-protect) on
/// an in-memory `BTreeMap` without touching the on-disk
/// `topology.json`. Returns either the post-move map (caller
/// writes it back) or a user-readable error string.
pub fn apply_set_parent(
topo: &BTreeMap<String, Option<String>>,
child: &str,
new_parent: Option<&str>,
) -> Result<BTreeMap<String, Option<String>>, String> {
if child == crate::lifecycle::MANAGER_NAME {
return Err("cannot reparent the manager — it is structurally root".to_owned());
}
if !topo.contains_key(child) {
return Err(format!("unknown agent: {child}"));
}
if let Some(p) = new_parent {
if !topo.contains_key(p) {
return Err(format!("unknown parent: {p}"));
}
if p == child {
return Err("an agent cannot be its own parent".to_owned());
}
// Cycle check: walk `p`'s ancestors in the EXISTING map. If
// we hit `child`, then making `child`'s parent = `p` would
// close the loop (child → … → p → child).
let mut cur = p.to_owned();
for _ in 0..32 {
if cur == child {
return Err(format!(
"cycle: {p} is in {child}'s subtree (would create a loop)"
));
}
let Some(next) = topo.get(&cur).cloned().flatten() else {
break;
};
cur = next;
}
}
let mut next = topo.clone();
next.insert(child.to_owned(), new_parent.map(str::to_owned));
Ok(next)
}
/// Operator-driven parent move (#486 / #487). Set `child`'s parent
/// to `new_parent` (or `None` to promote to root). See
/// [`apply_set_parent`] for the validation rules. The operator-set
/// parent sticks across `reconcile()` calls (which preserves
/// existing entries).
///
/// No bind-mount / container churn today — the hierarchy is
/// currently logical-only (see #486 comment 5042). Once
/// sub-manager bind mounts land alongside #361, the caller adds
/// an umount-old / mount-new / restart-cascade step on top.
pub fn set_parent(child: &str, new_parent: Option<&str>) -> Result<(), String> {
let current = read();
// Idempotent no-op fast path: skip the disk write when nothing
// changes. apply_set_parent still runs to surface validation
// errors (e.g. unknown child) so the caller gets a real signal.
let next = apply_set_parent(&current, child, new_parent)?;
if next == current {
return Ok(());
}
write(&next).map_err(|e| format!("write topology.json: {e}"))
}
/// 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());
}
fn topo_three_level() -> BTreeMap<String, Option<String>> {
let mut m = BTreeMap::new();
m.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None);
m.insert(
"alice".to_owned(),
Some(crate::lifecycle::MANAGER_NAME.to_owned()),
);
m.insert("bob".to_owned(), Some("alice".to_owned()));
m.insert("carol".to_owned(), Some("alice".to_owned()));
m
}
#[test]
fn apply_set_parent_promotes_to_root() {
let next = apply_set_parent(&topo_three_level(), "alice", None).unwrap();
assert_eq!(next.get("alice"), Some(&None));
}
#[test]
fn apply_set_parent_reparents_under_sibling_subtree() {
// bob and carol both under alice; move carol under bob.
let next = apply_set_parent(&topo_three_level(), "carol", Some("bob")).unwrap();
assert_eq!(next.get("carol"), Some(&Some("bob".to_owned())));
}
#[test]
fn apply_set_parent_refuses_manager_move() {
let err = apply_set_parent(&topo_three_level(), crate::lifecycle::MANAGER_NAME, None)
.unwrap_err();
assert!(err.contains("manager"), "err = {err}");
}
#[test]
fn apply_set_parent_refuses_unknown_child() {
let err = apply_set_parent(&topo_three_level(), "nobody", Some("alice")).unwrap_err();
assert!(err.contains("unknown agent"), "err = {err}");
}
#[test]
fn apply_set_parent_refuses_unknown_parent() {
let err = apply_set_parent(&topo_three_level(), "bob", Some("nobody")).unwrap_err();
assert!(err.contains("unknown parent"), "err = {err}");
}
#[test]
fn apply_set_parent_refuses_self() {
let err = apply_set_parent(&topo_three_level(), "alice", Some("alice")).unwrap_err();
assert!(err.contains("own parent"), "err = {err}");
}
#[test]
fn apply_set_parent_refuses_cycle() {
// bob's parent is alice; trying to make alice's parent =
// bob would close the loop alice → bob → alice.
let err = apply_set_parent(&topo_three_level(), "alice", Some("bob")).unwrap_err();
assert!(err.contains("cycle"), "err = {err}");
}
#[test]
fn apply_set_parent_refuses_deep_cycle() {
// Three-deep chain: manager → alice → bob → carol. Moving
// alice under carol would create the loop alice → carol → bob → alice.
let mut topo = BTreeMap::new();
topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None);
topo.insert(
"alice".to_owned(),
Some(crate::lifecycle::MANAGER_NAME.to_owned()),
);
topo.insert("bob".to_owned(), Some("alice".to_owned()));
topo.insert("carol".to_owned(), Some("bob".to_owned()));
let err = apply_set_parent(&topo, "alice", Some("carol")).unwrap_err();
assert!(err.contains("cycle"), "err = {err}");
}
#[test]
fn apply_set_parent_is_idempotent_noop() {
// bob is already under alice — same value returned.
let next = apply_set_parent(&topo_three_level(), "bob", Some("alice")).unwrap();
assert_eq!(next, topo_three_level());
}
#[test]
fn resolve_recipient_passes_through_ordinary_names() {
let topo = topo_three_level();
// Real labels, broadcast, and the operator literal all
// shortcut through unchanged — no resolution magic.
assert_eq!(resolve_recipient_in(&topo, "bob", "alice"), "alice");
assert_eq!(resolve_recipient_in(&topo, "bob", "*"), "*");
assert_eq!(
resolve_recipient_in(&topo, "bob", hive_sh4re::OPERATOR_RECIPIENT),
hive_sh4re::OPERATOR_RECIPIENT
);
}
#[test]
fn resolve_recipient_rewrites_parent_sentinel_to_parent_label() {
let topo = topo_three_level();
// bob's parent is alice → `<parent>` from bob goes to alice.
assert_eq!(
resolve_recipient_in(&topo, "bob", hive_sh4re::PARENT_RECIPIENT),
"alice"
);
// alice's parent is the manager — same one-hop rewrite.
assert_eq!(
resolve_recipient_in(&topo, "alice", hive_sh4re::PARENT_RECIPIENT),
crate::lifecycle::MANAGER_NAME
);
}
#[test]
fn resolve_recipient_falls_back_to_operator_for_root_agent() {
let topo = topo_three_level();
// Manager is structurally root (parent = None) → `<parent>`
// resolves to the operator (matching the "no parent → tell mara"
// rule from #692#issuecomment-8592).
assert_eq!(
resolve_recipient_in(
&topo,
crate::lifecycle::MANAGER_NAME,
hive_sh4re::PARENT_RECIPIENT
),
hive_sh4re::OPERATOR_RECIPIENT
);
}
#[test]
fn resolve_recipient_falls_back_to_operator_for_unknown_sender() {
// Sender absent from topology entirely — defensive fallback
// covers the race window where an agent's spawn has registered
// its socket but the meta-flake `sync_agents` hasn't yet added
// its row.
let topo = topo_three_level();
assert_eq!(
resolve_recipient_in(&topo, "nobody", hive_sh4re::PARENT_RECIPIENT),
hive_sh4re::OPERATOR_RECIPIENT
);
}
}