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";