topology: meta-repo agent hierarchy + ContainerView.parent (#361)
This commit is contained in:
parent
e931c08739
commit
0b03d5bcfb
6 changed files with 403 additions and 3 deletions
|
|
@ -87,6 +87,14 @@ pub struct ContainerView {
|
|||
/// status is set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status_set_at: Option<i64>,
|
||||
/// Name of this agent's parent in the agent hierarchy (#361). `None`
|
||||
/// marks the agent as root-level; the dashboard renders it without
|
||||
/// indentation. Sourced from `meta/topology.json` (single source of
|
||||
/// truth, hive-c0re-owned) — NOT from per-agent agent.nix, because
|
||||
/// an agent shouldn't be able to unilaterally declare its own place
|
||||
/// in the tree.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the full container list. Wraps `lifecycle::list()` and
|
||||
|
|
@ -94,6 +102,10 @@ pub struct ContainerView {
|
|||
pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
|
||||
let raw = lifecycle::list().await.unwrap_or_default();
|
||||
let locked = read_meta_locked_revs();
|
||||
// Pull the topology map once and look up each agent's parent below.
|
||||
// Empty / absent topology.json → every agent root-level (matches
|
||||
// the pre-#361 status quo for fresh installs).
|
||||
let topology = crate::topology::read();
|
||||
let mut out = Vec::new();
|
||||
for c in &raw {
|
||||
let (logical, is_manager) = if c == MANAGER_NAME {
|
||||
|
|
@ -130,6 +142,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
|
|||
let rate_limited = is_rate_limited(&logical);
|
||||
let extra_links = read_dashboard_links(&logical);
|
||||
let (status_text, status_set_at) = read_status(&logical);
|
||||
let parent = topology.get(&logical).cloned().flatten();
|
||||
out.push(ContainerView {
|
||||
port: lifecycle::agent_web_port(&logical),
|
||||
running: lifecycle::is_running(&logical).await,
|
||||
|
|
@ -146,6 +159,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
|
|||
extra_links,
|
||||
status_text,
|
||||
status_set_at,
|
||||
parent,
|
||||
});
|
||||
}
|
||||
out
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ mod migrate;
|
|||
mod operator_questions;
|
||||
mod questions;
|
||||
mod rebuild_queue;
|
||||
mod topology;
|
||||
mod reminder_scheduler;
|
||||
mod server;
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,16 @@ pub async fn sync_agents(
|
|||
std::fs::write(&flake_path, &new_flake)
|
||||
.with_context(|| format!("write {}", flake_path.display()))?;
|
||||
|
||||
// Reconcile topology.json against the live agent set — adds
|
||||
// entries for newly-spawned agents (default: manager as parent,
|
||||
// manager itself as root) and drops removed agents. Operator
|
||||
// overrides via the write API (#361 follow-up) are preserved
|
||||
// because reconcile only fills in missing entries. Idempotent;
|
||||
// when nothing changed the file isn't touched.
|
||||
let agent_names: Vec<String> = agents.iter().map(|a| a.name.clone()).collect();
|
||||
let topology_changed = crate::topology::reconcile(&agent_names)
|
||||
.with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?;
|
||||
|
||||
if initial {
|
||||
git(&dir, &["init", "--initial-branch=main"]).await?;
|
||||
}
|
||||
|
|
@ -96,12 +106,20 @@ pub async fn sync_agents(
|
|||
// contain '/flake.nix'". Lock then commit once with both
|
||||
// flake.nix and flake.lock — single commit per change.
|
||||
git(&dir, &["add", "flake.nix"]).await?;
|
||||
// Stage topology.json on every sync (regenerated by reconcile
|
||||
// above when the agent set changed). git add is a no-op when the
|
||||
// file content is unchanged.
|
||||
if crate::topology::topology_path().exists() {
|
||||
git(&dir, &["add", "topology.json"]).await?;
|
||||
}
|
||||
nix(&dir, &["flake", "lock"]).await?;
|
||||
if std::path::Path::new(&dir).join("flake.lock").exists() {
|
||||
git(&dir, &["add", "flake.lock"]).await?;
|
||||
}
|
||||
let msg = if initial {
|
||||
format!("seed meta from {} agent(s)", agents.len())
|
||||
} else if topology_changed {
|
||||
"regenerate meta flake + topology".to_owned()
|
||||
} else {
|
||||
"regenerate meta flake".to_owned()
|
||||
};
|
||||
|
|
@ -348,7 +366,7 @@ where
|
|||
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port }}:"
|
||||
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null }}:"
|
||||
);
|
||||
out.push_str(
|
||||
r#" let
|
||||
|
|
@ -357,6 +375,7 @@ where
|
|||
else hyperhive.nixosConfigurations.agent-base;
|
||||
input = inputs."agent-${name}";
|
||||
service = if isManager then "hive-m1nd" else "hive-ag3nt";
|
||||
parentEnv = if parent == null then {} else { HIVE_PARENT = parent; };
|
||||
in
|
||||
base.extendModules {
|
||||
modules = [
|
||||
|
|
@ -372,7 +391,7 @@ where
|
|||
HIVE_LABEL = name;
|
||||
HYPERHIVE_STATE_DIR = "/agents/${name}/state";
|
||||
};
|
||||
systemd.services.${service}.environment = {
|
||||
systemd.services.${service}.environment = parentEnv // {
|
||||
HIVE_PORT = toString port;
|
||||
HIVE_LABEL = name;
|
||||
HIVE_DASHBOARD_PORT = toString dashboardPort;
|
||||
|
|
@ -406,14 +425,25 @@ where
|
|||
nixosConfigurations = {
|
||||
"#,
|
||||
);
|
||||
// Pull the topology map once and look up each agent's parent. An
|
||||
// empty / absent topology.json yields `parent = null` for everyone
|
||||
// — equivalent to the pre-#361 status quo (every container at root).
|
||||
// `meta::sync_agents` seeds the file on first run with manager as
|
||||
// root + everyone else under manager.
|
||||
let topology = crate::topology::read();
|
||||
for spec in agents {
|
||||
let parent_attr = topology
|
||||
.get(&spec.name)
|
||||
.and_then(|p| p.as_ref())
|
||||
.map_or_else(|| "null".to_owned(), |p| format!("\"{p}\""));
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; }};",
|
||||
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; }};",
|
||||
spec.name,
|
||||
spec.name,
|
||||
if spec.is_manager { "true" } else { "false" },
|
||||
spec.port,
|
||||
parent_attr,
|
||||
);
|
||||
}
|
||||
out.push_str(" };\n };\n}\n");
|
||||
|
|
|
|||
168
hive-c0re/src/topology.rs
Normal file
168
hive-c0re/src/topology.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
//! 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(¤t)?;
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue