From 207484dfa627c8894f05f290160b6f841c16ca2d Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 13 Oct 2026 00:42:27 +0200 Subject: [PATCH] swarmctl: drop `agent mint-identity` and its controller route Re-creating an agent mints its store identity the same way, so the backfill verb and the `POST /api/agents/{name}/identity` route that only it called go. The `MintAgentIdentity` job node stays: agent creation and the renewal pass still queue it. Part of #4765 (c). --- docs/getting-started/setup.md | 2 +- docs/swarm/credentials.md | 31 +------- docs/tools/swarmctl-cli.md | 26 ------- swarm-controller/src/main.rs | 135 ---------------------------------- swarmctl/README.md | 13 ---- swarmctl/src/agent.rs | 39 +--------- swarmctl/src/main.rs | 62 ---------------- 7 files changed, 6 insertions(+), 302 deletions(-) diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 6ccc60b3..dd7f4791 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -95,7 +95,7 @@ hive-c0re creates the manager agent, ruth, on its own at startup — so unlike agents made through the swarm, she has no store identity yet: ```bash -swarmctl agent mint-identity ruth # on the swarm-controller's host +swarmctl agent create ruth --hive # on the swarm-controller's host hivectl agent ruth rebuild # on ruth's hive: hands her the identity ``` diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index f1125789..7dfeb09e 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -102,27 +102,7 @@ carries it, and no hive ever needs the capability to mint an identity. `swarm-controller` proves the leaf it publishes before the creation job reports success, by logging in with it and reading the row back. -**Backfilling an agent that predates a credential.** Agent creation at swarm -level is purely event-driven — `swarm-controller` mints an agent's store -identity on the job graph `POST /api/agents` inserts, and nothing sweeps for -agents that already exist. An agent created before a credential joined that -mint therefore never receives one, and nothing will ever come back around to -it. Re-run the mint for one agent with: - -```sh -swarmctl agent mint-identity -``` - -The queue secret half is idempotent — an agent that already has one keeps exactly the -value it holds, so running this against an already-migrated agent doesn't drop -its queue connection. The certificate half isn't: the agent gets a fresh leaf -and picks it up on its next boot. - -The renewal pass in the table doesn't replace this step: it only re-issues a -certificate or re-mints a queue secret that already exists, and only for an -agent some hive's wanted state declares as anything but `destroyed`. - -The forge token needs no such step: `swarm-controller` checks every agent +`swarm-controller` checks every forge token itself: it looks at every agent that has a store identity at start and every five minutes. For any whose stored token is missing or stale it creates the forge user if there isn't one, then mints the token. To check one agent now: @@ -132,13 +112,8 @@ swarmctl agent mint-forge-token ``` An agent without a store identity gets no swarm token and keeps using the -`forge-token` file in its state dir, if it has one; run `mint-identity` for it -first. - -⚠️ **Run this for every existing agent before deploying a hive-side change -that makes a container require a credential it may not have.** A container -whose credential is absent doesn't start — that's deliberate, and it's what -makes the backfill a step rather than a suggestion. +`forge-token` file in its state dir, if it has one; re-create it with +`swarmctl agent create` to give it one. **Revoking an agent's queue credential.** Declaring an agent `destroyed` — `PUT /api/hives/{hive}/agents/{agent}/state` — deletes diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index aad42920..a769ec59 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -7,7 +7,6 @@ This document contains the help content for the `swarmctl` command-line program. * [`swarmctl`↴](#swarmctl) * [`swarmctl agent`↴](#swarmctl-agent) * [`swarmctl agent create`↴](#swarmctl-agent-create) -* [`swarmctl agent mint-identity`↴](#swarmctl-agent-mint-identity) * [`swarmctl agent mint-forge-token`↴](#swarmctl-agent-mint-forge-token) * [`swarmctl agent inference`↴](#swarmctl-agent-inference) * [`swarmctl agent inference get`↴](#swarmctl-agent-inference-get) @@ -65,7 +64,6 @@ Manage agents across the swarm ###### **Subcommands:** * `create` — Queue creation of a new agent on a hive in this swarm -* `mint-identity` — Queue a re-mint of an existing agent's identity at the swarm's secret store * `mint-forge-token` — Check one agent's forge token, and mint it if it's missing or stale * `inference` — Show or change the model, effort and subagent models the swarm sets for an agent @@ -100,30 +98,6 @@ No approval gate guards this: the caller is root on the controller's host, or lo -## `swarmctl agent mint-identity` - -Queue a re-mint of an existing agent's identity at the swarm's secret store. - -**The backfill verb.** Agent creation is event-driven and nothing at swarm level sweeps for agents that are missing a credential, so an agent created before a credential joined the mint never receives one. This re-runs the mint for one agent that already exists. - -⚠️ **It re-mints the agent's store certificate**, which that agent picks up the next time its container boots. It leaves an existing queue secret exactly as it stands, so running this against an already-migrated agent doesn't disturb its queue connection. - -Queues and returns, the same way `agent create` does — watch the swarm UI's job view for the outcome. - -**Usage:** `swarmctl agent mint-identity [OPTIONS] ` - -###### **Arguments:** - -* `` — Name of an agent that already exists - -###### **Options:** - -* `--controller-socket ` — swarm-controller's unix socket. - - Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`. - - - ## `swarmctl agent mint-forge-token` Check one agent's forge token, and mint it if it's missing or stale. diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index c57244af..874e09c0 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -2312,79 +2312,6 @@ async fn get_agent_config_pr( Ok(Json(cache.get(&name))) } -/// Success body of `POST /api/agents/{name}/identity`. -#[derive(Clone, Debug, Serialize, ToSchema)] -struct MintAgentIdentityResponse { - /// The queued node, so a caller can follow it in the job view. - node_id: u64, -} - -/// Re-run the store-identity mint for an agent that already exists. -/// -/// **This route exists for backfill.** Agent creation is event-driven and -/// there is no reconcile sweep at swarm level, so an agent created before a -/// credential was part of the mint never gets one — nothing would ever come -/// back around to it. Without a way to re-run the node by hand, the only way -/// to give an existing agent its queue credential would be to delete and -/// recreate the agent. -/// -/// A job node rather than an inline write, and specifically the *same* node -/// `POST /api/agents` declares: a second code path that mints an identity is a -/// second place for the four strings in [`agent_identity`] to disagree. -/// -/// ⚠️ **Not idempotent in one respect**: the node re-mints the agent's mTLS -/// leaf, which the agent picks up on its next boot. Its queue secret is left -/// alone — see [`agent_identity::mint_and_verify`] for which half is which and -/// why. -#[utoipa::path( - post, - path = "/api/agents/{name}/identity", - params(("name" = String, Path, description = "agent name")), - responses( - (status = 200, description = "mint queued", body = MintAgentIdentityResponse), - (status = 400, description = "`name` is not a valid identifier (problem+json)", body = String), - (status = 500, description = "the job could not be queued (problem+json)", body = String), - ), - tag = "agents" -)] -async fn mint_agent_identity( - State(state): State, - Path(name): Path, -) -> Result, problem_details::ProblemDetails> { - // The name is interpolated into store paths and policy documents - // downstream, so it is validated here as well as there. - let agent = hive_types::Ident::parse(&name) - .map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))? - .into_string(); - - // No reserved-name or collision warnings here, unlike `create_agent`: - // those answer "is this name available", and this route is only ever - // pointed at a name that was already taken. - let mut sched = state - .jobq - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let ids = sched - .insert_job(None, |b| { - vec![ - b.node(SwarmNodeKind::MintAgentIdentity { - agent: agent.clone(), - }) - .guid(), - ] - }) - .map_err(|e| { - error_problem( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - &e.to_string(), - ) - })?; - let [id] = ids[..] else { - unreachable!("exactly one handle was asked for"); - }; - Ok(Json(MintAgentIdentityResponse { node_id: id.get() })) -} - /// Success body of `POST /api/agents/{name}/forge-token`. #[derive(Clone, Debug, Serialize, ToSchema)] struct MintAgentForgeTokenResponse { @@ -3312,7 +3239,6 @@ fn build_app(state: AppState) -> axum::Router { )) .routes(routes!(get_config_prs)) .routes(routes!(create_agent)) - .routes(routes!(mint_agent_identity)) .routes(routes!(mint_agent_forge_token)) .routes(routes!(make_forge_admin)) .routes(routes!(get_agents)) @@ -4286,67 +4212,6 @@ mod tests { assert_eq!(resp.change, super::ForgeAdminChange::AlreadyAdmin); } - /// 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()), - ) - .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()), - ) - .await - .expect("a valid agent name 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 { agent } if agent == "atlas" - ), - "the one node must be the mint, for the named agent: {:?}", - 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()); - } - /// Every node whose first request goes to the forge, run against a forge /// that accepts the connection and never answers: each ends `Failed` /// with an error naming the node instead of holding its executor slot. diff --git a/swarmctl/README.md b/swarmctl/README.md index 88c2a547..e6525ae6 100644 --- a/swarmctl/README.md +++ b/swarmctl/README.md @@ -117,19 +117,6 @@ crate does not link it, and there is no wire-type crate between them. Two fields out, two in, both ends validating — a drift shows up as a 400 naming the field. -## `agent mint-identity` - -```console -# swarmctl agent mint-identity ruth -``` - -`POST /api/agents/{name}/identity` on the swarm-controller. Re-runs the -store-identity mint for one agent that already exists — the backfill for an -agent the swarm never created, such as the manager agent `hive-c0re` makes at -startup. It re-mints the agent's store certificate, which the agent picks up -the next time its container boots, and leaves an existing queue secret alone. -Queues and returns, like `agent create`. - ## `agent mint-forge-token` ```console diff --git a/swarmctl/src/agent.rs b/swarmctl/src/agent.rs index 315c34bf..60b43f5e 100644 --- a/swarmctl/src/agent.rs +++ b/swarmctl/src/agent.rs @@ -1,5 +1,5 @@ -//! `swarmctl agent create`, `swarmctl agent mint-identity` and `swarmctl agent -//! mint-forge-token` — queue work on the swarm-controller's job graph. +//! `swarmctl agent create` and `swarmctl agent mint-forge-token` — queue work +//! on the swarm-controller's job graph. //! //! Each POSTs and returns as soon as the work is *inserted*. Both verbs //! print the queued node id and stop: creation's last node only *publishes* @@ -53,12 +53,6 @@ struct CreateAgentResponse { warnings: Vec, } -/// Success body of `POST /api/agents/{name}/identity`. -#[derive(Deserialize)] -struct MintIdentityResponse { - node_id: u64, -} - /// Success body of `POST /api/agents/{name}/forge-token`, which takes no body. #[derive(Deserialize)] struct MintForgeTokenResponse { @@ -108,35 +102,6 @@ pub(crate) fn parse_ident(value: &str, what: &str) -> Result { .map_err(|reason| anyhow::anyhow!("invalid {what} {value:?}: {reason}")) } -/// Run `swarmctl agent mint-identity`. -/// -/// Synchronous for the same reason [`create`] is, and built on the same -/// round trip. -pub(crate) fn mint_identity(target: &Target, name: &str) -> Result<()> { - // Client-side first, so a typo is a local error rather than a 400 the - // operator waits for. The controller validates it again. - let name = parse_ident(name, "agent name")?; - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .context("starting a tokio runtime for the controller request")?; - let resp: MintIdentityResponse = rt.block_on(post( - target, - &format!("/api/agents/{name}/identity"), - &serde_json::json!({}), - "mint-identity", - ))?; - - println!("queued: job node {}", resp.node_id); - println!( - "agent {name:?} will have its identity re-minted once the job graph runs; `swarmctl` \ - does not wait for it. An existing queue secret is kept as it is; the store \ - certificate is re-minted and the agent picks the new one up on its next boot" - ); - Ok(()) -} - /// Run `swarmctl agent mint-forge-token`. /// /// Synchronous for the same reason [`create`] is, and built on the same diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index 84edc540..b2836536 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -237,22 +237,6 @@ enum AgentVerb { /// No approval gate guards this: the caller is root on the controller's /// host, or logged in through the swarm domain's SSO gate. Create(AgentCreateArgs), - /// Queue a re-mint of an existing agent's identity at the swarm's - /// secret store. - /// - /// **The backfill verb.** Agent creation is event-driven and nothing at - /// swarm level sweeps for agents that are missing a credential, so an - /// agent created before a credential joined the mint never receives one. - /// This re-runs the mint for one agent that already exists. - /// - /// ⚠️ **It re-mints the agent's store certificate**, which that agent - /// picks up the next time its container boots. It leaves an existing - /// queue secret exactly as it stands, so running this against an - /// already-migrated agent doesn't disturb its queue connection. - /// - /// Queues and returns, the same way `agent create` does — watch the - /// swarm UI's job view for the outcome. - MintIdentity(AgentMintIdentityArgs), /// Check one agent's forge token, and mint it if it's missing or stale. /// /// swarm-controller does this for every agent with a store identity at @@ -353,19 +337,6 @@ struct AgentMintForgeTokenArgs { controller_socket: Option, } -#[derive(Args)] -struct AgentMintIdentityArgs { - /// Name of an agent that already exists. - name: String, - /// swarm-controller's unix socket. - /// - /// Supplied by the nix module that installs this binary, from the same - /// `socketPath` option the daemon binds; falls back to - /// `SWARM_CONTROLLER_SOCKET`. - #[arg(long, value_name = "PATH")] - controller_socket: Option, -} - #[derive(Subcommand)] enum ForgeVerb { /// Make an existing forge user a site admin. @@ -589,13 +560,6 @@ fn main() -> Result<()> { agent::create(&target, &args.name, &args.hive) } // Same socket-resolution reasoning as `Create` above. - Verb::Agent { - command: AgentVerb::MintIdentity(args), - } => { - let target = remote.target(args.controller_socket)?; - agent::mint_identity(&target, &args.name) - } - // Same socket-resolution reasoning as `Create` above. Verb::Agent { command: AgentVerb::MintForgeToken(args), } => { @@ -1141,32 +1105,6 @@ mod tests { assert!(args.controller_socket.is_none()); } - #[test] - fn the_backfill_verb_takes_an_agent_and_no_hive() { - let cli = Cli::try_parse_from(["swarmctl", "agent", "mint-identity", "scribe"]) - .expect("the minimal form parses"); - let Verb::Agent { - command: AgentVerb::MintIdentity(args), - } = cli.command - else { - panic!("expected `agent mint-identity`"); - }; - assert_eq!(args.name, "scribe"); - assert!(args.controller_socket.is_none()); - assert!( - Cli::try_parse_from([ - "swarmctl", - "agent", - "mint-identity", - "scribe", - "--hive", - "a" - ]) - .is_err(), - "the identity has no hive, so the verb must not take one" - ); - } - #[test] fn the_forge_token_verb_takes_an_agent_and_no_hive() { let cli = Cli::try_parse_from(["swarmctl", "agent", "mint-forge-token", "scribe"])