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:
parent
3700167279
commit
6a1a349a71
3 changed files with 141 additions and 23 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue