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
57
swarm-controller/src/agent_icon.rs
Normal file
57
swarm-controller/src/agent_icon.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
//! Reads an agent's icon out of the swarm's agent-icon KV bucket.
|
||||||
|
//!
|
||||||
|
//! Sibling of [`crate::agent_status`] and built the same way — the bucket
|
||||||
|
//! is resolved on first use and cached, a resolution failure is not — but
|
||||||
|
//! a much smaller read: one key, no roster to be complete against and no
|
||||||
|
//! freshness to derive. An icon is not a report about the agent, it is a
|
||||||
|
//! property of it, so there is nothing for a timestamp to mean here.
|
||||||
|
//!
|
||||||
|
//! **No hive is named on this path**, because the key does not carry one —
|
||||||
|
//! see `swarm_queue_client::agent_icon` for why, and for the grant work
|
||||||
|
//! the publish side is still waiting on.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
/// Reads the agent-icon bucket. Holds a NATS client rather than a bucket
|
||||||
|
/// handle, so a controller that starts before the bucket exists picks it
|
||||||
|
/// up without a restart.
|
||||||
|
pub struct AgentIconReader {
|
||||||
|
client: async_nats::Client,
|
||||||
|
store: tokio::sync::OnceCell<async_nats::jetstream::kv::Store>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentIconReader {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(client: async_nats::Client) -> Self {
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
store: tokio::sync::OnceCell::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store(
|
||||||
|
&self,
|
||||||
|
) -> std::result::Result<&async_nats::jetstream::kv::Store, swarm_queue_client::Error> {
|
||||||
|
self.store
|
||||||
|
.get_or_try_init(|| swarm_queue_client::agent_icon::open_or_create(&self.client))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `agent`'s icon, or `None` when it has published none.
|
||||||
|
///
|
||||||
|
/// The bytes are returned unopened: this daemon does not parse, rewrite
|
||||||
|
/// or validate the SVG, and a consumer that renders it must treat it as
|
||||||
|
/// the untrusted document it is — see `get_agent_icon`'s response
|
||||||
|
/// headers.
|
||||||
|
pub async fn get(&self, agent: &str) -> Result<Option<Vec<u8>>> {
|
||||||
|
// An unconnected client does not fail a JetStream request, it hangs
|
||||||
|
// on it — see `swarm_queue_client::ensure_connected`.
|
||||||
|
swarm_queue_client::ensure_connected(&self.client)?;
|
||||||
|
let store = self.store().await?;
|
||||||
|
let icon = store
|
||||||
|
.get(agent)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("reading the agent-icon entry for {agent}"))?;
|
||||||
|
Ok(icon.map(Into::into))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,7 @@ use swarm_authelia_bridge_sock::BridgeResponse;
|
||||||
use utoipa::{OpenApi, ToSchema};
|
use utoipa::{OpenApi, ToSchema};
|
||||||
use utoipa_axum::{router::OpenApiRouter, routes};
|
use utoipa_axum::{router::OpenApiRouter, routes};
|
||||||
|
|
||||||
|
mod agent_icon;
|
||||||
mod agent_identity;
|
mod agent_identity;
|
||||||
mod agent_state_stream;
|
mod agent_state_stream;
|
||||||
mod agent_status;
|
mod agent_status;
|
||||||
|
|
@ -607,6 +608,10 @@ struct AppState {
|
||||||
/// connection, several consumers" rationale as `wanted` above. `None`
|
/// connection, several consumers" rationale as `wanted` above. `None`
|
||||||
/// in exactly the state `status` is.
|
/// in exactly the state `status` is.
|
||||||
agent_status: Option<Arc<agent_status::AgentStatusReader>>,
|
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
|
/// The swarm-level job graph, wrapped in its
|
||||||
/// [`hive_jobq::scheduler::Scheduler`] now that something drives it
|
/// [`hive_jobq::scheduler::Scheduler`] now that something drives it
|
||||||
/// (`spawn_jobq_worker`) — the graph alone was enough for the
|
/// (`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)
|
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
|
/// `WantedWriter::view`/`set` call `ensure_connected` internally and
|
||||||
/// propagate through `anyhow`, so the concrete
|
/// 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.
|
/// 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
|
/// Reports the status and the detail rather than a rendered
|
||||||
|
|
@ -1996,6 +2012,95 @@ where
|
||||||
Ok(Json(MakeForgeAdminResponse { change }))
|
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
|
/// 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
|
/// 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
|
/// 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()),
|
links: Arc::new(load_links()),
|
||||||
wanted: wanted_writer(status.as_ref()),
|
wanted: wanted_writer(status.as_ref()),
|
||||||
agent_status: agent_status_reader(status.as_ref()),
|
agent_status: agent_status_reader(status.as_ref()),
|
||||||
|
agent_icons: agent_icon_reader(status.as_ref()),
|
||||||
status,
|
status,
|
||||||
jobq,
|
jobq,
|
||||||
webhook_secret,
|
webhook_secret,
|
||||||
|
|
@ -2334,6 +2440,7 @@ fn build_app(state: AppState) -> axum::Router {
|
||||||
.routes(routes!(get_jobq_graph))
|
.routes(routes!(get_jobq_graph))
|
||||||
.routes(routes!(get_jobq_rollup))
|
.routes(routes!(get_jobq_rollup))
|
||||||
.routes(routes!(get_agent_config_pr))
|
.routes(routes!(get_agent_config_pr))
|
||||||
|
.routes(routes!(get_agent_icon))
|
||||||
.routes(routes!(get_config_prs))
|
.routes(routes!(get_config_prs))
|
||||||
.routes(routes!(create_agent))
|
.routes(routes!(create_agent))
|
||||||
.routes(routes!(mint_agent_identity))
|
.routes(routes!(mint_agent_identity))
|
||||||
|
|
@ -2455,6 +2562,7 @@ mod tests {
|
||||||
// that would write a declaration.
|
// that would write a declaration.
|
||||||
wanted: None,
|
wanted: None,
|
||||||
agent_status: None,
|
agent_status: None,
|
||||||
|
agent_icons: None,
|
||||||
jobq: std::sync::Arc::clone(&sched),
|
jobq: std::sync::Arc::clone(&sched),
|
||||||
webhook_secret: None,
|
webhook_secret: None,
|
||||||
config_prs: 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
|
/// 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
|
||||||
|
|
@ -3688,6 +3823,32 @@ mod tests {
|
||||||
"{problem:?}"
|
"{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)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -573,6 +573,7 @@ mod tests {
|
||||||
status: Some(std::sync::Arc::new(status)),
|
status: Some(std::sync::Arc::new(status)),
|
||||||
wanted: None,
|
wanted: None,
|
||||||
agent_status: None,
|
agent_status: None,
|
||||||
|
agent_icons: None,
|
||||||
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||||
hive_jobq::Graph::new(),
|
hive_jobq::Graph::new(),
|
||||||
hive_jobq::resources::ResourceTable::new(),
|
hive_jobq::resources::ResourceTable::new(),
|
||||||
|
|
|
||||||
91
swarm-queue-client/src/agent_icon.rs
Normal file
91
swarm-queue-client/src/agent_icon.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
//! The per-agent icon KV bucket: one key per agent, holding the icon the
|
||||||
|
//! swarm shows for it.
|
||||||
|
//!
|
||||||
|
//! **Keyed by the agent alone — no hive token**, which is the one way this
|
||||||
|
//! bucket deliberately differs from its otherwise-identical sibling
|
||||||
|
//! [`crate::agent_status`] (`{hive}.{agent}`). An agent is not fixed to a
|
||||||
|
//! hive and can move between them; an icon keyed by where the agent
|
||||||
|
//! currently runs would be stranded under the old key by a migration, and
|
||||||
|
//! a reader would have to know the placement to ask the question at all.
|
||||||
|
//! Operator ruling, hyperhive#4502: *"the bucket is per agent, not per
|
||||||
|
//! hive. agents can move hives."*
|
||||||
|
//!
|
||||||
|
//! That is also what makes the icon answerable for an agent whose
|
||||||
|
//! container is stopped: the value's lifetime is the agent's, not its
|
||||||
|
//! placement's.
|
||||||
|
//!
|
||||||
|
//! ⚠️ **Nothing writes this bucket yet.** The publisher runs inside the
|
||||||
|
//! agent's own container, and an agent's NATS grants are hive-scoped
|
||||||
|
//! (`$KV.<bucket>.<hive>.*`), which cannot authorise a write to a
|
||||||
|
//! single-token agent key — so the publish side is blocked on a grant
|
||||||
|
//! shape the policy layer does not have today. Until it lands, every read
|
||||||
|
//! here answers "no icon", which is the same answer an agent that never
|
||||||
|
//! set one gets, and the same 404 the per-agent harness's own `GET /icon`
|
||||||
|
//! has always returned for an unconfigured agent.
|
||||||
|
|
||||||
|
#[cfg(feature = "kv")]
|
||||||
|
use crate::Error;
|
||||||
|
|
||||||
|
/// The KV bucket agent icons are published into, keyed by agent name.
|
||||||
|
///
|
||||||
|
/// A constant and not an option, for the reason
|
||||||
|
/// [`crate::agent_status::BUCKET`] gives: writer and reader must name the
|
||||||
|
/// same bucket, and an option is a way for the two to disagree.
|
||||||
|
pub const BUCKET: &str = "agent-icons";
|
||||||
|
|
||||||
|
/// The media type of every value in this bucket.
|
||||||
|
///
|
||||||
|
/// The value is the icon's bytes **verbatim, not a JSON envelope**: an SVG
|
||||||
|
/// carries no metadata this bucket would have to describe, and the one
|
||||||
|
/// consumer serves the bytes straight back out. So the type is fixed here
|
||||||
|
/// rather than stored per entry — a publisher that has something other
|
||||||
|
/// than an SVG does not have an agent icon.
|
||||||
|
pub const MEDIA_TYPE: &str = "image/svg+xml";
|
||||||
|
|
||||||
|
/// Open the agent-icon bucket, creating it if nothing has yet.
|
||||||
|
///
|
||||||
|
/// `history: 1`, same rationale as [`crate::agent_status::open_or_create`]:
|
||||||
|
/// a consumer wants each agent's current icon, not every icon it has ever
|
||||||
|
/// had.
|
||||||
|
#[cfg(feature = "kv")]
|
||||||
|
pub async fn open_or_create(
|
||||||
|
client: &async_nats::Client,
|
||||||
|
) -> Result<async_nats::jetstream::kv::Store, Error> {
|
||||||
|
let js = async_nats::jetstream::new(client.clone());
|
||||||
|
match js.get_key_value(BUCKET).await {
|
||||||
|
Ok(store) => Ok(store),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::info!(
|
||||||
|
bucket = BUCKET,
|
||||||
|
reason = %e,
|
||||||
|
"agent-icon bucket not available, creating it"
|
||||||
|
);
|
||||||
|
js.create_key_value(async_nats::jetstream::kv::Config {
|
||||||
|
bucket: BUCKET.to_owned(),
|
||||||
|
description: "Current icon published by each agent".to_owned(),
|
||||||
|
history: 1,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|source| Error::CreateBucket {
|
||||||
|
bucket: BUCKET.to_owned(),
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::BUCKET;
|
||||||
|
|
||||||
|
/// The published subject is `$KV.<bucket>.<key>`, and this key is the
|
||||||
|
/// agent name alone — so the subject carries **one** token after the
|
||||||
|
/// bucket. Pinned here because that is precisely what a hive-scoped
|
||||||
|
/// grant (`$KV.<bucket>.<hive>.*`, two tokens) cannot match, and the
|
||||||
|
/// reason the publish side needs a grant shape of its own.
|
||||||
|
#[test]
|
||||||
|
fn the_published_subject_carries_the_agent_and_no_placement() {
|
||||||
|
assert_eq!(format!("$KV.{BUCKET}.iris"), "$KV.agent-icons.iris");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -182,6 +182,12 @@ pub mod agent_status;
|
||||||
/// formats it without linking the secret-store client.
|
/// formats it without linking the secret-store client.
|
||||||
pub mod agent_token;
|
pub mod agent_token;
|
||||||
|
|
||||||
|
/// The bucket agent icons are published into — one key per agent, with no
|
||||||
|
/// hive in it, unlike [`agent_status`]. See the module doc for why the
|
||||||
|
/// placement stays out of the key, and for what still has to land before
|
||||||
|
/// anything can write it.
|
||||||
|
pub mod agent_icon;
|
||||||
|
|
||||||
/// The subject the swarm controller publishes on when the hive-wide knowledge
|
/// The subject the swarm controller publishes on when the hive-wide knowledge
|
||||||
/// repository has changed. One writer, many readers — every hive subscribes.
|
/// repository has changed. One writer, many readers — every hive subscribes.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue