From 8b6dc7252683c5d98f67498b08c448fab18b0e6b Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 21 Sep 2026 19:24:15 +0200 Subject: [PATCH] swarm-controller: pin what the backfill route queues The mint route shipped without tests while the create route beside it has three, so the two properties that make it a backfill rather than a second creation route were unasserted: that it refuses before queuing, and that it queues the mint node and nothing else. Queuing a config-repo scaffold or a deploy against an agent that already exists is the failure the second of those catches, and it is invisible from the status code -- a version that queued the whole creation graph would answer 200 with a node id just the same. The agent name gets its own arm. create_agent's hive is checked against the swarm roster, which incidentally rejects a name that is not an identifier; an agent name has no roster to check against, so the parse is the only thing between a traversal and a store path built out of it. MintAgentIdentityResponse derives Clone + Debug to match CreateAgentResponse -- expect_err on the refusal arms needs Debug on the success type. --- swarm-controller/src/main.rs | 103 ++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 3621ad39..04f69945 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -1515,7 +1515,7 @@ struct MintAgentIdentityRequest { } /// Success body of `POST /api/agents/{name}/identity`. -#[derive(Serialize, ToSchema)] +#[derive(Clone, Debug, Serialize, ToSchema)] struct MintAgentIdentityResponse { /// The queued node, so a caller can follow it in the job view. node_id: u64, @@ -2176,6 +2176,107 @@ mod tests { assert!(queued > 0, "an accepted creation must queue work"); } + /// The backfill route's roster check, asserted by effect for the same + /// reason its sibling above is: a refusal that queued first would still + /// re-mint the agent's certificate, which every running agent on the + /// named hive picks up on its next boot. + #[tokio::test] + async fn a_backfill_for_a_hive_outside_the_roster_queues_nothing() { + let (state, sched) = state_with_roster(); + + let err = super::mint_agent_identity( + axum::extract::State(state), + axum::extract::Path("atlas".to_owned()), + axum::Json(super::MintAgentIdentityRequest { + hive: "pr1maa".to_owned(), + }), + ) + .await + .expect_err("a hive outside the roster must be refused"); + + let rendered = format!("{err:?}"); + assert!( + rendered.contains("pr1ma"), + "the refusal should name the known hives, got: {rendered}" + ); + + let queued = sched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .graph() + .nodes() + .count(); + assert_eq!(queued, 0, "a refused backfill must queue no work"); + } + + /// A name that is not an identifier is refused before it can reach a + /// store path. `create_agent` gets this for free from the roster check + /// on the hive; the agent name has no roster to check against, so this + /// is the only thing standing between `../beta` and a path built out of + /// it. + #[tokio::test] + async fn a_backfill_for_a_name_that_is_not_an_identifier_queues_nothing() { + let (state, sched) = state_with_roster(); + + super::mint_agent_identity( + axum::extract::State(state), + axum::extract::Path("../beta".to_owned()), + axum::Json(super::MintAgentIdentityRequest { + hive: "pr1ma".to_owned(), + }), + ) + .await + .expect_err("a traversal in the agent name must be refused"); + + let queued = sched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .graph() + .nodes() + .count(); + assert_eq!(queued, 0, "a refused backfill must queue no work"); + } + + /// The accept arm, and the property that makes this a *backfill* route + /// rather than a second creation route: it queues the mint node **and + /// nothing else**. `create_agent`'s graph also scaffolds a config repo + /// and publishes a deploy; running either of those against an agent that + /// already exists is the failure this assertion exists to catch. + #[tokio::test] + async fn a_backfill_queues_the_mint_node_and_only_the_mint_node() { + let (state, sched) = state_with_roster(); + + let queued = super::mint_agent_identity( + axum::extract::State(state), + axum::extract::Path("atlas".to_owned()), + axum::Json(super::MintAgentIdentityRequest { + hive: "pr1ma".to_owned(), + }), + ) + .await + .expect("a hive in the roster must be accepted"); + + let guard = sched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let nodes: Vec<_> = guard.graph().nodes().collect(); + let [node] = nodes[..] else { + panic!("a backfill queues exactly one node, got {}", nodes.len()); + }; + assert!( + matches!( + &node.payload, + SwarmNodeKind::MintAgentIdentity { hive, agent } + if hive == "pr1ma" && agent == "atlas" + ), + "the one node must be the mint, carrying both names: {:?}", + node.payload + ); + // The id the operator is told to watch has to be the node that was + // actually inserted, or `swarmctl` prints a handle to nothing. + assert_eq!(queued.node_id, node.id.get()); + } + #[tokio::test] async fn create_repo_node_runs_end_to_end_and_fails_without_forge_configured() { unsafe {