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
12
CLAUDE.md
12
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
|
||||
|
|
|
|||
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
20
swarm-controller-api/Cargo.toml
Normal file
20
swarm-controller-api/Cargo.toml
Normal 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
|
||||
21
swarm-controller-api/README.md
Normal file
21
swarm-controller-api/README.md
Normal 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`.
|
||||
42
swarm-controller-api/src/forge.rs
Normal file
42
swarm-controller-api/src/forge.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
8
swarm-controller-api/src/lib.rs
Normal file
8
swarm-controller-api/src/lib.rs
Normal 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;
|
||||
218
swarm-controller-api/src/schedules.rs
Normal file
218
swarm-controller-api/src/schedules.rs
Normal 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}"#,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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()
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue