Week: 4 -- Network Automation
Points: 20
Time estimate: 90 min lab + 3 hr independent
Deliverable: lab-4-report.md + playbook/script files
Objectives
- Author an Ansible playbook that idempotently configures BGP peer relationships across the Lab 1/2 topology.
- Re-implement the same task in Nornir using the Netmiko connection plugin.
- Verify idempotency by running each twice and confirming no re-application occurs on the second run.
- Create a Jinja2 template that generates per-device FRR configuration from a YAML inventory.
Setup
Use the Lab 1 or Lab 2 Containerlab topology as the managed network. The Containerlab containers are reachable via SSH (FRR default: user root, no password in dev mode -- set a password in production).
Create lab4/ directory structure:
lab4/
├── inventory/
│ ├── hosts.yaml # Ansible + Nornir inventory
│ └── group_vars/
│ └── all.yaml # credentials, common vars
├── playbooks/
│ └── bgp_peers.yaml # Ansible playbook
├── nornir_task/
│ └── configure_bgp.py # Nornir script
└── templates/
└── frr_bgp.j2 # Jinja2 FRR config template
Part A: Ansible Playbook (40 min)
Create lab4/inventory/hosts.yaml:
all:
children:
spines:
hosts:
r1:
ansible_host: <containerlab-r1-ip>
ansible_user: root
ansible_connection: network_cli
ansible_network_os: frr
leaves:
hosts:
r2:
ansible_host: <containerlab-r2-ip>
ansible_user: root
ansible_connection: network_cli
ansible_network_os: frr
Create lab4/playbooks/bgp_peers.yaml:
---
- name: Configure BGP peer relationships
hosts: all
gather_facts: false
vars:
bgp_asn: "{{ hostvars[inventory_hostname].bgp_asn }}"
bgp_peers: "{{ hostvars[inventory_hostname].peers }}"
tasks:
- name: Check current BGP neighbor state
ansible.netcommon.cli_command:
command: "vtysh -c 'show bgp summary'"
register: bgp_state
changed_when: false
- name: Configure BGP peers
ansible.netcommon.cli_config:
config: |
router bgp {{ bgp_asn }}
{% for peer in bgp_peers %}
neighbor {{ peer.address }} remote-as {{ peer.remote_asn }}
neighbor {{ peer.address }} description "{{ peer.description }}"
{% endfor %}
when: >
bgp_state.stdout is not search(bgp_peers[0].address)
notify: verify bgp
handlers:
- name: verify bgp
ansible.netcommon.cli_command:
command: "vtysh -c 'show bgp summary'"
register: bgp_verify
failed_when: "'Established' not in bgp_verify.stdout"
Run the playbook:
cd lab4/
ansible-playbook -i inventory/hosts.yaml playbooks/bgp_peers.yaml -v
# Run twice to verify idempotency
ansible-playbook -i inventory/hosts.yaml playbooks/bgp_peers.yaml -v
Record: on the second run, does Ansible report changed=0 for the BGP configuration task?
Part B: Nornir Script (30 min)
Create lab4/nornir_task/configure_bgp.py:
from nornir import InitNornir
from nornir.core.task import Task, Result
from nornir_netmiko import netmiko_send_command, netmiko_send_config
def check_bgp_state(task: Task) -> Result:
"""Check current BGP neighbor state."""
result = task.run(task=netmiko_send_command, command_string="vtysh -c 'show bgp summary'")
return Result(host=task.host, result=result.result)
def configure_bgp_peer(task: Task, peer_address: str, peer_asn: int, description: str) -> Result:
"""Configure a BGP peer if not already present."""
# Check state first
state_result = task.run(task=netmiko_send_command,
command_string="vtysh -c 'show bgp summary'")
if peer_address in state_result.result:
return Result(host=task.host, result="Already configured", changed=False)
# Apply configuration
config = [
f"router bgp {task.host['bgp_asn']}",
f"neighbor {peer_address} remote-as {peer_asn}",
f"neighbor {peer_address} description '{description}'"
]
task.run(task=netmiko_send_config, config_commands=config)
return Result(host=task.host, result="Configured", changed=True)
if __name__ == "__main__":
nr = InitNornir(
runner={"plugin": "threaded", "options": {"num_workers": 5}},
inventory={
"plugin": "SimpleInventory",
"options": {
"host_file": "lab4/inventory/hosts.yaml",
"group_file": "lab4/inventory/groups.yaml",
}
}
)
# Filter to spine routers
spines = nr.filter(groups=["spines"])
# Check current state
print("=== Current BGP State ===")
result = spines.run(task=check_bgp_state)
for host, r in result.items():
print(f"\n{host}:\n{r.result[:300]}")
# Configure peers (idempotent)
print("\n=== Configuring BGP Peers ===")
for peer_data in [
{"peer_address": "10.0.12.2", "peer_asn": 65002, "description": "r2"},
{"peer_address": "10.0.13.3", "peer_asn": 65003, "description": "r3"},
]:
result = nr.filter(name="r1").run(task=configure_bgp_peer, **peer_data)
for host, r in result.items():
print(f"{host}: {r.result} (changed={r.changed})")
Run twice; record whether changed=True on first run and changed=False on second.
Part C: Jinja2 Configuration Template (20 min)
Create lab4/templates/frr_bgp.j2:
! FRR configuration for {{ hostname }}
! Generated by NET-301 Lab 4 automation
!
hostname {{ hostname }}
!
{% for iface in interfaces %}
interface {{ iface.name }}
ip address {{ iface.address }}
{% if iface.isis %}
ip router isis 1
isis network {{ iface.isis_type | default('point-to-point') }}
{% endif %}
!
{% endfor %}
router bgp {{ bgp.asn }}
bgp router-id {{ bgp.router_id }}
no bgp default ipv4-unicast
!
{% for peer in bgp.peers %}
neighbor {{ peer.address }} remote-as {{ peer.remote_asn }}
neighbor {{ peer.address }} description "{{ peer.description }}"
{% endfor %}
!
address-family ipv4 unicast
{% for peer in bgp.peers %}
neighbor {{ peer.address }} activate
{% endfor %}
exit-address-family
!
Render the template for R1 from a YAML data file:
from jinja2 import Template
import yaml
data = yaml.safe_load(open("lab4/inventory/r1-data.yaml"))
template = Template(open("lab4/templates/frr_bgp.j2").read())
rendered = template.render(**data)
print(rendered)
Verify the rendered output matches the manually configured R1 configuration from Lab 1.
Lab Report
Create lab-4-report.md with:
- Ansible run 1:
changedcount for the BGP configuration task - Ansible run 2:
changedcount (should be 0 -- idempotent) - Nornir run 1 and run 2:
changedoutput for each (confirming idempotency) - Jinja2 rendered config for R1 (paste the output)
- One-paragraph analysis: "In a production network, what prevents a misconfigured playbook from pushing a BGP configuration that drops all routes? Name two specific safeguards."
Grading
| Component | Points |
|---|---|
| Ansible run 1: BGP configured (changed > 0) | 4 |
| Ansible run 2: idempotent (changed = 0) | 4 |
| Nornir: both changed behaviors correct | 4 |
| Jinja2: rendered config syntactically valid FRR config | 4 |
| Production safeguards paragraph: two specific controls named | 4 |
| Total | 20 |