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:
parent
a3b672d1d5
commit
30fa54cbc6
9 changed files with 521 additions and 17 deletions
14
CLAUDE.md
14
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.
|
**no identity** and a TLS handshake failure that names no cause.
|
||||||
- **`swarmctl/`** — swarm-level operator CLI, installed by the
|
- **`swarmctl/`** — swarm-level operator CLI, installed by the
|
||||||
swarm-controller module on the host that runs the daemon. Runs as
|
swarm-controller module on the host that runs the daemon. Runs as
|
||||||
**root and acts directly** — no socket, no HTTP route, no priv helper;
|
**root and acts directly** — it serves no socket, publishes no HTTP
|
||||||
the crate's README records why the rootless shape was examined and
|
route and has no priv helper; the crate's README records why the
|
||||||
rejected. Does not link `swarm-controller`, mirroring `hivectl` ÷
|
rootless shape was examined and rejected. `agent create` is the one
|
||||||
`hive-c0re`. ⚠️ Its user store is authelia's own **`users.yml`, read and
|
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
|
written in place** — one file, shared with `swarm-authelia-bridge`; see
|
||||||
that crate's README for what both writers must uphold. Full, always-current
|
that crate's README for what both writers must uphold. Full, always-current
|
||||||
verb reference (CI-enforced against the clap tree, same pattern as
|
verb reference (CI-enforced against the clap tree, same pattern as
|
||||||
|
|
|
||||||
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -4816,11 +4816,18 @@ name = "swarmctl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"bytes",
|
||||||
"clap",
|
"clap",
|
||||||
"clap-markdown",
|
"clap-markdown",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
|
"hive-types",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"serde_norway",
|
"serde_norway",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,10 @@ debug agent behavior.
|
||||||
flag-by-flag reference for `swarmctl`, kept in lockstep with the
|
flag-by-flag reference for `swarmctl`, kept in lockstep with the
|
||||||
binary by CI the same way `hivectl-cli.md` is. `swarmctl` itself
|
binary by CI the same way `hivectl-cli.md` is. `swarmctl` itself
|
||||||
runs as root on the swarm-controller host, not through `hivectl` —
|
runs as root on the swarm-controller host, not through `hivectl` —
|
||||||
see `swarmctl/README.md` for why. No curated guide yet (one verb,
|
see `swarmctl/README.md` for why. Two verb families today: `user`
|
||||||
`user add`, doesn't need one); add one here if/when that grows.
|
(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
|
## What your agents can do
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ This document contains the help content for the `swarmctl` command-line program.
|
||||||
**Command Overview:**
|
**Command Overview:**
|
||||||
|
|
||||||
* [`swarmctl`↴](#swarmctl)
|
* [`swarmctl`↴](#swarmctl)
|
||||||
|
* [`swarmctl agent`↴](#swarmctl-agent)
|
||||||
|
* [`swarmctl agent create`↴](#swarmctl-agent-create)
|
||||||
* [`swarmctl user`↴](#swarmctl-user)
|
* [`swarmctl user`↴](#swarmctl-user)
|
||||||
* [`swarmctl user add`↴](#swarmctl-user-add)
|
* [`swarmctl user add`↴](#swarmctl-user-add)
|
||||||
* [`swarmctl user update`↴](#swarmctl-user-update)
|
* [`swarmctl user update`↴](#swarmctl-user-update)
|
||||||
|
|
@ -19,6 +21,7 @@ swarm-level operator CLI
|
||||||
|
|
||||||
###### **Subcommands:**
|
###### **Subcommands:**
|
||||||
|
|
||||||
|
* `agent` — Manage agents across the swarm
|
||||||
* `user` — Manage subjects in the swarm's SSO provider
|
* `user` — Manage subjects in the swarm's SSO provider
|
||||||
* `completions` — Generate a shell completion script for `swarmctl` and print it to stdout
|
* `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 <COMMAND>`
|
||||||
|
|
||||||
|
###### **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 <HIVE> <NAME>`
|
||||||
|
|
||||||
|
###### **Arguments:**
|
||||||
|
|
||||||
|
* `<NAME>` — 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>` — 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 <PATH>` — 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`
|
## `swarmctl user`
|
||||||
|
|
||||||
Manage subjects in the swarm's SSO provider
|
Manage subjects in the swarm's SSO provider
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,14 @@ let
|
||||||
SWARMCTL_AUTHELIA_USERS_FILE = deployCfg.authelia.hostUsersFile;
|
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;
|
forgeCfg = config.services.hyperhive.swarm.forge;
|
||||||
swarmDomain = config.services.hyperhive.swarm.domain;
|
swarmDomain = config.services.hyperhive.swarm.domain;
|
||||||
|
|
||||||
|
|
@ -244,7 +252,9 @@ let
|
||||||
postBuild = ''
|
postBuild = ''
|
||||||
wrapProgram $out/bin/swarmctl ${
|
wrapProgram $out/bin/swarmctl ${
|
||||||
lib.concatStringsSep " " (
|
lib.concatStringsSep " " (
|
||||||
lib.mapAttrsToList (name: value: "--set ${name} ${lib.escapeShellArg value}") autheliaEnv
|
lib.mapAttrsToList (name: value: "--set ${name} ${lib.escapeShellArg value}") (
|
||||||
|
autheliaEnv // controllerSocketEnv
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
'';
|
'';
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,21 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
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.workspace = true
|
||||||
clap-markdown = "0.1"
|
clap-markdown = "0.1"
|
||||||
clap_complete.workspace = true
|
clap_complete.workspace = true
|
||||||
|
hive-types.workspace = true
|
||||||
|
http-body-util.workspace = true
|
||||||
|
hyper.workspace = true
|
||||||
|
hyper-util.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
serde_norway = "0.9.42"
|
serde_norway = "0.9.42"
|
||||||
|
tokio.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
|
||||||
|
|
@ -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
|
allocated inside it, at activation — see the uid-assignment issue) or
|
||||||
world-readable password hashes. Both are worse than root.
|
world-readable password hashes. Both are worse than root.
|
||||||
|
|
||||||
So there is no socket, no HTTP route and no privileged helper here. When
|
So this binary **serves** no socket, publishes no HTTP route and has no
|
||||||
a verb has to run as a non-root user or from another host, the answer is
|
privileged helper. When a verb has to run as a non-root user or from
|
||||||
a **group-gated admin socket**, separate from the controller's `0666`
|
another host, the answer is a **group-gated admin socket**, separate from
|
||||||
gateway-facing one — not a widening of what root does here.
|
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
|
## One file, two writers
|
||||||
|
|
||||||
|
|
@ -56,8 +62,9 @@ an error.
|
||||||
| `SWARMCTL_AUTHELIA_USERS_FILE` | host-side path of the users database |
|
| `SWARMCTL_AUTHELIA_USERS_FILE` | host-side path of the users database |
|
||||||
| `SWARMCTL_AUTHELIA_MACHINE` | container name, for `systemctl -M` |
|
| `SWARMCTL_AUTHELIA_MACHINE` | container name, for `systemctl -M` |
|
||||||
| `SWARMCTL_AUTHELIA_UNIT` | authelia's unit inside that container |
|
| `SWARMCTL_AUTHELIA_UNIT` | authelia's unit inside that container |
|
||||||
|
| `SWARM_CONTROLLER_SOCKET` | the controller's unix socket, for `agent create` |
|
||||||
|
|
||||||
## Usage
|
## `user add`
|
||||||
|
|
||||||
```console
|
```console
|
||||||
# swarmctl user add mara --display-name "Mara" --group admins
|
# 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:
|
--random`) and printed once. It is never passed on a command line:
|
||||||
`/proc/<pid>/cmdline` is world-readable, so a password in argv is readable
|
`/proc/<pid>/cmdline` is world-readable, so a password in argv is readable
|
||||||
by any local process for the lifetime of the call.
|
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.
|
||||||
|
|
|
||||||
255
swarmctl/src/agent.rs
Normal file
255
swarmctl/src/agent.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String> {
|
||||||
|
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<CreateAgentResponse> {
|
||||||
|
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::<serde_json::Value>(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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,16 +11,23 @@
|
||||||
//! activation) or world-readable password hashes. Both are worse than
|
//! activation) or world-readable password hashes. Both are worse than
|
||||||
//! root.
|
//! root.
|
||||||
//!
|
//!
|
||||||
//! So there is no socket, no HTTP route and no privileged helper here.
|
//! So this binary **serves** no socket, publishes no HTTP route and has
|
||||||
//! When a verb eventually has to run as a non-root user or from another
|
//! no privileged helper. When a verb eventually has to run as a non-root
|
||||||
//! host, the answer is a **group-gated admin socket** — separate from the
|
//! user or from another host, the answer is a **group-gated admin
|
||||||
//! controller's `0666` gateway-facing one — not a widening of what root
|
//! socket** — separate from the controller's `0666` gateway-facing one —
|
||||||
//! does here.
|
//! 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
|
//! Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
|
||||||
//! admin socket. This crate deliberately does not link `swarm-controller`,
|
//! admin socket. This crate deliberately does not link `swarm-controller`,
|
||||||
//! for the same reason `hivectl` does not link `hive-c0re`.
|
//! for the same reason `hivectl` does not link `hive-c0re`.
|
||||||
|
|
||||||
|
mod agent;
|
||||||
mod users;
|
mod users;
|
||||||
|
|
||||||
use std::fs::{self, File, Permissions};
|
use std::fs::{self, File, Permissions};
|
||||||
|
|
@ -106,6 +113,11 @@ fn missing(env: &str) -> String {
|
||||||
/// a derive, several errors away from the actual cause.
|
/// a derive, several errors away from the actual cause.
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Verb {
|
enum Verb {
|
||||||
|
/// Manage agents across the swarm.
|
||||||
|
Agent {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: AgentVerb,
|
||||||
|
},
|
||||||
/// Manage subjects in the swarm's SSO provider.
|
/// Manage subjects in the swarm's SSO provider.
|
||||||
User {
|
User {
|
||||||
#[command(subcommand)]
|
#[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<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum UserVerb {
|
enum UserVerb {
|
||||||
/// Add a user, generating a password for them.
|
/// Add a user, generating a password for them.
|
||||||
|
|
@ -197,6 +252,16 @@ struct UpdateArgs {
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let Cli { paths, command } = Cli::parse();
|
let Cli { paths, command } = Cli::parse();
|
||||||
match command {
|
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
|
// Resolved lazily, inside the one arm that actually touches the
|
||||||
// deployment env vars — see the `MarkdownDocs` doc comment above
|
// deployment env vars — see the `MarkdownDocs` doc comment above
|
||||||
// for why an unconditional resolve up front would be wrong.
|
// for why an unconditional resolve up front would be wrong.
|
||||||
|
|
@ -489,6 +554,69 @@ fn write_atomic(path: &Path, contents: &str) -> Result<()> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn parses_authelia_hash_output() {
|
fn parses_authelia_hash_output() {
|
||||||
let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";
|
let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue