swarm: serve an agent's icon at swarm scope
An agent is not fixed to a hive, so its icon cannot be resolved as hive -> agent. This adds the swarm-level half: an `agent-icons` KV bucket keyed by the agent name alone — no hive token, so an agent that moves hives keeps its icon and one that is stopped still has one — and `GET /api/agents/<name>/icon` on swarm-controller serving it same-origin, like every other `/api/*` route swarm-ui calls. 404 is the "this agent has no icon" answer, the same contract the per-agent harness's own `GET /icon` has for an unconfigured agent. Until the agent-side publisher lands, that is every agent's answer: the publisher runs inside the container and an agent's NATS grants are hive-scoped, which cannot authorise a write to a single-token agent key. The read side needs no grant change — the controller already holds `$KV.*.>` and `$JS.API.DIRECT.GET.*.>`. The response carries `Content-Security-Policy: sandbox` and `nosniff`: the body is an operator-authored SVG served from this daemon's own origin, and an SVG can carry script. Hive-side icon serving is untouched. Refs #4502
This commit is contained in:
parent
2e67656de8
commit
9513058a71
5 changed files with 317 additions and 1 deletions
|
|
@ -40,6 +40,7 @@ use swarm_authelia_bridge_sock::BridgeResponse;
|
|||
use utoipa::{OpenApi, ToSchema};
|
||||
use utoipa_axum::{router::OpenApiRouter, routes};
|
||||
|
||||
mod agent_icon;
|
||||
mod agent_identity;
|
||||
mod agent_state_stream;
|
||||
mod agent_status;
|
||||
|
|
@ -607,6 +608,10 @@ struct AppState {
|
|||
/// connection, several consumers" rationale as `wanted` above. `None`
|
||||
/// in exactly the state `status` is.
|
||||
agent_status: Option<Arc<agent_status::AgentStatusReader>>,
|
||||
/// Per-agent icons, sharing `status`'s connection — same "one queue
|
||||
/// connection, several consumers" rationale as `agent_status` above,
|
||||
/// and `None` in exactly the state `status` is.
|
||||
agent_icons: Option<Arc<agent_icon::AgentIconReader>>,
|
||||
/// The swarm-level job graph, wrapped in its
|
||||
/// [`hive_jobq::scheduler::Scheduler`] now that something drives it
|
||||
/// (`spawn_jobq_worker`) — the graph alone was enough for the
|
||||
|
|
@ -862,7 +867,8 @@ fn error_problem(status: axum::http::StatusCode, detail: &str) -> problem_detail
|
|||
problem_details::ProblemDetails::from_status_code(status).with_detail(detail)
|
||||
}
|
||||
|
||||
/// The status a [`wanted::WantedWriter`] failure should answer with.
|
||||
/// The status a [`wanted::WantedWriter`] or [`agent_icon::AgentIconReader`]
|
||||
/// failure should answer with.
|
||||
///
|
||||
/// `WantedWriter::view`/`set` call `ensure_connected` internally and
|
||||
/// propagate through `anyhow`, so the concrete
|
||||
|
|
@ -936,6 +942,16 @@ fn agent_status_reader(
|
|||
})
|
||||
}
|
||||
|
||||
/// The per-agent icon reader, sharing the status reader's connection —
|
||||
/// same rationale as [`wanted_writer`]. No staleness threshold, unlike
|
||||
/// [`agent_status_reader`]: an icon is a property of the agent rather than
|
||||
/// a report about it, so there is no cadence it could be late against.
|
||||
fn agent_icon_reader(
|
||||
status: Option<&Arc<status::StatusReader>>,
|
||||
) -> Option<Arc<agent_icon::AgentIconReader>> {
|
||||
status.map(|s| Arc::new(agent_icon::AgentIconReader::new(s.queue_client())))
|
||||
}
|
||||
|
||||
/// A hive name that is shaped like one and names a hive this swarm has.
|
||||
///
|
||||
/// Reports the status and the detail rather than a rendered
|
||||
|
|
@ -1996,6 +2012,95 @@ where
|
|||
Ok(Json(MakeForgeAdminResponse { change }))
|
||||
}
|
||||
|
||||
/// How long a served icon may be reused without asking again.
|
||||
///
|
||||
/// An agent roster asks once per row, and an icon changes about as often
|
||||
/// as an agent is reconfigured — so a few minutes of browser caching is
|
||||
/// what keeps a page render from being one bucket read per agent, and
|
||||
/// costs nothing anyone will notice.
|
||||
const ICON_MAX_AGE: u32 = 300;
|
||||
|
||||
/// `agent`'s icon — the swarm-level answer to "what does this agent look
|
||||
/// like".
|
||||
///
|
||||
/// **Nothing on this path names a hive.** The icon is a property of the
|
||||
/// agent, held at swarm scope under the agent's own key, so it is
|
||||
/// answerable for an agent that has moved hives or that no hive is
|
||||
/// currently running. That is the whole point of it living here rather
|
||||
/// than being fetched from wherever the agent happens to run.
|
||||
///
|
||||
/// **404 means this agent has no icon**, which is the expected
|
||||
/// unconfigured state and not an error — the same contract the per-agent
|
||||
/// harness's own `GET /icon` already has, so a caller falls back
|
||||
/// client-side on a failed load rather than probing first.
|
||||
///
|
||||
/// ⚠️ Until the agent-side publisher lands (see
|
||||
/// `swarm_queue_client::agent_icon`), that 404 is every agent's answer.
|
||||
///
|
||||
/// 🩸 **The body is an untrusted document served from this daemon's own
|
||||
/// origin**, and an SVG can carry script. The response headers say it may
|
||||
/// not run any: `Content-Security-Policy: sandbox` with no `allow-scripts`
|
||||
/// keeps a direct navigation to this URL from executing it, and `nosniff`
|
||||
/// keeps a browser from deciding the bytes are something else. An `<img>`
|
||||
/// load — the one consumer — never runs script regardless; the headers are
|
||||
/// for every other way a URL gets opened.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/agents/{name}/icon",
|
||||
params(("name" = String, Path, description = "agent name")),
|
||||
responses(
|
||||
(status = 200, description = "the agent's icon, an SVG", content_type = "image/svg+xml"),
|
||||
(status = 400, description = "not an identifier (problem+json)", body = String),
|
||||
(status = 404, description = "this agent has no icon (problem+json)", body = String),
|
||||
(status = 500, description = "the icon bucket could not be read (problem+json)", body = String),
|
||||
(status = 503, description = "no swarm queue is wired up, or it is not connected (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
async fn get_agent_icon(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<axum::response::Response, problem_details::ProblemDetails> {
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::IntoResponse as _;
|
||||
|
||||
let Some(reader) = state.agent_icons.as_ref() else {
|
||||
return Err(error_problem(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"no swarm queue is wired up on this host",
|
||||
));
|
||||
};
|
||||
let agent = hive_types::Ident::parse(&name)
|
||||
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
let icon = reader.get(&agent).await.map_err(|e| {
|
||||
tracing::warn!(agent = %agent, error = %format!("{e:#}"), "reading the agent icon failed");
|
||||
error_problem(wanted_error_status(&e), &format!("{e:#}"))
|
||||
})?;
|
||||
let Some(icon) = icon else {
|
||||
return Err(error_problem(
|
||||
StatusCode::NOT_FOUND,
|
||||
"this agent has no icon",
|
||||
));
|
||||
};
|
||||
Ok((
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
swarm_queue_client::agent_icon::MEDIA_TYPE.to_owned(),
|
||||
),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
format!("public, max-age={ICON_MAX_AGE}"),
|
||||
),
|
||||
(header::CONTENT_SECURITY_POLICY, "sandbox".to_owned()),
|
||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_owned()),
|
||||
],
|
||||
icon,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Every agent with an open config PR, in one response — the bulk
|
||||
/// counterpart to [`get_agent_config_pr`]. swarm-ui's config-PR table needs
|
||||
/// every agent's status to render, and fetching them one at a time doesn't
|
||||
|
|
@ -2303,6 +2408,7 @@ async fn main() -> Result<()> {
|
|||
links: Arc::new(load_links()),
|
||||
wanted: wanted_writer(status.as_ref()),
|
||||
agent_status: agent_status_reader(status.as_ref()),
|
||||
agent_icons: agent_icon_reader(status.as_ref()),
|
||||
status,
|
||||
jobq,
|
||||
webhook_secret,
|
||||
|
|
@ -2334,6 +2440,7 @@ fn build_app(state: AppState) -> axum::Router {
|
|||
.routes(routes!(get_jobq_graph))
|
||||
.routes(routes!(get_jobq_rollup))
|
||||
.routes(routes!(get_agent_config_pr))
|
||||
.routes(routes!(get_agent_icon))
|
||||
.routes(routes!(get_config_prs))
|
||||
.routes(routes!(create_agent))
|
||||
.routes(routes!(mint_agent_identity))
|
||||
|
|
@ -2455,6 +2562,7 @@ mod tests {
|
|||
// that would write a declaration.
|
||||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
jobq: std::sync::Arc::clone(&sched),
|
||||
webhook_secret: None,
|
||||
config_prs: None,
|
||||
|
|
@ -2506,6 +2614,33 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// An icon route with no bucket behind it must say it could not ask,
|
||||
/// not that the agent has no icon.
|
||||
///
|
||||
/// The two are one status code apart and read identically to an
|
||||
/// `<img>` — both end in the fallback glyph — which is exactly why the
|
||||
/// distinction has to be asserted here: nothing downstream would ever
|
||||
/// notice the 503 silently becoming a 404.
|
||||
#[tokio::test]
|
||||
async fn an_icon_with_no_queue_refuses_rather_than_answering_no_icon() {
|
||||
use axum::response::IntoResponse as _;
|
||||
|
||||
let (state, _sched) = state_with_roster();
|
||||
assert!(
|
||||
state.agent_icons.is_none(),
|
||||
"the fixture must have no queue"
|
||||
);
|
||||
|
||||
let err = super::get_agent_icon(
|
||||
axum::extract::State(state),
|
||||
axum::extract::Path("iris".to_owned()),
|
||||
)
|
||||
.await
|
||||
.expect_err("a queue-less controller cannot answer an icon");
|
||||
let resp = err.into_response();
|
||||
assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -3688,6 +3823,32 @@ mod tests {
|
|||
"{problem:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `GET /api/agents/{name}/icon` against a disconnected queue answers
|
||||
/// 503 like its siblings, not the 404 an `<img>` would show identically
|
||||
/// or the 500 of a failed read.
|
||||
#[tokio::test]
|
||||
async fn get_agent_icon_answers_503_on_a_disconnected_queue() {
|
||||
let (state, _sched) = state_with_roster();
|
||||
let state = super::AppState {
|
||||
agent_icons: Some(std::sync::Arc::new(
|
||||
super::agent_icon::AgentIconReader::new(disconnected_client().await),
|
||||
)),
|
||||
..state
|
||||
};
|
||||
|
||||
let problem = super::get_agent_icon(
|
||||
axum::extract::State(state),
|
||||
axum::extract::Path("iris".to_owned()),
|
||||
)
|
||||
.await
|
||||
.expect_err("a disconnected queue must not read as an icon");
|
||||
assert_eq!(
|
||||
problem.status,
|
||||
Some(axum::http::StatusCode::SERVICE_UNAVAILABLE),
|
||||
"{problem:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
Loading…
Reference in a new issue