Watch
0
0
Fork
You've already forked hyperhive
0

take the agent-create and forge-token responses from swarm-controller-api

swarmctl decoded POST /api/agents and POST /api/agents/{name}/forge-token
into hand-written copies of the controller's private response structs.
Both now live in swarm-controller-api, so a field renamed on one side is a
compile error on the other. Wire format and OpenAPI schema are unchanged.

The request side stays mirrored: CreateAgentRequest carries inference
settings and an ArchetypeRef (swarm-queue-client types) and Permission
(hive-sh4re), and the permissions body is a list of Permission. Where
those types live is #5471's decision.

Files:
- swarm-controller-api/src/agents.rs, lib.rs: the two responses, with
  round-trip tests whose literals come from the tests that move here.
- swarm-controller/src/main.rs: imports them; the struct definitions and
  create_agent_warning_tests (now in the api crate) go.
- swarmctl/src/agent.rs: imports them; the mirrors and their decode tests
  go, and the module doc says only the request is mirrored now.
- swarmctl/src/permissions.rs: its doc pointed at agent.rs for why it
  mirrors; that reason no longer covers Permissions, so it gives its own.
- swarmctl/README.md, swarm-controller-api/README.md, CLAUDE.md: which
  bodies are shared and which are mirrored.

Part of #5472
This commit is contained in:
atlas 2026-10-17 23:37:17 +02:00 • committed by mara
commit 98501d2d88
8 changed files with 110 additions and 118 deletions

View file

@ -118,11 +118,13 @@ Swarm-level:
**the socket's directory is its access control**; the crate README has **the socket's directory is its access control**; the crate README has
the constraint and a unit test pins the path. the constraint and a unit test pins the path.
- **`swarm-controller-api/`** — serde-only request and response bodies of - **`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** - **`swarmctl/`** — swarm operator CLI; runs as **root and acts directly**
(README says why). Doesn't link `swarm-controller`: the forge and (README says why). Doesn't link `swarm-controller`: the forge and
schedule bodies come from `swarm-controller-api`, the agent-create, schedule bodies and the agent responses come from
inference and permissions ones are a local mirror. ⚠️ Edits authelia's `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 **`users.yml` in place**, shared with `swarm-authelia-bridge`; that
crate's README says what both writers must uphold. Verb reference crate's README says what both writers must uphold. Verb reference
(CI-enforced): (CI-enforced):

View file

@ -16,6 +16,8 @@ controller's `OpenAPI` spec. `swarmctl` leaves it off.
## What's here ## What's here
One module per endpoint family: `forge` (`POST /api/forge/users/{name}/admin`) One module per endpoint family: `agents` (the responses of `POST /api/agents`
and `schedules` (`/api/schedules`). The agent-create, inference and and `POST /api/agents/{name}/forge-token`), `forge`
permissions bodies are still mirrored by hand in `swarmctl`. (`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`.

View file

@ -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<String>,
}
/// 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::<CreateAgentResponse>(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::<MintAgentForgeTokenResponse>(wire).unwrap(),
resp
);
}
}

View file

@ -4,5 +4,6 @@
//! them, so the shapes cannot drift between the two. Serde only; with the //! them, so the shapes cannot drift between the two. Serde only; with the
//! `schema` feature each type also derives `utoipa::ToSchema`. //! `schema` feature each type also derives `utoipa::ToSchema`.
pub mod agents;
pub mod forge; pub mod forge;
pub mod schedules; pub mod schedules;

View file

@ -39,6 +39,7 @@ use backon::{ExponentialBuilder, Retryable as _};
use hive_jobq_wire::GraphWire as _; use hive_jobq_wire::GraphWire as _;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use swarm_authelia_bridge_sock::BridgeResponse; use swarm_authelia_bridge_sock::BridgeResponse;
use swarm_controller_api::agents::{CreateAgentResponse, MintAgentForgeTokenResponse};
use swarm_controller_api::forge::{ForgeAdminChange, MakeForgeAdminResponse}; use swarm_controller_api::forge::{ForgeAdminChange, MakeForgeAdminResponse};
use utoipa::{OpenApi, ToSchema}; use utoipa::{OpenApi, ToSchema};
use utoipa_axum::{router::OpenApiRouter, routes}; use utoipa_axum::{router::OpenApiRouter, routes};
@ -1816,28 +1817,6 @@ struct CreateAgentRequest {
archetype: Option<swarm_queue_client::agent_archetype::ArchetypeRef>, archetype: Option<swarm_queue_client::agent_archetype::ArchetypeRef>,
} }
/// 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<String>,
}
/// Queue the whole agent-creation job graph for `name` — two independent /// Queue the whole agent-creation job graph for `name` — two independent
/// roots, `CreateIdentity` and `CreateForgeUser`, since an authelia /// roots, `CreateIdentity` and `CreateForgeUser`, since an authelia
/// subject and a forge account need nothing from each other. `CreateRepo` /// 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))) 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` /// Insert one job per agent: `CreateForgeUser`, then `MintAgentForgeToken`
/// `after_ok` it — the same edge [`declare_agent_job`] uses. Returns the mint /// `after_ok` it — the same edge [`declare_agent_job`] uses. Returns the mint
/// nodes' ids. /// 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)] #[cfg(test)]
mod mint_identity_tests { mod mint_identity_tests {
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};

View file

@ -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 Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
admin socket. This crate doesn't link `swarm-controller`, for the same admin socket. This crate doesn't link `swarm-controller`, for the same
reason `hivectl` doesn't link `hive-c0re`; the forge and schedule bodies 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 and the agent responses both sides speak come from `swarm-controller-api`,
`hive-host-sock`. as `hivectl`'s come from `hive-host-sock`.
## Why root, and why no socket ## 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 controller checks it against the swarm's hive roster and names the hives
that would have worked when it misses. that would have worked when it misses.
The request and response shapes are mirrored in `src/agent.rs` rather The response shapes come from `swarm-controller-api`. `src/agent.rs`
than shared: the controller's own types are private to its binary, this mirrors the request shape: the controller's `CreateAgentRequest`
crate does not link it, and there is no wire-type crate between them. carries types from `swarm-queue-client` and `hive-sh4re`, neither of
Two fields out, two in, both ends validating — a drift shows up as a which `swarm-controller-api` depends on. Two fields out, both ends
400 naming the field. validating — a drift shows up as a 400 naming the field.
## `agent mint-forge-token` ## `agent mint-forge-token`

View file

@ -13,11 +13,11 @@
//! over the socket rather than pulling in a full client stack for one //! over the socket rather than pulling in a full client stack for one
//! POST. //! POST.
//! //!
//! The two shapes below **mirror** the controller's own private //! The responses come from `swarm-controller-api`. The request body below
//! `CreateAgentRequest` / `CreateAgentResponse` rather than sharing them: //! **mirrors** the controller's own private `CreateAgentRequest`, whose
//! this crate does not link `swarm-controller`, and they are not in //! fields include types from `swarm-queue-client` and `hive-sh4re`, and
//! `swarm-controller-api`. The seam is narrow and both ends validate, so //! `swarm-controller-api` depends on neither. The seam is narrow and both
//! drift surfaces as a 400 naming the field. //! ends validate, so drift surfaces as a 400 naming the field.
//! //!
//! Long-form rationale for all three: `swarmctl/README.md`. //! Long-form rationale for all three: `swarmctl/README.md`.
@ -26,7 +26,8 @@ use std::path::Path;
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use http_body_util::{BodyExt as _, Full}; use http_body_util::{BodyExt as _, Full};
use hyper_util::rt::TokioIo; use hyper_util::rt::TokioIo;
use serde::{Deserialize, Serialize}; use serde::Serialize;
use swarm_controller_api::agents::{CreateAgentResponse, MintAgentForgeTokenResponse};
use tokio::net::UnixStream; use tokio::net::UnixStream;
use crate::remote::Target; use crate::remote::Target;
@ -42,23 +43,6 @@ struct CreateAgentRequest<'a> {
hive: &'a str, 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<String>,
}
/// Success body of `POST /api/agents/{name}/forge-token`, which takes no body.
#[derive(Deserialize)]
struct MintForgeTokenResponse {
node_id: u64,
}
/// Run `swarmctl agent create`. /// Run `swarmctl agent create`.
/// ///
/// Synchronous on purpose: every other verb in this crate is, and this is /// 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() .enable_all()
.build() .build()
.context("starting a tokio runtime for the controller request")?; .context("starting a tokio runtime for the controller request")?;
let resp: MintForgeTokenResponse = rt.block_on(post( let resp: MintAgentForgeTokenResponse = rt.block_on(post(
target, target,
&format!("/api/agents/{name}/forge-token"), &format!("/api/agents/{name}/forge-token"),
&serde_json::json!({}), &serde_json::json!({}),
@ -283,25 +267,7 @@ fn connect_hint(socket: &Path) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{CreateAgentResponse, describe_error, parse_ident}; use super::{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()]);
}
/// The whole point of parsing problem+json: the 400 for an unknown /// The whole point of parsing problem+json: the 400 for an unknown
/// hive carries the roster of hives that would have worked, and that /// hive carries the roster of hives that would have worked, and that

View file

@ -5,9 +5,10 @@
//! it restarts it, after its current turn, when `manage_root_agent` changes. //! 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. //! Unset leaves the agent on the default set; an empty list grants nothing.
//! //!
//! The shape below mirrors the controller's `Permissions`, for the reason //! The shape below mirrors the controller's `Permissions`, whose names are
//! [`crate::agent`]'s module doc gives. Permission names are checked by the //! `hive_sh4re::permissions::Permission`, and `swarm-controller-api` does not
//! controller, which answers 422 for one it does not know. //! 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 anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};