feat(swarm-controller): serve the agent roster

GET /api/agents, beside the POST that creates one. The identity store is
the roster rather than a source to assemble one from, so this is a read
with nothing to merge or reconcile.

It says nothing about health, deliberately. A roster is the set other views
are complete against — it is what makes "this agent has never reported"
expressible, and that only survives while the declared set and the reported
set stay apart.

The two verbs treat a missing bridge differently and the asymmetry is the
design: POST queues a job that fails loud when claimed, GET has nowhere to
defer to and returns 503. Answering [] there would render a store nobody
could read as a swarm with no agents.
This commit is contained in:
atlas 2026-08-19 21:27:08 +02:00
commit 6a1a349a71
3 changed files with 141 additions and 23 deletions

View file

@ -6,7 +6,7 @@
//! `swarm-authelia.nix`'s `unitName` already derives) — the same user
//! authelia's own instance runs as, and therefore the file's actual
//! owner. That is the whole answer to "how does an unprivileged
//! `swarm-controller` write a file it doesn't own": it doesn't — this
//! `swarm-controller` touch a file it doesn't own": it doesn't — this
//! process does, sidestepping the uid boundary instead of bridging it
//! with root/`CAP_CHOWN`/a shared group (all examined and rejected — see
//! `swarmctl/README.md`'s own identical analysis of this same file).
@ -27,11 +27,6 @@
//! exists as an authelia subject, and list the ones that do. Not a
//! wholesale-replace-the-file API — this process owns rendering
//! `users.yml` internally; see `store` module.
//!
//! The read is here rather than in its caller for the same reason the
//! write is: the store is owned by a uid `swarm-controller` does not have,
//! and a caller that opened the file itself would be a second parser of a
//! format this process owns.
mod introspect;
mod store;

View file

@ -1,6 +1,6 @@
//! Client for `swarm-authelia-bridge` — the only writer of the swarm's
//! authelia users database (see that crate's README for why this daemon
//! cannot write it directly).
//! Client for `swarm-authelia-bridge` — this daemon's only access to the
//! swarm's authelia users database, in either direction (see that crate's
//! README for why the file cannot be touched from here).
//!
//! Authenticated with THIS daemon's own queue OIDC identity
//! (`SWARM_CONTROLLER_OIDC_*`, the same one `swarm-queue-client` mints for
@ -13,9 +13,12 @@
//!
//! `None` when this deployment did not wire a bridge up — the bridge only
//! exists on hosts that also run `swarm-authelia`, so a controller split
//! from it simply has no identity-creation capability yet
//! (`SwarmNodeKind::CreateIdentity` fails such a job explicitly rather
//! than this module papering over the gap).
//! from it can neither create an identity nor read the roster.
//!
//! Each caller answers that absence in its own terms rather than this
//! module inventing a shared one: `SwarmNodeKind::CreateIdentity` fails
//! the job explicitly, and the roster endpoint refuses. Neither substitutes
//! an empty answer, which is the failure mode a default here would create.
use anyhow::{Context, Result};
use swarm_authelia_bridge_sock::{BridgeRequest, BridgeResponse};
@ -59,6 +62,37 @@ impl AuthBridge {
/// Idempotently ensure `name` exists as an authelia subject.
pub async fn ensure_agent_identity(&self, name: &str) -> Result<BridgeResponse> {
self.request(&BridgeRequest::EnsureAgentIdentity {
name: name.to_owned(),
})
.await
}
/// The swarm's agent roster, straight from the identity store.
///
/// Returns the names rather than the whole [`BridgeResponse`]: every
/// other variant is a protocol error for this request, and a caller that
/// had to match them would be free to treat one as an empty roster. An
/// empty roster and a bridge that answered something else are different
/// facts, and only one of them is a valid render.
pub async fn list_agent_identities(&self) -> Result<Vec<String>> {
match self.request(&BridgeRequest::ListAgentIdentities).await? {
BridgeResponse::Agents { agents } => {
Ok(agents.into_iter().map(|entry| entry.name).collect())
}
other => {
anyhow::bail!("swarm-authelia-bridge answered a roster request with {other:?}")
}
}
}
/// One authenticated round-trip to the bridge.
///
/// A fresh token per call, deliberately — see the module doc. Shared by
/// every operation so the auth and the error handling cannot drift
/// between them; a second copy is how one path ends up treating a 500 as
/// an answer.
async fn request(&self, req: &BridgeRequest) -> Result<BridgeResponse> {
// `mint_token_for` builds its own HTTP client (trusting the queue's
// configured CA, if any) — deliberately not `self.http`, which is
// the bridge's own client and has nothing to do with authelia's
@ -71,9 +105,7 @@ impl AuthBridge {
.http
.post(format!("{}/requests", self.base_url))
.bearer_auth(token)
.json(&BridgeRequest::EnsureAgentIdentity {
name: name.to_owned(),
})
.json(req)
.send()
.await
.context("calling swarm-authelia-bridge")?;

View file

@ -338,14 +338,22 @@ struct AppState {
/// is synchronous (no `.await` while held). Always present, never
/// gated on the swarm queue: this is process state, not something
/// read over the network.
/// **Not** consulted by `POST /api/agents` — that endpoint only ever
/// queues the job (see [`create_agent`]); whether a bridge is
/// configured is `run_swarm_node`'s concern (it holds its own clone,
/// handed to it by `spawn_jobq_worker`), not this handler's. A request
/// still queues cleanly on a bridge-less host, then fails loud once
/// claimed — same "queue now, fail per-job" shape as an unreachable
/// swarm queue.
jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
/// The identity store's only reader, for `GET /api/agents`.
///
/// `None` when no bridge is wired up, which is the one state in which
/// the roster cannot be answered at all — the same shape as `status`
/// above, and for the same reason: an empty roster and an unreadable
/// one are different facts.
///
/// ⚠️ The two `/api/agents` verbs treat this differently on purpose.
/// **POST does not consult it**: creation only queues a job, and
/// whether a bridge exists is `run_swarm_node`'s concern (it holds its
/// own clone from `spawn_jobq_worker`), so a request still queues
/// cleanly on a bridge-less host and fails loud once claimed. **GET
/// has nowhere to defer to** — there is no job, only an answer it
/// either has or does not.
auth: Option<Arc<auth::AuthBridge>>,
/// HMAC secret for swarm-wide forge webhooks, loaded once at startup.
/// `None` when it could not be read or created — the webhook endpoint
/// then refuses every delivery with 503 rather than admitting one it
@ -407,6 +415,45 @@ async fn get_hives(State(state): State<AppState>) -> Json<Vec<HiveEntry>> {
Json((*state.hives).clone())
}
/// The swarm's agent roster — every agent the swarm holds an identity for.
///
/// The identity store **is** the roster rather than one source to assemble
/// one from: an agent without a swarm identity is not a swarm agent, so
/// there is no hive-side list to merge in and no reconciliation to do.
///
/// Deliberately says nothing about health. A roster is the set other views
/// are *complete against* — it is what makes "this agent has never reported"
/// expressible at all, and that distinction only survives while the declared
/// set and the reported set stay separate.
///
/// A hive-less swarm answers `[]`; a swarm with no bridge answers 503. Those
/// are different facts and collapsing them would render an unreadable store
/// as an empty one.
#[utoipa::path(
get,
path = "/api/agents",
responses(
(status = 200, description = "every agent the swarm holds an identity for", body = Vec<String>),
(status = 503, description = "no identity bridge is configured here, or its store could not be read", body = String),
),
tag = "agents"
)]
async fn get_agents(State(state): State<AppState>) -> Result<Json<Vec<String>>, StatusUnavailable> {
let Some(bridge) = state.auth.as_ref() else {
return Err(StatusUnavailable(
"no identity bridge is configured on this host".to_owned(),
));
};
match bridge.list_agent_identities().await {
Ok(agents) => Ok(Json(agents)),
Err(e) => {
let detail = format!("{e:#}");
tracing::warn!(error = %detail, "reading the agent roster failed");
Err(StatusUnavailable(detail))
}
}
}
/// One quick link to a swarm-wide service (authelia, matrix, forge, this
/// daemon's own swagger UI, …). Deliberately generic rather than named
/// fields per service: each service's own nix module contributes its own
@ -988,7 +1035,7 @@ async fn main() -> Result<()> {
}
};
let deps = WorkerDeps {
auth,
auth: auth.clone(),
forge: forge_client.clone(),
};
@ -1031,6 +1078,7 @@ async fn main() -> Result<()> {
webhook_secret,
config_prs,
swarm_name: load_swarm_name().map(Arc::from),
auth,
};
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
@ -1044,6 +1092,7 @@ async fn main() -> Result<()> {
.routes(routes!(get_agent_config_pr))
.routes(routes!(get_config_prs))
.routes(routes!(create_agent))
.routes(routes!(get_agents))
.routes(routes!(webhook::post_webhook_forge))
.split_for_parts();
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
@ -1151,10 +1200,52 @@ mod tests {
webhook_secret: None,
config_prs: None,
swarm_name: None,
// No bridge: these tests drive agent *creation*, which queues a
// job and never consults one. The roster read is the verb that
// needs it, and it has its own test below.
auth: None,
};
(state, sched)
}
/// A swarm with no identity bridge cannot answer the roster, and must
/// say so rather than answer `[]`.
///
/// The distinction is the whole reason this endpoint exists: an empty
/// roster means *no agents*, and a UI that renders "no agents" for a
/// store it could not read is showing a fact nobody established. 503
/// rather than 500 for the same reason the status route uses it — a
/// bridge is wired up per deployment, so a caller retrying is right.
#[tokio::test]
async fn a_roster_with_no_bridge_refuses_rather_than_answering_empty() {
use axum::response::IntoResponse as _;
let (state, _sched) = state_with_roster();
assert!(state.auth.is_none(), "the fixture must have no bridge");
let err = super::get_agents(axum::extract::State(state))
.await
.expect_err("a bridge-less controller cannot produce a roster");
let resp = err.into_response();
assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.expect("body reads");
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("problem+json parses");
// Asserted on the rendered body rather than the error value: what a
// consumer can distinguish is what matters, and `[]` and this share
// a type on the Rust side.
assert_eq!(v["status"], 503);
assert!(
v["detail"]
.as_str()
.is_some_and(|d| d.contains("identity bridge")),
"the cause must name what is missing, got {:?}",
v["detail"]
);
}
/// The roster check is the half that makes the recorded hive worth
/// having, so assert it by EFFECT rather than by the message: a hive
/// that is not in this swarm must be refused **before anything is