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
|
//! `swarm-authelia.nix`'s `unitName` already derives) — the same user
|
||||||
//! authelia's own instance runs as, and therefore the file's actual
|
//! authelia's own instance runs as, and therefore the file's actual
|
||||||
//! owner. That is the whole answer to "how does an unprivileged
|
//! 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
|
//! process does, sidestepping the uid boundary instead of bridging it
|
||||||
//! with root/`CAP_CHOWN`/a shared group (all examined and rejected — see
|
//! with root/`CAP_CHOWN`/a shared group (all examined and rejected — see
|
||||||
//! `swarmctl/README.md`'s own identical analysis of this same file).
|
//! `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
|
//! exists as an authelia subject, and list the ones that do. Not a
|
||||||
//! wholesale-replace-the-file API — this process owns rendering
|
//! wholesale-replace-the-file API — this process owns rendering
|
||||||
//! `users.yml` internally; see `store` module.
|
//! `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 introspect;
|
||||||
mod store;
|
mod store;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Client for `swarm-authelia-bridge` — the only writer of the swarm's
|
//! Client for `swarm-authelia-bridge` — this daemon's only access to the
|
||||||
//! authelia users database (see that crate's README for why this daemon
|
//! swarm's authelia users database, in either direction (see that crate's
|
||||||
//! cannot write it directly).
|
//! README for why the file cannot be touched from here).
|
||||||
//!
|
//!
|
||||||
//! Authenticated with THIS daemon's own queue OIDC identity
|
//! Authenticated with THIS daemon's own queue OIDC identity
|
||||||
//! (`SWARM_CONTROLLER_OIDC_*`, the same one `swarm-queue-client` mints for
|
//! (`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
|
//! `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
|
//! exists on hosts that also run `swarm-authelia`, so a controller split
|
||||||
//! from it simply has no identity-creation capability yet
|
//! from it can neither create an identity nor read the roster.
|
||||||
//! (`SwarmNodeKind::CreateIdentity` fails such a job explicitly rather
|
//!
|
||||||
//! than this module papering over the gap).
|
//! 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 anyhow::{Context, Result};
|
||||||
use swarm_authelia_bridge_sock::{BridgeRequest, BridgeResponse};
|
use swarm_authelia_bridge_sock::{BridgeRequest, BridgeResponse};
|
||||||
|
|
@ -59,6 +62,37 @@ impl AuthBridge {
|
||||||
|
|
||||||
/// Idempotently ensure `name` exists as an authelia subject.
|
/// Idempotently ensure `name` exists as an authelia subject.
|
||||||
pub async fn ensure_agent_identity(&self, name: &str) -> Result<BridgeResponse> {
|
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
|
// `mint_token_for` builds its own HTTP client (trusting the queue's
|
||||||
// configured CA, if any) — deliberately not `self.http`, which is
|
// configured CA, if any) — deliberately not `self.http`, which is
|
||||||
// the bridge's own client and has nothing to do with authelia's
|
// the bridge's own client and has nothing to do with authelia's
|
||||||
|
|
@ -71,9 +105,7 @@ impl AuthBridge {
|
||||||
.http
|
.http
|
||||||
.post(format!("{}/requests", self.base_url))
|
.post(format!("{}/requests", self.base_url))
|
||||||
.bearer_auth(token)
|
.bearer_auth(token)
|
||||||
.json(&BridgeRequest::EnsureAgentIdentity {
|
.json(req)
|
||||||
name: name.to_owned(),
|
|
||||||
})
|
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("calling swarm-authelia-bridge")?;
|
.context("calling swarm-authelia-bridge")?;
|
||||||
|
|
|
||||||
|
|
@ -338,14 +338,22 @@ struct AppState {
|
||||||
/// is synchronous (no `.await` while held). Always present, never
|
/// is synchronous (no `.await` while held). Always present, never
|
||||||
/// gated on the swarm queue: this is process state, not something
|
/// gated on the swarm queue: this is process state, not something
|
||||||
/// read over the network.
|
/// 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>>>,
|
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.
|
/// HMAC secret for swarm-wide forge webhooks, loaded once at startup.
|
||||||
/// `None` when it could not be read or created — the webhook endpoint
|
/// `None` when it could not be read or created — the webhook endpoint
|
||||||
/// then refuses every delivery with 503 rather than admitting one it
|
/// 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())
|
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
|
/// One quick link to a swarm-wide service (authelia, matrix, forge, this
|
||||||
/// daemon's own swagger UI, …). Deliberately generic rather than named
|
/// daemon's own swagger UI, …). Deliberately generic rather than named
|
||||||
/// fields per service: each service's own nix module contributes its own
|
/// fields per service: each service's own nix module contributes its own
|
||||||
|
|
@ -988,7 +1035,7 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let deps = WorkerDeps {
|
let deps = WorkerDeps {
|
||||||
auth,
|
auth: auth.clone(),
|
||||||
forge: forge_client.clone(),
|
forge: forge_client.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1031,6 +1078,7 @@ async fn main() -> Result<()> {
|
||||||
webhook_secret,
|
webhook_secret,
|
||||||
config_prs,
|
config_prs,
|
||||||
swarm_name: load_swarm_name().map(Arc::from),
|
swarm_name: load_swarm_name().map(Arc::from),
|
||||||
|
auth,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
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_agent_config_pr))
|
||||||
.routes(routes!(get_config_prs))
|
.routes(routes!(get_config_prs))
|
||||||
.routes(routes!(create_agent))
|
.routes(routes!(create_agent))
|
||||||
|
.routes(routes!(get_agents))
|
||||||
.routes(routes!(webhook::post_webhook_forge))
|
.routes(routes!(webhook::post_webhook_forge))
|
||||||
.split_for_parts();
|
.split_for_parts();
|
||||||
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
|
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
|
||||||
|
|
@ -1151,10 +1200,52 @@ mod tests {
|
||||||
webhook_secret: None,
|
webhook_secret: None,
|
||||||
config_prs: None,
|
config_prs: None,
|
||||||
swarm_name: 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)
|
(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
|
/// 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
|
/// 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
|
/// that is not in this swarm must be refused **before anything is
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue