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

@ -117,11 +117,15 @@ Swarm-level:
one per swarm. Serves HTTP over a unix socket nginx proxies to — ⚠️
**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).
- **`swarmctl/`** — swarm operator CLI; runs as **root and acts directly**
(README says why). Does not link `swarm-controller` — its wire shapes
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):
(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
**`users.yml` in place**, shared with `swarm-authelia-bridge`; that
crate's README says what both writers must uphold. Verb reference
(CI-enforced):
[`docs/tools/swarmctl-cli.md`](docs/tools/swarmctl-cli.md).
- **`swarm-authelia-bridge/`** (+ **`swarm-authelia-bridge-sock/`** wire
types) — the other writer of authelia's users DB; runs inside

11
Cargo.lock generated
View file

@ -4884,6 +4884,7 @@ dependencies = [
"similar",
"strum",
"swarm-authelia-bridge-sock",
"swarm-controller-api",
"swarm-matrix-client",
"swarm-queue-client",
"swarm-secret-client",
@ -4898,6 +4899,15 @@ dependencies = [
"x509-cert",
]
[[package]]
name = "swarm-controller-api"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"utoipa",
]
[[package]]
name = "swarm-logs"
version = "0.1.0"
@ -5085,6 +5095,7 @@ dependencies = [
"serde_json",
"serde_norway",
"sha2 0.11.0",
"swarm-controller-api",
"tokio",
"url",
]

View file

@ -32,6 +32,7 @@ members = [
"swarm-claude-login",
"swarm-authelia-bridge-sock",
"swarm-controller",
"swarm-controller-api",
"swarm-matrix-client",
"swarm-matrix-ctl",
"swarm-nats-auth",
@ -113,6 +114,7 @@ hive-sock-client = { path = "hive-sock-client" }
hive-types = { path = "hive-types" }
swarm-agent-verify = { path = "swarm-agent-verify" }
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
swarm-controller-api = { path = "swarm-controller-api" }
swarm-matrix-client = { path = "swarm-matrix-client" }
swarm-queue-client = { path = "swarm-queue-client" }
swarm-secret-client = { path = "swarm-secret-client" }

View file

@ -0,0 +1,20 @@
[package]
name = "swarm-controller-api"
version.workspace = true
edition.workspace = true
readme = "README.md"
[features]
# `ToSchema` on every type, for swarm-controller, whose `OpenAPI` spec
# describes them. Off by default so swarmctl does not link utoipa.
schema = ["dep:utoipa"]
[dependencies]
serde.workspace = true
utoipa = { workspace = true, optional = true }
[dev-dependencies]
serde_json.workspace = true
[lints]
workspace = true

View file

@ -0,0 +1,21 @@
# swarm-controller-api
Wire types for **swarm-controller's HTTP API**, the request and response
bodies both `swarm-controller` (server) and `swarmctl` (client) import.
Serde only: no server or client logic, no tokio, no axum.
## Why it's its own crate
`swarmctl` doesn't link `swarm-controller`, for the same reason `hivectl`
doesn't link `hive-c0re`. The two still share one contract, the same way
`hivectl` and `hive-c0re` share `hive-host-sock`: a field renamed on one side
is a compile error on the other, not a 400 the operator finds at runtime.
The `schema` feature derives utoipa's `ToSchema` on every type, for the
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`.

View file

@ -0,0 +1,42 @@
//! `POST /api/forge/users/{name}/admin`.
use serde::{Deserialize, Serialize};
/// What `POST /api/forge/users/{name}/admin` found.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ForgeAdminChange {
Promoted,
AlreadyAdmin,
}
/// Success body of `POST /api/forge/users/{name}/admin`.
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct MakeForgeAdminResponse {
pub change: ForgeAdminChange,
}
#[cfg(test)]
mod tests {
use super::{ForgeAdminChange, MakeForgeAdminResponse};
#[test]
fn both_changes_round_trip() {
for (change, wire) in [
(ForgeAdminChange::Promoted, r#"{"change":"promoted"}"#),
(
ForgeAdminChange::AlreadyAdmin,
r#"{"change":"already_admin"}"#,
),
] {
let resp = MakeForgeAdminResponse { change };
assert_eq!(serde_json::to_string(&resp).unwrap(), wire);
assert_eq!(
serde_json::from_str::<MakeForgeAdminResponse>(wire).unwrap(),
resp
);
}
}
}

View file

@ -0,0 +1,8 @@
//! Wire types for swarm-controller's HTTP API.
//!
//! `swarm-controller` serves these bodies and `swarmctl` sends and reads
//! them, so the shapes cannot drift between the two. Serde only; with the
//! `schema` feature each type also derives `utoipa::ToSchema`.
pub mod forge;
pub mod schedules;

View file

@ -0,0 +1,218 @@
//! `/api/schedules`: the operator's scheduled prompts.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// One schedule as the API shows it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct ScheduleView {
pub id: String,
/// The operator's title for it.
pub name: String,
/// The agents it fires in, sorted.
pub agents: Vec<String>,
/// The prompt, delivered to each agent verbatim.
pub body: String,
/// The first fire time, unix seconds.
pub anchor_unix: i64,
/// Seconds between fires; `null` for a one-shot.
pub interval_seconds: Option<u64>,
pub paused: bool,
/// The next fire time on the schedule's grid, unix seconds, computed when
/// the response is built; `null` when paused or a one-shot whose time
/// has passed. Says nothing about whether an agent fired an earlier one.
pub next_fire_unix: Option<i64>,
}
/// A new schedule.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct AddScheduleRequest {
/// The title. Must not be empty.
pub name: String,
/// The agents to fire in, at least one. Each must be declared on a hive.
pub agents: Vec<String>,
/// The prompt, delivered verbatim. Must not be empty.
pub body: String,
/// The first fire time, unix seconds. Defaults to now.
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor_unix: Option<i64>,
/// Seconds between fires, at least 1. Absent for a one-shot.
#[serde(skip_serializing_if = "Option::is_none")]
pub interval_seconds: Option<u64>,
#[serde(default)]
pub paused: bool,
}
/// A partial change to one schedule; an absent field is left as it is.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct EditScheduleRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor_unix: Option<i64>,
/// A number sets the interval; `null` makes the schedule a one-shot.
#[serde(default, skip_serializing_if = "IntervalEdit::is_keep")]
#[cfg_attr(feature = "schema", schema(value_type = Option<u64>))]
pub interval_seconds: IntervalEdit,
#[serde(skip_serializing_if = "Option::is_none")]
pub paused: Option<bool>,
/// Agents to add. Each must be declared on a hive; one already targeted
/// is left as it is.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub add_agents: Vec<String>,
/// Agents to remove. Each must be a current target, and at least one
/// target must remain.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub remove_agents: Vec<String>,
}
/// What an edit does to the interval: an absent field keeps it, `null` makes
/// a one-shot, a number sets it. `Keep` is never serialized; the field is
/// left out instead.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum IntervalEdit {
#[default]
Keep,
Set(Option<u64>),
}
impl IntervalEdit {
#[must_use]
pub fn is_keep(&self) -> bool {
matches!(self, Self::Keep)
}
}
impl Serialize for IntervalEdit {
fn serialize<S: 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),
}
}
}
impl<'de> Deserialize<'de> for IntervalEdit {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Option::<u64>::deserialize(d).map(Self::Set)
}
}
#[cfg(test)]
mod tests {
use super::{AddScheduleRequest, EditScheduleRequest, IntervalEdit, ScheduleView};
/// Serialize `value`, check it against `wire`, and decode `wire` back.
fn round_trip<T>(value: &T, wire: &str)
where
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
{
assert_eq!(serde_json::to_string(value).unwrap(), wire);
assert_eq!(&serde_json::from_str::<T>(wire).unwrap(), value);
}
#[test]
fn a_view_round_trips() {
round_trip(
&ScheduleView {
id: "abc".to_owned(),
name: "standup".to_owned(),
agents: vec!["atlas".to_owned(), "iris".to_owned()],
body: "post the standup".to_owned(),
anchor_unix: 1000,
interval_seconds: Some(3600),
paused: false,
next_fire_unix: Some(4600),
},
r#"{"id":"abc","name":"standup","agents":["atlas","iris"],"body":"post the standup","anchor_unix":1000,"interval_seconds":3600,"paused":false,"next_fire_unix":4600}"#,
);
round_trip(
&ScheduleView {
id: "abc".to_owned(),
name: "once".to_owned(),
agents: vec!["atlas".to_owned()],
body: "hi".to_owned(),
anchor_unix: 0,
interval_seconds: None,
paused: true,
next_fire_unix: None,
},
r#"{"id":"abc","name":"once","agents":["atlas"],"body":"hi","anchor_unix":0,"interval_seconds":null,"paused":true,"next_fire_unix":null}"#,
);
}
/// An absent anchor or interval is left out, `paused` is always sent, and
/// a body without `paused` decodes as not paused.
#[test]
fn an_add_round_trips() {
let mut add = AddScheduleRequest {
name: "n".to_owned(),
agents: vec!["iris".to_owned()],
body: "hi".to_owned(),
anchor_unix: Some(5),
interval_seconds: Some(60),
paused: true,
};
round_trip(
&add,
r#"{"name":"n","agents":["iris"],"body":"hi","anchor_unix":5,"interval_seconds":60,"paused":true}"#,
);
add.anchor_unix = None;
add.interval_seconds = None;
add.paused = false;
round_trip(
&add,
r#"{"name":"n","agents":["iris"],"body":"hi","paused":false}"#,
);
assert_eq!(
serde_json::from_str::<AddScheduleRequest>(
r#"{"name":"n","agents":["iris"],"body":"hi"}"#
)
.unwrap(),
add
);
}
/// An edit sends only what it changes: `Keep` and empty agent lists are
/// left out, and a one-shot is an explicit `null`.
#[test]
fn an_edit_round_trips() {
round_trip(&EditScheduleRequest::default(), "{}");
round_trip(
&EditScheduleRequest {
name: Some("hi".to_owned()),
..EditScheduleRequest::default()
},
r#"{"name":"hi"}"#,
);
round_trip(
&EditScheduleRequest {
interval_seconds: IntervalEdit::Set(None),
..EditScheduleRequest::default()
},
r#"{"interval_seconds":null}"#,
);
round_trip(
&EditScheduleRequest {
anchor_unix: Some(5),
interval_seconds: IntervalEdit::Set(Some(60)),
add_agents: vec!["iris".to_owned()],
remove_agents: vec!["atlas".to_owned()],
..EditScheduleRequest::default()
},
r#"{"anchor_unix":5,"interval_seconds":60,"add_agents":["iris"],"remove_agents":["atlas"]}"#,
);
round_trip(
&EditScheduleRequest {
body: Some("b".to_owned()),
paused: Some(true),
..EditScheduleRequest::default()
},
r#"{"body":"b","paused":true}"#,
);
}
}

View file

@ -89,6 +89,8 @@ serde_json.workspace = true
serde_norway = "0.9.42"
strum.workspace = true
swarm-authelia-bridge-sock.workspace = true
# `schema`: the forge and schedule routes describe these bodies in the spec.
swarm-controller-api = { workspace = true, features = ["schema"] }
# The queue connect (token mint + auth callback + reconnect) is shared with
# every other participant - a hive publishing its own status runs the same
# code with a different client id. Two copies of credential handling is one

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::forge::{ForgeAdminChange, MakeForgeAdminResponse};
use utoipa::{OpenApi, ToSchema};
use utoipa_axum::{router::OpenApiRouter, routes};
@ -3122,20 +3123,6 @@ async fn mint_agent_forge_token(
Ok(Json(MintAgentForgeTokenResponse { node_id: id.get() }))
}
/// What `POST /api/forge/users/{name}/admin` found.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
enum ForgeAdminChange {
Promoted,
AlreadyAdmin,
}
/// Success body of `POST /api/forge/users/{name}/admin`.
#[derive(Serialize, Deserialize, ToSchema)]
struct MakeForgeAdminResponse {
change: ForgeAdminChange,
}
/// Make an existing human forge account a site admin. Idempotent.
///
/// Never creates the account: a human's is made by their first SSO login to

View file

@ -26,9 +26,11 @@ use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use problem_details::ProblemDetails;
use serde::{Deserialize, Deserializer, Serialize};
use serde::{Deserialize, Serialize};
use swarm_controller_api::schedules::{
AddScheduleRequest, EditScheduleRequest, IntervalEdit, ScheduleView,
};
use swarm_queue_client::agent_schedules::{self, AgentSchedules, Schedule};
use utoipa::ToSchema;
use crate::agent_renewal::unix_now;
use crate::wanted::WantedWriter;
@ -168,6 +170,20 @@ impl Definition {
let interval = i64::try_from(self.interval_seconds?).ok()?;
Some(self.for_agent().latest_slot(now)? + interval)
}
/// This schedule as the API shows it, with its next fire after `now`.
fn view(&self, now: i64) -> ScheduleView {
ScheduleView {
id: self.id.clone(),
name: self.name.clone(),
agents: self.agents.iter().cloned().collect(),
body: self.body.clone(),
anchor_unix: self.anchor_unix,
interval_seconds: self.interval_seconds,
paused: self.paused,
next_fire_unix: self.next_fire(now),
}
}
}
/// `agent`'s key: the schedules that name it, in definition order, then the
@ -190,95 +206,6 @@ fn projection(definitions: &Definitions, agent: &str, current: &AgentSchedules)
}
}
/// One schedule as the API shows it.
#[derive(Debug, Serialize, ToSchema)]
pub struct ScheduleView {
id: String,
/// The operator's title for it.
name: String,
/// The agents it fires in, sorted.
agents: Vec<String>,
/// The prompt, delivered to each agent verbatim.
body: String,
/// The first fire time, unix seconds.
anchor_unix: i64,
/// Seconds between fires; `null` for a one-shot.
interval_seconds: Option<u64>,
paused: bool,
/// The next fire time on the schedule's grid, unix seconds, computed when
/// the response is built; `null` when paused or a one-shot whose time
/// has passed. Says nothing about whether an agent fired an earlier one.
next_fire_unix: Option<i64>,
}
impl ScheduleView {
fn new(d: &Definition, now: i64) -> Self {
Self {
id: d.id.clone(),
name: d.name.clone(),
agents: d.agents.iter().cloned().collect(),
body: d.body.clone(),
anchor_unix: d.anchor_unix,
interval_seconds: d.interval_seconds,
paused: d.paused,
next_fire_unix: d.next_fire(now),
}
}
}
/// A new schedule.
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct AddScheduleRequest {
/// The title. Must not be empty.
name: String,
/// The agents to fire in, at least one. Each must be declared on a hive.
agents: Vec<String>,
/// The prompt, delivered verbatim. Must not be empty.
body: String,
/// The first fire time, unix seconds. Defaults to now.
anchor_unix: Option<i64>,
/// Seconds between fires, at least 1. Absent for a one-shot.
interval_seconds: Option<u64>,
#[serde(default)]
paused: bool,
}
/// A partial change to one schedule; an absent field is left as it is.
#[derive(Debug, Clone, Default, Deserialize, ToSchema)]
pub struct EditScheduleRequest {
name: Option<String>,
body: Option<String>,
anchor_unix: Option<i64>,
/// A number sets the interval; `null` makes the schedule a one-shot.
#[serde(default)]
#[schema(value_type = Option<u64>)]
interval_seconds: IntervalEdit,
paused: Option<bool>,
/// Agents to add. Each must be declared on a hive; one already targeted
/// is left as it is.
#[serde(default)]
add_agents: Vec<String>,
/// Agents to remove. Each must be a current target, and at least one
/// target must remain.
#[serde(default)]
remove_agents: Vec<String>,
}
/// What an edit does to the interval: an absent field keeps it, `null` makes
/// a one-shot, a number sets it.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum IntervalEdit {
#[default]
Keep,
Set(Option<u64>),
}
impl<'de> Deserialize<'de> for IntervalEdit {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Option::<u64>::deserialize(d).map(Self::Set)
}
}
/// Why a change was refused before it was written.
#[derive(Debug, PartialEq, Eq)]
enum Refusal {
@ -322,51 +249,48 @@ fn check_agents(names: &[String], live: &BTreeSet<String>) -> Result<BTreeSet<St
Ok(agents)
}
impl AddScheduleRequest {
fn into_definition(
self,
fn new_definition(
req: AddScheduleRequest,
id: String,
now: i64,
live: &BTreeSet<String>,
) -> Result<Definition, Refusal> {
check_text(&self.name, "name")?;
check_text(&self.body, "body")?;
check_interval(self.interval_seconds)?;
let agents = check_agents(&self.agents, live)?;
check_text(&req.name, "name")?;
check_text(&req.body, "body")?;
check_interval(req.interval_seconds)?;
let agents = check_agents(&req.agents, live)?;
if agents.is_empty() {
return Err(invalid("a schedule needs at least one agent"));
}
Ok(Definition {
id,
name: self.name,
name: req.name,
agents,
body: self.body,
anchor_unix: self.anchor_unix.unwrap_or(now),
interval_seconds: self.interval_seconds,
paused: self.paused,
body: req.body,
anchor_unix: req.anchor_unix.unwrap_or(now),
interval_seconds: req.interval_seconds,
paused: req.paused,
})
}
}
impl EditScheduleRequest {
/// Apply the edit to schedule `id`, returning it as changed and every
/// agent it named before or after.
fn apply(
self,
fn apply_edit(
edit: EditScheduleRequest,
definitions: &mut Definitions,
id: &str,
live: &BTreeSet<String>,
) -> Result<(Definition, BTreeSet<String>), Refusal> {
if let Some(name) = &self.name {
if let Some(name) = &edit.name {
check_text(name, "name")?;
}
if let Some(body) = &self.body {
if let Some(body) = &edit.body {
check_text(body, "body")?;
}
if let IntervalEdit::Set(interval) = self.interval_seconds {
if let IntervalEdit::Set(interval) = edit.interval_seconds {
check_interval(interval)?;
}
let added = check_agents(&self.add_agents, live)?;
let added = check_agents(&edit.add_agents, live)?;
let d = definitions
.schedules
.iter_mut()
@ -374,7 +298,7 @@ impl EditScheduleRequest {
.ok_or_else(|| Refusal::NoSuchSchedule(id.to_owned()))?;
let mut agents = d.agents.clone();
agents.extend(added);
for name in &self.remove_agents {
for name in &edit.remove_agents {
if !agents.remove(name) {
return Err(Refusal::Invalid(format!(
"agent {name:?} is not a target of schedule {id:?}"
@ -388,24 +312,23 @@ impl EditScheduleRequest {
}
let touched = d.agents.union(&agents).cloned().collect();
d.agents = agents;
if let Some(name) = self.name {
if let Some(name) = edit.name {
d.name = name;
}
if let Some(body) = self.body {
if let Some(body) = edit.body {
d.body = body;
}
if let Some(anchor) = self.anchor_unix {
if let Some(anchor) = edit.anchor_unix {
d.anchor_unix = anchor;
}
if let IntervalEdit::Set(interval) = self.interval_seconds {
if let IntervalEdit::Set(interval) = edit.interval_seconds {
d.interval_seconds = interval;
}
if let Some(paused) = self.paused {
if let Some(paused) = edit.paused {
d.paused = paused;
}
Ok((d.clone(), touched))
}
}
/// Remove schedule `id`, returning the agents it named.
fn remove(definitions: &mut Definitions, id: &str) -> Result<BTreeSet<String>, Refusal> {
@ -757,12 +680,7 @@ pub(crate) async fn list_schedules(
all.schedules
.sort_by(|a, b| (&a.name, &a.id).cmp(&(&b.name, &b.id)));
let now = unix_now();
Ok(Json(
all.schedules
.iter()
.map(|d| ScheduleView::new(d, now))
.collect(),
))
Ok(Json(all.schedules.iter().map(|d| d.view(now)).collect()))
}
#[utoipa::path(
@ -794,7 +712,7 @@ pub(crate) async fn add_schedule(
.map_err(|e| problem(e.into()))?;
let added = commit(&store, |all| {
let id = mint_id(all)?;
let d = req.clone().into_definition(id, now, &live)?;
let d = new_definition(req.clone(), id, now, &live)?;
all.schedules.push(d.clone());
Ok(d)
})
@ -802,7 +720,7 @@ pub(crate) async fn add_schedule(
.map_err(problem)?;
tracing::info!(id = %added.id, agents = ?added.agents, "added a schedule");
project_all(&client, &store, &added.agents).await;
Ok((StatusCode::CREATED, Json(ScheduleView::new(&added, now))))
Ok((StatusCode::CREATED, Json(added.view(now))))
}
#[utoipa::path(
@ -834,12 +752,12 @@ pub(crate) async fn edit_schedule(
let store = open_or_create_definitions(&client)
.await
.map_err(|e| problem(e.into()))?;
let (edited, touched) = commit(&store, |all| Ok(req.clone().apply(all, &id, &live)?))
let (edited, touched) = commit(&store, |all| Ok(apply_edit(req.clone(), all, &id, &live)?))
.await
.map_err(problem)?;
tracing::info!(%id, "edited a schedule");
project_all(&client, &store, &touched).await;
Ok(Json(ScheduleView::new(&edited, unix_now())))
Ok(Json(edited.view(unix_now())))
}
#[utoipa::path(
@ -934,8 +852,12 @@ mod tests {
#[test]
fn a_text_edit_leaves_the_cadence_and_id_alone() {
let mut defs = all();
let (out, touched) = edit(r#"{"body":"retro","name":"retro"}"#)
.apply(&mut defs, "abc", &live())
let (out, touched) = apply_edit(
edit(r#"{"body":"retro","name":"retro"}"#),
&mut defs,
"abc",
&live(),
)
.unwrap();
assert_eq!(
(out.id.as_str(), out.body.as_str(), out.name.as_str()),
@ -950,8 +872,12 @@ mod tests {
#[test]
fn the_agent_set_changes_without_a_new_id() {
let mut defs = all();
let (out, touched) = edit(r#"{"add_agents":["scribe"],"remove_agents":["iris"]}"#)
.apply(&mut defs, "abc", &live())
let (out, touched) = apply_edit(
edit(r#"{"add_agents":["scribe"],"remove_agents":["iris"]}"#),
&mut defs,
"abc",
&live(),
)
.unwrap();
assert_eq!(out.id, "abc");
assert_eq!(out.agents, ["atlas", "scribe"].map(str::to_owned).into());
@ -974,7 +900,10 @@ mod tests {
(r#"{"body":"x"}"#, "nope"),
] {
let mut defs = all();
assert!(edit(json).apply(&mut defs, id, &live()).is_err(), "{json}");
assert!(
apply_edit(edit(json), &mut defs, id, &live()).is_err(),
"{json}"
);
assert_eq!(defs, all(), "{json}");
}
}
@ -982,13 +911,15 @@ mod tests {
#[test]
fn a_null_interval_makes_a_one_shot_and_an_absent_one_keeps_it() {
let mut defs = all();
let (kept, _) = edit(r#"{"paused":true}"#)
.apply(&mut defs, "abc", &live())
.unwrap();
let (kept, _) = apply_edit(edit(r#"{"paused":true}"#), &mut defs, "abc", &live()).unwrap();
assert_eq!(kept.interval_seconds, Some(3600));
assert!(kept.paused);
let (once, _) = edit(r#"{"interval_seconds":null}"#)
.apply(&mut defs, "abc", &live())
let (once, _) = apply_edit(
edit(r#"{"interval_seconds":null}"#),
&mut defs,
"abc",
&live(),
)
.unwrap();
assert_eq!(once.interval_seconds, None);
}
@ -1008,9 +939,12 @@ mod tests {
#[test]
fn an_add_needs_a_name_and_declared_agents_and_anchors_at_now() {
let add = |json: &str| {
serde_json::from_str::<AddScheduleRequest>(json)
.unwrap()
.into_definition("id".to_owned(), 42, &live())
new_definition(
serde_json::from_str::<AddScheduleRequest>(json).unwrap(),
"id".to_owned(),
42,
&live(),
)
};
let d = add(r#"{"name":"n","agents":["iris","atlas","iris"],"body":"hi"}"#).unwrap();
assert_eq!(
@ -1043,7 +977,7 @@ mod tests {
#[test]
fn a_view_shows_the_wire_shape() {
let v = serde_json::to_value(ScheduleView::new(&standup(), 1500)).unwrap();
let v = serde_json::to_value(standup().view(1500)).unwrap();
assert_eq!(
v,
serde_json::json!({

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,16 +24,15 @@ 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> {
/// 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 = Self {
let edit = EditScheduleRequest {
name: flags.name,
body: flags.body,
anchor_unix: flags.at.map(parse_at).transpose()?,
@ -127,11 +56,10 @@ impl EditRequest {
Ok(edit)
}
fn paused(paused: bool) -> Self {
Self {
fn paused_edit(paused: bool) -> EditScheduleRequest {
EditScheduleRequest {
paused: Some(paused),
..Self::default()
}
..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()
})