Wheelhouse docs

wheelhouse-agent#

One binary does three jobs: it is the daemon that serves the web UI and the API, and it is also plan and apply, which diff a desired-state file against the router from a shell, a cron job or a CI step. With no subcommand it is the daemon. Every subcommand shares the same flag set, so --api-url and --api-key-file mean the same thing in all three.

console
$ wheelhouse-agent version
wheelhouse-agent 0.5.1

Synopsis#

wheelhouse-agent [flags] [command]

commands:
  plan     print the set/delete commands that would bring the router to the desired file (exit 2 if any)
  apply    stage them in a running agent (--agent-url), or commit with --commit --confirm-minutes N

run 'wheelhouse-agent --help' for the full flag list

That text is usageLine() in agent/desired.go, printed on an unknown command or an unexpected argument.

The three commands#

CommandWhat it doesNeeds a licence
(none)Runs the daemon: opens the state file, loads the catalogue, bootstraps the first admin account, starts the listener, the throughput sampler, the licence refresher, the cache primer and — if --reconcile-file is set — the reconcile loop.No, but the write plane answers 402 without one
planReads the desired-state file, reads the live configuration, prints the set/delete commands that would close the gap. Changes nothing.No
applyThe same diff, then either stages it in a running agent (--agent-url) or commits it (--commit --confirm-minutes N).Yes
versionPrints wheelhouse-agent <version> and exits 0, before any config is read or any secret file is opened.No

version is handled as a subcommand and as the --version flag, and both are answered first thing in main() — deliberately, because "what version are you on" is the first question of every support call and a router that will not start still has to be able to answer it (agent/main.go).

Flags and the subcommand may interleave#

The subcommand is not required to come first, and it is not required to come last. splitSubcommand walks the command line and asks the flag table whether each bare word is a flag's value or the command itself, so all four of these are the same run:

bash
wheelhouse-agent plan --file /config/wheelhouse/agent.yaml
wheelhouse-agent --file /config/wheelhouse/agent.yaml plan
wheelhouse-agent --json plan --file /config/wheelhouse/agent.yaml
wheelhouse-agent --file=/config/wheelhouse/agent.yaml plan --json

A boolean flag stands alone (--json, not --json true), which the parser discovers through the flag's own IsBoolFlag method rather than by guessing. An argument that is neither a flag nor a known command is refused:

console
$ wheelhouse-agent --json planx
wheelhouse: unknown command "planx"

usage: wheelhouse-agent [flags] [command]
...

Exit status 2. See Exit codes.

Starting the daemon by hand#

Either --demo, or both --api-url and an API key, are required; the agent refuses to start otherwise. On an appliance the shipped systemd unit supplies all of it — see systemd units — so this form is for a lab:

bash
wheelhouse-agent \
  --api-url https://127.0.0.1 \
  --api-key-file /etc/wheelhouse/api-key \
  --addr 127.0.0.1:8090 \
  --data-dir /config/wheelhouse \
  --ui-dir /opt/wheelhouse/ui \
  --log-level info

On the very first start with an empty data directory the agent creates the admin account and logs its generated password exactly once:

bash
journalctl -u wheelhouse-agent | grep "shown once"

The same password is written to <data-dir>/initial-password at mode 0600 so the console banner can show it, and removed as soon as that account's password is changed.

plan#

bash
wheelhouse-agent \
  --api-url https://127.0.0.1 --api-key-file /config/wheelhouse/api-key \
  plan --file /config/wheelhouse/agent.yaml

Prints one set or delete line per operation on stdout, a count on stderr, and any advisory warnings on stderr before them. --json swaps the lines for a JSON document with file, full, ops, count and warnings. --full also emits the deletes for live nodes the file does not declare.

Exit 0 means the router already matches the file; exit 2 means it does not, the way diff -q behaves, so it drops into a shell test or a CI gate without parsing text. Exit 1 is a real failure — the file will not parse, or the router will not answer.

apply#

apply needs a usable licence key, given as --license-key-file because a one-shot command has no state file to read one from. Without one it prints

wheelhouse: apply needs a usable licence key (--license-key-file); plan works without one

and exits 1.

It then takes one of two routes:

bash
# Route 1: hand the ops to a running agent's Commit Bar. A human still commits.
wheelhouse-agent apply \
  --file /config/wheelhouse/agent.yaml \
  --agent-url https://127.0.0.1:8443 \
  --admin-token-file /config/wheelhouse/admin-token \
  --license-key-file /config/wheelhouse/license-key

# Route 2: commit directly, behind the router's own commit-confirm.
wheelhouse-agent apply \
  --file /config/wheelhouse/agent.yaml \
  --commit --confirm-minutes 2 \
  --license-key-file /config/wheelhouse/license-key

Route 1 posts to the running agent's POST /api/stage over HTTPS with the break-glass token as the credential, skipping certificate verification because the agent's listener is routinely self-signed on loopback. Without --admin-token or --admin-token-file it refuses. Without --agent-url and without --commit it also refuses, because staging into this process's own memory would look like it worked and then evaporate on exit.

Route 2 refuses to run without --confirm-minutes N: a mistake should be revertible. After the commit it re-reads the configuration; if the router still answers, the change did not cut the agent off, so it confirms — the way an operator does when the page still loads. If the router has stopped answering it deliberately does not confirm, and says so, leaving the router to roll back on its own timer.

What the daemon starts#

GoroutineWhenSource
Throughput samplerAlways. One show interfaces counter read every 5 s, 240 samples kept per interface in memory.newHistory(240, 5*time.Second)
Licence refresherAlways. First attempt 20 s after start, then daily; hourly after a failure.runLicenseRefresher
Cache primerUnless --demo. Warms the read cache at start and after every commit.runPrimer
Reconcile loopOnly when --reconcile-file is set.reconciles.Run

On SIGTERM or SIGINT the agent stops those, then gives in-flight requests 15 seconds to drain rather than cutting an operator off mid-commit.

See also#

Checked against#

agent/main.go (main, registerFlags, splitSubcommand, runDaemon, bootstrapAdmin, loadSecretFiles), agent/desired.go (runPlanApply, stageViaAgent, usageLine), agent/metrics.go, agent/primer.go, docs/deploy.md.

Updated 2026-09-02 cli agent