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:
parent
d4714072a9
commit
98501d2d88
8 changed files with 110 additions and 118 deletions
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
78
swarm-controller-api/src/agents.rs
Normal file
78
swarm-controller-api/src/agents.rs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue