Wheelhouse docs

Desired state#

A desired-state file says what the router should look like. The agent reads it, reads the router, and works out the set and delete operations that would close the gap. Those operations then travel the same road as anything typed into a form: they are staged, shown as commands, and committed by a human — unless you deliberately start a loop in commit mode, which commits behind the router's own commit-confirm window. Nothing about this is a second control plane. It is a different way of filling the same working set.

The file mirrors the configuration tree#

The document is the shape of the config tree, nested. Keys are node names, values are leaf values.

/config/wheelhouse/agent.yaml
service:
  dns:
    forwarding:
      cache-size: 10000
  ssh:
qos:
  interface:
    eth1:
      egress: WAN-OUT

That plans exactly what it reads as:

set service dns forwarding cache-size 10000
set service ssh
set qos interface eth1 egress WAN-OUT

Both formats parse, chosen by content. A document starting with { or [ is read as JSON; anything else is read as YAML. So agent.yaml holds YAML, an inline document in an API body stays JSON, and a mislabelled file fails on its contents instead of being parsed into the wrong shape (agent/desired.go, parseDesiredDoc).

Values keep the spelling you wrote. The YAML decoder walks nodes rather than decoding into typed values, because the router stores text: 0755 decoded normally becomes 493, 1.10 becomes 1.1, a date becomes a timestamp, and a mapping under a numeric key such as rule: 10: becomes something the engine reads as a leaf and plans as set … rule ''.

A key with no value#

ssh: in YAML, {} in JSON, means this node exists — the shape the config tree uses for a switch-on node. What it plans depends on what the router already holds, so the engine reads it against live state (agent/reconcile.go):

On the routerWhat the engine does
Nothing therePlans a bare set service ssh, and the plan carries a warning saying so.
A node that already has childrenSatisfied. Nothing planned.
A node holding a valueA contradiction, and the plan refuses to run: system.host-name: the router holds "vyoslab" here but the file gives it no value.

An explicit empty string is different and is refused where the document is read, before any diffing, with the hint an empty value is not something VyOS can set — give it a value, or drop the key. Warnings come back in the API's warnings field; the loop logs a set of warnings once, not once per pass.

Partial by default, full on request#

By default the engine only emits set operations for differences. It never deletes something the file does not mention, so a partial file — "these six things must be true" — is safe next to configuration that nothing manages.

--full (on the command line), full: true (in an API body) or --reconcile-full (on the loop) changes that: what the file does not declare is deleted. It is the right mode for a file that is meant to be the whole router, and the wrong one for a fragment.

Four ways to use it#

SurfaceDefault behaviourEscape hatch
The Reconcile pageStages into the Commit Bar
POST /api/reconcileReturns the plan; apply: true stages it
GET /api/reconcile/fileDiffs a file inside the agent's data directory against live
wheelhouse-agent planPrints the commands, changes nothing, exits 2 when it found work--json, --full
wheelhouse-agent applyStages through a running agent (--agent-url)--commit --confirm-minutes N
--reconcile-file (the loop)Stages (--reconcile-mode stage)--reconcile-mode commit, window from --reconcile-confirm

The exit code is deliberate: plan exits 2 when the router differs from the file, the way diff -q does, so it drops into a shell test or a CI gate without anyone parsing text.

bash
wheelhouse-agent plan --file /config/wheelhouse/agent.yaml
# set service dns forwarding cache-size 10000
# set service ssh
# warning: service.ssh: planned as a bare set (a node with no value)
#
# 2 change(s) needed                       exit 2

# Stage into 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

# Or commit from cron, behind the router's own commit-confirm:
wheelhouse-agent apply --file /config/wheelhouse/agent.yaml --commit --confirm-minutes 2

Two refusals in that command are worth knowing before you meet them:

  • apply without --agent-url refuses to stage. The working set is the running agent's memory. A one-shot process that filled its own copy would report success and lose the work on exit, so staging is a POST /api/stage to the agent, authenticated with the break-glass token.
  • --commit without a confirm window refuses. Automation is allowed to commit, never silently.

GET /api/reconcile/file takes a name inside the agent's data directory, never a path — an endpoint that handed a client-supplied string to a file read would turn any session into a file reader. An absolute path is accepted only when it resolves inside the data directory, and the name must end in .yaml, .yml or .json (agent/reconcile.go, resolveDesiredFile and allowedDesiredName). The default name is agent.yaml.

The loop#

Off unless asked for. --reconcile-file <path> starts it; it waits --reconcile-delay (15 seconds by default, so boot can settle) and then re-diffs every --reconcile-interval (60 seconds by default, clamped to a 10-second floor).

  • In stage mode it fills the Commit Bar and stops. The Commit Bar stays the thing that commits.
  • In commit mode it commits with a --reconcile-confirm minute window (2 by default) and confirms only once the router still answers — so a change that cut the agent off is left to roll back on its own (agent/desired.go, applyCommit).

It will not stage the same operation twice. Operations it has already put in the working set are not offered again until a human commits or un-stages them, which is what makes "re-applies idempotently" safe next to an open browser tab. An operation a human deliberately un-staged is offered again on the next pass; the working set is the source of truth, not the loop's memory.

Its writes are attributed to it. Everything the loop does is audited as actor: reconcile-loop, role: system, so the record never blurs a machine write with a human one — see The audit log.

Reading the drift without a shell#

bash
curl -sk -H "Authorization: Bearer $T" "$R/api/drift"
# {"managed":true,"file":"/config/wheelhouse/agent.yaml","mode":"stage",
#  "drifted":true,"ops":2,"checked_at":"2026-09-02T10:27:07Z"}

managed: false and mode: "off" mean no file is being managed, which is the default. The metrics wheelhouse_desired_managed, wheelhouse_desired_drift and wheelhouse_desired_pending_ops exist only while a file is being managed.

What it is not#

  • It is not the agent's state file. state.json is the agent's own store — accounts, tokens, sessions, settings, the licence key. Never call the desired-state file the state file.
  • It is not templated. One file, one router, no per-host substitution. Deferred deliberately, along with file-watch triggers, in ADR-001.
  • It is not a second source of truth. The router's configuration tree is still the truth. The file is a claim about what the tree should contain.

See also#

Checked against#

agent/desired.go · agent/reconcile.go · agent/main.go · docs/adr/001-desired-state-file.md · docs/deploy.md · PLAN.md §6

Updated 2026-09-02 concepts automation reconcile