diff --git a/hive-c0re/src/workers/swarm_limits.rs b/hive-c0re/src/workers/swarm_limits.rs index a362b356..d0a41d7d 100644 --- a/hive-c0re/src/workers/swarm_limits.rs +++ b/hive-c0re/src/workers/swarm_limits.rs @@ -1,7 +1,7 @@ //! Reading the caps the swarm sets for this hive's agents. //! //! swarm-controller keeps one key per agent in the `agent-limits` bucket -//! ([`swarm_queue_client::agent_limits`]). Every [`READ_INTERVAL`] this reads +//! ([`swarm_wire::agent_limits`]). Every [`READ_INTERVAL`] this reads //! the key of each agent this hive has and stores what it finds in //! `swarm-resource-limits.json`, where the drop-in reads it. //! A running agent gets a changed cap at its first spawn or rebuild after @@ -14,7 +14,8 @@ use std::collections::BTreeMap; use std::time::Duration; use anyhow::{Context, Result}; -use swarm_queue_client::agent_limits::{self, AgentCaps, Read}; +use swarm_queue_client::agent_limits::{self, Read}; +use swarm_wire::agent_limits::AgentCaps; use crate::resource_limits::AgentLimits; use crate::stats::sweep_health::{self, SweepHealth}; @@ -127,7 +128,7 @@ mod tests { use std::collections::BTreeMap; use super::validated; - use swarm_queue_client::agent_limits::AgentCaps; + use swarm_wire::agent_limits::AgentCaps; /// `atlas`'s answer with `atlas_key` as the value read for it, over the /// hive-wide defaults of 200% / 4G. diff --git a/swarm-controller/src/agent_limits.rs b/swarm-controller/src/agent_limits.rs index bc463b71..151cb9a4 100644 --- a/swarm-controller/src/agent_limits.rs +++ b/swarm-controller/src/agent_limits.rs @@ -2,7 +2,7 @@ //! one agent. //! //! Each agent's caps are one value under its own key of `agent-limits` -//! (`swarm_queue_client::agent_limits`), which this daemon alone writes. The +//! (`swarm_wire::agent_limits`), which this daemon alone writes. The //! hive running the agent reads the key; on the hive, a cap replaces the //! hive's own answer for that field, and a field left unset stays with the //! hive. @@ -16,7 +16,8 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use problem_details::ProblemDetails; use serde::{Deserialize, Serialize}; -use swarm_queue_client::agent_limits::{self, AgentCaps}; +use swarm_queue_client::agent_limits; +use swarm_wire::agent_limits::AgentCaps; use utoipa::ToSchema; use crate::routes::{AppState, error_problem, wanted_error_status}; diff --git a/swarm-controller/src/archetypes.rs b/swarm-controller/src/archetypes.rs index e2f31e81..531ef8ea 100644 --- a/swarm-controller/src/archetypes.rs +++ b/swarm-controller/src/archetypes.rs @@ -191,7 +191,7 @@ pub(crate) struct ArchetypeView { /// As `PUT /api/agents/{name}/limits` takes them. #[serde(skip_serializing_if = "Option::is_none")] #[schema(value_type = Option)] - limits: Option, + limits: Option, /// As `PUT /api/agents/{name}/inference` takes them. #[serde(skip_serializing_if = "Option::is_none")] #[schema(value_type = Option)] diff --git a/swarm-nats-auth/src/policy.rs b/swarm-nats-auth/src/policy.rs index 4229a70a..6473809d 100644 --- a/swarm-nats-auth/src/policy.rs +++ b/swarm-nats-auth/src/policy.rs @@ -566,7 +566,7 @@ impl Policy { format!("$KV.{}.{hive}.*", swarm_wire::agent_status::BUCKET), ]); // Reading the caps the swarm sets for the agents this hive runs - // (`swarm_queue_client::agent_limits`): a direct get, so read-only, and + // (`swarm_wire::agent_limits`): a direct get, so read-only, and // no `STREAM.INFO` because the reader skips the lookup. // // Every agent's key, not just this hive's: the bucket is keyed by agent @@ -574,7 +574,7 @@ impl Policy { // `hive_name`'s doc argues against. subjects.push(format!( "$JS.API.DIRECT.GET.{}.>", - swarm_queue_client::agent_limits::STREAM + swarm_wire::agent_limits::STREAM )); // The permissions the swarm grants those agents // (`swarm_queue_client::agent_permissions`), read the same way and @@ -847,7 +847,7 @@ mod tests { /// grant reaches no other bucket. #[test] fn a_hive_may_read_every_agents_caps_and_write_none() { - use swarm_queue_client::agent_limits::{STREAM, direct_get_subject, key_subject}; + use swarm_wire::agent_limits::{STREAM, direct_get_subject, key_subject}; let p = policy() .permissions("hive-alpha") diff --git a/swarm-queue-client/src/agent_archetype.rs b/swarm-queue-client/src/agent_archetype.rs index 9c0aa739..51563b43 100644 --- a/swarm-queue-client/src/agent_archetype.rs +++ b/swarm-queue-client/src/agent_archetype.rs @@ -9,9 +9,9 @@ //! **The hive running the agent reads the key** when it provisions or //! rebuilds the agent, and renders the archetype's module into its meta flake //! in place of a config repo. Its grant covers every key of this bucket, -//! read-only, for the reason [`crate::agent_limits`] gives. +//! read-only, for the reason `swarm_wire::agent_limits` gives. //! -//! Keyed by the agent alone, like [`crate::agent_limits`]: an agent can move +//! Keyed by the agent alone, like `swarm_wire::agent_limits`: an agent can move //! between hives, and its archetype goes with it. /// The KV bucket archetype choices are published into, keyed by agent name. diff --git a/swarm-queue-client/src/agent_limits.rs b/swarm-queue-client/src/agent_limits.rs index 77cf3b7f..25b78097 100644 --- a/swarm-queue-client/src/agent_limits.rs +++ b/swarm-queue-client/src/agent_limits.rs @@ -1,58 +1,6 @@ -//! The per-agent resource-cap KV bucket: one key per agent, holding the CPU -//! and memory caps the swarm sets for it. -//! -//! **swarm-controller is the only writer**, and creates the bucket on its -//! first write. Every field is optional: an absent field leaves the agent on -//! its hive's own answer, so an agent with no key runs exactly as it would -//! with no swarm. -//! -//! **The hive running the agent reads the key**, not the agent: the caps go -//! into the container's systemd drop-in, which the host writes. A hive reads -//! with one `DIRECT.GET` per agent -//! ([`crate::agent_limits::direct_get_subject`]), and its grant covers every -//! key of this bucket, read-only. Scoping it to the hive's own agents would -//! need a roster in the auth responder. -//! -//! Keyed by the agent alone, like [`crate::agent_inference`]: an agent can -//! move between hives, and its caps go with it. - -/// The KV bucket caps are published into, keyed by agent name. -pub const BUCKET: &str = "agent-limits"; - -/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`. -pub const STREAM: &str = "KV_agent-limits"; - -/// The subject `agent`'s entry is stored under. -#[must_use] -pub fn key_subject(agent: &str) -> String { - format!("$KV.{BUCKET}.{agent}") -} - -/// The request subject that reads `agent`'s current entry. -#[must_use] -pub fn direct_get_subject(agent: &str) -> String { - format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject(agent)) -} - -/// One agent's caps: the value under `BUCKET`/``, as systemd values -/// carried verbatim. `None` is "not capped by the swarm", never "uncapped". -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct AgentCaps { - /// systemd `CPUQuota=`, e.g. `"400%"`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cpu_quota: Option, - /// systemd `MemoryMax=`, e.g. `"8G"`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub memory_max: Option, -} - -impl AgentCaps { - /// True when no field is set, which a hive reads the same as no key. - #[must_use] - pub fn is_empty(&self) -> bool { - self.cpu_quota.is_none() && self.memory_max.is_none() - } -} +//! The connection side of the per-agent resource-cap KV bucket: opening it for +//! writing or reading, and the hive's direct read. The bucket's names, subjects +//! and stored type are `swarm_wire::agent_limits`. /// Open the caps bucket for writing, creating it if nothing has yet. /// @@ -64,7 +12,7 @@ pub async fn open_or_create( let js = async_nats::jetstream::new(client.clone()); crate::kv::open_or_create( &js, - BUCKET, + swarm_wire::agent_limits::BUCKET, "agent-limits", "Resource caps the swarm controller sets for each agent".to_owned(), ) @@ -85,11 +33,11 @@ pub async fn open( client: &async_nats::Client, ) -> Result, crate::Error> { let js = async_nats::jetstream::new(client.clone()); - match js.get_key_value(BUCKET).await { + match js.get_key_value(swarm_wire::agent_limits::BUCKET).await { Ok(store) => Ok(Some(store)), Err(e) if crate::wanted::is_bucket_not_found(&e) => Ok(None), Err(source) => Err(crate::Error::OpenBucket { - bucket: BUCKET.to_owned(), + bucket: swarm_wire::agent_limits::BUCKET.to_owned(), source, }), } @@ -100,7 +48,7 @@ pub async fn open( #[derive(Debug)] pub enum Read { /// The bucket answered; an absent or deleted key has every field unset. - Current(AgentCaps), + Current(swarm_wire::agent_limits::AgentCaps), /// Nothing answers for the bucket's stream: no cap has been written yet, /// or `JetStream` is not serving it right now. Not an unset value, since /// the second case says nothing about the agent's caps. @@ -119,6 +67,7 @@ pub async fn read( agent: &str, ) -> Result> { use async_nats::jetstream::stream::DirectGetErrorKind; + use swarm_wire::agent_limits::{AgentCaps, STREAM, key_subject}; let js = async_nats::jetstream::new(client.clone()); let stream = js.get_stream_no_info(STREAM).await?; @@ -149,42 +98,3 @@ fn is_no_responders(e: &async_nats::jetstream::stream::DirectGetError) -> bool { if status == async_nats::StatusCode::NO_RESPONDERS ) } - -#[cfg(test)] -mod tests { - use super::*; - - /// The names the hive's grant in `swarm-nats-auth` is built from. - #[test] - fn names_match_the_queue_grant() { - assert_eq!(STREAM, format!("KV_{BUCKET}")); - assert_eq!(key_subject("iris"), "$KV.agent-limits.iris"); - assert_eq!( - direct_get_subject("iris"), - "$JS.API.DIRECT.GET.KV_agent-limits.$KV.agent-limits.iris" - ); - } - - /// An unset field is left out on the wire, and `{}` is every field unset. - #[test] - fn unset_fields_are_absent() { - let value = AgentCaps { - memory_max: Some("8G".to_owned()), - ..AgentCaps::default() - }; - assert_eq!( - serde_json::to_string(&value).unwrap(), - r#"{"memory_max":"8G"}"# - ); - let empty: AgentCaps = serde_json::from_str("{}").unwrap(); - assert!(empty.is_empty()); - } - - /// Fields a newer controller adds must not cost an older hive its caps. - #[test] - fn decoding_tolerates_unknown_fields() { - let decoded: AgentCaps = - serde_json::from_str(r#"{"cpu_quota":"400%","io_weight":"10"}"#).unwrap(); - assert_eq!(decoded.cpu_quota.as_deref(), Some("400%")); - } -} diff --git a/swarm-queue-client/src/agent_permissions.rs b/swarm-queue-client/src/agent_permissions.rs index 306fe6df..d0e86dcd 100644 --- a/swarm-queue-client/src/agent_permissions.rs +++ b/swarm-queue-client/src/agent_permissions.rs @@ -9,12 +9,12 @@ //! **Two readers, each enforcing its own part.** The hive running the agent //! applies `manage_root_agent` as bind mounts at container start; its grant //! covers every key of this bucket, read-only, for the reason -//! [`crate::agent_limits`] gives. The agent itself (its harness and subagent +//! `swarm_wire::agent_limits` gives. The agent itself (its harness and subagent //! daemon) applies the tool permissions; its grant covers only its own key. //! Both read with one `DIRECT.GET` on the agent's key //! ([`crate::agent_permissions::key_subject`]). //! -//! Keyed by the agent alone, like [`crate::agent_limits`]: an agent can move +//! Keyed by the agent alone, like `swarm_wire::agent_limits`: an agent can move //! between hives, and its permissions go with it. //! //! Also carries the one-shot import a hive sends of the grants its meta repo diff --git a/swarm-queue-client/src/archetypes.rs b/swarm-queue-client/src/archetypes.rs index 48e09abe..acb9e085 100644 --- a/swarm-queue-client/src/archetypes.rs +++ b/swarm-queue-client/src/archetypes.rs @@ -11,7 +11,7 @@ use std::collections::BTreeMap; -use crate::agent_limits::AgentCaps; +use swarm_wire::agent_limits::AgentCaps; use swarm_wire::agent_inference::AgentInference; /// The KV bucket archetype sources are published into, keyed by source name. diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index 85fa5a2e..f22e01d7 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -225,9 +225,10 @@ pub mod agent_schedules; /// unconditional; writing needs `kv` and reading needs `agent-inference`. pub mod agent_inference; -/// The bucket swarm-controller sets each agent's CPU and memory caps in — one -/// key per agent, read by the hive that runs it. The names are unconditional; -/// writing needs `kv` and reading needs `agent-limits`. +/// Opening and reading the bucket swarm-controller sets each agent's CPU and +/// memory caps in — one key per agent, read by the hive that runs it. The +/// names and the stored type are `swarm_wire::agent_limits`; writing needs `kv` +/// and reading needs `agent-limits`. pub mod agent_limits; /// The bucket swarm-controller sets each agent's permissions in — one key diff --git a/swarm-wire/src/agent_limits.rs b/swarm-wire/src/agent_limits.rs new file mode 100644 index 00000000..0d8a7e4f --- /dev/null +++ b/swarm-wire/src/agent_limits.rs @@ -0,0 +1,93 @@ +//! The per-agent resource-cap KV bucket: one key per agent, holding the CPU +//! and memory caps the swarm sets for it. +//! +//! **swarm-controller is the only writer**, and creates the bucket on its +//! first write. Every field is optional: an absent field leaves the agent on +//! its hive's own answer, so an agent with no key runs exactly as it would +//! with no swarm. +//! +//! **The hive running the agent reads the key**, not the agent: the caps go +//! into the container's systemd drop-in, which the host writes. A hive reads +//! with one `DIRECT.GET` per agent ([`direct_get_subject`]), and its grant +//! covers every key of this bucket, read-only. Scoping it to the hive's own +//! agents would need a roster in the auth responder. +//! +//! Keyed by the agent alone, like [`crate::agent_inference`]: an agent can +//! move between hives, and its caps go with it. + +/// The KV bucket caps are published into, keyed by agent name. +pub const BUCKET: &str = "agent-limits"; + +/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`. +pub const STREAM: &str = "KV_agent-limits"; + +/// The subject `agent`'s entry is stored under. +#[must_use] +pub fn key_subject(agent: &str) -> String { + format!("$KV.{BUCKET}.{agent}") +} + +/// The request subject that reads `agent`'s current entry. +#[must_use] +pub fn direct_get_subject(agent: &str) -> String { + format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject(agent)) +} + +/// One agent's caps: the value under `BUCKET`/``, as systemd values +/// carried verbatim. `None` is "not capped by the swarm", never "uncapped". +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct AgentCaps { + /// systemd `CPUQuota=`, e.g. `"400%"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpu_quota: Option, + /// systemd `MemoryMax=`, e.g. `"8G"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory_max: Option, +} + +impl AgentCaps { + /// True when no field is set, which a hive reads the same as no key. + #[must_use] + pub fn is_empty(&self) -> bool { + self.cpu_quota.is_none() && self.memory_max.is_none() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The names the hive's grant in `swarm-nats-auth` is built from. + #[test] + fn names_match_the_queue_grant() { + assert_eq!(STREAM, format!("KV_{BUCKET}")); + assert_eq!(key_subject("iris"), "$KV.agent-limits.iris"); + assert_eq!( + direct_get_subject("iris"), + "$JS.API.DIRECT.GET.KV_agent-limits.$KV.agent-limits.iris" + ); + } + + /// An unset field is left out on the wire, and `{}` is every field unset. + #[test] + fn unset_fields_are_absent() { + let value = AgentCaps { + memory_max: Some("8G".to_owned()), + ..AgentCaps::default() + }; + assert_eq!( + serde_json::to_string(&value).unwrap(), + r#"{"memory_max":"8G"}"# + ); + let empty: AgentCaps = serde_json::from_str("{}").unwrap(); + assert!(empty.is_empty()); + } + + /// Fields a newer controller adds must not cost an older hive its caps. + #[test] + fn decoding_tolerates_unknown_fields() { + let decoded: AgentCaps = + serde_json::from_str(r#"{"cpu_quota":"400%","io_weight":"10"}"#).unwrap(); + assert_eq!(decoded.cpu_quota.as_deref(), Some("400%")); + } +} diff --git a/swarm-wire/src/lib.rs b/swarm-wire/src/lib.rs index c8a3602c..546735a7 100644 --- a/swarm-wire/src/lib.rs +++ b/swarm-wire/src/lib.rs @@ -7,6 +7,7 @@ pub mod agent_icon; pub mod agent_inference; +pub mod agent_limits; pub mod agent_login; pub mod agent_status; pub mod builder;