diff --git a/CLAUDE.md b/CLAUDE.md index f19648bf..457fe4db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 16ddc9ed..f6970c53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index c8e7968e..d194aaf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/swarm-controller-api/Cargo.toml b/swarm-controller-api/Cargo.toml new file mode 100644 index 00000000..9821ffd9 --- /dev/null +++ b/swarm-controller-api/Cargo.toml @@ -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 diff --git a/swarm-controller-api/README.md b/swarm-controller-api/README.md new file mode 100644 index 00000000..9b854929 --- /dev/null +++ b/swarm-controller-api/README.md @@ -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`. diff --git a/swarm-controller-api/src/forge.rs b/swarm-controller-api/src/forge.rs new file mode 100644 index 00000000..b34422f9 --- /dev/null +++ b/swarm-controller-api/src/forge.rs @@ -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::(wire).unwrap(), + resp + ); + } + } +} diff --git a/swarm-controller-api/src/lib.rs b/swarm-controller-api/src/lib.rs new file mode 100644 index 00000000..6be7f150 --- /dev/null +++ b/swarm-controller-api/src/lib.rs @@ -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; diff --git a/swarm-controller-api/src/schedules.rs b/swarm-controller-api/src/schedules.rs new file mode 100644 index 00000000..22a05c8f --- /dev/null +++ b/swarm-controller-api/src/schedules.rs @@ -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, + /// 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, + 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, +} + +/// 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, + /// 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, + /// Seconds between fires, at least 1. Absent for a one-shot. + #[serde(skip_serializing_if = "Option::is_none")] + pub interval_seconds: Option, + #[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub anchor_unix: Option, + /// 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))] + pub interval_seconds: IntervalEdit, + #[serde(skip_serializing_if = "Option::is_none")] + pub paused: Option, + /// 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, + /// 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, +} + +/// 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), +} + +impl IntervalEdit { + #[must_use] + pub fn is_keep(&self) -> bool { + matches!(self, Self::Keep) + } +} + +impl Serialize for IntervalEdit { + fn serialize(&self, s: S) -> Result { + 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: D) -> Result { + Option::::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(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::(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::( + 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}"#, + ); + } +} diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index a18270d9..f1e40ade 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -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 diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 88b57e58..0a09cbc3 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -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 diff --git a/swarm-controller/src/schedules.rs b/swarm-controller/src/schedules.rs index f92bd331..c354a00c 100644 --- a/swarm-controller/src/schedules.rs +++ b/swarm-controller/src/schedules.rs @@ -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, - /// 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, - 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, -} - -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, - /// The prompt, delivered verbatim. Must not be empty. - body: String, - /// The first fire time, unix seconds. Defaults to now. - anchor_unix: Option, - /// Seconds between fires, at least 1. Absent for a one-shot. - interval_seconds: Option, - #[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, - body: Option, - anchor_unix: Option, - /// A number sets the interval; `null` makes the schedule a one-shot. - #[serde(default)] - #[schema(value_type = Option)] - interval_seconds: IntervalEdit, - paused: Option, - /// Agents to add. Each must be declared on a hive; one already targeted - /// is left as it is. - #[serde(default)] - add_agents: Vec, - /// Agents to remove. Each must be a current target, and at least one - /// target must remain. - #[serde(default)] - remove_agents: Vec, -} - -/// 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), -} - -impl<'de> Deserialize<'de> for IntervalEdit { - fn deserialize>(d: D) -> Result { - Option::::deserialize(d).map(Self::Set) - } -} - /// Why a change was refused before it was written. #[derive(Debug, PartialEq, Eq)] enum Refusal { @@ -322,89 +249,85 @@ fn check_agents(names: &[String], live: &BTreeSet) -> Result, - ) -> Result { - check_text(&self.name, "name")?; - check_text(&self.body, "body")?; - check_interval(self.interval_seconds)?; - let agents = check_agents(&self.agents, live)?; - if agents.is_empty() { - return Err(invalid("a schedule needs at least one agent")); - } - Ok(Definition { - id, - name: self.name, - agents, - body: self.body, - anchor_unix: self.anchor_unix.unwrap_or(now), - interval_seconds: self.interval_seconds, - paused: self.paused, - }) +fn new_definition( + req: AddScheduleRequest, + id: String, + now: i64, + live: &BTreeSet, +) -> Result { + 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: req.name, + agents, + 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, - definitions: &mut Definitions, - id: &str, - live: &BTreeSet, - ) -> Result<(Definition, BTreeSet), Refusal> { - if let Some(name) = &self.name { - check_text(name, "name")?; - } - if let Some(body) = &self.body { - check_text(body, "body")?; - } - if let IntervalEdit::Set(interval) = self.interval_seconds { - check_interval(interval)?; - } - let added = check_agents(&self.add_agents, live)?; - let d = definitions - .schedules - .iter_mut() - .find(|d| d.id == id) - .ok_or_else(|| Refusal::NoSuchSchedule(id.to_owned()))?; - let mut agents = d.agents.clone(); - agents.extend(added); - for name in &self.remove_agents { - if !agents.remove(name) { - return Err(Refusal::Invalid(format!( - "agent {name:?} is not a target of schedule {id:?}" - ))); - } - } - if agents.is_empty() { - return Err(invalid( - "this would leave the schedule with no agents; remove the schedule instead", - )); - } - let touched = d.agents.union(&agents).cloned().collect(); - d.agents = agents; - if let Some(name) = self.name { - d.name = name; - } - if let Some(body) = self.body { - d.body = body; - } - if let Some(anchor) = self.anchor_unix { - d.anchor_unix = anchor; - } - if let IntervalEdit::Set(interval) = self.interval_seconds { - d.interval_seconds = interval; - } - if let Some(paused) = self.paused { - d.paused = paused; - } - Ok((d.clone(), touched)) +/// Apply the edit to schedule `id`, returning it as changed and every +/// agent it named before or after. +fn apply_edit( + edit: EditScheduleRequest, + definitions: &mut Definitions, + id: &str, + live: &BTreeSet, +) -> Result<(Definition, BTreeSet), Refusal> { + if let Some(name) = &edit.name { + check_text(name, "name")?; } + if let Some(body) = &edit.body { + check_text(body, "body")?; + } + if let IntervalEdit::Set(interval) = edit.interval_seconds { + check_interval(interval)?; + } + let added = check_agents(&edit.add_agents, live)?; + let d = definitions + .schedules + .iter_mut() + .find(|d| d.id == id) + .ok_or_else(|| Refusal::NoSuchSchedule(id.to_owned()))?; + let mut agents = d.agents.clone(); + agents.extend(added); + for name in &edit.remove_agents { + if !agents.remove(name) { + return Err(Refusal::Invalid(format!( + "agent {name:?} is not a target of schedule {id:?}" + ))); + } + } + if agents.is_empty() { + return Err(invalid( + "this would leave the schedule with no agents; remove the schedule instead", + )); + } + let touched = d.agents.union(&agents).cloned().collect(); + d.agents = agents; + if let Some(name) = edit.name { + d.name = name; + } + if let Some(body) = edit.body { + d.body = body; + } + if let Some(anchor) = edit.anchor_unix { + d.anchor_unix = anchor; + } + if let IntervalEdit::Set(interval) = edit.interval_seconds { + d.interval_seconds = interval; + } + if let Some(paused) = edit.paused { + d.paused = paused; + } + Ok((d.clone(), touched)) } /// Remove schedule `id`, returning the agents it named. @@ -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,9 +852,13 @@ 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()) - .unwrap(); + 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()), ("abc", "retro", "retro") @@ -950,9 +872,13 @@ 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()) - .unwrap(); + 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()); assert_eq!( @@ -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,14 +911,16 @@ 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()) - .unwrap(); + 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::(json) - .unwrap() - .into_definition("id".to_owned(), 42, &live()) + new_definition( + serde_json::from_str::(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!({ diff --git a/swarmctl/Cargo.toml b/swarmctl/Cargo.toml index 3fb5ab0f..8ca3441c 100644 --- a/swarmctl/Cargo.toml +++ b/swarmctl/Cargo.toml @@ -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 diff --git a/swarmctl/README.md b/swarmctl/README.md index 4497148f..db285e67 100644 --- a/swarmctl/README.md +++ b/swarmctl/README.md @@ -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` diff --git a/swarmctl/src/agent.rs b/swarmctl/src/agent.rs index 60b43f5e..eb0e0e1e 100644 --- a/swarmctl/src/agent.rs +++ b/swarmctl/src/agent.rs @@ -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`. diff --git a/swarmctl/src/forge.rs b/swarmctl/src/forge.rs index 564e7ef0..70eb5da2 100644 --- a/swarmctl/src/forge.rs +++ b/swarmctl/src/forge.rs @@ -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); - } - } -} diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index a4f475c3..18991c38 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -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(), diff --git a/swarmctl/src/schedule.rs b/swarmctl/src/schedule.rs index 83adaa28..1fb91565 100644 --- a/swarmctl/src/schedule.rs +++ b/swarmctl/src/schedule.rs @@ -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, - body: String, - anchor_unix: i64, - interval_seconds: Option, - paused: bool, - next_fire_unix: Option, -} - -/// Body of `POST /api/schedules`. -#[derive(Serialize)] -struct AddRequest<'a> { - name: &'a str, - agents: Vec, - body: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - anchor_unix: Option, - #[serde(skip_serializing_if = "Option::is_none")] - interval_seconds: Option, - 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, - #[serde(skip_serializing_if = "Option::is_none")] - body: Option, - #[serde(skip_serializing_if = "Option::is_none")] - anchor_unix: Option, - #[serde(skip_serializing_if = "IntervalEdit::is_keep")] - interval_seconds: IntervalEdit, - #[serde(skip_serializing_if = "Option::is_none")] - paused: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - add_agents: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - remove_agents: Vec, -} - -/// 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), -} - -impl IntervalEdit { - fn is_keep(&self) -> bool { - matches!(self, Self::Keep) - } -} - -impl Serialize for IntervalEdit { - fn serialize(&self, s: S) -> Result { - 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, @@ -94,44 +24,42 @@ pub(crate) struct EditFlags<'a> { pub remove_agents: Vec, } -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 { - 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 { + 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() })