diff --git a/CLAUDE.md b/CLAUDE.md index 457fe4db..931dbe9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,11 +118,13 @@ Swarm-level: **the socket's directory is its access control**; the crate README has the constraint and a unit test pins the path. - **`swarm-controller-api/`** — serde-only request and response bodies of - the controller's HTTP API, shared with `swarmctl` (forge and schedules). + the controller's HTTP API, shared with `swarmctl` (forge, schedules and + the agent-create and forge-token responses). - **`swarmctl/`** — swarm operator CLI; runs as **root and acts directly** (README says why). Doesn't link `swarm-controller`: the forge and - schedule bodies come from `swarm-controller-api`, the agent-create, - inference and permissions ones are a local mirror. ⚠️ Edits authelia's + schedule bodies and the agent responses come from + `swarm-controller-api`; the agent-create request, inference and + permissions ones are a local mirror. ⚠️ Edits authelia's **`users.yml` in place**, shared with `swarm-authelia-bridge`; that crate's README says what both writers must uphold. Verb reference (CI-enforced): diff --git a/swarm-controller-api/README.md b/swarm-controller-api/README.md index 9b854929..b8800a64 100644 --- a/swarm-controller-api/README.md +++ b/swarm-controller-api/README.md @@ -16,6 +16,8 @@ controller's `OpenAPI` spec. `swarmctl` leaves it off. ## What's here -One module per endpoint family: `forge` (`POST /api/forge/users/{name}/admin`) -and `schedules` (`/api/schedules`). The agent-create, inference and -permissions bodies are still mirrored by hand in `swarmctl`. +One module per endpoint family: `agents` (the responses of `POST /api/agents` +and `POST /api/agents/{name}/forge-token`), `forge` +(`POST /api/forge/users/{name}/admin`) and `schedules` (`/api/schedules`). +The agent-create request, inference and permissions bodies are still mirrored +by hand in `swarmctl`. diff --git a/swarm-controller-api/src/agents.rs b/swarm-controller-api/src/agents.rs new file mode 100644 index 00000000..1aa6d79f --- /dev/null +++ b/swarm-controller-api/src/agents.rs @@ -0,0 +1,78 @@ +//! `POST /api/agents` and `POST /api/agents/{name}/forge-token`. + +use serde::{Deserialize, Serialize}; + +/// Where the queued job landed — a caller polls `/api/jobq/graph` (or +/// `?states=`) with this id to watch it settle, same as every other job +/// kind this daemon will ever queue. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +pub struct CreateAgentResponse { + pub node_id: u64, + /// Name collisions that were **allowed through**, phrased for the + /// operator who just chose the name. + /// + /// The creation still happened — this is the "loud warning now, refuse + /// after a grace period" step, so a caller that ignores this field gets + /// exactly today's behaviour. It rides on the response rather than + /// living only in the daemon's log because the person who can still fix + /// the name in one keystroke is the one holding this response, and they + /// are not reading the journal. + /// + /// Empty (and omitted from the JSON) in the normal case, so a client + /// that never looks at it is not handed an empty array to reason about. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +/// Success body of `POST /api/agents/{name}/forge-token`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +pub struct MintAgentForgeTokenResponse { + /// The queued node, so a caller can follow it in the job view. + pub node_id: u64, +} + +#[cfg(test)] +mod tests { + use super::{CreateAgentResponse, MintAgentForgeTokenResponse}; + + /// No warnings means no `warnings` key, and a body without one still + /// decodes: that is the normal response. + #[test] + fn a_create_response_round_trips() { + for (resp, wire) in [ + ( + CreateAgentResponse { + node_id: 7, + warnings: vec![], + }, + r#"{"node_id":7}"#, + ), + ( + CreateAgentResponse { + node_id: 7, + warnings: vec!["name is reserved".to_owned()], + }, + r#"{"node_id":7,"warnings":["name is reserved"]}"#, + ), + ] { + assert_eq!(serde_json::to_string(&resp).unwrap(), wire); + assert_eq!( + serde_json::from_str::(wire).unwrap(), + resp + ); + } + } + + #[test] + fn a_forge_token_response_round_trips() { + let resp = MintAgentForgeTokenResponse { node_id: 7 }; + let wire = r#"{"node_id":7}"#; + assert_eq!(serde_json::to_string(&resp).unwrap(), wire); + assert_eq!( + serde_json::from_str::(wire).unwrap(), + resp + ); + } +} diff --git a/swarm-controller-api/src/lib.rs b/swarm-controller-api/src/lib.rs index 6be7f150..e80a2c67 100644 --- a/swarm-controller-api/src/lib.rs +++ b/swarm-controller-api/src/lib.rs @@ -4,5 +4,6 @@ //! them, so the shapes cannot drift between the two. Serde only; with the //! `schema` feature each type also derives `utoipa::ToSchema`. +pub mod agents; pub mod forge; pub mod schedules; diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index d244e1e3..90c6266e 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -39,6 +39,7 @@ use backon::{ExponentialBuilder, Retryable as _}; use hive_jobq_wire::GraphWire as _; use serde::{Deserialize, Serialize}; use swarm_authelia_bridge_sock::BridgeResponse; +use swarm_controller_api::agents::{CreateAgentResponse, MintAgentForgeTokenResponse}; use swarm_controller_api::forge::{ForgeAdminChange, MakeForgeAdminResponse}; use utoipa::{OpenApi, ToSchema}; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -1816,28 +1817,6 @@ struct CreateAgentRequest { archetype: Option, } -/// Where the queued job landed — a caller polls `/api/jobq/graph` (or -/// `?states=`) with this id to watch it settle, same as every other job -/// kind this daemon will ever queue. -#[derive(Clone, Debug, Serialize, ToSchema)] -struct CreateAgentResponse { - node_id: u64, - /// Name collisions that were **allowed through**, phrased for the - /// operator who just chose the name. - /// - /// The creation still happened — this is the "loud warning now, refuse - /// after a grace period" step, so a caller that ignores this field gets - /// exactly today's behaviour. It rides on the response rather than - /// living only in the daemon's log because the person who can still fix - /// the name in one keystroke is the one holding this response, and they - /// are not reading the journal. - /// - /// Empty (and omitted from the JSON) in the normal case, so a client - /// that never looks at it is not handed an empty array to reason about. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - warnings: Vec, -} - /// Queue the whole agent-creation job graph for `name` — two independent /// roots, `CreateIdentity` and `CreateForgeUser`, since an authelia /// subject and a forge account need nothing from each other. `CreateRepo` @@ -2706,13 +2685,6 @@ async fn get_agent_config_pr( Ok(Json(cache.get(&name))) } -/// Success body of `POST /api/agents/{name}/forge-token`. -#[derive(Clone, Debug, Serialize, ToSchema)] -struct MintAgentForgeTokenResponse { - /// The queued node, so a caller can follow it in the job view. - node_id: u64, -} - /// Insert one job per agent: `CreateForgeUser`, then `MintAgentForgeToken` /// `after_ok` it — the same edge [`declare_agent_job`] uses. Returns the mint /// nodes' ids. @@ -6601,36 +6573,6 @@ mod tests { } } -#[cfg(test)] -mod create_agent_warning_tests { - use super::CreateAgentResponse; - - /// The normal case must not grow a field. A client that has never - /// heard of warnings should see the response it always saw — that is - /// what makes "warn now, refuse later" a non-breaking first step. - #[test] - fn no_warnings_are_omitted_from_the_json_entirely() { - let json = serde_json::to_string(&CreateAgentResponse { - node_id: 7, - warnings: vec![], - }) - .unwrap(); - assert_eq!(json, r#"{"node_id":7}"#); - } - - /// Presence arm — without it, a `skip_serializing_if` that dropped the - /// field unconditionally would pass the test above. - #[test] - fn warnings_are_serialized_when_present() { - let json = serde_json::to_string(&CreateAgentResponse { - node_id: 7, - warnings: vec!["name is reserved".to_owned()], - }) - .unwrap(); - assert_eq!(json, r#"{"node_id":7,"warnings":["name is reserved"]}"#); - } -} - #[cfg(test)] mod mint_identity_tests { use std::sync::{Arc, Mutex}; diff --git a/swarmctl/README.md b/swarmctl/README.md index db285e67..b495a41c 100644 --- a/swarmctl/README.md +++ b/swarmctl/README.md @@ -8,8 +8,8 @@ that talk to the controller, from anywhere as your own SSO account (see Distinct from `hivectl`, which drives one hive's `hive-c0re` over its admin socket. This crate doesn't link `swarm-controller`, for the same reason `hivectl` doesn't link `hive-c0re`; the forge and schedule bodies -both sides speak come from `swarm-controller-api`, as `hivectl`'s come from -`hive-host-sock`. +and the agent responses both sides speak come from `swarm-controller-api`, +as `hivectl`'s come from `hive-host-sock`. ## Why root, and why no socket @@ -118,11 +118,11 @@ domain's SSO gate. controller checks it against the swarm's hive roster and names the hives that would have worked when it misses. -The request and response shapes are mirrored in `src/agent.rs` rather -than shared: the controller's own types are private to its binary, this -crate does not link it, and there is no wire-type crate between them. -Two fields out, two in, both ends validating — a drift shows up as a -400 naming the field. +The response shapes come from `swarm-controller-api`. `src/agent.rs` +mirrors the request shape: the controller's `CreateAgentRequest` +carries types from `swarm-queue-client` and `hive-sh4re`, neither of +which `swarm-controller-api` depends on. Two fields out, both ends +validating — a drift shows up as a 400 naming the field. ## `agent mint-forge-token` diff --git a/swarmctl/src/agent.rs b/swarmctl/src/agent.rs index eb0e0e1e..b71fa1a5 100644 --- a/swarmctl/src/agent.rs +++ b/swarmctl/src/agent.rs @@ -13,11 +13,11 @@ //! over the socket rather than pulling in a full client stack for one //! POST. //! -//! The two shapes below **mirror** the controller's own private -//! `CreateAgentRequest` / `CreateAgentResponse` rather than sharing them: -//! this crate does not link `swarm-controller`, and they are not in -//! `swarm-controller-api`. The seam is narrow and both ends validate, so -//! drift surfaces as a 400 naming the field. +//! The responses come from `swarm-controller-api`. The request body below +//! **mirrors** the controller's own private `CreateAgentRequest`, whose +//! fields include types from `swarm-queue-client` and `hive-sh4re`, and +//! `swarm-controller-api` depends on neither. The seam is narrow and both +//! ends validate, so drift surfaces as a 400 naming the field. //! //! Long-form rationale for all three: `swarmctl/README.md`. @@ -26,7 +26,8 @@ use std::path::Path; use anyhow::{Context as _, Result, bail}; use http_body_util::{BodyExt as _, Full}; use hyper_util::rt::TokioIo; -use serde::{Deserialize, Serialize}; +use serde::Serialize; +use swarm_controller_api::agents::{CreateAgentResponse, MintAgentForgeTokenResponse}; use tokio::net::UnixStream; use crate::remote::Target; @@ -42,23 +43,6 @@ struct CreateAgentRequest<'a> { hive: &'a str, } -/// Success body of `POST /api/agents`. -#[derive(Deserialize)] -struct CreateAgentResponse { - node_id: u64, - /// Name collisions the controller **allowed through** — it is the - /// "warn now, refuse later" step, so an empty list is the normal case - /// and the field is omitted from the JSON entirely when it is empty. - #[serde(default)] - warnings: Vec, -} - -/// Success body of `POST /api/agents/{name}/forge-token`, which takes no body. -#[derive(Deserialize)] -struct MintForgeTokenResponse { - node_id: u64, -} - /// Run `swarmctl agent create`. /// /// Synchronous on purpose: every other verb in this crate is, and this is @@ -113,7 +97,7 @@ pub(crate) fn mint_forge_token(target: &Target, name: &str) -> Result<()> { .enable_all() .build() .context("starting a tokio runtime for the controller request")?; - let resp: MintForgeTokenResponse = rt.block_on(post( + let resp: MintAgentForgeTokenResponse = rt.block_on(post( target, &format!("/api/agents/{name}/forge-token"), &serde_json::json!({}), @@ -283,25 +267,7 @@ fn connect_hint(socket: &Path) -> String { #[cfg(test)] mod tests { - use super::{CreateAgentResponse, describe_error, parse_ident}; - - /// The endpoint omits `warnings` entirely when there are none, so the - /// normal response must still decode — a missing field here would make - /// every successful creation fail to parse. - #[test] - fn a_response_without_warnings_decodes() { - let resp: CreateAgentResponse = - serde_json::from_str(r#"{"node_id":7}"#).expect("the normal shape decodes"); - assert_eq!(resp.node_id, 7); - assert!(resp.warnings.is_empty()); - } - - #[test] - fn warnings_are_carried_through() { - let resp: CreateAgentResponse = - serde_json::from_str(r#"{"node_id":9,"warnings":["reserved name"]}"#).expect("decodes"); - assert_eq!(resp.warnings, vec!["reserved name".to_owned()]); - } + use super::{describe_error, parse_ident}; /// The whole point of parsing problem+json: the 400 for an unknown /// hive carries the roster of hives that would have worked, and that diff --git a/swarmctl/src/permissions.rs b/swarmctl/src/permissions.rs index c5973b48..3bd15f28 100644 --- a/swarmctl/src/permissions.rs +++ b/swarmctl/src/permissions.rs @@ -5,9 +5,10 @@ //! it restarts it, after its current turn, when `manage_root_agent` changes. //! Unset leaves the agent on the default set; an empty list grants nothing. //! -//! The shape below mirrors the controller's `Permissions`, for the reason -//! [`crate::agent`]'s module doc gives. Permission names are checked by the -//! controller, which answers 422 for one it does not know. +//! The shape below mirrors the controller's `Permissions`, whose names are +//! `hive_sh4re::permissions::Permission`, and `swarm-controller-api` does not +//! depend on `hive-sh4re`. Permission names are checked by the controller, +//! which answers 422 for one it does not know. use anyhow::{Context as _, Result}; use serde::{Deserialize, Serialize};