The auth callout grants an agent that presents its own queue credential one more subject, `$KV.agent-icons.<agent>`: its own key in the agent-icons bucket and no other. The hive's shared agent client is granted none of the bucket, since every agent on a hive presents it. hive-agent writes `/etc/hyperhive/icon.svg`, the file its `GET /icon` serves, to that key once per start, as a JetStream publish straight to the subject (what `kv::Store::put` sends, minus the bucket lookup), so the one subject is the whole grant. No icon deletes the key. A failed write, including one that arrives before the bucket exists, is retried with backoff until acked. An agent connected with the hive's shared client publishes nothing. swarm-controller creates the bucket as soon as its queue connection is up, instead of on the first icon read, so an agent's write does not wait for someone to look. Measured against a local nats-server with a user allowed publish on `$KV.agent-icons.atlas` only: the write to its own key is stored and readable, a write to `$KV.agent-icons.argus` is refused (the ack times out), the DEL marker makes the key read as absent, and a write before the bucket exists fails with "no responders".
102 lines
4.1 KiB
Rust
102 lines
4.1 KiB
Rust
//! 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: *"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.
|
|
//!
|
|
//! **Each agent writes its own key** with its own queue credential. The auth
|
|
//! callout grants that credential `$KV.agent-icons.<agent>` and no other key.
|
|
//! The hive's shared agent client gets nothing in this bucket: every agent on
|
|
//! a hive presents that same client, so its grant cannot be narrowed to one
|
|
//! agent's key.
|
|
//!
|
|
//! The writer publishes to [`crate::agent_icon::subject`] directly instead of
|
|
//! opening the bucket, so that one subject is its whole grant. Creating the
|
|
//! bucket is left to the reader, with `open_or_create`. A write that arrives
|
|
//! before the bucket exists is refused with "no stream", and the writer
|
|
//! retries.
|
|
|
|
#[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";
|
|
|
|
/// The subject `agent`'s entry is written on: `$KV.<bucket>.<key>`, where the
|
|
/// key is the agent name and nothing else.
|
|
///
|
|
/// A KV put is a `JetStream` publish to this subject, so a writer that
|
|
/// publishes directly has to use the same subject the bucket's `get` reads.
|
|
#[must_use]
|
|
pub fn subject(agent: &str) -> String {
|
|
format!("$KV.{BUCKET}.{agent}")
|
|
}
|
|
|
|
/// 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::subject;
|
|
|
|
/// One token after the bucket, the agent name alone. That is what the
|
|
/// per-agent grant `$KV.agent-icons.{agent}` expands to, and what a
|
|
/// hive-scoped grant (`$KV.<bucket>.<hive>.*`, two tokens) cannot match.
|
|
#[test]
|
|
fn the_published_subject_carries_the_agent_and_no_placement() {
|
|
assert_eq!(subject("iris"), "$KV.agent-icons.iris");
|
|
}
|
|
}
|