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.
service:
dns:
forwarding:
cache-size: 10000
ssh:
qos:
interface:
eth1:
egress: WAN-OUTThat plans exactly what it reads as:
set service dns forwarding cache-size 10000
set service ssh
set qos interface eth1 egress WAN-OUTBoth 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 router | What the engine does |
|---|---|
| Nothing there | Plans a bare set service ssh, and the plan carries a warning saying so. |
| A node that already has children | Satisfied. Nothing planned. |
| A node holding a value | A 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#
| Surface | Default behaviour | Escape hatch |
|---|---|---|
| The Reconcile page | Stages into the Commit Bar | — |
POST /api/reconcile | Returns the plan; apply: true stages it | — |
GET /api/reconcile/file | Diffs a file inside the agent's data directory against live | — |
wheelhouse-agent plan | Prints the commands, changes nothing, exits 2 when it found work | --json, --full |
wheelhouse-agent apply | Stages 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.
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 2Two refusals in that command are worth knowing before you meet them:
applywithout--agent-urlrefuses 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 aPOST /api/stageto the agent, authenticated with the break-glass token.--commitwithout 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-confirmminute 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#
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.jsonis 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#
- Staging: the working set — where the loop's operations land.
- State, intent and drift — the other thing called drift.
- Commit-confirm — what commit mode arms.
- Desired-state reference — the file format, key by key.
- Agent flags — every
--reconcile-*flag.
Checked against#
agent/desired.go ·
agent/reconcile.go ·
agent/main.go ·
docs/adr/001-desired-state-file.md ·
docs/deploy.md ·
PLAN.md §6