hyperhive/swarmctl
Repository files (latest commit first)
Filename Latest commit message Latest commit date
atlas 1442168715 swarmctl: re-mint an existing agent's store identity
Agent creation at swarm level is event-driven and nothing sweeps for
agents missing a credential, so an agent created before a credential
joined the mint never receives one -- nothing comes back around to it.
Without a way to re-run the mint by hand, the only route to giving an
existing agent its queue credential would be to delete and recreate the
agent.

POST /api/agents/{name}/identity enqueues the same MintAgentIdentity
node POST /api/agents declares, rather than writing inline: a second
code path that mints an identity is a second place for the four strings
that have to agree to disagree. swarmctl agent mint-identity is the
operator end, the same POST-and-print-the-node-id shape agent create
already has.

--hive is required on both ends. Neither the CLI nor the controller
keeps a roster of which agent runs where, and the credentials this mints
name a hive, so a default would be a guess that hands an agent subjects
on a hive it does not run on.

Documents the backfill as a runbook step, and fills in the renewal cell
the credential matrix requires for the new row.
2026-09-21 20:38:55 +02:00
..
src swarmctl: re-mint an existing agent's store identity 2026-09-21 20:38:55 +02:00
Cargo.toml feat(swarmctl): add agent create, queueing the swarm-controller creation DAG 2026-09-14 19:40:23 +02:00
README.md feat(swarmctl): add agent create, queueing the swarm-controller creation DAG 2026-09-14 19:40:23 +02:00

swarmctl

Swarm-level operator CLI. Runs as root on the host running swarm-controller, and acts on that host directly.

Distinct from hivectl, which drives one hive's hive-c0re over its admin socket. This crate does not link swarm-controller, for the same reason hivectl does not link hive-c0re.

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. 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

users.yml — authelia's own users database — is read and written directly. There is no second store.

There used to be: a private users.json here, canonical, with users.yml rendered from it, while swarm-authelia-bridge kept its own pair against the same physical file. Two canonical stores for one file is a seam, and it bit — a writer whose own JSON was missing could not tell "nothing here yet" from "someone else's users", and refused to write.

The argument for the split was that it let this crate work without a YAML parser. It didn't: the JSON was read back on every run, so the round-trip was already being paid — the two files differed only in format.

⚠️ 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

Every path comes from the nix module that installs the binary, because every one is derived from an option that module owns. 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 create

user add

# swarmctl user add mara --display-name "Mara" --group admins

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.

agent create

# 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.