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 — ⚠️
|
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 socket's directory is its access control**; the crate README has
|
||||||
the constraint and a unit test pins the path.
|
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**
|
- **`swarmctl/`** — swarm operator CLI; runs as **root and acts directly**
|
||||||
(README says why). Does not link `swarm-controller` — its wire shapes
|
(README says why). Doesn't link `swarm-controller`: the forge and
|
||||||
are a local mirror. ⚠️ Edits authelia's **`users.yml` in place**, shared
|
schedule bodies come from `swarm-controller-api`, the agent-create,
|
||||||
with `swarm-authelia-bridge`; that crate's README says what both writers
|
inference and permissions ones are a local mirror. ⚠️ Edits authelia's
|
||||||
must uphold. Verb reference (CI-enforced):
|
**`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).
|
[`docs/tools/swarmctl-cli.md`](docs/tools/swarmctl-cli.md).
|
||||||
- **`swarm-authelia-bridge/`** (+ **`swarm-authelia-bridge-sock/`** wire
|
- **`swarm-authelia-bridge/`** (+ **`swarm-authelia-bridge-sock/`** wire
|
||||||
types) — the other writer of authelia's users DB; runs inside
|
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",
|
"similar",
|
||||||
"strum",
|
"strum",
|
||||||
"swarm-authelia-bridge-sock",
|
"swarm-authelia-bridge-sock",
|
||||||
|
"swarm-controller-api",
|
||||||
"swarm-matrix-client",
|
"swarm-matrix-client",
|
||||||
"swarm-queue-client",
|
"swarm-queue-client",
|
||||||
"swarm-secret-client",
|
"swarm-secret-client",
|
||||||
|
|
@ -4898,6 +4899,15 @@ dependencies = [
|
||||||
"x509-cert",
|
"x509-cert",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "swarm-controller-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"utoipa",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swarm-logs"
|
name = "swarm-logs"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -5085,6 +5095,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_norway",
|
"serde_norway",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
|
"swarm-controller-api",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ members = [
|
||||||
"swarm-claude-login",
|
"swarm-claude-login",
|
||||||
"swarm-authelia-bridge-sock",
|
"swarm-authelia-bridge-sock",
|
||||||
"swarm-controller",
|
"swarm-controller",
|
||||||
|
"swarm-controller-api",
|
||||||
"swarm-matrix-client",
|
"swarm-matrix-client",
|
||||||
"swarm-matrix-ctl",
|
"swarm-matrix-ctl",
|
||||||
"swarm-nats-auth",
|
"swarm-nats-auth",
|
||||||
|
|
@ -113,6 +114,7 @@ hive-sock-client = { path = "hive-sock-client" }
|
||||||
hive-types = { path = "hive-types" }
|
hive-types = { path = "hive-types" }
|
||||||
swarm-agent-verify = { path = "swarm-agent-verify" }
|
swarm-agent-verify = { path = "swarm-agent-verify" }
|
||||||
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
|
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
|
||||||
|
swarm-controller-api = { path = "swarm-controller-api" }
|
||||||
swarm-matrix-client = { path = "swarm-matrix-client" }
|
swarm-matrix-client = { path = "swarm-matrix-client" }
|
||||||
swarm-queue-client = { path = "swarm-queue-client" }
|
swarm-queue-client = { path = "swarm-queue-client" }
|
||||||
swarm-secret-client = { path = "swarm-secret-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"
|
serde_norway = "0.9.42"
|
||||||
strum.workspace = true
|
strum.workspace = true
|
||||||
swarm-authelia-bridge-sock.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
|
# The queue connect (token mint + auth callback + reconnect) is shared with
|
||||||
# every other participant - a hive publishing its own status runs the same
|
# 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
|
# 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 hive_jobq_wire::GraphWire as _;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use swarm_authelia_bridge_sock::BridgeResponse;
|
use swarm_authelia_bridge_sock::BridgeResponse;
|
||||||
|
use swarm_controller_api::forge::{ForgeAdminChange, MakeForgeAdminResponse};
|
||||||
use utoipa::{OpenApi, ToSchema};
|
use utoipa::{OpenApi, ToSchema};
|
||||||
use utoipa_axum::{router::OpenApiRouter, routes};
|
use utoipa_axum::{router::OpenApiRouter, routes};
|
||||||
|
|
||||||
|
|
@ -3122,20 +3123,6 @@ async fn mint_agent_forge_token(
|
||||||
Ok(Json(MintAgentForgeTokenResponse { node_id: id.get() }))
|
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.
|
/// 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
|
/// 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::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use problem_details::ProblemDetails;
|
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 swarm_queue_client::agent_schedules::{self, AgentSchedules, Schedule};
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
use crate::agent_renewal::unix_now;
|
use crate::agent_renewal::unix_now;
|
||||||
use crate::wanted::WantedWriter;
|
use crate::wanted::WantedWriter;
|
||||||
|
|
@ -168,6 +170,20 @@ impl Definition {
|
||||||
let interval = i64::try_from(self.interval_seconds?).ok()?;
|
let interval = i64::try_from(self.interval_seconds?).ok()?;
|
||||||
Some(self.for_agent().latest_slot(now)? + interval)
|
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
|
/// `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.
|
/// Why a change was refused before it was written.
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
enum Refusal {
|
enum Refusal {
|
||||||
|
|
@ -322,51 +249,48 @@ fn check_agents(names: &[String], live: &BTreeSet<String>) -> Result<BTreeSet<St
|
||||||
Ok(agents)
|
Ok(agents)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AddScheduleRequest {
|
fn new_definition(
|
||||||
fn into_definition(
|
req: AddScheduleRequest,
|
||||||
self,
|
|
||||||
id: String,
|
id: String,
|
||||||
now: i64,
|
now: i64,
|
||||||
live: &BTreeSet<String>,
|
live: &BTreeSet<String>,
|
||||||
) -> Result<Definition, Refusal> {
|
) -> Result<Definition, Refusal> {
|
||||||
check_text(&self.name, "name")?;
|
check_text(&req.name, "name")?;
|
||||||
check_text(&self.body, "body")?;
|
check_text(&req.body, "body")?;
|
||||||
check_interval(self.interval_seconds)?;
|
check_interval(req.interval_seconds)?;
|
||||||
let agents = check_agents(&self.agents, live)?;
|
let agents = check_agents(&req.agents, live)?;
|
||||||
if agents.is_empty() {
|
if agents.is_empty() {
|
||||||
return Err(invalid("a schedule needs at least one agent"));
|
return Err(invalid("a schedule needs at least one agent"));
|
||||||
}
|
}
|
||||||
Ok(Definition {
|
Ok(Definition {
|
||||||
id,
|
id,
|
||||||
name: self.name,
|
name: req.name,
|
||||||
agents,
|
agents,
|
||||||
body: self.body,
|
body: req.body,
|
||||||
anchor_unix: self.anchor_unix.unwrap_or(now),
|
anchor_unix: req.anchor_unix.unwrap_or(now),
|
||||||
interval_seconds: self.interval_seconds,
|
interval_seconds: req.interval_seconds,
|
||||||
paused: self.paused,
|
paused: req.paused,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EditScheduleRequest {
|
/// Apply the edit to schedule `id`, returning it as changed and every
|
||||||
/// Apply the edit to schedule `id`, returning it as changed and every
|
/// agent it named before or after.
|
||||||
/// agent it named before or after.
|
fn apply_edit(
|
||||||
fn apply(
|
edit: EditScheduleRequest,
|
||||||
self,
|
|
||||||
definitions: &mut Definitions,
|
definitions: &mut Definitions,
|
||||||
id: &str,
|
id: &str,
|
||||||
live: &BTreeSet<String>,
|
live: &BTreeSet<String>,
|
||||||
) -> Result<(Definition, BTreeSet<String>), Refusal> {
|
) -> Result<(Definition, BTreeSet<String>), Refusal> {
|
||||||
if let Some(name) = &self.name {
|
if let Some(name) = &edit.name {
|
||||||
check_text(name, "name")?;
|
check_text(name, "name")?;
|
||||||
}
|
}
|
||||||
if let Some(body) = &self.body {
|
if let Some(body) = &edit.body {
|
||||||
check_text(body, "body")?;
|
check_text(body, "body")?;
|
||||||
}
|
}
|
||||||
if let IntervalEdit::Set(interval) = self.interval_seconds {
|
if let IntervalEdit::Set(interval) = edit.interval_seconds {
|
||||||
check_interval(interval)?;
|
check_interval(interval)?;
|
||||||
}
|
}
|
||||||
let added = check_agents(&self.add_agents, live)?;
|
let added = check_agents(&edit.add_agents, live)?;
|
||||||
let d = definitions
|
let d = definitions
|
||||||
.schedules
|
.schedules
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
|
|
@ -374,7 +298,7 @@ impl EditScheduleRequest {
|
||||||
.ok_or_else(|| Refusal::NoSuchSchedule(id.to_owned()))?;
|
.ok_or_else(|| Refusal::NoSuchSchedule(id.to_owned()))?;
|
||||||
let mut agents = d.agents.clone();
|
let mut agents = d.agents.clone();
|
||||||
agents.extend(added);
|
agents.extend(added);
|
||||||
for name in &self.remove_agents {
|
for name in &edit.remove_agents {
|
||||||
if !agents.remove(name) {
|
if !agents.remove(name) {
|
||||||
return Err(Refusal::Invalid(format!(
|
return Err(Refusal::Invalid(format!(
|
||||||
"agent {name:?} is not a target of schedule {id:?}"
|
"agent {name:?} is not a target of schedule {id:?}"
|
||||||
|
|
@ -388,23 +312,22 @@ impl EditScheduleRequest {
|
||||||
}
|
}
|
||||||
let touched = d.agents.union(&agents).cloned().collect();
|
let touched = d.agents.union(&agents).cloned().collect();
|
||||||
d.agents = agents;
|
d.agents = agents;
|
||||||
if let Some(name) = self.name {
|
if let Some(name) = edit.name {
|
||||||
d.name = name;
|
d.name = name;
|
||||||
}
|
}
|
||||||
if let Some(body) = self.body {
|
if let Some(body) = edit.body {
|
||||||
d.body = body;
|
d.body = body;
|
||||||
}
|
}
|
||||||
if let Some(anchor) = self.anchor_unix {
|
if let Some(anchor) = edit.anchor_unix {
|
||||||
d.anchor_unix = anchor;
|
d.anchor_unix = anchor;
|
||||||
}
|
}
|
||||||
if let IntervalEdit::Set(interval) = self.interval_seconds {
|
if let IntervalEdit::Set(interval) = edit.interval_seconds {
|
||||||
d.interval_seconds = interval;
|
d.interval_seconds = interval;
|
||||||
}
|
}
|
||||||
if let Some(paused) = self.paused {
|
if let Some(paused) = edit.paused {
|
||||||
d.paused = paused;
|
d.paused = paused;
|
||||||
}
|
}
|
||||||
Ok((d.clone(), touched))
|
Ok((d.clone(), touched))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove schedule `id`, returning the agents it named.
|
/// Remove schedule `id`, returning the agents it named.
|
||||||
|
|
@ -757,12 +680,7 @@ pub(crate) async fn list_schedules(
|
||||||
all.schedules
|
all.schedules
|
||||||
.sort_by(|a, b| (&a.name, &a.id).cmp(&(&b.name, &b.id)));
|
.sort_by(|a, b| (&a.name, &a.id).cmp(&(&b.name, &b.id)));
|
||||||
let now = unix_now();
|
let now = unix_now();
|
||||||
Ok(Json(
|
Ok(Json(all.schedules.iter().map(|d| d.view(now)).collect()))
|
||||||
all.schedules
|
|
||||||
.iter()
|
|
||||||
.map(|d| ScheduleView::new(d, now))
|
|
||||||
.collect(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|
@ -794,7 +712,7 @@ pub(crate) async fn add_schedule(
|
||||||
.map_err(|e| problem(e.into()))?;
|
.map_err(|e| problem(e.into()))?;
|
||||||
let added = commit(&store, |all| {
|
let added = commit(&store, |all| {
|
||||||
let id = mint_id(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());
|
all.schedules.push(d.clone());
|
||||||
Ok(d)
|
Ok(d)
|
||||||
})
|
})
|
||||||
|
|
@ -802,7 +720,7 @@ pub(crate) async fn add_schedule(
|
||||||
.map_err(problem)?;
|
.map_err(problem)?;
|
||||||
tracing::info!(id = %added.id, agents = ?added.agents, "added a schedule");
|
tracing::info!(id = %added.id, agents = ?added.agents, "added a schedule");
|
||||||
project_all(&client, &store, &added.agents).await;
|
project_all(&client, &store, &added.agents).await;
|
||||||
Ok((StatusCode::CREATED, Json(ScheduleView::new(&added, now))))
|
Ok((StatusCode::CREATED, Json(added.view(now))))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|
@ -834,12 +752,12 @@ pub(crate) async fn edit_schedule(
|
||||||
let store = open_or_create_definitions(&client)
|
let store = open_or_create_definitions(&client)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| problem(e.into()))?;
|
.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
|
.await
|
||||||
.map_err(problem)?;
|
.map_err(problem)?;
|
||||||
tracing::info!(%id, "edited a schedule");
|
tracing::info!(%id, "edited a schedule");
|
||||||
project_all(&client, &store, &touched).await;
|
project_all(&client, &store, &touched).await;
|
||||||
Ok(Json(ScheduleView::new(&edited, unix_now())))
|
Ok(Json(edited.view(unix_now())))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|
@ -934,8 +852,12 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn a_text_edit_leaves_the_cadence_and_id_alone() {
|
fn a_text_edit_leaves_the_cadence_and_id_alone() {
|
||||||
let mut defs = all();
|
let mut defs = all();
|
||||||
let (out, touched) = edit(r#"{"body":"retro","name":"retro"}"#)
|
let (out, touched) = apply_edit(
|
||||||
.apply(&mut defs, "abc", &live())
|
edit(r#"{"body":"retro","name":"retro"}"#),
|
||||||
|
&mut defs,
|
||||||
|
"abc",
|
||||||
|
&live(),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(out.id.as_str(), out.body.as_str(), out.name.as_str()),
|
(out.id.as_str(), out.body.as_str(), out.name.as_str()),
|
||||||
|
|
@ -950,8 +872,12 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn the_agent_set_changes_without_a_new_id() {
|
fn the_agent_set_changes_without_a_new_id() {
|
||||||
let mut defs = all();
|
let mut defs = all();
|
||||||
let (out, touched) = edit(r#"{"add_agents":["scribe"],"remove_agents":["iris"]}"#)
|
let (out, touched) = apply_edit(
|
||||||
.apply(&mut defs, "abc", &live())
|
edit(r#"{"add_agents":["scribe"],"remove_agents":["iris"]}"#),
|
||||||
|
&mut defs,
|
||||||
|
"abc",
|
||||||
|
&live(),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(out.id, "abc");
|
assert_eq!(out.id, "abc");
|
||||||
assert_eq!(out.agents, ["atlas", "scribe"].map(str::to_owned).into());
|
assert_eq!(out.agents, ["atlas", "scribe"].map(str::to_owned).into());
|
||||||
|
|
@ -974,7 +900,10 @@ mod tests {
|
||||||
(r#"{"body":"x"}"#, "nope"),
|
(r#"{"body":"x"}"#, "nope"),
|
||||||
] {
|
] {
|
||||||
let mut defs = all();
|
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}");
|
assert_eq!(defs, all(), "{json}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -982,13 +911,15 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn a_null_interval_makes_a_one_shot_and_an_absent_one_keeps_it() {
|
fn a_null_interval_makes_a_one_shot_and_an_absent_one_keeps_it() {
|
||||||
let mut defs = all();
|
let mut defs = all();
|
||||||
let (kept, _) = edit(r#"{"paused":true}"#)
|
let (kept, _) = apply_edit(edit(r#"{"paused":true}"#), &mut defs, "abc", &live()).unwrap();
|
||||||
.apply(&mut defs, "abc", &live())
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(kept.interval_seconds, Some(3600));
|
assert_eq!(kept.interval_seconds, Some(3600));
|
||||||
assert!(kept.paused);
|
assert!(kept.paused);
|
||||||
let (once, _) = edit(r#"{"interval_seconds":null}"#)
|
let (once, _) = apply_edit(
|
||||||
.apply(&mut defs, "abc", &live())
|
edit(r#"{"interval_seconds":null}"#),
|
||||||
|
&mut defs,
|
||||||
|
"abc",
|
||||||
|
&live(),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(once.interval_seconds, None);
|
assert_eq!(once.interval_seconds, None);
|
||||||
}
|
}
|
||||||
|
|
@ -1008,9 +939,12 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn an_add_needs_a_name_and_declared_agents_and_anchors_at_now() {
|
fn an_add_needs_a_name_and_declared_agents_and_anchors_at_now() {
|
||||||
let add = |json: &str| {
|
let add = |json: &str| {
|
||||||
serde_json::from_str::<AddScheduleRequest>(json)
|
new_definition(
|
||||||
.unwrap()
|
serde_json::from_str::<AddScheduleRequest>(json).unwrap(),
|
||||||
.into_definition("id".to_owned(), 42, &live())
|
"id".to_owned(),
|
||||||
|
42,
|
||||||
|
&live(),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let d = add(r#"{"name":"n","agents":["iris","atlas","iris"],"body":"hi"}"#).unwrap();
|
let d = add(r#"{"name":"n","agents":["iris","atlas","iris"],"body":"hi"}"#).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1043,7 +977,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_view_shows_the_wire_shape() {
|
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!(
|
assert_eq!(
|
||||||
v,
|
v,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
serde_norway = "0.9.42"
|
serde_norway = "0.9.42"
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
# The forge and schedule bodies, shared with swarm-controller.
|
||||||
|
swarm-controller-api.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
url.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)).
|
[Remote use](#remote-use-swarmctl-login)).
|
||||||
|
|
||||||
Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
|
Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
|
||||||
admin socket. This crate does not link `swarm-controller`, for the same
|
admin socket. This crate doesn't link `swarm-controller`, for the same
|
||||||
reason `hivectl` does not link `hive-c0re`.
|
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
|
## 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
|
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
|
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
|
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
|
refused. The response body comes from `swarm-controller-api`, imported in
|
||||||
reason as `agent create`'s.
|
`src/forge.rs`.
|
||||||
|
|
||||||
## `schedule`
|
## `schedule`
|
||||||
|
|
||||||
|
|
@ -160,8 +162,8 @@ reason as `agent create`'s.
|
||||||
The `/api/schedules` routes on the swarm-controller, over the
|
The `/api/schedules` routes on the swarm-controller, over the
|
||||||
same socket as `agent create`; each verb waits for the stored result. What a
|
same socket as `agent create`; each verb waits for the stored result. What a
|
||||||
schedule does once stored is in
|
schedule does once stored is in
|
||||||
[`docs/swarm/schedules.md`](../docs/swarm/schedules.md). The shapes are
|
[`docs/swarm/schedules.md`](../docs/swarm/schedules.md). The bodies come
|
||||||
mirrored in `src/schedule.rs`, for the same reason as `agent create`'s.
|
from `swarm-controller-api`, imported in `src/schedule.rs`.
|
||||||
|
|
||||||
## Remote use: `swarmctl login`
|
## Remote use: `swarmctl login`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@
|
||||||
//!
|
//!
|
||||||
//! The two shapes below **mirror** the controller's own private
|
//! The two shapes below **mirror** the controller's own private
|
||||||
//! `CreateAgentRequest` / `CreateAgentResponse` rather than sharing them:
|
//! `CreateAgentRequest` / `CreateAgentResponse` rather than sharing them:
|
||||||
//! this crate does not link `swarm-controller` and there is no wire
|
//! this crate does not link `swarm-controller`, and they are not in
|
||||||
//! crate between them. The seam is narrow and both ends validate, so
|
//! `swarm-controller-api`. The seam is narrow and both ends validate, so
|
||||||
//! drift surfaces as a 400 naming the field.
|
//! drift surfaces as a 400 naming the field.
|
||||||
//!
|
//!
|
||||||
//! Long-form rationale for all three: `swarmctl/README.md`.
|
//! 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
|
//! 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
|
//! it found. Same round trip as [`crate::agent`], over the controller's unix
|
||||||
//! socket; the shape below mirrors the controller's own private
|
//! socket.
|
||||||
//! `MakeForgeAdminResponse` for the reason that module's doc comment gives.
|
|
||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use serde::Deserialize;
|
use swarm_controller_api::forge::{ForgeAdminChange, MakeForgeAdminResponse};
|
||||||
|
|
||||||
use crate::agent::{parse_ident, post};
|
use crate::agent::{parse_ident, post};
|
||||||
use crate::remote::Target;
|
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`.
|
/// Run `swarmctl forge make-admin`.
|
||||||
///
|
///
|
||||||
/// Synchronous for the same reason `agent create` is.
|
/// 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 {
|
match resp.change {
|
||||||
AdminChange::Promoted => println!("forge: {name:?} is now a site admin"),
|
ForgeAdminChange::Promoted => println!("forge: {name:?} is now a site admin"),
|
||||||
AdminChange::AlreadyAdmin => println!("forge: {name:?} was already a site admin"),
|
ForgeAdminChange::AlreadyAdmin => println!("forge: {name:?} was already a site admin"),
|
||||||
}
|
}
|
||||||
Ok(())
|
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::List(a) => schedule::list(&a.socket.resolve(remote)?, a.agent.as_deref()),
|
||||||
ScheduleVerb::Edit(a) => {
|
ScheduleVerb::Edit(a) => {
|
||||||
let edit = schedule::EditRequest::new(schedule::EditFlags {
|
let edit = schedule::edit_request(schedule::EditFlags {
|
||||||
name: a.name,
|
name: a.name,
|
||||||
body: a.body,
|
body: a.body,
|
||||||
at: a.at.as_deref(),
|
at: a.at.as_deref(),
|
||||||
|
|
|
||||||
|
|
@ -4,85 +4,15 @@
|
||||||
//! Each verb waits for the controller's answer, which is the list or the
|
//! 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
|
//! 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.
|
//! 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 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::agent::{parse_ident, send};
|
||||||
use crate::remote::Target;
|
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.
|
/// What `schedule edit` was asked to change.
|
||||||
pub(crate) struct EditFlags<'a> {
|
pub(crate) struct EditFlags<'a> {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
|
@ -94,16 +24,15 @@ pub(crate) struct EditFlags<'a> {
|
||||||
pub remove_agents: Vec<String>,
|
pub remove_agents: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EditRequest {
|
/// The edit `schedule edit` asks for. `once` and `every` are exclusive in the
|
||||||
/// The edit `schedule edit` asks for. `once` and `every` are exclusive
|
/// CLI, so at most one of them is set here.
|
||||||
/// in the CLI, so at most one of them is set here.
|
pub(crate) fn edit_request(flags: EditFlags<'_>) -> Result<EditScheduleRequest> {
|
||||||
pub(crate) fn new(flags: EditFlags<'_>) -> Result<Self> {
|
|
||||||
let interval_seconds = match (flags.every, flags.once) {
|
let interval_seconds = match (flags.every, flags.once) {
|
||||||
(Some(every), _) => IntervalEdit::Set(Some(parse_every(every)?)),
|
(Some(every), _) => IntervalEdit::Set(Some(parse_every(every)?)),
|
||||||
(None, true) => IntervalEdit::Set(None),
|
(None, true) => IntervalEdit::Set(None),
|
||||||
(None, false) => IntervalEdit::Keep,
|
(None, false) => IntervalEdit::Keep,
|
||||||
};
|
};
|
||||||
let edit = Self {
|
let edit = EditScheduleRequest {
|
||||||
name: flags.name,
|
name: flags.name,
|
||||||
body: flags.body,
|
body: flags.body,
|
||||||
anchor_unix: flags.at.map(parse_at).transpose()?,
|
anchor_unix: flags.at.map(parse_at).transpose()?,
|
||||||
|
|
@ -125,13 +54,12 @@ impl EditRequest {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(edit)
|
Ok(edit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paused(paused: bool) -> Self {
|
fn paused_edit(paused: bool) -> EditScheduleRequest {
|
||||||
Self {
|
EditScheduleRequest {
|
||||||
paused: Some(paused),
|
paused: Some(paused),
|
||||||
..Self::default()
|
..EditScheduleRequest::default()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,10 +95,10 @@ pub(crate) struct AddFlags<'a> {
|
||||||
|
|
||||||
/// Run `swarmctl schedule add`.
|
/// Run `swarmctl schedule add`.
|
||||||
pub(crate) fn add(target: &Target, flags: &AddFlags<'_>) -> Result<()> {
|
pub(crate) fn add(target: &Target, flags: &AddFlags<'_>) -> Result<()> {
|
||||||
let request = AddRequest {
|
let request = AddScheduleRequest {
|
||||||
name: flags.name,
|
name: flags.name.to_owned(),
|
||||||
agents: agents(flags.agents)?,
|
agents: agents(flags.agents)?,
|
||||||
body: flags.body,
|
body: flags.body.to_owned(),
|
||||||
anchor_unix: flags.at.map(parse_at).transpose()?,
|
anchor_unix: flags.at.map(parse_at).transpose()?,
|
||||||
interval_seconds: flags.every.map(parse_every).transpose()?,
|
interval_seconds: flags.every.map(parse_every).transpose()?,
|
||||||
paused: flags.paused,
|
paused: flags.paused,
|
||||||
|
|
@ -211,7 +139,7 @@ pub(crate) fn list(target: &Target, agent: Option<&str>) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `swarmctl schedule edit`.
|
/// 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(
|
let edited: ScheduleView = runtime()?.block_on(send(
|
||||||
target,
|
target,
|
||||||
hyper::Method::PATCH,
|
hyper::Method::PATCH,
|
||||||
|
|
@ -225,7 +153,7 @@ pub(crate) fn edit(target: &Target, id: &str, edit: &EditRequest) -> Result<()>
|
||||||
|
|
||||||
/// Run `swarmctl schedule pause` or `resume`.
|
/// Run `swarmctl schedule pause` or `resume`.
|
||||||
pub(crate) fn set_paused(target: &Target, id: &str, paused: bool) -> Result<()> {
|
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`.
|
/// Run `swarmctl schedule rm`.
|
||||||
|
|
@ -359,20 +287,20 @@ mod tests {
|
||||||
/// explicit `null` interval, and an unset flag sends nothing.
|
/// explicit `null` interval, and an unset flag sends nothing.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_edit_sends_only_what_was_asked() {
|
fn an_edit_sends_only_what_was_asked() {
|
||||||
let body = |e: &EditRequest| serde_json::to_string(e).unwrap();
|
let body = |e: &EditScheduleRequest| serde_json::to_string(e).unwrap();
|
||||||
let e = EditRequest::new(EditFlags {
|
let e = edit_request(EditFlags {
|
||||||
name: Some("hi".to_owned()),
|
name: Some("hi".to_owned()),
|
||||||
..flags()
|
..flags()
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(body(&e), r#"{"name":"hi"}"#);
|
assert_eq!(body(&e), r#"{"name":"hi"}"#);
|
||||||
let e = EditRequest::new(EditFlags {
|
let e = edit_request(EditFlags {
|
||||||
once: true,
|
once: true,
|
||||||
..flags()
|
..flags()
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(body(&e), r#"{"interval_seconds":null}"#);
|
assert_eq!(body(&e), r#"{"interval_seconds":null}"#);
|
||||||
let e = EditRequest::new(EditFlags {
|
let e = edit_request(EditFlags {
|
||||||
at: Some("5"),
|
at: Some("5"),
|
||||||
every: Some("1m"),
|
every: Some("1m"),
|
||||||
add_agents: vec!["iris".to_owned()],
|
add_agents: vec!["iris".to_owned()],
|
||||||
|
|
@ -384,10 +312,10 @@ mod tests {
|
||||||
body(&e),
|
body(&e),
|
||||||
r#"{"anchor_unix":5,"interval_seconds":60,"add_agents":["iris"],"remove_agents":["atlas"]}"#
|
r#"{"anchor_unix":5,"interval_seconds":60,"add_agents":["iris"],"remove_agents":["atlas"]}"#
|
||||||
);
|
);
|
||||||
assert_eq!(body(&EditRequest::paused(true)), r#"{"paused":true}"#);
|
assert_eq!(body(&paused_edit(true)), r#"{"paused":true}"#);
|
||||||
assert!(EditRequest::new(flags()).is_err());
|
assert!(edit_request(flags()).is_err());
|
||||||
assert!(
|
assert!(
|
||||||
EditRequest::new(EditFlags {
|
edit_request(EditFlags {
|
||||||
add_agents: vec!["Not An Ident".to_owned()],
|
add_agents: vec!["Not An Ident".to_owned()],
|
||||||
..flags()
|
..flags()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue