The desired-state file#
One file says what the router's configuration should look like; the agent reads the live
configuration, works out the set and delete commands that close the gap, and hands
them to the same staging area everything typed into the web UI goes through. The file
mirrors the router's configuration tree. There is no Wheelhouse-specific schema:
if you can write the node path in set, you can write it here as nesting.
service:
dns:
forwarding:
cache-size: 10000
ssh:
qos:
interface:
eth1:
egress: WAN-OUT$ 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 in /config/wheelhouse/agent.yamlWhere the file lives#
| Surface | Path |
|---|---|
wheelhouse-agent plan / apply | --file, defaulting to <data-dir>/agent.yaml |
| The reconcile loop | --reconcile-file, a full path |
GET /api/reconcile/file?file= | a bare name inside the data directory, defaulting to agent.yaml |
POST /api/reconcile | no file at all: the document is the request body's desired_state |
agent.yaml is a convention, not a format promise — the parser sniffs the content, so a
.yaml file holding JSON works and a mislabelled file fails on its contents rather than
silently parsing into the wrong shape.
The API route is deliberately narrow: the name must end .yaml, .yml or .json, must
not begin state., and must resolve inside the data directory — no separators, no
.., and an absolute path only if it lands in the data directory anyway. Without those
rules the endpoint would render state.json — accounts, password hashes, TOTP secrets
and raw session ids — as a diff of set operations for anyone who could sign in.
Format sniffing#
| First non-whitespace byte | Parsed as |
|---|---|
{ or [ | JSON |
| anything else | YAML |
The YAML path decodes through the node tree rather than into a Go map, so the text in the file is the value. That matters more than it sounds:
| Written | Without the node walk | What actually happens |
|---|---|---|
rule: 10: | a map keyed by the integer 10, taken for a leaf, planned as set … rule '' | a tag node named 10 |
mode: 0755 | the integer 493 | the string 0755 |
version: 1.10 | the float 1.1 | the string 1.10 |
expires: 2026-09-02 | an RFC 3339 timestamp | the string 2026-09-02 |
The router stores text. Anything that retypes a value on the way in produces a diff that never converges.
A key with no value#
A mapping key with nothing after the colon (ssh: in YAML, {} in JSON) means this
node exists — the shape the configuration tree uses for a node that is switched on
rather than set to something. What it does depends on what the router holds there, so
the engine decides against live state:
| Live state | Result |
|---|---|
| Nothing there | A bare set service ssh, plus the warning service.ssh: planned as a bare set (a node with no value). |
| A node that already has children | Satisfied. Nothing planned. |
| A node holding a value | Refused. system.host-name: the router holds "edge" here but the file gives it no value — give it a value, or drop the key (full mode deletes it). |
That last one is the case no operation can express: the router cannot set a node that requires a value to nothing, so the file is broken rather than the router being wrong.
An explicit empty string is a different thing and is refused earlier, where the document is read, before any diffing:
service.dns.forwarding.cache-size: an empty value is not something VyOS can set — give
it a value, or drop the key (a key with nothing after the colon switches a node on)The check walks the whole document, so a blank in a subtree the diff never reaches is still reported, and the message names the file rather than the router.
Multi-valued leaves#
A node that holds several values — name-server, address, allow-from — is written
as a list and diffed value by value:
system:
name-server:
- 192.0.2.53
- 198.51.100.53Each missing value becomes its own set system name-server <value>; in full mode each
extra live value becomes its own delete. A single value may be written bare rather
than as a one-element list; the router reads back a single-valued node as a string and a
multi-valued one as an array, and both shapes compare the same way.
An empty element anywhere in a list is refused with the same message as an empty scalar.
Partial and full#
| Mode | Flag | Emits |
|---|---|---|
| Partial (default) | — | set operations only. Nodes the file does not mention are left alone. |
| Full | --full, --reconcile-full, or "full": true | Also a delete for every live node the file does not declare, at every level it walked. |
How a value is compared#
Every value in the configuration tree is text. A document may spell the same thing typed
— YAML cache-size: 10000, JSON true — while the router answers "10000". Both sides
are canonicalised to their string form before comparison, so a typed document does not
produce a plan that re-sets what the router already satisfies and never converges.
Warnings#
Warnings are advisory, never refusals: the operations they accompany are still what would change.
| Warning | Means |
|---|---|
<path>: planned as a bare set (a node with no value) | The file switched a node on. Check that it is a switch and not a value you forgot to type. |
They come back in the warnings field of POST /api/reconcile and
GET /api/reconcile/file, on stderr from plan, and the reconcile loop logs a set of
warnings once per change rather than once per pass, so a steady file stays quiet.
Reading against the router, not the cache#
The diff always reads the router's configuration uncached. The agent's read cache is invalidated by the agent's own commits, so a change somebody made over SSH — the thing drift detection exists to notice — would otherwise stay invisible for up to an hour.
plan exit codes#
| Code | Means |
|---|---|
| 0 | The router already matches the file. |
| 2 | It does not. There are operations to apply. |
| 1 | Something failed: the file will not parse, holds an empty value, contradicts the router, or the router did not answer. |
Exit 2 is the diff -q convention, so plan drops into a shell test or a CI gate
without parsing text:
if ! wheelhouse-agent plan --file /config/wheelhouse/agent.yaml >/dev/null; then
echo "router has drifted"
fi--json gives a machine-readable document instead of lines:
{
"file": "/config/wheelhouse/agent.yaml",
"full": false,
"ops": [{"op": "set", "path": ["service", "dns", "forwarding", "cache-size", "10000"]}],
"count": 1,
"warnings": []
}Note the shape of an operation: for a set, the last path element is the value.
That is the router's own /configure convention, and it is why a path element may never
be empty.
The four surfaces, and what each may do#
| Surface | Reads | May commit |
|---|---|---|
wheelhouse-agent plan | file | never |
wheelhouse-agent apply --agent-url | file | no — it stages into a running agent's Commit Bar |
wheelhouse-agent apply --commit --confirm-minutes N | file | yes, behind the router's own commit-confirm |
POST /api/reconcile | request body | no — apply: true stages, and the Commit Bar owns the commit |
GET /api/reconcile/file | a file in the data directory | never |
| The reconcile loop | --reconcile-file | only in --reconcile-mode commit |
The reconcile loop#
Off unless --reconcile-file is set. One pass is: read the file, read the router, diff,
then either stage the difference or commit it.
| Behaviour | Detail |
|---|---|
| First pass | --reconcile-delay after start, 15 s by default, so boot can settle. |
| Interval | --reconcile-interval, 60 s by default, clamped up to a 10 s floor. |
| Default mode | stage. Enabling the loop cannot move a router out from under somebody. |
| Commit mode | --reconcile-mode commit, with --reconcile-confirm minutes, 2 by default. |
| Licence | Required. Without one the loop records no usable licence: the reconcile loop is paused until one is entered and changes nothing. |
| Idempotence | Operations already in the working set are not re-staged. An operation a person deliberately un-staged is offered again on the next pass — the staging area is the source of truth. |
| Audit | Every pass that acts writes an entry with actor reconcile-loop, role system, operation reconcile-stage or reconcile-commit, and the rendered commands. |
In commit mode with a confirm window, the loop commits, then 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 does not confirm and says so, leaving the router to roll back on its own timer.
State is readable at GET /api/drift:
{"managed":true,"file":"/config/wheelhouse/agent.yaml","mode":"stage",
"drifted":true,"ops":2,"checked_at":"2026-09-02T22:27:07Z"}mode:"off" means no file is being managed. The three
wheelhouse_desired_* metrics are absent entirely in that case.
See also#
wheelhouse-agent—planandapply.- Every flag — the six
--reconcile-*flags. - Endpoint index —
/api/reconcile,/api/reconcile/file,/api/drift. - Exit codes
- Prometheus metrics — the drift gauges.
- Audit entries — what the loop records.
- Desired state — the idea, and what it costs.
- State against intent
Checked against#
agent/desired.go (parseDesiredDoc, decodeYAMLTree,
yamlValue, validateBlank, loadDesiredFile, reconciler, runPlanApply,
printOps),
agent/reconcile.go (reconcile, flattenDesired,
multiLeafOps, sameLeaf, currentConfig, handleReconcile, handleReconcileFile,
allowedDesiredName, resolveDesiredFile, emptyValueHint),
agent/desired_test.go,
docs/deploy.md "Desired state",
docs/adr/001-desired-state-file.md,
PLAN.md §6.