From 30fa54cbc692d8f20f8f9e68a4dda79db75f5296 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 14 Sep 2026 18:54:14 +0200 Subject: [PATCH] feat(swarmctl): add `agent create`, queueing the swarm-controller creation DAG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swarmctl agent create --hive ` POSTs `/api/agents` to swarm-controller over the daemon's unix socket and prints the queued job's node id. It deliberately does not wait. The endpoint queues a DAG whose last node *publishes* a deploy message; the hive's `hive-c0re` then converges on its own clock, out of the controller's sight. So even a fully settled graph would not mean the agent is up, and there is nothing this CLI could wait for that would let it claim otherwise. Printing the id is exactly what the response says and all of what it says. Transport is a bare hyper HTTP/1.1 client handshaked onto a tokio `UnixStream` via `hyper_util::rt::TokioIo` — the same crate family `hivectl/src/watch.rs` and `hive-agent/src/web_ui/proxy.rs` already use, all of it already workspace-pinned. The request/response shapes are a local mirror rather than a shared crate: the controller's own types are private to its binary and this crate does not link it, the same separation `hivectl` keeps from `hive-c0re`. Errors are reduced to one actionable line — the controller answers RFC 9457 problem+json, so an unknown `--hive` reaches the operator as the roster of hives that would have worked rather than a body dump. Response `warnings` are printed when non-empty. The nix module wraps the binary with `SWARM_CONTROLLER_SOCKET`, read from the same `socketPath` the daemon binds. Refs #4399 --- CLAUDE.md | 14 +- Cargo.lock | 7 + docs/tools/README.md | 6 +- docs/tools/swarmctl-cli.md | 44 +++++ nix/host-modules/swarm-controller.nix | 12 +- swarmctl/Cargo.toml | 10 + swarmctl/README.md | 52 +++++- swarmctl/src/agent.rs | 255 ++++++++++++++++++++++++++ swarmctl/src/main.rs | 138 +++++++++++++- 9 files changed, 521 insertions(+), 17 deletions(-) create mode 100644 swarmctl/src/agent.rs diff --git a/CLAUDE.md b/CLAUDE.md index d8ce5e71..80e867cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,10 +169,16 @@ hand-maintained per-file tree drifts out of sync with the code. **no identity** and a TLS handshake failure that names no cause. - **`swarmctl/`** — swarm-level operator CLI, installed by the swarm-controller module on the host that runs the daemon. Runs as - **root and acts directly** — no socket, no HTTP route, no priv helper; - the crate's README records why the rootless shape was examined and - rejected. Does not link `swarm-controller`, mirroring `hivectl` ÷ - `hive-c0re`. ⚠️ Its user store is authelia's own **`users.yml`, read and + **root and acts directly** — it serves no socket, publishes no HTTP + route and has no priv helper; the crate's README records why the + rootless shape was examined and rejected. `agent create` is the one + verb that talks to a service rather than acting locally: it POSTs + `/api/agents` to swarm-controller over the daemon's unix socket and + prints the queued job id without waiting (the DAG settling would not + mean the agent is up). Does not link `swarm-controller` — its wire + shapes are a local mirror kept in this crate, not a shared crate, so + the two binaries can be deployed independently. ⚠️ Its user store is + authelia's own **`users.yml`, read and written in place** — one file, shared with `swarm-authelia-bridge`; see that crate's README for what both writers must uphold. Full, always-current verb reference (CI-enforced against the clap tree, same pattern as diff --git a/Cargo.lock b/Cargo.lock index 1e68b91b..c3342892 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4816,11 +4816,18 @@ name = "swarmctl" version = "0.1.0" dependencies = [ "anyhow", + "bytes", "clap", "clap-markdown", "clap_complete", + "hive-types", + "http-body-util", + "hyper", + "hyper-util", "serde", + "serde_json", "serde_norway", + "tokio", ] [[package]] diff --git a/docs/tools/README.md b/docs/tools/README.md index 441b9aab..23918906 100644 --- a/docs/tools/README.md +++ b/docs/tools/README.md @@ -21,8 +21,10 @@ debug agent behavior. flag-by-flag reference for `swarmctl`, kept in lockstep with the binary by CI the same way `hivectl-cli.md` is. `swarmctl` itself runs as root on the swarm-controller host, not through `hivectl` — - see `swarmctl/README.md` for why. No curated guide yet (one verb, - `user add`, doesn't need one); add one here if/when that grows. + see `swarmctl/README.md` for why. Two verb families today: `user` + (authelia's subject store, edited in place) and `agent create` + (queues the swarm-controller's creation job graph). No curated guide + yet; add one here if/when that grows. ## What your agents can do diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index e1449840..125e95d0 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -5,6 +5,8 @@ This document contains the help content for the `swarmctl` command-line program. **Command Overview:** * [`swarmctl`↴](#swarmctl) +* [`swarmctl agent`↴](#swarmctl-agent) +* [`swarmctl agent create`↴](#swarmctl-agent-create) * [`swarmctl user`↴](#swarmctl-user) * [`swarmctl user add`↴](#swarmctl-user-add) * [`swarmctl user update`↴](#swarmctl-user-update) @@ -19,6 +21,7 @@ swarm-level operator CLI ###### **Subcommands:** +* `agent` — Manage agents across the swarm * `user` — Manage subjects in the swarm's SSO provider * `completions` — Generate a shell completion script for `swarmctl` and print it to stdout @@ -31,6 +34,47 @@ swarm-level operator CLI +## `swarmctl agent` + +Manage agents across the swarm + +**Usage:** `swarmctl agent ` + +###### **Subcommands:** + +* `create` — Queue creation of a new agent on a hive in this swarm + + + +## `swarmctl agent create` + +Queue creation of a new agent on a hive in this swarm. + +Asks the swarm-controller to insert its agent-creation job graph — SSO identity, forge user, config repo, and the deploy message that puts the agent on `--hive` — and prints the queued job's node id. + +**This returns as soon as the work is queued.** It doesn't wait, and a finished graph would not mean the agent is up either: the last node publishes a deploy, after which the hive converges on its own clock. Watch the swarm UI's job view, or the hive itself, for the rest. + +No approval gate guards this: running this binary already means being root on the controller's host. + +**Usage:** `swarmctl agent create [OPTIONS] --hive ` + +###### **Arguments:** + +* `` — Name for the new agent: 1–63 characters of `[a-z0-9-]`. + + Becomes an SSO subject, a forge user and a repository name, so it's validated here before anything is queued. + +###### **Options:** + +* `--hive ` — Hive in this swarm to deploy the agent to. + + Required, and deliberately not defaulted: it's an *address* — the hive a deploy message is sent to — and only the operator knows which one they mean. The controller checks it against the swarm's hive roster and names the known hives if it misses. +* `--controller-socket ` — swarm-controller's unix socket. + + Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`. + + + ## `swarmctl user` Manage subjects in the swarm's SSO provider diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index e2168bcf..05f7f7dd 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -75,6 +75,14 @@ let SWARMCTL_AUTHELIA_USERS_FILE = deployCfg.authelia.hostUsersFile; }; + # Where `swarmctl agent create` posts to. Unconditional, unlike + # `autheliaEnv` above: this binary is only installed where the daemon + # runs, so the socket is always the one right below it — and it is the + # same `socketPath` the daemon binds, read once rather than restated. + controllerSocketEnv = { + SWARM_CONTROLLER_SOCKET = deployCfg.swarm-controller.socketPath; + }; + forgeCfg = config.services.hyperhive.swarm.forge; swarmDomain = config.services.hyperhive.swarm.domain; @@ -244,7 +252,9 @@ let postBuild = '' wrapProgram $out/bin/swarmctl ${ lib.concatStringsSep " " ( - lib.mapAttrsToList (name: value: "--set ${name} ${lib.escapeShellArg value}") autheliaEnv + lib.mapAttrsToList (name: value: "--set ${name} ${lib.escapeShellArg value}") ( + autheliaEnv // controllerSocketEnv + ) ) } ''; diff --git a/swarmctl/Cargo.toml b/swarmctl/Cargo.toml index 58095c99..5a85f2a7 100644 --- a/swarmctl/Cargo.toml +++ b/swarmctl/Cargo.toml @@ -10,11 +10,21 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true +# `agent create` — bare HTTP/1.1 client over swarm-controller's unix +# socket. No new workspace dependency: `bytes`, `http-body-util`, +# `hyper` and `hyper-util` are all already pinned in the root manifest. +bytes = "1" clap.workspace = true clap-markdown = "0.1" clap_complete.workspace = true +hive-types.workspace = true +http-body-util.workspace = true +hyper.workspace = true +hyper-util.workspace = true serde.workspace = true +serde_json.workspace = true serde_norway = "0.9.42" +tokio.workspace = true [lints] workspace = true diff --git a/swarmctl/README.md b/swarmctl/README.md index 51158f47..527e6ea0 100644 --- a/swarmctl/README.md +++ b/swarmctl/README.md @@ -18,10 +18,16 @@ _that_ work needs either a hand-pinned gid (the container's uids are allocated inside it, at activation — see the uid-assignment issue) or world-readable password hashes. Both are worse than root. -So there is no socket, no HTTP route and no privileged helper here. When -a verb has to run as a non-root user or from another host, the answer is -a **group-gated admin socket**, separate from the controller's `0666` -gateway-facing one — not a widening of what root does here. +So this binary **serves** no socket, publishes no HTTP route and has no +privileged helper. When a verb has to run as a non-root user or from +another host, the answer is a **group-gated admin socket**, separate from +the controller's `0666` gateway-facing one — not a widening of what root +does here. + +That rules out a way in, not a way out: `agent create` is a _client_ of +the controller's socket, because the work it asks for is a job graph only +the controller can queue (see below). Nothing here becomes reachable by +that. ## One file, two writers @@ -56,8 +62,9 @@ an error. | `SWARMCTL_AUTHELIA_USERS_FILE` | host-side path of the users database | | `SWARMCTL_AUTHELIA_MACHINE` | container name, for `systemctl -M` | | `SWARMCTL_AUTHELIA_UNIT` | authelia's unit inside that container | +| `SWARM_CONTROLLER_SOCKET` | the controller's unix socket, for `agent create` | -## Usage +## `user add` ```console # swarmctl user add mara --display-name "Mara" --group admins @@ -67,3 +74,38 @@ The password is **generated by authelia** (`crypto hash generate argon2 --random`) and printed once. It is never passed on a command line: `/proc//cmdline` is world-readable, so a password in argv is readable by any local process for the lifetime of the call. + +## `agent create` + +```console +# swarmctl agent create scribe --hive alpha +queued: job node 42 +agent "scribe" will be deployed to hive "alpha" once the job graph runs; `swarmctl` does not wait for it +``` + +`POST /api/agents` on the swarm-controller, over its unix socket +(`--controller-socket`, else `SWARM_CONTROLLER_SOCKET`, which the nix +module sets from the daemon's own `socketPath`). It prints the queued +job's node id **and stops there**. + +It deliberately does not wait. The endpoint queues a DAG — SSO identity, +forge user, forge repo, repo membership, config-repo seed, then a deploy +message — and the last of those _publishes_: the hive's `hive-c0re` picks +it up and converges on its own clock, out of the controller's sight. So +even a fully settled graph would not mean the agent is up, and there is +nothing this CLI could wait for that would let it say so honestly. Watch +the swarm UI's job view for the rest. + +No approval gate, for the same reason nothing else here has one: running +this binary already means being root on the controller's host. + +`--hive` is required. It is an _address_ — where the deploy message goes +— not an attribute of the agent, so there is no sensible default. The +controller checks it against the swarm's hive roster and names the hives +that would have worked when it misses. + +The request and response shapes are mirrored in `src/agent.rs` rather +than shared: the controller's own types are private to its binary, this +crate does not link it, and there is no wire-type crate between them. +Two fields out, two in, both ends validating — a drift shows up as a +400 naming the field. diff --git a/swarmctl/src/agent.rs b/swarmctl/src/agent.rs new file mode 100644 index 00000000..ad228c9c --- /dev/null +++ b/swarmctl/src/agent.rs @@ -0,0 +1,255 @@ +//! `swarmctl agent create` — queue the swarm-controller's agent-creation +//! job graph. +//! +//! `POST /api/agents` inserts a DAG and returns as soon as it is +//! *inserted*. This verb prints the queued node id and stops: the graph's +//! last node only *publishes* a deploy, after which the hive converges on +//! its own clock, so even a settled graph would not mean the agent is up +//! and there is nothing here that could be waited on honestly. +//! +//! Transport is a bare `hyper` HTTP/1.1 client handshaked onto a +//! [`tokio::net::UnixStream`] via [`hyper_util::rt::TokioIo`] — the +//! controller has no TCP port, so this crate speaks plain HTTP directly +//! over the socket rather than pulling in a full client stack for one +//! POST. +//! +//! 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 +//! drift surfaces as a 400 naming the field. +//! +//! Long-form rationale for all three: `swarmctl/README.md`. + +use std::path::Path; + +use anyhow::{Context as _, Result, bail}; +use http_body_util::{BodyExt as _, Full}; +use hyper_util::rt::TokioIo; +use serde::{Deserialize, Serialize}; +use tokio::net::UnixStream; + +/// Body of `POST /api/agents`. Mirrors the controller's own +/// `CreateAgentRequest` — see this module's doc comment. +#[derive(Serialize)] +struct CreateAgentRequest<'a> { + name: &'a str, + /// Where the agent's deploy message is addressed. Required by the + /// endpoint and not defaulted anywhere, because it is only knowable + /// from the operator making the choice. + hive: &'a str, +} + +/// Success body of `POST /api/agents`. +#[derive(Deserialize)] +struct CreateAgentResponse { + node_id: u64, + /// Name collisions the controller **allowed through** — it is the + /// "warn now, refuse later" step, so an empty list is the normal case + /// and the field is omitted from the JSON entirely when it is empty. + #[serde(default)] + warnings: Vec, +} + +/// Run `swarmctl agent create`. +/// +/// Synchronous on purpose: every other verb in this crate is, and this is +/// the only one that needs a reactor at all. A current-thread runtime +/// built here keeps that cost inside the one arm that incurs it, the same +/// way `PathArgs::resolve` is called only in the arms that need the +/// deployment's env vars. +pub(crate) fn create(socket: &Path, name: &str, hive: &str) -> Result<()> { + // Client-side before the round trip, so a typo is an immediate local + // error rather than a 400 the operator has to wait for. The controller + // validates both again — that is the gate that matters, this is the + // one that is fast. + let name = parse_ident(name, "agent name")?; + let hive = parse_ident(hive, "hive")?; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_io() + .build() + .context("starting a tokio runtime for the controller request")?; + let resp = rt.block_on(post_create(socket, &name, &hive))?; + + println!("queued: job node {}", resp.node_id); + println!( + "agent {name:?} will be deployed to hive {hive:?} once the job graph runs; \ + `swarmctl` does not wait for it" + ); + // stderr, and after the id: the id is the result, these are asides the + // operator should still not have to go find in the daemon's journal. + for warning in &resp.warnings { + eprintln!("warning: {warning}"); + } + Ok(()) +} + +/// Parse a CLI-supplied name into a validated identifier, naming which +/// argument was wrong — `invalid hive` and `invalid agent name` send the +/// operator to different flags. +fn parse_ident(value: &str, what: &str) -> Result { + hive_types::Ident::parse(value) + .map(hive_types::Ident::into_string) + .map_err(|reason| anyhow::anyhow!("invalid {what} {value:?}: {reason}")) +} + +/// One `POST /api/agents` round trip over the controller's unix socket. +async fn post_create(socket: &Path, name: &str, hive: &str) -> Result { + let body = serde_json::to_vec(&CreateAgentRequest { name, hive }) + .context("serialising the create-agent request")?; + + let stream = UnixStream::connect(socket) + .await + .with_context(|| connect_hint(socket))?; + let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(stream)) + .await + .with_context(|| format!("HTTP handshake with {}", socket.display()))?; + // Drives the connection's I/O; a one-shot POST has nothing to do with + // the join handle, and a connection that dies surfaces as an error on + // `send_request` or on reading the body. + tokio::spawn(conn); + + let req = hyper::Request::builder() + .method(hyper::Method::POST) + .uri("/api/agents") + // A unix socket has no authority of its own, but HTTP/1.1 requires + // the header; the controller routes on the path alone. + .header(hyper::header::HOST, "localhost") + .header(hyper::header::CONTENT_TYPE, "application/json") + .body(Full::new(bytes::Bytes::from(body))) + .context("building the create-agent request")?; + + let resp = sender + .send_request(req) + .await + .context("sending the create-agent request to swarm-controller")?; + let status = resp.status(); + let body = resp + .into_body() + .collect() + .await + .context("reading swarm-controller's response")? + .to_bytes(); + + if !status.is_success() { + bail!("{}", describe_error(status, &body)); + } + serde_json::from_slice(&body).with_context(|| { + format!( + "swarm-controller answered {status} but its body is not a create-agent response: {}", + String::from_utf8_lossy(&body).trim() + ) + }) +} + +/// Turn a failed response into one line the operator can act on. +/// +/// The controller answers errors as RFC 9457 `application/problem+json`, +/// where the actionable sentence is `detail` — a 400 for an unknown hive +/// names the hives that *would* have worked. Printing the raw body instead +/// would bury that in JSON punctuation, and printing only the status would +/// throw it away. Falls back through `title` to the raw body so a response +/// from something that is *not* the controller is still shown rather than +/// reduced to a bare number. +fn describe_error(status: hyper::StatusCode, body: &[u8]) -> String { + let text = String::from_utf8_lossy(body); + let text = text.trim(); + let field = |v: &serde_json::Value, k: &str| { + v.get(k) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + }; + let detail = serde_json::from_str::(text) + .ok() + .and_then(|v| field(&v, "detail").or_else(|| field(&v, "title"))); + match detail { + Some(detail) => format!("swarm-controller refused the request ({status}): {detail}"), + None if text.is_empty() => format!("swarm-controller refused the request ({status})"), + None => format!("swarm-controller refused the request ({status}): {text}"), + } +} + +/// Message for a socket that could not be dialled. +/// +/// Worth spelling out because the three ways this fails need three +/// different fixes and `No such file or directory (os error 2)` names +/// none of them: the daemon is off, it binds elsewhere, or this host is +/// not the one running it at all. No group-membership case to mention +/// here — `swarmctl` already runs as root. +fn connect_hint(socket: &Path) -> String { + format!( + "could not connect to swarm-controller at {} — is it running on this host? \ + (`systemctl status swarm-controller`). The path comes from \ + `--controller-socket` or `SWARM_CONTROLLER_SOCKET`, which the \ + swarm-controller nix module sets from `socketPath`", + socket.display() + ) +} + +#[cfg(test)] +mod tests { + use super::{CreateAgentResponse, describe_error, parse_ident}; + + /// The endpoint omits `warnings` entirely when there are none, so the + /// normal response must still decode — a missing field here would make + /// every successful creation fail to parse. + #[test] + fn a_response_without_warnings_decodes() { + let resp: CreateAgentResponse = + serde_json::from_str(r#"{"node_id":7}"#).expect("the normal shape decodes"); + assert_eq!(resp.node_id, 7); + assert!(resp.warnings.is_empty()); + } + + #[test] + fn warnings_are_carried_through() { + let resp: CreateAgentResponse = + serde_json::from_str(r#"{"node_id":9,"warnings":["reserved name"]}"#).expect("decodes"); + assert_eq!(resp.warnings, vec!["reserved name".to_owned()]); + } + + /// The whole point of parsing problem+json: the 400 for an unknown + /// hive carries the roster of hives that would have worked, and that + /// sentence has to reach the operator's terminal intact. + #[test] + fn a_problem_json_detail_reaches_the_operator() { + let body = r#"{"status":400,"title":"Bad Request","detail":"hive \"tyop\" is not in this swarm — known hives: alpha, beta"}"#; + let msg = describe_error(hyper::StatusCode::BAD_REQUEST, body.as_bytes()); + assert!(msg.contains("known hives: alpha, beta"), "{msg}"); + assert!(msg.contains("400"), "{msg}"); + // No JSON punctuation: the operator gets prose, not a body dump. + assert!(!msg.contains('{'), "{msg}"); + } + + /// A response from something that is not the controller (an nginx + /// error page, a proxy) must still be shown rather than swallowed. + #[test] + fn a_non_json_body_is_shown_verbatim() { + let msg = describe_error(hyper::StatusCode::BAD_GATEWAY, b" 502 Bad Gateway "); + assert!(msg.contains("502 Bad Gateway"), "{msg}"); + } + + #[test] + fn an_empty_body_still_names_the_status() { + let msg = describe_error(hyper::StatusCode::INTERNAL_SERVER_ERROR, b""); + assert!(msg.contains("500"), "{msg}"); + } + + /// Which argument was wrong has to be in the message: `agent create` + /// takes two identifiers and they are fixed in different places. + #[test] + fn an_invalid_ident_names_the_argument_it_came_from() { + let err = parse_ident("Not An Ident", "hive").expect_err("rejected"); + let msg = err.to_string(); + assert!(msg.contains("invalid hive"), "{msg}"); + assert!(msg.contains("Not An Ident"), "{msg}"); + + assert!( + parse_ident("scribe-01", "agent name").is_ok(), + "a normal name must pass" + ); + } +} diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index 5d32de7a..4efe3d7c 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -11,16 +11,23 @@ //! activation) or world-readable password hashes. Both are worse than //! root. //! -//! So there is no socket, no HTTP route and no privileged helper here. -//! When a verb eventually has to run as a non-root user or from another -//! host, the answer is a **group-gated admin socket** — separate from the -//! controller's `0666` gateway-facing one — not a widening of what root -//! does here. +//! So this binary **serves** no socket, publishes no HTTP route and has +//! no privileged helper. When a verb eventually has to run as a non-root +//! user or from another host, the answer is a **group-gated admin +//! socket** — separate from the controller's `0666` gateway-facing one — +//! not a widening of what root does here. +//! +//! Acting directly is not the same as acting *alone*, though: `agent +//! create` is a client of the controller's own unix socket, because the +//! work it asks for is a job graph only the controller can queue (see +//! [`agent`]). That is the opposite direction from the socket the +//! paragraph above rules out — nothing here becomes reachable by it. //! //! Distinct from `hivectl`, which drives one hive's `hive-c0re` over its //! admin socket. This crate deliberately does not link `swarm-controller`, //! for the same reason `hivectl` does not link `hive-c0re`. +mod agent; mod users; use std::fs::{self, File, Permissions}; @@ -106,6 +113,11 @@ fn missing(env: &str) -> String { /// a derive, several errors away from the actual cause. #[derive(Subcommand)] enum Verb { + /// Manage agents across the swarm. + Agent { + #[command(subcommand)] + command: AgentVerb, + }, /// Manage subjects in the swarm's SSO provider. User { #[command(subcommand)] @@ -139,6 +151,49 @@ enum Verb { }, } +#[derive(Subcommand)] +enum AgentVerb { + /// Queue creation of a new agent on a hive in this swarm. + /// + /// Asks the swarm-controller to insert its agent-creation job graph — + /// SSO identity, forge user, config repo, and the deploy message that + /// puts the agent on `--hive` — and prints the queued job's node id. + /// + /// **This returns as soon as the work is queued.** It doesn't wait, + /// and a finished graph would not mean the agent is up either: the + /// last node publishes a deploy, after which the hive converges on its + /// own clock. Watch the swarm UI's job view, or the hive itself, for + /// the rest. + /// + /// No approval gate guards this: running this binary already means + /// being root on the controller's host. + Create(AgentCreateArgs), +} + +#[derive(Args)] +struct AgentCreateArgs { + /// Name for the new agent: 1–63 characters of `[a-z0-9-]`. + /// + /// Becomes an SSO subject, a forge user and a repository name, so + /// it's validated here before anything is queued. + name: String, + /// Hive in this swarm to deploy the agent to. + /// + /// Required, and deliberately not defaulted: it's an *address* — the + /// hive a deploy message is sent to — and only the operator knows + /// which one they mean. The controller checks it against the swarm's + /// hive roster and names the known hives if it misses. + #[arg(long, value_name = "HIVE")] + hive: String, + /// swarm-controller's unix socket. + /// + /// Supplied by the nix module that installs this binary, from the same + /// `socketPath` option the daemon binds; falls back to + /// `SWARM_CONTROLLER_SOCKET`. + #[arg(long, value_name = "PATH")] + controller_socket: Option, +} + #[derive(Subcommand)] enum UserVerb { /// Add a user, generating a password for them. @@ -197,6 +252,16 @@ struct UpdateArgs { fn main() -> Result<()> { let Cli { paths, command } = Cli::parse(); match command { + // Resolves its own socket path, not `PathArgs`: this verb needs + // none of the `SWARMCTL_AUTHELIA_*` values, and requiring them + // would make agent creation fail on a controller host that is not + // also the swarm's SSO host. + Verb::Agent { + command: AgentVerb::Create(args), + } => { + let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?; + agent::create(&socket, &args.name, &args.hive) + } // Resolved lazily, inside the one arm that actually touches the // deployment env vars — see the `MarkdownDocs` doc comment above // for why an unconditional resolve up front would be wrong. @@ -489,6 +554,69 @@ fn write_atomic(path: &Path, contents: &str) -> Result<()> { mod tests { use super::*; + /// clap's own consistency checks over the whole tree — a duplicated + /// long flag or a malformed `value_name` panics at parse time in + /// production and is otherwise only found by running the binary. + #[test] + fn the_clap_tree_is_well_formed() { + use clap::CommandFactory as _; + Cli::command().debug_assert(); + } + + /// `--hive` carries the address the agent is deployed to and the + /// endpoint has no default for it, so omitting it has to fail at parse + /// time rather than reach the controller as an empty string. + #[test] + fn agent_create_requires_a_hive() { + assert!( + Cli::try_parse_from(["swarmctl", "agent", "create", "scribe"]).is_err(), + "a create with no --hive must not parse" + ); + } + + #[test] + fn agent_create_parses_its_name_hive_and_socket() { + let cli = Cli::try_parse_from([ + "swarmctl", + "agent", + "create", + "scribe", + "--hive", + "alpha", + "--controller-socket", + "/run/elsewhere/controller.sock", + ]) + .expect("the full form parses"); + let Verb::Agent { + command: AgentVerb::Create(args), + } = cli.command + else { + panic!("expected `agent create`"); + }; + assert_eq!(args.name, "scribe"); + assert_eq!(args.hive, "alpha"); + assert_eq!( + args.controller_socket.as_deref(), + Some(Path::new("/run/elsewhere/controller.sock")) + ); + } + + /// The socket is optional on the command line because the nix wrapper + /// sets `SWARM_CONTROLLER_SOCKET`; `path_from` is what turns an absent + /// pair into an error rather than a guess. + #[test] + fn an_omitted_socket_flag_leaves_the_env_to_supply_it() { + let cli = Cli::try_parse_from(["swarmctl", "agent", "create", "scribe", "--hive", "alpha"]) + .expect("the minimal form parses"); + let Verb::Agent { + command: AgentVerb::Create(args), + } = cli.command + else { + panic!("expected `agent create`"); + }; + assert!(args.controller_socket.is_none()); + } + #[test] fn parses_authelia_hash_output() { let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";