hive-c0re: let request_init_config spawn a brand-new sub-agent under its requester
This commit is contained in:
parent
63a79d76ba
commit
b3d002e4a7
8 changed files with 254 additions and 23 deletions
|
|
@ -70,7 +70,11 @@ where system-level facts live.
|
||||||
alongside its `flake.nix` regeneration. New agents land at their
|
alongside its `flake.nix` regeneration. New agents land at their
|
||||||
default position (manager as parent, manager itself as root);
|
default position (manager as parent, manager itself as root);
|
||||||
removed agents drop. Existing entries are preserved as-is so
|
removed agents drop. Existing entries are preserved as-is so
|
||||||
operator overrides stick across regenerations.
|
operator overrides stick across regenerations. Pending-init agents
|
||||||
|
(an operator-approved proposed config repo but no container yet —
|
||||||
|
`Coordinator::pending_init_names`) are kept too, so the
|
||||||
|
`child -> parent` edge written when `request_init_config` is approved
|
||||||
|
survives the gap until the first apply-commit spawns the container.
|
||||||
3. **Inject**: `meta::render_flake` looks up each agent's parent and
|
3. **Inject**: `meta::render_flake` looks up each agent's parent and
|
||||||
passes it to `mkAgent`. When non-null, the mkAgent body sets
|
passes it to `mkAgent`. When non-null, the mkAgent body sets
|
||||||
`HIVE_PARENT = parent` in the agent's systemd service environment
|
`HIVE_PARENT = parent` in the agent's systemd service environment
|
||||||
|
|
@ -121,7 +125,7 @@ can't:
|
||||||
|
|
||||||
| variant | semantic | post-milestone |
|
| variant | semantic | post-milestone |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `RequestInitConfig` | seed an agent's proposed config repo | **topology** — descendants only |
|
| `RequestInitConfig` | seed an agent's proposed config repo | **topology** — existing direct child (re-init) or a brand-new name (child added under self on approval); a name owned by a different parent is refused |
|
||||||
| `RequestApplyCommit` | submit a commit sha for operator approval | **topology** — descendants only |
|
| `RequestApplyCommit` | submit a commit sha for operator approval | **topology** — descendants only |
|
||||||
| `Kill` / `Start` / `Restart` / `Update` | container lifecycle on an existing agent | **topology** — descendants only |
|
| `Kill` / `Start` / `Restart` / `Update` | container lifecycle on an existing agent | **topology** — descendants only |
|
||||||
| `RequestUpdateMetaInputs` | bump meta `flake.lock` | **per-agent cap** (root-only today; a future "let coder bump its own input" might grant it) |
|
| `RequestUpdateMetaInputs` | bump meta `flake.lock` | **per-agent cap** (root-only today; a future "let coder bump its own input" might grant it) |
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,16 @@ async fn run_approval_init_config(
|
||||||
notes_dir: std::path::PathBuf,
|
notes_dir: std::path::PathBuf,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let result: Result<()> = async {
|
let result: Result<()> = async {
|
||||||
|
// Place the new child under its requesting parent (carried in
|
||||||
|
// commit_ref by submit_init_config). An empty commit_ref means
|
||||||
|
// no explicit parent was named (privileged manager socket, or an
|
||||||
|
// approval queued before the new-child feature) — write no edge
|
||||||
|
// and let `topology::reconcile` assign the default position on
|
||||||
|
// first spawn, so this path never names a specific root agent.
|
||||||
|
if !approval.commit_ref.is_empty() {
|
||||||
|
crate::topology::add_child(&approval.agent, &approval.commit_ref)
|
||||||
|
.map_err(|e| anyhow::anyhow!("topology add_child: {e}"))?;
|
||||||
|
}
|
||||||
lifecycle::setup_proposed(&proposed_dir, &approval.agent).await?;
|
lifecycle::setup_proposed(&proposed_dir, &approval.agent).await?;
|
||||||
lifecycle::ensure_agent_state_subvolume(&approval.agent).await?;
|
lifecycle::ensure_agent_state_subvolume(&approval.agent).await?;
|
||||||
lifecycle::ensure_claude_dir(&claude_dir)?;
|
lifecycle::ensure_claude_dir(&claude_dir)?;
|
||||||
|
|
|
||||||
|
|
@ -456,6 +456,40 @@ fn require_child(agent: &str, target: &str, action: &str) -> Option<AgentRespons
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Topology guard for `request_init_config` / `request_apply_commit`,
|
||||||
|
/// which may legitimately target a child that does not exist *yet*
|
||||||
|
/// (spawning a brand-new sub-agent). The caller may act on a
|
||||||
|
/// `target` that is EITHER already its direct child (re-init / config
|
||||||
|
/// update of an existing child) OR brand-new (absent from the topology
|
||||||
|
/// tree — the requester becomes its parent). A name that already
|
||||||
|
/// belongs to a *different* parent (or is a root agent) is refused so
|
||||||
|
/// one agent can't hijack another's sub-tree.
|
||||||
|
///
|
||||||
|
/// Also re-runs the agent-name format check that `require_child`
|
||||||
|
/// implicitly provided (a traversal / malformed name could never be a
|
||||||
|
/// child): a brand-new name now flows straight to `submit_init_config`,
|
||||||
|
/// which builds filesystem paths from it, so validate before that.
|
||||||
|
fn require_new_child(agent: &str, target: &str, action: &str) -> Option<AgentResponse> {
|
||||||
|
if let Some(reason) = crate::dashboard::validate_agent_name(target) {
|
||||||
|
return Some(AgentResponse::Err {
|
||||||
|
message: format!("agent `{agent}` cannot {action} `{target}`: {reason}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
match crate::topology::read().get(target) {
|
||||||
|
// brand-new name — requester becomes the parent on approval.
|
||||||
|
None => None,
|
||||||
|
// already our direct child — re-init / config update path.
|
||||||
|
Some(Some(p)) if p == agent => None,
|
||||||
|
// owned by someone else, or a root agent — refuse.
|
||||||
|
Some(_) => Some(AgentResponse::Err {
|
||||||
|
message: format!(
|
||||||
|
"agent `{agent}` cannot {action} `{target}`: it already exists \
|
||||||
|
under a different parent in the topology tree"
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `GetLooseEnds` — resolve the (optionally cross-agent) target then
|
/// `GetLooseEnds` — resolve the (optionally cross-agent) target then
|
||||||
/// read its loose ends.
|
/// read its loose ends.
|
||||||
fn handle_get_loose_ends(
|
fn handle_get_loose_ends(
|
||||||
|
|
@ -696,11 +730,11 @@ fn handle_request_init_config(
|
||||||
name: &str,
|
name: &str,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
) -> AgentResponse {
|
) -> AgentResponse {
|
||||||
if let Some(err) = require_child(agent, name, "request_init_config for") {
|
if let Some(err) = require_new_child(agent, name, "request_init_config for") {
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
tracing::info!(%agent, %name, "agent: request_init_config for child");
|
tracing::info!(%agent, %name, "agent: request_init_config for child");
|
||||||
match crate::manager_server::submit_init_config(coord, name, description) {
|
match crate::manager_server::submit_init_config(coord, name, Some(agent), description) {
|
||||||
Ok(_id) => AgentResponse::Ok,
|
Ok(_id) => AgentResponse::Ok,
|
||||||
Err(e) => AgentResponse::Err {
|
Err(e) => AgentResponse::Err {
|
||||||
message: format!("{e:#}"),
|
message: format!("{e:#}"),
|
||||||
|
|
@ -717,7 +751,7 @@ async fn handle_request_apply_commit(
|
||||||
commit_ref: &str,
|
commit_ref: &str,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
) -> AgentResponse {
|
) -> AgentResponse {
|
||||||
if let Some(err) = require_child(agent, target_agent, "request_apply_commit for") {
|
if let Some(err) = require_new_child(agent, target_agent, "request_apply_commit for") {
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
tracing::info!(%agent, %target_agent, %commit_ref, "agent: request_apply_commit for child");
|
tracing::info!(%agent, %target_agent, %commit_ref, "agent: request_apply_commit for child");
|
||||||
|
|
|
||||||
|
|
@ -1403,4 +1403,21 @@ impl Coordinator {
|
||||||
out.sort();
|
out.sort();
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Agents that have an operator-approved proposed config repo but
|
||||||
|
/// were never deployed — proposed `.git` exists, applied `.git` does
|
||||||
|
/// not. Their `topology.json` parent edge (written at `InitConfig`
|
||||||
|
/// approval) must survive `topology::reconcile` until the first
|
||||||
|
/// apply-commit spawns the container. Distinct from tombstones,
|
||||||
|
/// which have an applied repo from a prior deploy.
|
||||||
|
#[must_use]
|
||||||
|
pub fn pending_init_names() -> Vec<String> {
|
||||||
|
Self::kept_state_names()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| {
|
||||||
|
Self::agent_proposed_dir(n).join(".git").exists()
|
||||||
|
&& !Self::agent_applied_dir(n).join(".git").exists()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1370,7 +1370,7 @@ async fn api_audit_log(State(state): State<AppState>) -> Response {
|
||||||
/// on reject — caller wraps the reason in a 400 response. Conservative
|
/// on reject — caller wraps the reason in a 400 response. Conservative
|
||||||
/// whitelist matching `nixos-container` basename rules and the existing
|
/// whitelist matching `nixos-container` basename rules and the existing
|
||||||
/// agent-name convention across the codebase.
|
/// agent-name convention across the codebase.
|
||||||
fn validate_agent_name(name: &str) -> Option<&'static str> {
|
pub(crate) fn validate_agent_name(name: &str) -> Option<&'static str> {
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Some("agent name must not be empty");
|
return Some("agent name must not be empty");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,11 @@ fn handle_manager_init_config(
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
) -> ManagerResponse {
|
) -> ManagerResponse {
|
||||||
tracing::info!(%name, "manager: request_init_config");
|
tracing::info!(%name, "manager: request_init_config");
|
||||||
match submit_init_config(coord, name, description) {
|
// No explicit parent edge from the privileged socket — the new
|
||||||
|
// agent takes `topology::reconcile`'s default position on first
|
||||||
|
// spawn. The agent socket is the path that records an explicit
|
||||||
|
// requester-as-parent edge.
|
||||||
|
match submit_init_config(coord, name, None, description) {
|
||||||
Ok(_id) => ManagerResponse::Ok,
|
Ok(_id) => ManagerResponse::Ok,
|
||||||
Err(e) => ManagerResponse::Err {
|
Err(e) => ManagerResponse::Err {
|
||||||
message: format!("{e:#}"),
|
message: format!("{e:#}"),
|
||||||
|
|
@ -403,9 +407,20 @@ pub(crate) fn validate_commit_ref(commit_ref: &str) -> Result<()> {
|
||||||
|
|
||||||
/// Queue an `InitConfig` approval for a brand-new agent whose config repo
|
/// Queue an `InitConfig` approval for a brand-new agent whose config repo
|
||||||
/// does not yet exist. Shared between the manager and agent sockets.
|
/// does not yet exist. Shared between the manager and agent sockets.
|
||||||
|
///
|
||||||
|
/// `parent`, when `Some`, is the agent that will own the new child once
|
||||||
|
/// the operator approves: it is stashed in the approval's `commit_ref`
|
||||||
|
/// field (unused for `InitConfig` otherwise — same pattern
|
||||||
|
/// `UpdateMetaInputs` uses to carry its inputs JSON) and consumed in
|
||||||
|
/// `run_approval_init_config` to write the `child -> parent` topology
|
||||||
|
/// edge. The agent socket passes the requesting agent. `None` (the
|
||||||
|
/// privileged manager socket) writes no explicit edge — the new agent
|
||||||
|
/// lands at `topology::reconcile`'s default position when it first
|
||||||
|
/// spawns, so no caller has to name a specific root agent here.
|
||||||
pub(crate) fn submit_init_config(
|
pub(crate) fn submit_init_config(
|
||||||
coord: &Arc<Coordinator>,
|
coord: &Arc<Coordinator>,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
parent: Option<&str>,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
) -> anyhow::Result<i64> {
|
) -> anyhow::Result<i64> {
|
||||||
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(name);
|
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(name);
|
||||||
|
|
@ -421,7 +436,7 @@ pub(crate) fn submit_init_config(
|
||||||
.submit_kind(
|
.submit_kind(
|
||||||
name,
|
name,
|
||||||
hive_sh4re::ApprovalKind::InitConfig,
|
hive_sh4re::ApprovalKind::InitConfig,
|
||||||
"",
|
parent.unwrap_or(""),
|
||||||
description.as_deref(),
|
description.as_deref(),
|
||||||
)
|
)
|
||||||
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
|
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,8 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
||||||
// only fills in missing entries. Idempotent; when nothing changed
|
// only fills in missing entries. Idempotent; when nothing changed
|
||||||
// the file isn't touched.
|
// the file isn't touched.
|
||||||
let agent_names: Vec<String> = agents.iter().map(|a| a.name.clone()).collect();
|
let agent_names: Vec<String> = agents.iter().map(|a| a.name.clone()).collect();
|
||||||
crate::topology::reconcile(&agent_names)
|
let pending = crate::coordinator::Coordinator::pending_init_names();
|
||||||
|
crate::topology::reconcile(&agent_names, &pending)
|
||||||
.with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?;
|
.with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?;
|
||||||
|
|
||||||
// Refresh /var/lib/hyperhive/run/agent-ports.json so the hive-gateway
|
// Refresh /var/lib/hyperhive/run/agent-ports.json so the hive-gateway
|
||||||
|
|
|
||||||
|
|
@ -272,42 +272,108 @@ pub fn set_parent(child: &str, new_parent: Option<&str>) -> Result<(), String> {
|
||||||
write(&next).map_err(|e| format!("write topology.json: {e}"))
|
write(&next).map_err(|e| format!("write topology.json: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Declare a brand-new agent's parent edge before the agent exists in
|
||||||
|
/// the container set. Unlike [`set_parent`] (which reparents an entry
|
||||||
|
/// that must already be present), this inserts a fresh `child -> parent`
|
||||||
|
/// row. Used by the `InitConfig` approval to place a just-scaffolded
|
||||||
|
/// sub-agent under its requesting parent, so the edge is in place
|
||||||
|
/// before the first apply-commit spawns the container (and before
|
||||||
|
/// [`reconcile`] would otherwise default it to the manager).
|
||||||
|
///
|
||||||
|
/// Idempotent when the edge already exists. Refuses to clobber an entry
|
||||||
|
/// whose parent differs (one agent can't steal another's child) and
|
||||||
|
/// validates that `parent` is itself a known agent.
|
||||||
|
pub fn add_child(child: &str, parent: &str) -> Result<(), String> {
|
||||||
|
let current = read();
|
||||||
|
match apply_add_child(¤t, child, parent)? {
|
||||||
|
Some(next) => write(&next).map_err(|e| format!("write topology.json: {e}")),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure form of [`add_child`] for unit tests. Returns the post-insert
|
||||||
|
/// map (caller writes it back), `None` for an idempotent no-op (edge
|
||||||
|
/// already present), or an error string (unknown parent / name owned by
|
||||||
|
/// a different parent).
|
||||||
|
pub fn apply_add_child(
|
||||||
|
topo: &BTreeMap<String, Option<String>>,
|
||||||
|
child: &str,
|
||||||
|
parent: &str,
|
||||||
|
) -> Result<Option<BTreeMap<String, Option<String>>>, String> {
|
||||||
|
if !topo.contains_key(parent) {
|
||||||
|
return Err(format!("unknown parent: {parent}"));
|
||||||
|
}
|
||||||
|
match topo.get(child) {
|
||||||
|
Some(Some(p)) if p == parent => return Ok(None),
|
||||||
|
Some(existing) => {
|
||||||
|
return Err(format!(
|
||||||
|
"agent {child} already exists in topology (parent: {existing:?}); \
|
||||||
|
refusing to reparent via add_child"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
let mut next = topo.clone();
|
||||||
|
next.insert(child.to_owned(), Some(parent.to_owned()));
|
||||||
|
Ok(Some(next))
|
||||||
|
}
|
||||||
|
|
||||||
/// Reconcile `topology.json` against the current agent set. Adds an
|
/// Reconcile `topology.json` against the current agent set. Adds an
|
||||||
/// entry (default: parent = manager, manager itself = root) for any
|
/// entry (default: parent = manager, manager itself = root) for any
|
||||||
/// agent missing from the file; removes entries for agents no longer
|
/// agent missing from the file; removes entries for agents no longer
|
||||||
/// present. Existing entries are preserved as-is — operator/manager
|
/// present. Existing entries are preserved as-is — operator/manager
|
||||||
/// choices stick across regenerations. Returns true when the file
|
/// choices stick across regenerations. Returns true when the file
|
||||||
/// changed and should be re-committed by the caller.
|
/// changed and should be re-committed by the caller.
|
||||||
pub fn reconcile(agent_names: &[String]) -> std::io::Result<bool> {
|
///
|
||||||
let mut current = read();
|
/// `pending` lists agents that have an operator-approved proposed config
|
||||||
|
/// repo but no container yet (init'd, not yet spawned). They are KEPT
|
||||||
|
/// (not dropped) so the parent edge written at `InitConfig` approval
|
||||||
|
/// survives until the first apply-commit, but they are NOT seeded with a
|
||||||
|
/// default parent here and NOT added to roles — that happens when the
|
||||||
|
/// container actually spawns and the name moves into `agent_names`.
|
||||||
|
pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result<bool> {
|
||||||
|
let (next, changed) = apply_reconcile(&read(), agent_names, pending);
|
||||||
|
if changed {
|
||||||
|
write(&next)?;
|
||||||
|
}
|
||||||
|
// Keep roles in sync with the live agent set (pending agents get
|
||||||
|
// roles when they spawn).
|
||||||
|
reconcile_roles(agent_names)?;
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure form of [`reconcile`] for unit tests. Adds missing live agents
|
||||||
|
/// at their default position, drops entries for agents that are neither
|
||||||
|
/// live nor pending-init, and reports whether anything changed.
|
||||||
|
#[must_use]
|
||||||
|
pub fn apply_reconcile(
|
||||||
|
current: &BTreeMap<String, Option<String>>,
|
||||||
|
agent_names: &[String],
|
||||||
|
pending: &[String],
|
||||||
|
) -> (BTreeMap<String, Option<String>>, bool) {
|
||||||
|
let mut next = current.clone();
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
// Add missing agents at their default position.
|
|
||||||
for name in agent_names {
|
for name in agent_names {
|
||||||
if !current.contains_key(name) {
|
if !next.contains_key(name) {
|
||||||
let parent = if name == crate::lifecycle::MANAGER_NAME {
|
let parent = if name == crate::lifecycle::MANAGER_NAME {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(crate::lifecycle::MANAGER_NAME.to_owned())
|
Some(crate::lifecycle::MANAGER_NAME.to_owned())
|
||||||
};
|
};
|
||||||
current.insert(name.clone(), parent);
|
next.insert(name.clone(), parent);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Drop entries for agents that no longer exist.
|
let known: std::collections::HashSet<&String> =
|
||||||
let known: std::collections::HashSet<_> = agent_names.iter().collect();
|
agent_names.iter().chain(pending.iter()).collect();
|
||||||
current.retain(|name, _| {
|
next.retain(|name, _| {
|
||||||
let keep = known.contains(name);
|
let keep = known.contains(name);
|
||||||
if !keep {
|
if !keep {
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
keep
|
keep
|
||||||
});
|
});
|
||||||
if changed {
|
(next, changed)
|
||||||
write(¤t)?;
|
|
||||||
}
|
|
||||||
// Keep roles in sync with the agent set.
|
|
||||||
reconcile_roles(agent_names)?;
|
|
||||||
Ok(changed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -562,6 +628,90 @@ mod tests {
|
||||||
assert_eq!(next, topo_three_level());
|
assert_eq!(next, topo_three_level());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_add_child_inserts_new_edge_under_parent() {
|
||||||
|
// alice spawns a brand-new child `dora`: the edge lands
|
||||||
|
// with alice as parent without disturbing the rest of the tree.
|
||||||
|
let next = apply_add_child(&topo_three_level(), "dora", "alice")
|
||||||
|
.unwrap()
|
||||||
|
.expect("brand-new edge should produce a map");
|
||||||
|
assert_eq!(next.get("dora"), Some(&Some("alice".to_owned())));
|
||||||
|
// existing entries untouched.
|
||||||
|
assert_eq!(next.get("bob"), Some(&Some("alice".to_owned())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_add_child_is_idempotent_when_edge_exists() {
|
||||||
|
// bob already under alice — re-init returns a no-op (None).
|
||||||
|
assert!(
|
||||||
|
apply_add_child(&topo_three_level(), "bob", "alice")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_add_child_refuses_unknown_parent() {
|
||||||
|
let err = apply_add_child(&topo_three_level(), "dora", "nobody").unwrap_err();
|
||||||
|
assert!(err.contains("unknown parent"), "err = {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_add_child_refuses_name_owned_by_other_parent() {
|
||||||
|
// bob lives under alice; the manager can't claim it via add_child.
|
||||||
|
let err = apply_add_child(&topo_three_level(), "bob", crate::lifecycle::MANAGER_NAME)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("already exists"), "err = {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_reconcile_adds_missing_live_agent_under_manager() {
|
||||||
|
let live = vec![
|
||||||
|
crate::lifecycle::MANAGER_NAME.to_owned(),
|
||||||
|
"newbie".to_owned(),
|
||||||
|
];
|
||||||
|
let (next, changed) = apply_reconcile(&BTreeMap::new(), &live, &[]);
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(
|
||||||
|
next.get("newbie"),
|
||||||
|
Some(&Some(crate::lifecycle::MANAGER_NAME.to_owned()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_reconcile_drops_vanished_agent() {
|
||||||
|
let live = vec![
|
||||||
|
crate::lifecycle::MANAGER_NAME.to_owned(),
|
||||||
|
"alice".to_owned(),
|
||||||
|
];
|
||||||
|
// carol + bob are gone from the live set and not pending.
|
||||||
|
let (next, changed) = apply_reconcile(&topo_three_level(), &live, &[]);
|
||||||
|
assert!(changed);
|
||||||
|
assert!(!next.contains_key("bob"));
|
||||||
|
assert!(!next.contains_key("carol"));
|
||||||
|
assert!(next.contains_key("alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_reconcile_keeps_pending_init_agent_edge() {
|
||||||
|
// `dora` was init'd under alice (edge present) but has no
|
||||||
|
// container yet, so it's absent from the live set. It must NOT
|
||||||
|
// be dropped, and its alice-parent edge must be preserved (not
|
||||||
|
// re-seeded under the manager).
|
||||||
|
let mut topo = topo_three_level();
|
||||||
|
topo.insert("dora".to_owned(), Some("alice".to_owned()));
|
||||||
|
let live = vec![
|
||||||
|
crate::lifecycle::MANAGER_NAME.to_owned(),
|
||||||
|
"alice".to_owned(),
|
||||||
|
"bob".to_owned(),
|
||||||
|
"carol".to_owned(),
|
||||||
|
];
|
||||||
|
let pending = vec!["dora".to_owned()];
|
||||||
|
let (next, changed) = apply_reconcile(&topo, &live, &pending);
|
||||||
|
assert!(!changed, "no change expected: {next:?}");
|
||||||
|
assert_eq!(next.get("dora"), Some(&Some("alice".to_owned())));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_recipient_passes_through_ordinary_names() {
|
fn resolve_recipient_passes_through_ordinary_names() {
|
||||||
let topo = topo_three_level();
|
let topo = topo_three_level();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue