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:
parent
22b9c570ba
commit
be92bc0835
17 changed files with 529 additions and 377 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue