feat(#962): topology-driven child bind mounts + can_manage_top_level_agents role
Removes the MANAGER_NAME special-case from set_nspawn_flags in favour of two general mechanisms: 1. Topology-driven child mounts: every agent now gets its direct children's state, harness, and config dirs bind-mounted (RW). Root's children are the top-level agents, so root gets the same access it did before via the old /agents blob bind — but derived from topology, not a hardcoded name check. 2. can_manage_top_level_agents role: agents holding this role additionally get every top-level agent treated as a virtual child (same RW mounts) plus /applied and /meta as RO. Designed for recovery: a role holder can update a top-level agent's config even when that agent is down. Root receives this role by default on first reconcile_roles call. Operator can revoke it with set_role. Every agent (including root) now gets its own state/harness/config dirs via the standard path. Roles are stored in meta/roles.json (same dir as topology.json); reconcile_roles is called from reconcile so both files stay in sync.
This commit is contained in:
parent
ba43b5f869
commit
6d2d0ed847
2 changed files with 164 additions and 50 deletions
|
|
@ -4,6 +4,10 @@
|
|||
//! alongside the meta `flake.nix`, so topology changes thread through
|
||||
//! the same git commit log as deploys.
|
||||
//!
|
||||
//! Agent roles are stored alongside in `roles.json` as a flat map of
|
||||
//! `name → [role, ...]`. Roles gate additional bind-mount grants; see
|
||||
//! `lifecycle::set_nspawn_flags` for the consumer.
|
||||
//!
|
||||
//! Format, rationale, read/reconcile/inject/surface flow, and target
|
||||
//! enforcement semantics: `docs/agent-hierarchy.md::Current state`.
|
||||
//! `<parent>` sentinel resolution (delivered by [`resolve_recipient`]):
|
||||
|
|
@ -278,6 +282,114 @@ pub fn reconcile(agent_names: &[String]) -> std::io::Result<bool> {
|
|||
if changed {
|
||||
write(¤t)?;
|
||||
}
|
||||
// Keep roles in sync with the agent set.
|
||||
reconcile_roles(agent_names)?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Agents with this role have the top-level agents (direct children of the
|
||||
/// root/manager agent) added as virtual children for bind-mount and
|
||||
/// config-change purposes. Enables recovery: if a top-level agent is down,
|
||||
/// a role holder can still read its state and update its config.
|
||||
///
|
||||
/// The root agent receives this role by default on first `reconcile_roles`
|
||||
/// call; operators can revoke it with `set_role`.
|
||||
pub const ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS: &str = "can_manage_top_level_agents";
|
||||
|
||||
const ROLES_FILE: &str = "roles.json";
|
||||
|
||||
#[must_use]
|
||||
pub fn roles_path() -> std::path::PathBuf {
|
||||
crate::meta::meta_dir().join(ROLES_FILE)
|
||||
}
|
||||
|
||||
/// Read the roles map from disk. Returns an empty map when absent or
|
||||
/// unparsable — same safe-degradation pattern as `topology::read`.
|
||||
#[must_use]
|
||||
pub fn read_roles() -> BTreeMap<String, Vec<String>> {
|
||||
let Ok(raw) = std::fs::read_to_string(roles_path()) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
serde_json::from_str(&raw).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Persist the roles map. Sorted output keeps git diffs minimal.
|
||||
pub fn write_roles(roles: &BTreeMap<String, Vec<String>>) -> std::io::Result<()> {
|
||||
let path = roles_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(roles)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
std::fs::write(&path, format!("{text}\n"))
|
||||
}
|
||||
|
||||
/// Return true when `name` holds `role`.
|
||||
#[must_use]
|
||||
pub fn has_role(name: &str, role: &str) -> bool {
|
||||
has_role_in(&read_roles(), name, role)
|
||||
}
|
||||
|
||||
/// Pure form of [`has_role`] for unit tests.
|
||||
#[must_use]
|
||||
pub fn has_role_in(roles: &BTreeMap<String, Vec<String>>, name: &str, role: &str) -> bool {
|
||||
roles
|
||||
.get(name)
|
||||
.is_some_and(|rs| rs.iter().any(|r| r == role))
|
||||
}
|
||||
|
||||
/// Grant or revoke a role for `name`. Idempotent — no disk write when the
|
||||
/// state is already correct.
|
||||
pub fn set_role(name: &str, role: &str, enabled: bool) -> Result<(), String> {
|
||||
let mut roles = read_roles();
|
||||
let list = roles.entry(name.to_owned()).or_default();
|
||||
let held = list.iter().any(|r| r == role);
|
||||
match (enabled, held) {
|
||||
(true, false) => list.push(role.to_owned()),
|
||||
(false, true) => list.retain(|r| r != role),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
if roles.get(name).is_some_and(|l| l.is_empty()) {
|
||||
roles.remove(name);
|
||||
}
|
||||
write_roles(&roles).map_err(|e| format!("write roles.json: {e}"))
|
||||
}
|
||||
|
||||
/// Reconcile `roles.json` against the current agent set:
|
||||
/// - Seeds root's default `can_manage_top_level_agents` role on first
|
||||
/// appearance (operator can revoke with `set_role`).
|
||||
/// - Drops entries for agents that no longer exist.
|
||||
///
|
||||
/// Returns true when the file changed.
|
||||
pub fn reconcile_roles(agent_names: &[String]) -> std::io::Result<bool> {
|
||||
let mut roles = read_roles();
|
||||
let mut changed = false;
|
||||
|
||||
let root = crate::lifecycle::MANAGER_NAME;
|
||||
if agent_names.iter().any(|n| n == root) && !roles.contains_key(root) {
|
||||
roles.insert(
|
||||
root.to_owned(),
|
||||
vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()],
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let known: std::collections::HashSet<_> = agent_names.iter().collect();
|
||||
roles.retain(|name, _| {
|
||||
let keep = known.contains(name);
|
||||
if !keep {
|
||||
changed = true;
|
||||
}
|
||||
keep
|
||||
});
|
||||
|
||||
if changed {
|
||||
write_roles(&roles)?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue