Classroom Glossary Public page

Lab 4: Network Automation -- Ansible + Nornir Idempotent Configuration Push

307 words

Week: 4 -- Network Automation
Points: 20
Time estimate: 90 min lab + 3 hr independent
Deliverable: lab-4-report.md + playbook/script files


Objectives

  1. Author an Ansible playbook that idempotently configures BGP peer relationships across the Lab 1/2 topology.
  2. Re-implement the same task in Nornir using the Netmiko connection plugin.
  3. Verify idempotency by running each twice and confirming no re-application occurs on the second run.
  4. 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:

  1. Ansible run 1: changed count for the BGP configuration task
  2. Ansible run 2: changed count (should be 0 -- idempotent)
  3. Nornir run 1 and run 2: changed output for each (confirming idempotency)
  4. Jinja2 rendered config for R1 (paste the output)
  5. 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