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

@ -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")?;