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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue