feat(swarmctl): add agent create, queueing the swarm-controller creation DAG

`swarmctl agent create <name> --hive <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
This commit is contained in:
atlas 2026-09-14 18:54:14 +02:00 committed by mara
commit 30fa54cbc6
9 changed files with 521 additions and 17 deletions

View file

@ -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: 163 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<PathBuf>,
}
#[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";