Watch
0
0
Fork
You've already forked hyperhive
0

share the forge and schedule wire types through swarm-controller-api

swarmctl hand-mirrored the controller's forge make-admin response and
the /api/schedules bodies. Both now import them from a new serde-only
crate, swarm-controller-api, the way hivectl and hive-c0re share
hive-host-sock. ToSchema sits behind its schema feature, which only
the controller turns on.

The wire format is unchanged: round-trip tests in the new crate pin
each type against the JSON the controller and swarmctl produced, and
the controller's OpenAPI document for these routes is byte-identical.

Files: swarm-controller-api/ is the new crate; swarm-controller and
swarmctl (src, Cargo.toml, Cargo.lock, root Cargo.toml) switch to it;
CLAUDE.md lists the crate and which swarmctl shapes are still local
mirrors; swarmctl/README.md says the forge and schedule bodies come
from the crate, so none of its lines still call them mirrored.

Part of #5472.
This commit is contained in:
atlas 2026-10-17 16:44:10 +02:00 • committed by mara
commit be92bc0835
17 changed files with 529 additions and 377 deletions

View file

@ -34,6 +34,8 @@ serde.workspace = true
serde_json.workspace = true
serde_norway = "0.9.42"
sha2.workspace = true
# The forge and schedule bodies, shared with swarm-controller.
swarm-controller-api.workspace = true
tokio.workspace = true
url.workspace = true

View file

@ -6,8 +6,10 @@ that talk to the controller, from anywhere as your own SSO account (see
[Remote use](#remote-use-swarmctl-login)).
Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
admin socket. This crate does not link `swarm-controller`, for the same
reason `hivectl` does not link `hive-c0re`.
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`.
## Why root, and why no socket
@ -144,8 +146,8 @@ forge: "mara" is now a site admin
socket as `agent create`. It never creates an account: the forge makes a
person's on their first login through authelia, and until then this fails
saying so. Running it on a site admin changes nothing, and an agent's name is
refused. The response shape is mirrored in `src/forge.rs`, for the same
reason as `agent create`'s.
refused. The response body comes from `swarm-controller-api`, imported in
`src/forge.rs`.
## `schedule`
@ -160,8 +162,8 @@ reason as `agent create`'s.
The `/api/schedules` routes on the swarm-controller, over the
same socket as `agent create`; each verb waits for the stored result. What a
schedule does once stored is in
[`docs/swarm/schedules.md`](../docs/swarm/schedules.md). The shapes are
mirrored in `src/schedule.rs`, for the same reason as `agent create`'s.
[`docs/swarm/schedules.md`](../docs/swarm/schedules.md). The bodies come
from `swarm-controller-api`, imported in `src/schedule.rs`.
## Remote use: `swarmctl login`

View file

@ -15,8 +15,8 @@
//!
//! The two shapes below **mirror** the controller's own private
//! `CreateAgentRequest` / `CreateAgentResponse` rather than sharing them:
//! this crate does not link `swarm-controller` and there is no wire
//! crate between them. The seam is narrow and both ends validate, so
//! 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.
//!
//! Long-form rationale for all three: `swarmctl/README.md`.

View file

@ -3,28 +3,14 @@
//!
//! Unlike the `agent` verbs this one waits: the controller answers with what
//! it found. Same round trip as [`crate::agent`], over the controller's unix
//! socket; the shape below mirrors the controller's own private
//! `MakeForgeAdminResponse` for the reason that module's doc comment gives.
//! socket.
use anyhow::{Context as _, Result};
use serde::Deserialize;
use swarm_controller_api::forge::{ForgeAdminChange, MakeForgeAdminResponse};
use crate::agent::{parse_ident, post};
use crate::remote::Target;
/// Success body of `POST /api/forge/users/{name}/admin`.
#[derive(Deserialize)]
struct MakeForgeAdminResponse {
change: AdminChange,
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum AdminChange {
Promoted,
AlreadyAdmin,
}
/// Run `swarmctl forge make-admin`.
///
/// Synchronous for the same reason `agent create` is.
@ -43,23 +29,8 @@ pub(crate) fn make_admin(target: &Target, name: &str) -> Result<()> {
))?;
match resp.change {
AdminChange::Promoted => println!("forge: {name:?} is now a site admin"),
AdminChange::AlreadyAdmin => println!("forge: {name:?} was already a site admin"),
ForgeAdminChange::Promoted => println!("forge: {name:?} is now a site admin"),
ForgeAdminChange::AlreadyAdmin => println!("forge: {name:?} was already a site admin"),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{AdminChange, MakeForgeAdminResponse};
/// The controller's two answers, as it spells them.
#[test]
fn both_changes_decode() {
for (wire, want) in [("promoted", true), ("already_admin", false)] {
let resp: MakeForgeAdminResponse =
serde_json::from_str(&format!(r#"{{"change":"{wire}"}}"#)).expect("decodes");
assert_eq!(matches!(resp.change, AdminChange::Promoted), want);
}
}
}

View file

@ -739,7 +739,7 @@ fn schedule(remote: &RemoteArgs, command: ScheduleVerb) -> Result<()> {
),
ScheduleVerb::List(a) => schedule::list(&a.socket.resolve(remote)?, a.agent.as_deref()),
ScheduleVerb::Edit(a) => {
let edit = schedule::EditRequest::new(schedule::EditFlags {
let edit = schedule::edit_request(schedule::EditFlags {
name: a.name,
body: a.body,
at: a.at.as_deref(),

View file

@ -4,85 +4,15 @@
//! Each verb waits for the controller's answer, which is the list or the
//! schedule as stored. Each agent the schedule names picks a change up on its
//! next read of its schedule key and fires in its own container.
//!
//! The shapes below mirror the controller's own `ScheduleView` and request
//! bodies, for the reason [`crate::agent`]'s module doc gives.
use anyhow::{Context as _, Result, bail};
use serde::{Deserialize, Serialize};
use swarm_controller_api::schedules::{
AddScheduleRequest, EditScheduleRequest, IntervalEdit, ScheduleView,
};
use crate::agent::{parse_ident, send};
use crate::remote::Target;
/// One schedule as the controller shows it.
#[derive(Debug, Deserialize)]
struct ScheduleView {
id: String,
name: String,
agents: Vec<String>,
body: String,
anchor_unix: i64,
interval_seconds: Option<u64>,
paused: bool,
next_fire_unix: Option<i64>,
}
/// Body of `POST /api/schedules`.
#[derive(Serialize)]
struct AddRequest<'a> {
name: &'a str,
agents: Vec<String>,
body: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
anchor_unix: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
interval_seconds: Option<u64>,
paused: bool,
}
/// Body of `PATCH /api/schedules/{id}`. An absent field is left as it is.
#[derive(Default, Serialize)]
pub(crate) struct EditRequest {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
anchor_unix: Option<i64>,
#[serde(skip_serializing_if = "IntervalEdit::is_keep")]
interval_seconds: IntervalEdit,
#[serde(skip_serializing_if = "Option::is_none")]
paused: Option<bool>,
#[serde(skip_serializing_if = "Vec::is_empty")]
add_agents: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
remove_agents: Vec<String>,
}
/// What an edit does to the interval. `Set(None)` is sent as `null`, which
/// makes a one-shot; `Keep` is not sent at all.
#[derive(Clone, Copy, Default)]
enum IntervalEdit {
#[default]
Keep,
Set(Option<u64>),
}
impl IntervalEdit {
fn is_keep(&self) -> bool {
matches!(self, Self::Keep)
}
}
impl Serialize for IntervalEdit {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
Self::Keep | Self::Set(None) => s.serialize_none(),
Self::Set(Some(seconds)) => s.serialize_u64(*seconds),
}
}
}
/// What `schedule edit` was asked to change.
pub(crate) struct EditFlags<'a> {
pub name: Option<String>,
@ -94,44 +24,42 @@ pub(crate) struct EditFlags<'a> {
pub remove_agents: Vec<String>,
}
impl EditRequest {
/// The edit `schedule edit` asks for. `once` and `every` are exclusive
/// in the CLI, so at most one of them is set here.
pub(crate) fn new(flags: EditFlags<'_>) -> Result<Self> {
let interval_seconds = match (flags.every, flags.once) {
(Some(every), _) => IntervalEdit::Set(Some(parse_every(every)?)),
(None, true) => IntervalEdit::Set(None),
(None, false) => IntervalEdit::Keep,
};
let edit = Self {
name: flags.name,
body: flags.body,
anchor_unix: flags.at.map(parse_at).transpose()?,
interval_seconds,
paused: None,
add_agents: agents(&flags.add_agents)?,
remove_agents: agents(&flags.remove_agents)?,
};
if edit.name.is_none()
&& edit.body.is_none()
&& edit.anchor_unix.is_none()
&& edit.interval_seconds.is_keep()
&& edit.add_agents.is_empty()
&& edit.remove_agents.is_empty()
{
bail!(
"nothing to change: pass --name, --body, --at, --every, --once, --add-agent \
or --remove-agent"
);
}
Ok(edit)
/// The edit `schedule edit` asks for. `once` and `every` are exclusive in the
/// CLI, so at most one of them is set here.
pub(crate) fn edit_request(flags: EditFlags<'_>) -> Result<EditScheduleRequest> {
let interval_seconds = match (flags.every, flags.once) {
(Some(every), _) => IntervalEdit::Set(Some(parse_every(every)?)),
(None, true) => IntervalEdit::Set(None),
(None, false) => IntervalEdit::Keep,
};
let edit = EditScheduleRequest {
name: flags.name,
body: flags.body,
anchor_unix: flags.at.map(parse_at).transpose()?,
interval_seconds,
paused: None,
add_agents: agents(&flags.add_agents)?,
remove_agents: agents(&flags.remove_agents)?,
};
if edit.name.is_none()
&& edit.body.is_none()
&& edit.anchor_unix.is_none()
&& edit.interval_seconds.is_keep()
&& edit.add_agents.is_empty()
&& edit.remove_agents.is_empty()
{
bail!(
"nothing to change: pass --name, --body, --at, --every, --once, --add-agent \
or --remove-agent"
);
}
Ok(edit)
}
fn paused(paused: bool) -> Self {
Self {
paused: Some(paused),
..Self::default()
}
fn paused_edit(paused: bool) -> EditScheduleRequest {
EditScheduleRequest {
paused: Some(paused),
..EditScheduleRequest::default()
}
}
@ -167,10 +95,10 @@ pub(crate) struct AddFlags<'a> {
/// Run `swarmctl schedule add`.
pub(crate) fn add(target: &Target, flags: &AddFlags<'_>) -> Result<()> {
let request = AddRequest {
name: flags.name,
let request = AddScheduleRequest {
name: flags.name.to_owned(),
agents: agents(flags.agents)?,
body: flags.body,
body: flags.body.to_owned(),
anchor_unix: flags.at.map(parse_at).transpose()?,
interval_seconds: flags.every.map(parse_every).transpose()?,
paused: flags.paused,
@ -211,7 +139,7 @@ pub(crate) fn list(target: &Target, agent: Option<&str>) -> Result<()> {
}
/// Run `swarmctl schedule edit`.
pub(crate) fn edit(target: &Target, id: &str, edit: &EditRequest) -> Result<()> {
pub(crate) fn edit(target: &Target, id: &str, edit: &EditScheduleRequest) -> Result<()> {
let edited: ScheduleView = runtime()?.block_on(send(
target,
hyper::Method::PATCH,
@ -225,7 +153,7 @@ pub(crate) fn edit(target: &Target, id: &str, edit: &EditRequest) -> Result<()>
/// Run `swarmctl schedule pause` or `resume`.
pub(crate) fn set_paused(target: &Target, id: &str, paused: bool) -> Result<()> {
edit(target, id, &EditRequest::paused(paused))
edit(target, id, &paused_edit(paused))
}
/// Run `swarmctl schedule rm`.
@ -359,20 +287,20 @@ mod tests {
/// explicit `null` interval, and an unset flag sends nothing.
#[test]
fn an_edit_sends_only_what_was_asked() {
let body = |e: &EditRequest| serde_json::to_string(e).unwrap();
let e = EditRequest::new(EditFlags {
let body = |e: &EditScheduleRequest| serde_json::to_string(e).unwrap();
let e = edit_request(EditFlags {
name: Some("hi".to_owned()),
..flags()
})
.unwrap();
assert_eq!(body(&e), r#"{"name":"hi"}"#);
let e = EditRequest::new(EditFlags {
let e = edit_request(EditFlags {
once: true,
..flags()
})
.unwrap();
assert_eq!(body(&e), r#"{"interval_seconds":null}"#);
let e = EditRequest::new(EditFlags {
let e = edit_request(EditFlags {
at: Some("5"),
every: Some("1m"),
add_agents: vec!["iris".to_owned()],
@ -384,10 +312,10 @@ mod tests {
body(&e),
r#"{"anchor_unix":5,"interval_seconds":60,"add_agents":["iris"],"remove_agents":["atlas"]}"#
);
assert_eq!(body(&EditRequest::paused(true)), r#"{"paused":true}"#);
assert!(EditRequest::new(flags()).is_err());
assert_eq!(body(&paused_edit(true)), r#"{"paused":true}"#);
assert!(edit_request(flags()).is_err());
assert!(
EditRequest::new(EditFlags {
edit_request(EditFlags {
add_agents: vec!["Not An Ident".to_owned()],
..flags()
})