archetypes: create an agent from an archetype with no config repo
POST /api/agents takes an archetype the stored list holds, records it in the new agent-archetype bucket, and queues no CreateRepo, AddRepoMember or InitAgentConfigRepo for it. A destroy clears the key. Hives may read the bucket. Refs #5386
This commit is contained in:
parent
6b93839290
commit
b199c83899
7 changed files with 645 additions and 37 deletions
159
swarm-queue-client/src/agent_archetype.rs
Normal file
159
swarm-queue-client/src/agent_archetype.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
//! The per-agent archetype KV bucket: one key per agent created from an
|
||||
//! archetype, holding which archetype that is.
|
||||
//!
|
||||
//! **swarm-controller is the only writer**: `POST /api/agents` stores the key
|
||||
//! before it queues the agent's creation, and a destroy deletes it. An agent
|
||||
//! with no key is a custom agent, built from its own `agent-configs/<name>`
|
||||
//! repo.
|
||||
//!
|
||||
//! **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.
|
||||
//!
|
||||
//! Keyed by the agent alone, like [`crate::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.
|
||||
pub const BUCKET: &str = "agent-archetype";
|
||||
|
||||
/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`.
|
||||
pub const STREAM: &str = "KV_agent-archetype";
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
/// Which archetype an agent was created from: the value under
|
||||
/// `BUCKET`/`<agent>`. `source` is a key of [`crate::archetypes::BUCKET`]
|
||||
/// and `name` an archetype in that source's record.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ArchetypeRef {
|
||||
pub source: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Open the archetype-choice bucket for writing, creating it if nothing has
|
||||
/// yet.
|
||||
///
|
||||
/// **Controller-side only.** `history: 1`: a hive wants the current value.
|
||||
#[cfg(feature = "kv")]
|
||||
pub async fn open_or_create(
|
||||
client: &async_nats::Client,
|
||||
) -> Result<async_nats::jetstream::kv::Store, crate::Error> {
|
||||
let js = async_nats::jetstream::new(client.clone());
|
||||
crate::kv::open_or_create(
|
||||
&js,
|
||||
BUCKET,
|
||||
"agent-archetype",
|
||||
"The archetype each agent was created from, set by the swarm controller".to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// What reading one agent's key found.
|
||||
#[cfg(feature = "agent-archetype")]
|
||||
#[derive(Debug)]
|
||||
pub enum Read {
|
||||
/// The bucket answered: the agent's archetype, or `None` for an absent or
|
||||
/// deleted key.
|
||||
Current(Option<ArchetypeRef>),
|
||||
/// Nothing answers for the bucket's stream: no archetype agent has been
|
||||
/// created yet, or `JetStream` is not serving it right now.
|
||||
NoBucket,
|
||||
}
|
||||
|
||||
/// Read `agent`'s current archetype with one direct get.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// A failed request or an undecodable value. A request the queue refused for
|
||||
/// permissions reaches the client as a timeout, not as a refusal.
|
||||
#[cfg(feature = "agent-archetype")]
|
||||
pub async fn read(
|
||||
client: &async_nats::Client,
|
||||
agent: &str,
|
||||
) -> Result<Read, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use async_nats::jetstream::stream::DirectGetErrorKind;
|
||||
|
||||
let js = async_nats::jetstream::new(client.clone());
|
||||
let stream = js.get_stream_no_info(STREAM).await?;
|
||||
let message = match stream.direct_get_last_for_subject(key_subject(agent)).await {
|
||||
Ok(message) => message,
|
||||
Err(e) if matches!(e.kind(), DirectGetErrorKind::NotFound) => {
|
||||
return Ok(Read::Current(None));
|
||||
}
|
||||
Err(e)
|
||||
if matches!(
|
||||
e.kind(),
|
||||
DirectGetErrorKind::ErrorResponse(status, _)
|
||||
if status == async_nats::StatusCode::NO_RESPONDERS
|
||||
) =>
|
||||
{
|
||||
return Ok(Read::NoBucket);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
Ok(Read::Current(decode(
|
||||
message
|
||||
.headers
|
||||
.get("KV-Operation")
|
||||
.map(async_nats::HeaderValue::as_str),
|
||||
&message.payload,
|
||||
)?))
|
||||
}
|
||||
|
||||
/// One stored message as a value: a KV delete or purge is stored as an empty
|
||||
/// marker carrying the `KV-Operation` header, and reads as no archetype.
|
||||
#[cfg(feature = "agent-archetype")]
|
||||
fn decode(operation: Option<&str>, payload: &[u8]) -> serde_json::Result<Option<ArchetypeRef>> {
|
||||
if matches!(operation, Some("DEL" | "PURGE")) {
|
||||
return Ok(None);
|
||||
}
|
||||
serde_json::from_slice(payload).map(Some)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The names the 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-archetype.iris");
|
||||
assert_eq!(
|
||||
direct_get_subject("iris"),
|
||||
"$JS.API.DIRECT.GET.KV_agent-archetype.$KV.agent-archetype.iris"
|
||||
);
|
||||
}
|
||||
|
||||
/// A stored choice reads back, a field a newer controller adds is
|
||||
/// ignored, and a deleted key is no archetype.
|
||||
#[cfg(feature = "agent-archetype")]
|
||||
#[test]
|
||||
fn decoding_reads_the_choice_and_a_delete_as_none() {
|
||||
let android = ArchetypeRef {
|
||||
source: "hyperhive".to_owned(),
|
||||
name: "android-dev".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
decode(
|
||||
None,
|
||||
br#"{"source":"hyperhive","name":"android-dev","later":1}"#
|
||||
)
|
||||
.unwrap(),
|
||||
Some(android)
|
||||
);
|
||||
assert_eq!(decode(Some("DEL"), b"").unwrap(), None);
|
||||
assert_eq!(decode(Some("PURGE"), b"").unwrap(), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -232,6 +232,12 @@ pub mod agent_permissions;
|
|||
/// unconditional; writing needs `kv` and reading needs `operator-profiles`.
|
||||
pub mod operator_profiles;
|
||||
|
||||
/// The bucket swarm-controller records each archetype agent's archetype in
|
||||
/// — one key per agent, read by the hive that runs it. The names and the
|
||||
/// stored type are unconditional; writing needs `kv` and reading needs
|
||||
/// `agent-archetype`.
|
||||
pub mod agent_archetype;
|
||||
|
||||
/// The login flag in an agent's turn-state header. No bucket: it travels
|
||||
/// on [`agent_state_subject`] only.
|
||||
pub mod agent_login;
|
||||
|
|
|
|||
Loading…
Reference in a new issue