Objective
By the end of this lab you will have driven two VyOS routers from a real Ansible control node, and you will be able to answer four questions that separate an automation estate people trust from one they have stopped reading:
Which transport is actually carrying the change. Whether a task that reports
changed: true changed anything. Whether the gate after the change can ever
report failure. And what the router does when the change severs the path the
playbook was running over.
Every one of those is answered here with evidence from the router, not from
the playbook’s own summary line. That distinction is the lab. Ansible’s
ok/changed/failed counters describe what the module believes. The
commit log, the BGP neighbour detail and the console are what happened.
Architecture
Three VMs. control is an ordinary Linux host running ansible-core — it is
not optional and it is not your laptop, because the whole lab depends on being
able to state exactly which SSH key, which user and which known_hosts file
are in play. edge-01 and edge-02 are VyOS 1.5 routers that peer with each
other over a transit segment, so that Task 5 has a real protocol state to wait
for rather than a made-up one.
MGMT 192.0.2.0/24
+--------------+ | | |
| control |-----+ | |
| 192.0.2.10 | | |
| ansible-core | | |
+--------------+ | |
| |
+-------------+ +-------------+
| edge-01 | | edge-02 |
| eth0 .1 | | eth0 .2 |
| dum0 | | dum0 |
| 203.0.113.1 | | 203.0.113.2 |
| eth1 | | eth1 |
| 198.51.100.1|--------|198.51.100.2 |
+-------------+ TRANSIT+-------------+
198.51.100.0/30
| Segment | Prefix | control | edge-01 | edge-02 |
|---|---|---|---|---|
| MGMT | 192.0.2.0/24 | .10 | eth0 · .1 | eth0 · .2 |
| TRANSIT | 198.51.100.0/30 | — | eth1 · .1 | eth1 · .2 |
| Loopback | 203.0.113.0/24 | — | dum0 · .1/32 | dum0 · .2/32 |
Every address is from a range RFC 5737 reserves for documentation, so nothing here can collide with a real network if a bridge is attached to something it should not be.
Two separate layer-2 segments matter more than they look. The management path and the routed path are different wires, which is what lets Task 6 break one without breaking the other — and which is exactly the separation a production estate is supposed to have and frequently does not.
Requirements
- A hypervisor with roughly 5 GiB of free RAM and 40 GiB of free disk — two VyOS VMs at 1 GiB / 8 GiB and one Linux VM at 2 GiB / 20 GiB.
- The VyOS 1.5 LTS ISO. The BGP tree (
system-as), the firewall tree (ipv4 input filter) and the state-match syntax all differ on 1.3. - A Debian- or Ubuntu-based Linux VM for the control node, with package
access for
ansible-coreandpython3-paramiko, and either an internet route or a local mirror foransible-galaxy. - Two isolated layer-2 segments — bridges with no physical port: MGMT and TRANSIT.
- Console access to both routers, per the callout above.
- Roughly two hours, of which about twenty minutes is waiting on retry timers and confirm timers.
Scenario
A two-router estate has been configured by hand for three years. The team has decided to move it under Ansible, and the first playbook has been written by someone who read the module documentation carefully.
That is the interesting starting point, because the module documentation is accurate and the playbook will still be wrong in three separate ways that all pass review: a task that reports a change on every run forever, a dry run that proves less than the team thinks it does, and a post-change gate whose conditional cannot be satisfied by the command it is pointed at. None of the three produces an error. All three produce an automation estate that people learn to ignore.
Your job is to find each one with evidence, and then to establish the two things the estate needs before it is allowed near a router that carries traffic: a recovery path for a change that locks the automation out, and a bound on how many routers one bad commit can reach.
Tasks
VyOS configuration blocks are written for the [edit] prompt reached by
configure. Blocks tagged text that start with show or run are
operational-mode commands. Blocks tagged bash run on the control node,
never on a router.
Task 1 — Build the estate, and prove SSH before Ansible exists
Install the two routers from the ISO and give each one a management address, a transit address, a loopback, and an account for the automation to use.
On the control node, create the key pair the routers will trust. It is a dedicated key: it exists for this estate, it has no passphrase because a scheduled run cannot type one, and it never leaves the control node.
# On the control node.
ssh-keygen -t ed25519 -f ~/.ssh/vyos_automation -N '' -C 'ansible-control'
# The public key. You need the middle field only - the base64 body.
awk '{print $2}' ~/.ssh/vyos_automation.pub
Now edge-01:
configure
set system host-name 'edge-01'
set interfaces ethernet eth0 address '192.0.2.1/24'
set interfaces ethernet eth0 description 'MGMT'
set interfaces ethernet eth1 address '198.51.100.1/30'
set interfaces dummy dum0 address '203.0.113.1/32'
set service ssh port '22'
set system login user automation full-name 'Ansible control node'
set system login user automation authentication public-keys ansible-control type 'ssh-ed25519'
set system login user automation authentication public-keys ansible-control key 'AAAAC3NzaC1lZDI1NTE5AAAAIREPLACE-THIS-WITH-THE-OUTPUT-OF-THE-AWK-ABOVE'
commit
save
And edge-02, identical apart from the addresses:
configure
set system host-name 'edge-02'
set interfaces ethernet eth0 address '192.0.2.2/24'
set interfaces ethernet eth0 description 'MGMT'
set interfaces ethernet eth1 address '198.51.100.2/30'
set interfaces dummy dum0 address '203.0.113.2/32'
set service ssh port '22'
set system login user automation full-name 'Ansible control node'
set system login user automation authentication public-keys ansible-control type 'ssh-ed25519'
set system login user automation authentication public-keys ansible-control key 'AAAAC3NzaC1lZDI1NTE5AAAAIREPLACE-THIS-WITH-THE-OUTPUT-OF-THE-AWK-ABOVE'
commit
save
Two details in those blocks are worth a sentence each, because both are routinely got wrong and both fail in a way that looks like a network problem.
ansible-control is an arbitrary identifier for the key, not a filename. VyOS
uses it so one user can hold several keys and you can delete them
individually. And the key value is the base64 body alone — the
ssh-ed25519 prefix belongs in the type node and the trailing comment is
dropped. That is why the capture above uses awk rather than cat.
Now prove the login by hand, before Ansible is anywhere near this:
# On the control node.
EDGE1=192.0.2.1
EDGE2=192.0.2.2
ssh -i ~/.ssh/vyos_automation automation@"$EDGE1" 'id'
ssh -i ~/.ssh/vyos_automation automation@"$EDGE2" 'id'
Accept each host key when prompted — deliberately, once, so that
known_hosts is populated by a decision rather than by a global switch that
turns host-key checking off.
id is deliberately dull. You are not testing VyOS here; you are testing that
the key authenticates and a shell starts. If this fails, no amount of
inventory tuning will fix it, and the single most useful habit in this whole
subject is reproducing an Ansible connection problem without Ansible.
Now capture the baseline. This is the state everything after this point is measured against, and the state Cleanup restores to — not the bare install, which nothing in this lab ever returns to. On each router, from operational mode:
show configuration commands
show system commit
Paste both into the journal. Task 3 needs the revision numbers from the second
one as a starting count. Then leave a copy on the router itself, from
configuration mode, so Cleanup has a file to load even if the journal is on
a machine you no longer have:
configure
save /config/pre-lab-25.boot
exit
save with a path writes the running configuration to that file instead of to
/config/config.boot. It does not change what the router boots.
Task 2 — Install the collection, and prove the transport
On the control node:
sudo apt update
sudo apt install -y ansible-core python3-paramiko
ansible-galaxy collection install vyos.vyos
ansible --version
ansible-galaxy collection list vyos.vyos
python3-paramiko is there because the VyOS documentation calls for it:
network_cli uses Paramiko as its default SSH implementation. ansible-core
must be a supported release — the collection’s README records testing against
2.15 and later. ansible-galaxy pulls in the ansible.netcommon dependency
on its own; record the versions of both from the collection list output,
because the next time this estate behaves differently after an upgrade, that
line is what you will want.
Nothing is installed on the routers. network_cli opens an SSH session and
drives the CLI, so VyOS needs no Python interpreter and no agent for any of
this to work.
Build the repository:
mkdir -p ~/lab25/inventory ~/lab25/host_vars ~/lab25/journal
cd ~/lab25
ansible.cfg:
[defaults]
inventory = inventory/hosts.yml
host_key_checking = True
retry_files_enabled = False
[persistent_connection]
connect_timeout = 60
command_timeout = 60
inventory/hosts.yml:
all:
vars:
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: vyos.vyos.vyos
ansible_user: automation
ansible_ssh_private_key_file: ~/.ssh/vyos_automation
children:
edge:
hosts:
edge-01:
ansible_host: 192.0.2.1
edge-02:
ansible_host: 192.0.2.2
Three variables carry the whole transport. ansible_connection opens a
persistent SSH session and drives the CLI through it. ansible_network_os
selects the VyOS cliconf and terminal plugins, so the connection knows what
the prompt looks like and how to enter configuration mode. And the credential
is a key file path, not a password — this file goes into version control, and
the key does not.
00-connect.yml:
---
- name: Prove the transport before anything depends on it
hosts: edge
gather_facts: false
tasks:
- name: Ask each router what it is running
vyos.vyos.vyos_command:
commands:
- show version
register: version
- name: Print the first lines of the response
ansible.builtin.debug:
msg: "{{ version.stdout_lines[0][0:4] }}"
cd ~/lab25
ansible-playbook 00-connect.yml
gather_facts: false is on every play in this lab. The default fact gathering
runs setup, which expects a POSIX host with a Python interpreter, and there
is not one on the other end of this connection.
Now the negative test, which is the reason this task exists. The single most common wrong belief about VyOS automation is that Ansible reaches the router through its HTTP API. Build an inventory that says so and watch it fail.
inventory/broken-httpapi.yml:
all:
vars:
ansible_connection: ansible.netcommon.httpapi
ansible_network_os: vyos.vyos.vyos
ansible_user: automation
ansible_httpapi_use_ssl: true
ansible_httpapi_validate_certs: false
hosts:
edge-01:
ansible_host: 192.0.2.1
cd ~/lab25
ansible-playbook -i inventory/broken-httpapi.yml 00-connect.yml
Copy the failure into the journal verbatim. The exact wording moves between
ansible-core releases, so what matters is not the string but two facts you
can read off it: the failure happens at connection time, before any
command is sent, and it names a plugin lookup rather than a network problem.
Task 3 — The second-run test
This is the core of the lab and it takes fifteen minutes.
10-interfaces.yml, written the way an operator who trusts the module’s own
example writes it:
---
- name: Interface descriptions on the edge routers
hosts: edge
gather_facts: false
tasks:
- name: Describe the transit interface
vyos.vyos.vyos_config:
lines:
- set int eth eth1 description 'TRANSIT'
comment: 'lab25 interface descriptions'
Run it twice, back to back, and write both summary lines into the journal:
cd ~/lab25
ansible-playbook 10-interfaces.yml
ansible-playbook 10-interfaces.yml
Both runs report changed=2. Go and look at the router: the description is
correct, and has been since the first run. So the configuration is right and
the report is wrong.
Ask the module what it thinks it is sending:
ansible-playbook 10-interfaces.yml --diff
It proposes the same line again. That means it believes the line is absent.
Now ask the router what it actually has, from operational mode on edge-01:
$ show configuration commands | match eth1set interfaces ethernet eth1 address '198.51.100.1/30'
set interfaces ethernet eth1 description 'TRANSIT'Illustrative output
Put the two strings side by side, character by character. The playbook says
set int eth eth1; the router says set interfaces ethernet eth1. With the
default match: line, vyos_config reads the active configuration in set
format and compares your lines against it textually. The CLI parser
expands int eth when you type it, which is why the configuration is correct.
The text comparison does not, which is why the line is re-sent forever.
Now the step that makes this lab worth doing. The module’s own documentation
carries an example headed for idempotency, use full-form commands, and the
full form it shows is set interface ethernet eth2 description 'OUTSIDE'.
Try it — change the line to the singular interface — and run twice again:
lines:
- set interface ethernet eth1 description 'TRANSIT'
Still changed on both runs. The documentation’s “full form” is itself one
abbreviation short: the VyOS node is interfaces, plural, and that is what
the router prints. The rule that survives contact with a real router is not
“copy the module documentation”. It is write the line exactly as
show configuration commands prints it on the router you are targeting.
Third form, copied from the router’s own output, quotes included:
lines:
- set interfaces ethernet eth1 description 'TRANSIT'
ansible-playbook 10-interfaces.yml
ansible-playbook 10-interfaces.yml
The first run may report changed — the router already has it, so more likely
it does not — and the second run must report changed=0. That is convergence,
and it is the only one of the three forms that has it.
Fill in the journal table before moving on:
Form written in lines | Run 1 | Run 2 | What the router prints |
|---|---|---|---|
set int eth eth1 description 'TRANSIT' | |||
set interface ethernet eth1 description 'TRANSIT' | |||
set interfaces ethernet eth1 description 'TRANSIT' |
Then read the cost of the first two forms on the router itself:
$ show system commit0 2026-08-19 14:41:07 by automation via cli
1 2026-08-19 14:40:51 by automation via cli
2 2026-08-19 14:40:33 by automation via cli
3 2026-08-19 14:39:58 by automation via cli
4 2026-08-19 11:12:04 by vyos via cliIllustrative output
Compare the revision numbers with the capture you took in Task 1. Every
non-idempotent run pushed a real revision into the archive. On a nightly
schedule that is thirty commits a month that changed nothing, and the one
commit that mattered is somewhere underneath them. The archive is finite: keep
this up and the revision you actually want to rollback to ages out.
Task 4 — What --check --diff is worth on a router
The team’s plan is that every change is reviewed as a dry run first. Establish what that dry run actually proves.
Add a second line to the task so there is a real pending change:
lines:
- set interfaces ethernet eth1 description 'TRANSIT'
- set interfaces ethernet eth0 description 'MGMT to control'
Record show system commit on edge-01, then:
cd ~/lab25
ansible-playbook 10-interfaces.yml --check --diff
Read show system commit again. The revision numbers have not moved: the
module computed the delta and did not commit it. That is the claim worth
proving with the router rather than accepting from the summary line, and it is
the claim the review process depends on.
Now the part the review process usually gets wrong. A dry run here is not an offline operation:
cd ~/lab25
ansible-playbook 10-interfaces.yml --check --diff --limit edge-01 -e ansible_host=192.0.2.99
UNREACHABLE. Check mode still opened an SSH session, because it cannot
compute a delta without reading the active configuration off the device. Three
consequences for a change process built on this:
- The dry run needs the credentials, the reachability and the working host key that the real run needs. It is not something you can run from a laptop with no path to the estate.
- A router that is down produces
UNREACHABLEin the dry run, not a diff. If your review gate treats “no diff” as “no change”, an unreachable router reads as compliant. - The diff is a diff of set commands. It tells you which lines the module
would send. It says nothing about whether the resulting configuration
produces the routing you wanted — that is Task 5’s job, and no amount of
--checksubstitutes for it.
Run it for real when you are satisfied, then confirm the second-run rule still holds with two lines instead of one:
ansible-playbook 10-interfaces.yml
ansible-playbook 10-interfaces.yml
Task 5 — A gate that can actually fire
Now a change with a protocol behind it, and a post-change check to prove it converged.
host_vars/edge-01.yml:
bgp_asn: 64512
bgp_router_id: 203.0.113.1
bgp_network: 203.0.113.1/32
bgp_peer: 198.51.100.2
bgp_peer_asn: 64513
host_vars/edge-02.yml:
bgp_asn: 64513
bgp_router_id: 203.0.113.2
bgp_network: 203.0.113.2/32
bgp_peer: 198.51.100.1
bgp_peer_asn: 64512
20-bgp.yml:
---
- name: eBGP between the two edge routers
hosts: edge
gather_facts: false
tasks:
- name: Configure the BGP speaker
vyos.vyos.vyos_config:
lines:
- "set protocols bgp system-as '{{ bgp_asn }}'"
- "set protocols bgp parameters router-id '{{ bgp_router_id }}'"
- "set protocols bgp address-family ipv4-unicast network '{{ bgp_network }}'"
- "set protocols bgp neighbor {{ bgp_peer }} remote-as '{{ bgp_peer_asn }}'"
- "set protocols bgp neighbor {{ bgp_peer }} address-family ipv4-unicast"
comment: 'lab25 ebgp baseline'
backup: true
- name: Wait for the session to reach Established
vyos.vyos.vyos_command:
commands:
- show bgp summary
wait_for:
- "result[0] contains Established"
retries: 30
interval: 5
The single quotes around every leaf value are Task 3’s lesson applied. The
neighbour address and the address-family node take no quotes because they
are path elements, not values — which you can confirm from
show configuration commands after the first run rather than from this page.
backup: true copies each router’s pre-change active configuration to the
control node, into a backup/ directory beside the playbook, before
anything is sent. Look in it after the run. That file is the input to a
recovery, and it is the cheapest insurance in the module.
Run it:
cd ~/lab25
ansible-playbook 20-bgp.yml
The configuration task succeeds. Then the second task retries thirty times at five-second intervals — two and a half minutes — and fails the play. Sit with that for a moment, then go and look at the router:
$ show bgp summaryIPv4 Unicast Summary (VRF default):
BGP router identifier 203.0.113.1, local AS number 64512 vrf-id 0
BGP table version 1
RIB entries 1, using 96 bytes of memory
Peers 1, using 20 KiB of memory
Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd PfxSnt Desc
198.51.100.2 4 64513 14 14 0 0 0 00:01:12 1 1 N/AIllustrative output
The session is up. It has been up since a few seconds after the commit. The
gate failed anyway, and the reason is in the State/PfxRcd column: FRR
overloads it. While the session is down it holds the state name —
Active, Idle, Connect, OpenSent. Once the session is up it holds
the count of prefixes received. The word Established never appears in this
output for a working session.
So the conditional result[0] contains Established, pointed at
show bgp summary, is true exactly when the session is broken in one specific
way and false when it is healthy. It is not a weak gate. It is an inverted
one.
Point it at a command whose output contains the word:
- name: Wait for the session to reach Established
vyos.vyos.vyos_command:
commands:
- "show bgp neighbor {{ bgp_peer }}"
wait_for:
- "result[0] contains Established"
retries: 30
interval: 5
$ show bgp neighbor 198.51.100.2BGP neighbor is 198.51.100.2, remote AS 64513, local AS 64512, external link
BGP version 4, remote router ID 203.0.113.2, local router ID 203.0.113.1
BGP state = Established, up for 00:03:41
Last read 00:00:11, Last write 00:00:11
Hold time is 180, keepalive interval is 60 secondsIllustrative output
Re-run. The gate now passes in one poll.
Then prove it can still fail, which is the only way to know a gate is a gate.
Break edge-02’s view of the peer AS and re-run just that host:
cd ~/lab25
ansible-playbook 20-bgp.yml --limit edge-02 -e bgp_peer_asn=64599
The configuration task commits. The gate retries for two and a half minutes
and fails the play. On edge-01, the diagnosis is one command away:
$ show bgp neighbor 198.51.100.2BGP neighbor is 198.51.100.2, remote AS 64513, local AS 64512, external link
BGP state = Idle
Last reset 00:00:24, Notification received (OPEN Message Error/Bad Peer AS)Illustrative output
Put it back with ansible-playbook 20-bgp.yml --limit edge-02 and confirm the
gate passes.
Before Task 6, one more piece of evidence. vyos_config defaults save to
false: it commits what it sends and does not write the result to disk. On
edge-01, from configuration mode:
configure
compare saved
A diff. Everything the playbook has done this afternoon is committed and would
vanish on reload. Decide deliberately which you want — save: true on the
task, or a separate save step in the change procedure — and note the choice in
the journal. It matters immediately, because Task 6 may reboot a router.
Task 6 — Lock yourself out, then find out what confirm buys
On both routers, from configuration mode:
configure
save
exit
That save is not a formality. VyOS’s own commit-confirm revert action defaults
to reboot to the saved configuration — not an in-memory rollback. Anything
committed but unsaved on that router goes with the revert. You have exactly
such a state right now, which is what the compare saved at the end of Task 5
was showing you.
30-firewall.yml:
---
- name: Management-plane firewall baseline
hosts: edge
gather_facts: false
tasks:
- name: Push the input filter
vyos.vyos.vyos_config:
lines:
- "set firewall ipv4 input filter default-action 'drop'"
- "set firewall ipv4 input filter rule 20 action 'accept'"
- "set firewall ipv4 input filter rule 20 protocol 'tcp'"
- "set firewall ipv4 input filter rule 20 destination port '22'"
- "set firewall ipv4 input filter rule 20 source address '203.0.113.0/24'"
comment: 'lab25 mgmt firewall'
backup: true
Read it before you run it. input filter is the base chain for traffic
addressed to the router itself. The default action is drop. Rule 20 permits
SSH from 203.0.113.0/24 — and the control node is on 192.0.2.0/24. This is
a rendered template that is syntactically perfect, commits cleanly, and omits
the automation network. It is the single most common way an automation estate
takes a router off the map.
Run it against edge-01 only:
cd ~/lab25
ansible-playbook 30-firewall.yml --limit edge-01
Record what the playbook does — whether it hangs until the connection times out or fails immediately — and then confirm the router is genuinely gone rather than just slow:
# On the control node.
EDGE1=192.0.2.1
ssh -i ~/.ssh/vyos_automation -o ConnectTimeout=5 automation@"$EDGE1" 'id'
Now recover, from the console. The surgical form first:
configure
delete firewall ipv4 input filter
commit
save
The general form, when you do not know what the change was, is rollback —
and note that rollback N loads the revision into the candidate and does
not commit it. The workflow is four steps, and skipping the third leaves the
box exactly as broken as it was:
configure
rollback 1
compare
commit
save
The control-node copy from backup: true is the third option: it is the
router’s pre-change configuration, on a machine you can still reach, and it is
what a load would consume. Note in the journal which of the three you would
reach for at 03:00 and why.
Now the same change on edge-02, with the option that was off by default:
- name: Push the input filter
vyos.vyos.vyos_config:
lines:
- "set firewall ipv4 input filter default-action 'drop'"
- "set firewall ipv4 input filter rule 20 action 'accept'"
- "set firewall ipv4 input filter rule 20 protocol 'tcp'"
- "set firewall ipv4 input filter rule 20 destination port '22'"
- "set firewall ipv4 input filter rule 20 source address '203.0.113.0/24'"
comment: 'lab25 mgmt firewall with confirm'
backup: true
confirm: automatic
confirm_timeout: 1
cd ~/lab25
ansible-playbook 30-firewall.yml --limit edge-02
confirm takes automatic, manual or none and defaults to none —
which is why Task 6a had no safety net. With automatic the module confirms
the configuration itself, but only if the current session is still working
with the new config. The change you just pushed severs that session, so the
confirmation cannot arrive, and the router’s own timer decides what happens
next.
Watch the console for the whole minute and write down, precisely:
- Did the router reload the previous configuration in place, or did it
reboot? Read
show configuration commands | match config-managementon the console first: if nothing is set there, the default applies, and the documented default isrebootto the saved configuration.reloadhas to be asked for explicitly. - How long from the commit to the router answering SSH again?
- Is the firewall gone afterwards? Confirm with
show configuration commands | match firewall.
Clean up before Task 7. On both routers, delete firewall ipv4 input filter,
commit, save.
Task 7 — Drift, and bounding the blast radius
Two short experiments to finish.
First, drift. On edge-02, by hand, exactly as an operator under time
pressure would:
configure
set interfaces ethernet eth1 description 'CHANGED BY HAND DURING INCIDENT'
commit
save
exit
Then run the drift report — which is --check --diff pointed at the whole
estate, and nothing more elaborate than that:
cd ~/lab25
ansible-playbook 10-interfaces.yml --check --diff
edge-01 reports ok. edge-02 reports changed and prints the line it
would send. In check mode, changed is the drift signal: a host that
would be modified by a run that changes nothing is a host that no longer
matches the source of truth.
Read the other half of the evidence on the router:
$ show system commit0 2026-08-19 16:22:38 by vyos via cli
1 2026-08-19 15:58:02 by automation via cli
2 2026-08-19 15:41:19 by automation via cliIllustrative output
The by column separates a human at the CLI from an automation run. That
column is the reason the automation gets its own login account rather than
sharing vyos, and it is worth ten drift dashboards.
Now let the real run revert it:
ansible-playbook 10-interfaces.yml
The manual description is gone. Nobody was told. That is the whole manual-override failure mode: the change was not wrong, it was merely invisible to the system that owns the router, and it survived exactly as long as it took the next scheduled run to notice.
Second, blast radius. Everything so far has run against two routers at once by
default. Add the two settings that bound a bad change, at the top of
20-bgp.yml:
- name: eBGP between the two edge routers
hosts: edge
gather_facts: false
serial: 1
any_errors_fatal: true
serial: 1 runs one router at a time through the whole play — configure, then
gate — instead of configuring both and then gating both. any_errors_fatal
ends the play the moment any host fails. Together they mean the gate on
edge-01 runs before edge-02 is touched at all.
Prove it with the broken AS again:
cd ~/lab25
ansible-playbook 20-bgp.yml -e bgp_peer_asn=64599
The play stops after the first router. On a two-router estate that saves one router. On the fifty-router estate from Part LIV-06 it is the difference between one incident and an outage, and it costs two lines of YAML and a longer runtime.
Put the estate back: ansible-playbook 20-bgp.yml, then confirm the gate
passes on both.
Validation
- The journal holds both routers’ pre-lab
show configuration commandsandshow system commit, captured in Task 1 before anything was automated. ansible-playbook 00-connect.ymlreturns version output from both routers, and the run againstinventory/broken-httpapi.ymlfails at connection time with a message recorded verbatim.- The Task 3 table is filled in for all three command forms, and only the
third reports
changed=0on its second run. show system commitonedge-01shows more revisions after Task 3 than before it, allby automation via cli, and you can say how many were no-ops.- Running
10-interfaces.yml --check --diffleavesshow system commitunchanged, and the same command against-e ansible_host=192.0.2.99returnsUNREACHABLErather than a diff. - With the gate pointed at
show bgp summary, the play fails against a session thatshow bgp neighborreports asEstablished. With the gate pointed atshow bgp neighbor, it passes. - With
-e bgp_peer_asn=64599, the gate fails andshow bgp neighbor 198.51.100.2on the correctly configured router namesOPEN Message Error/Bad Peer AS. edge-01was recovered from the console after Task 6a, and the journal records whatedge-02did when the confirm timer expired — reload or reboot, and how long it took.- A
backup/directory exists beside the playbook containing pre-change configurations from both routers. - The check-mode run after the manual change reports
changedonedge-02only, andshow system commitonedge-02shows revision 0 made byvyosrather than byautomation. - With
serial: 1andany_errors_fatal: true, a deliberately broken run stops after one router.
Expected Outcome
A two-router estate driven from a control node whose configuration you can defend line by line: one connection type, one dedicated login, one key that never leaves the control node, and a playbook whose second run reports nothing.
More usefully, a written record of three defects that every gate in the pipeline passed. A task that reported a change every night for months and changed nothing. A dry run that proved less than the review process assumed. A post-change gate whose conditional could not be satisfied by the command it was pointed at, so it failed on every healthy run and would have passed on none.
None of the three is exotic. Two of them come straight from following the documentation accurately.
Troubleshooting
UNREACHABLE on the first playbook run. Reproduce it without Ansible:
ssh -i ~/.ssh/vyos_automation automation@192.0.2.1 'id'. Almost every
Ansible connection problem on this platform is an SSH problem that reproduces
in one command. If the router was rebuilt, its host key changed — refresh
known_hosts deliberately rather than turning host_key_checking off.
The play fails with a Python or setup error on the router. A play is
missing gather_facts: false. There is no Python interpreter on the other end
of a network_cli connection.
A task reports changed on every run and --diff shows a line the router
already has. That is Task 3. Read show configuration commands on the
router and copy the text exactly, quotes included. Then check the task is not
carrying match: none, which produces the identical symptom for a different
reason.
A configuration task fails with a commit error. The VyOS validator rejected the change and the active configuration is unchanged — the commit is atomic, so there is no half-applied state to clean up. Read the error the router printed; it names the node.
The wait_for task fails and the protocol looks fine. Run the command by
hand on the router and search its output for the string the conditional is
looking for. If it is not there, the conditional is wrong, not the network.
That is the Task 5 defect and it is easy to spend an afternoon on.
A vyos_command task hangs forever. If the command is
show system boot-messages all, that is documented: VyOS uses a custom pager
setting and the module hangs. Use a narrower command. If it is something else,
command_timeout in the [persistent_connection] section of ansible.cfg is
the bound.
A router is unreachable after a firewall push and the console shows nothing
obviously wrong. Read show firewall ipv4 input filter on the console. A
default-action drop with no rule matching your source address looks entirely
correct in a configuration review and is a lockout on the wire.
rollback 1 did not fix anything. rollback loads the revision into the
candidate; it does not commit. rollback 1, then compare, then commit,
then save.
Cleanup
Everything here is on isolated bridges, so cleanup means restoring a known state rather than restoring service.
Step 1. If you are keeping the topology for the automation break/fix
scenarios, stop here and save on both routers. Note in the journal that both
run eBGP over the transit segment and that an automation login with a key
from the control node exists, so the next person is not surprised.
Step 2. To return the routers to the Task 1 baseline, on both. Note that
this restores eth0’s original description rather than deleting it — Task 4
overwrote it, and removing it would leave the router in a state Task 1 never
built:
configure
delete protocols bgp
delete firewall
set interfaces ethernet eth0 description 'MGMT'
delete interfaces ethernet eth1 description
compare
commit
save
If a delete line reports that there is nothing to delete, that node is
already gone — you removed it during Task 6, or you stopped before it existed.
Drop the line and re-run the block; compare before commit will tell you
whether what remains is what you wanted.
Step 3. To remove the automation account and its key, on both. Do this after Step 2, not before — deleting the login while a playbook run is in flight leaves a commit half-described in the log:
configure
delete system login user automation
commit
save
Step 4. Steps 2 and 3 are the incremental path. If you would rather reset a
router wholesale, skip both and load the Task 1 capture instead — and read
compare before committing, because that file still contains the automation
login and its key, so this path restores the account that Step 3 removes:
configure
load /config/pre-lab-25.boot
compare
commit
save
Step 5. On the control node, remove the working tree and the key:
# The backup/ directory holds router configurations. Read it before deleting it.
ls -l ~/lab25/backup/
rm -rf ~/lab25
rm -f ~/.ssh/vyos_automation ~/.ssh/vyos_automation.pub
ssh-keygen -R 192.0.2.1
ssh-keygen -R 192.0.2.2
Step 6. To remove the topology, delete the three VMs and the two bridges on
the hypervisor. Confirm with ip -brief link show on the host that no lab
bridge survives.
What You Learned
- The transport is SSH to the CLI, and nothing else is on offer. The
vyos.vyoscollection ships a cliconf plugin and no httpapi plugin. You configured the wrong one and read the failure, which is a faster way to know it than reading a README. changedis a claim about text, not about the router.vyos_configwith the defaultmatch: linecompares your lines against the active configuration character by character. Three forms of the same command configure the router identically and only one of them converges.- The router is the authority, not the module documentation. The
documentation’s own “full form” example is still one abbreviation short of
what VyOS prints.
show configuration commandssettles it in one command. - A no-op commit is not free. Every non-idempotent run writes a real revision into a finite archive, and the revision you want to roll back to is the one that ages out.
--checkon a network device is a connected operation. It needs the credentials, the reachability and the host key of a real run, and it proves the delta, not the outcome.- A gate you have never seen fail is not a gate. The obvious conditional
against
show bgp summaryis inverted, because FRR prints the state name only while the session is down. You found that by breaking the session on purpose, which is the only way anyone ever finds it. confirmis off by default and asks a narrower question than you want. It reverts when the session it is running over stops working. A stateful input filter keeps that session working while locking out every future one.- Drift is a check-mode run. A host that reports
changedin check mode has diverged from the source of truth, and thebycolumn inshow system commitnames who diverged it. - Blast radius is two lines of YAML.
serial: 1andany_errors_fatal: trueturn a bad change from an estate-wide event into a single-router one.