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).
This commit is contained in:
parent
cf763da178
commit
207484dfa6
7 changed files with 6 additions and 302 deletions
|
|
@ -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 <hive> # on the swarm-controller's host
|
||||
hivectl agent ruth rebuild # on ruth's hive: hands her the identity
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <agent>
|
||||
```
|
||||
|
||||
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 <agent>
|
|||
```
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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] <NAME>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<NAME>` — Name of an agent that already exists
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--controller-socket <PATH>` — 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.
|
||||
|
|
|
|||
|
|
@ -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<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<MintAgentIdentityResponse>, 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String> {
|
|||
.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
|
||||
|
|
|
|||
|
|
@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
#[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<PathBuf>,
|
||||
}
|
||||
|
||||
#[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"])
|
||||
|
|
|
|||
Loading…
Reference in a new issue