swarmctl decoded POST /api/agents and POST /api/agents/{name}/forge-token
into hand-written copies of the controller's private response structs.
Both now live in swarm-controller-api, so a field renamed on one side is a
compile error on the other. Wire format and OpenAPI schema are unchanged.
The request side stays mirrored: CreateAgentRequest carries inference
settings and an ArchetypeRef (swarm-queue-client types) and Permission
(hive-sh4re), and the permissions body is a list of Permission. Where
those types live is #5471's decision.
Files:
- swarm-controller-api/src/agents.rs, lib.rs: the two responses, with
round-trip tests whose literals come from the tests that move here.
- swarm-controller/src/main.rs: imports them; the struct definitions and
create_agent_warning_tests (now in the api crate) go.
- swarmctl/src/agent.rs: imports them; the mirrors and their decode tests
go, and the module doc says only the request is mirrored now.
- swarmctl/src/permissions.rs: its doc pointed at agent.rs for why it
mirrors; that reason no longer covers Permissions, so it gives its own.
- swarmctl/README.md, swarm-controller-api/README.md, CLAUDE.md: which
bodies are shared and which are mirrored.
Part of #5472
200 lines
9.2 KiB
Markdown
200 lines
9.2 KiB
Markdown
# swarmctl
|
|
|
|
Swarm-level operator CLI. Runs as **root on the host running
|
|
`swarm-controller`**, and acts on that host directly; or, for the verbs
|
|
that talk to the controller, from anywhere as your own SSO account (see
|
|
[Remote use](#remote-use-swarmctl-login)).
|
|
|
|
Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
|
|
admin socket. This crate doesn't link `swarm-controller`, for the same
|
|
reason `hivectl` doesn't link `hive-c0re`; the forge and schedule bodies
|
|
and the agent responses both sides speak come from `swarm-controller-api`,
|
|
as `hivectl`'s come from `hive-host-sock`.
|
|
|
|
## Why root, and why no socket
|
|
|
|
The first verb writes authelia's users database. Making that write
|
|
rootless was examined and rejected — **relocating the file only turns a
|
|
write problem into a read problem.** Move `users.yml` into a directory
|
|
the controller owns and the controller can write it, but authelia then
|
|
has to read it across the same boundary in the other direction. Making
|
|
_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 this binary **serves** no socket, publishes no HTTP route and has no
|
|
privileged helper.
|
|
|
|
That rules out a way in, not a way out: `agent create` is a _client_ of
|
|
the controller, 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
|
|
|
|
`swarmctl` reads and writes `users.yml` — authelia's own users database —
|
|
directly. `swarm-authelia-bridge` writes agent subjects into the same file.
|
|
Every verb that writes holds an exclusive `flock` on `users.yml.lock` from
|
|
before the load until the rename, as the bridge does.
|
|
|
|
⚠️ The file is round-tripped, so **comments and hand-formatting do not
|
|
survive a write**. Values do, and so do keys this binary does not model.
|
|
See `swarm-authelia-bridge/README.md` for what both writers must uphold.
|
|
|
|
## Configuration
|
|
|
|
The nix deployment sets every path, each derived from an option it already
|
|
has: `swarm-controller.nix` sets the socket, and `glue-controller-env.nix` sets
|
|
`SWARMCTL_AUTHELIA_BIN` and `SWARMCTL_AUTHELIA_USERS_FILE`, only where
|
|
authelia runs on the same host. They are required
|
|
rather than defaulted — a default would be an address we _hope_ points at
|
|
something, and one that resolves cleanly to the wrong place is worse than
|
|
an error.
|
|
|
|
| variable | what |
|
|
| ------------------------------ | -------------------------------------------------------------------- |
|
|
| `SWARMCTL_AUTHELIA_BIN` | the **configured** authelia; argon2 params must match the verifier's |
|
|
| `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` and `forge` verbs |
|
|
| `SWARMCTL_LOGIN_DEFAULT_SWARM` | `https://<swarm domain>` of this host's swarm, for `login` only |
|
|
| `SWARMCTL_SWARM` | the swarm's domain, bare or `https://`: reach the controller there |
|
|
| `SWARMCTL_CA_FILE` | a PEM CA to trust beside the system roots, for `SWARMCTL_SWARM` |
|
|
|
|
Every verb and flag: [`docs/tools/swarmctl-cli.md`](../docs/tools/swarmctl-cli.md).
|
|
The sections below cover why each verb behaves as it does.
|
|
|
|
## `user add`
|
|
|
|
```console
|
|
# swarmctl user add mara --display-name "Mara" --email mara@example.com --group admins
|
|
```
|
|
|
|
Keep both flags:
|
|
|
|
- **`--group admins`** — the swarm UI and other operator surfaces gate on it.
|
|
- **`--email`** — the forge won't create an account without one.
|
|
|
|
`user add` refuses a username that already exists; fix an existing account
|
|
with `swarmctl user update mara --add-group admins --email …`.
|
|
|
|
The password is **generated by authelia** (`crypto hash generate argon2
|
|
--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
|
|
by any local process for the lifetime of the call. `user reset-password`
|
|
generates a new one the same way.
|
|
|
|
## `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, store identity,
|
|
forge token, matrix account, a `paused` wanted state, 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.
|
|
|
|
The new agent starts `paused`: it doesn't drive turns until you set it `up`
|
|
in the swarm UI.
|
|
|
|
No approval gate, for the same reason nothing else here has one: the
|
|
caller is root on the controller's host, or logged in through the swarm
|
|
domain's SSO gate.
|
|
|
|
`--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 response shapes come from `swarm-controller-api`. `src/agent.rs`
|
|
mirrors the request shape: the controller's `CreateAgentRequest`
|
|
carries types from `swarm-queue-client` and `hive-sh4re`, neither of
|
|
which `swarm-controller-api` depends on. Two fields out, both ends
|
|
validating — a drift shows up as a 400 naming the field.
|
|
|
|
## `agent mint-forge-token`
|
|
|
|
```console
|
|
# swarmctl agent mint-forge-token ruth
|
|
```
|
|
|
|
`POST /api/agents/{name}/forge-token` on the swarm-controller. Checks one
|
|
agent's forge token and mints it if it's missing or stale. The controller
|
|
already does this for every agent with a store identity at start and every
|
|
five minutes; this verb skips the wait. Queues and returns.
|
|
|
|
## `forge make-admin`
|
|
|
|
```console
|
|
# swarmctl forge make-admin mara
|
|
forge: "mara" is now a site admin
|
|
```
|
|
|
|
`POST /api/forge/users/{name}/admin` on the swarm-controller, over the same
|
|
socket as `agent create`. It never creates an account: the forge makes a
|
|
person's on their first login through authelia, and until then this fails
|
|
saying so. Running it on a site admin changes nothing, and an agent's name is
|
|
refused. The response body comes from `swarm-controller-api`, imported in
|
|
`src/forge.rs`.
|
|
|
|
## `schedule`
|
|
|
|
```console
|
|
# swarmctl schedule add --name standup --agent scribe --body "post the standup summary" --every 1d
|
|
# swarmctl schedule list --agent scribe
|
|
# swarmctl schedule edit kqzpmwab --at 2026-10-05T09:00:00+02:00 --add-agent iris
|
|
# swarmctl schedule pause kqzpmwab
|
|
# swarmctl schedule rm kqzpmwab
|
|
```
|
|
|
|
The `/api/schedules` routes on the swarm-controller, over the
|
|
same socket as `agent create`; each verb waits for the stored result. What a
|
|
schedule does once stored is in
|
|
[`docs/swarm/schedules.md`](../docs/swarm/schedules.md). The bodies come
|
|
from `swarm-controller-api`, imported in `src/schedule.rs`.
|
|
|
|
## Remote use: `swarmctl login`
|
|
|
|
The `agent`, `schedule` and `forge` verbs also work from any machine, as
|
|
your own SSO account, through the swarm domain's gateway:
|
|
|
|
```console
|
|
$ export SWARMCTL_SWARM=https://swarm.example.com
|
|
$ swarmctl login
|
|
Log in to https://swarm.example.com/ in your browser:
|
|
…
|
|
logged in to https://swarm.example.com/; login kept in ~/.local/state/swarmctl/swarm.example.com.json
|
|
$ swarmctl schedule list
|
|
```
|
|
|
|
`--swarm` (before or after the verb) names the swarm, else `SWARMCTL_SWARM`;
|
|
either takes the bare domain too. With neither, `login` alone falls back to
|
|
`SWARMCTL_LOGIN_DEFAULT_SWARM`, which the swarm-controller module sets to its
|
|
host's own swarm. It's not `SWARMCTL_SWARM` there because that would send
|
|
the host's other verbs through the gateway instead of the socket.
|
|
|
|
`login` opens the swarm's SSO login in a browser and receives the result on
|
|
a loopback port, so run it on the machine whose browser you use. swarmctl
|
|
keeps the login, mode 0600, under `$XDG_STATE_HOME/swarmctl/` (default
|
|
`~/.local/state`) and refreshes it as it's used, and once more if the gateway
|
|
refuses it. When it can't be refreshed, any verb says to run `swarmctl login`
|
|
again. To log out, delete the file.
|
|
|
|
A swarm whose gateway serves a certificate from the swarm's own CA needs
|
|
that CA. On a controller host the swarm-controller module's wrapper sets
|
|
`SSL_CERT_FILE` to the bundle the daemon itself trusts (the system CAs plus
|
|
the swarm's root), so nothing needs setting there. Elsewhere, pass the CA
|
|
with `--ca-file` or `SWARMCTL_CA_FILE`. The `user` verbs edit the local
|
|
users file and have no remote form.
|