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 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):

View file

@ -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`.

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
//! `schema` feature each type also derives `utoipa::ToSchema`.
pub mod agents;
pub mod forge;
pub mod schedules;

View file

@ -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<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
/// 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};

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
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`

View file

@ -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<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`.
///
/// 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

View file

@ -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};