From 6a1a349a7117f920a5332eb0ab65dc662957db7e Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 19 Aug 2026 21:27:08 +0200 Subject: [PATCH] feat(swarm-controller): serve the agent roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- swarm-authelia-bridge/src/main.rs | 7 +- swarm-controller/src/auth.rs | 50 +++++++++++--- swarm-controller/src/main.rs | 107 +++++++++++++++++++++++++++--- 3 files changed, 141 insertions(+), 23 deletions(-) diff --git a/swarm-authelia-bridge/src/main.rs b/swarm-authelia-bridge/src/main.rs index cfbcbf53..c80be1c4 100644 --- a/swarm-authelia-bridge/src/main.rs +++ b/swarm-authelia-bridge/src/main.rs @@ -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; diff --git a/swarm-controller/src/auth.rs b/swarm-controller/src/auth.rs index fcc759ba..cbc1cd2e 100644 --- a/swarm-controller/src/auth.rs +++ b/swarm-controller/src/auth.rs @@ -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 { + 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> { + 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 { // `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")?; diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 0e939810..e3d95e69 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -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>>, + /// 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>, /// 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) -> Json> { 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), + (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) -> Result>, 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::::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