Skip to content

10 - Playbooks

Ansible
Linux


Info This is me documenting my journey of learning Ansible that is focused on network engineering. It’s not a “how-to guide” per-say, more of a diary. Each part will build upon the last. A lot of information on here is so I can come back to and reference later. I also learn best when teaching someone, and this is kind of me teaching.

Playbooks

A playbook is a YAML file containing 1 of more “plays”. Each “play” targets a group of hosts and defines a list of tasks to run against them.


Structure
YAML
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
---

- name: "Play name"
  hosts: cisco_ios
  gather_facts: false
  connection: network_cli
  become: true
  become_method: enable

  vars:
    my_variable: value

  vars_files:
    - vars/bgp_policy.yml

  pre_tasks:
    - name: "Pre-task name"
      module_name:
        parameter: value
  tasks:
    - name: "Task name"
      module_name:
        parameter: value
      register: result_var
      when: condition
      loop: "{{ list }}"
      notify: handler_name
      tags:
        - tag_name

  post_tasks:
    - name: "Post-task name"
      module_name:
        parameter: value

  handlers:
    - name: handler_name
      module_name:
        parameter: value
Line 1:
YAML document start marker.
Line 3:
A play begins with a dash
Line 4:
Which inventory group or host to target
Line 5:
Whether to run the facts module before tasks
Line 6:
How to connect to devices
Line 7:
Enable privilege escalation (enable mode for IOS)
Line 8:
How to escalate (enable for IOS, sudo for Linux)
Line 10:
Play level variables (optional)
Line 13:
Load variables from external files (optional)
Line 16:
Tasks that run before roles and main tasks
Line 21:
The main task list
Line 22:
Always required (appears in playbook output)
Line 23:
The Ansible module to use
Line 24:
Module parameters
Line 25:
Store the task’s return value in a variable
Line 26:
Only run thistask if condition is true
Line 27:
Run this task once for each item in the list
Line 28:
Trigger a handler if this task reports changed
Line 29:
Tag for selective playbook execution

Required Fields

These 6 fields appear in every network device playbook I write.

  • - name: "Name" - Required for readable output
  • hosts: cisco_ios - Required to state who to run against
  • gather_facts: false - Required because network devices can’t run Python facts
  • connection: network_cli - Required because this tells Ansible to use CLI over SSH
  • become: true - Required for IOS so that Ansible can enter ’enable mode'
  • become_method: enable - Required for IOS since it’s how Ansible gets to ’enable`

Multiple Plays

A single playbook file can contain multiple plays, each targeting different devices.

YAML
---
- name: "Play 1 - Configure IOS devices"
  hosts: cisco_ios
  gather_facts: false
  connection: network_cli
  tasks:
    - ....

- name: "Play 2 - Configure NX-OS devices"
  hosts: cisco_nxos
  gather_facts: false
  connection: network_cli
  tasks:
    - ...


- name: "Play 3 - Verify Linux hosts"
  hosts: linux_hosts
  gather_facts: true
  tasks:
    - ....

Network_Cli Plugin

connection: network_cli is the engine behind all SSH-based network device automation.

When Ansible encounters a task targeting a host with connection: network_cli:

  1. Opens an SSH connection to ansible_host using Paramiko
  2. Authenticates with ansible_user / ansible_password (or SSH key)
  3. Detects the device prompt using ansible_network_os
  4. Handles login banners, pagination (–More–), and prompt patterns
  5. Enters enable mode if ansible_become: true (sends enable + become_password)
  6. Sends the CLI command(s) from the module
  7. Reads back the output until the device prompt returns
  8. Returns the output to the module for parsing
  9. Keeps the SSH connection alive for subsequent tasks in the same play

Ansible doesn’t open and close an SSH session for every task.


ansible_network_os is what tells the network_cli plugin which terminal handler to load (how to recognize the device prompt, how ot enter config mode, how to handle pagination).

Examples:

ansible_network_os: cisco.ios.ios

ansible_network_os: cisco.nxos.nxos

ansible_network_os: paloaltonetworks.panos.panos

The format is always <collection_namespace>.<collection_name>.


First Playbook

I’ll build 1 playbook file that grows throughout this part. Starting with gathering facts.

playbooks/report/first_playbook.yml
---
- name: "Report | Gather and display facts from IOS-XE devices"
  hosts: cisco_ios
  gather_facts: false
  connection: network_cli
  become: true
  become_method: enable

  tasks:

    - name: "Facts | Gather IOS device facts"
      cisco.ios.ios_facts:
        gather_subset:
          - default
          - interfaces
          - hardware

    - name: "Facts | Deplay device summary"
      ansible.builtin.debug:
        msg:
          - "============================"
          - "Device: {{ inventory_hostname }}"
          - "Hostname: {{ ansible_net_hostname }}"
          - "Version: {{ ansible_net_version }}"
          - "Model: {{ ansible_net_model | default{'unknown'} }}"
          - "Serial: {{ ansible_net_serialnum | default{'unknown'} }}"
          - "Interfaces: {{ansible_net_interfaces | length }} configured"
          - "============================"

Running it:

Bash
cd ~/projects/ansible-network
ansible-playbook playbooks/report/first_playbook.yml
Expected Output
PLAY [Report | Gather and display facts from IOS-XE devices] ***

TASK [Facts | Gather IOS device facts] *********************************
ok: [wan-r1]
ok: [wan-r2]

TASK [Facts | Display device summary] **********************************
ok: [wan-r1] => {
    "msg": [
        "======================================",
        "Device:    wan-r1",
        "Hostname:  wan-r1",
        "Version:   17.06.01",
        "Model:     CSR1000V",
        "Serial:    ...",
        "Interfaces:3 configured",
        "======================================"
    ]
}
ok: [wan-r2] => {
    "msg": [
        "======================================",
        "Device:    wan-r2",
        ...
    ]
}

PLAY RECAP *************************************************************
wan-r1    : ok=2    changed=0    unreachable=0    failed=0
wan-r2    : ok=2    changed=0    unreachable=0    failed=0

The play recap is the summary at the bottom of every playbook run:

wan-r1 : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
         │         │             │                │            │            │            │
         │         │             │                │            │            │            └── Tasks with ignore_errors: true that failed
         │         │             │                │            │            └── Tasks rescued by block/rescue
         │         │             │                │            └── Tasks skipped due to when: condition
         │         │             │                └── Tasks that returned an error
         │         │             └── Hosts unreachable via SSH
         │         └── Tasks that made a configuration change
         └── Total tasks that completed successfully (including changed)

Capturing Task Output

register saves a task’s return value into a variable for use in subsequent tasks. Without register the output of a task is displayed in the play recap but then discarded.


Adding Register

I add a task that runs a show command and registers the output:

YAML
---
- name: "Report | Gather and display facts from IOS-XE devices"
  hosts: cisco_ios
  gather_facts: false
  connection: network_cli
  become: true
  become_method: enable

  tasks:

    - name: "Facts | Gather IOS device facts"
      cisco.ios.ios_facts:
        gather_subset:
          - default
          - interfaces
          - hardware

    - name: "Facts | Display device summary"
      ansible.builtin.debug:
        msg:
          - "Device:    {{ inventory_hostname }}"
          - "Hostname:  {{ ansible_net_hostname }}"
          - "Version:   {{ ansible_net_version }}"
          - "Model:     {{ ansible_net_model | default('unknown') }}"

    # ── NEW: register a show command output ──────────────────────────
    - name: "Facts | Run show ip interface brief"
      cisco.ios.ios_command:
        commands:
          - show ip interface brief
      register: interface_brief

    - name: "Facts | Display interface brief output"
      ansible.builtin.debug:
        msg: "{{ interface_brief.stdout_lines[0] }}"
        # interface_brief is the dict returned by ios_command
        # .stdout_lines is a list — one entry per command
        # [0] is the first command's output, already split into lines

Anatomy of a Register Variable

When I register the ouput of ios_command the variable contains a dictionary with many keys. Here’s what’s inside interface_brief after the task runs:

Bash
interface_brief = {
    "changed": False,           # Did this task change anything?
    "failed": False,            # Did this task fail?
    "stdout": [                 # Raw output — one string per command
        "Interface              IP-Address      OK? Method Status    Protocol\n
         GigabitEthernet1       10.10.10.1      YES manual up        up\n
         GigabitEthernet2       10.10.20.1      YES manual up        up\n
         Loopback0              10.255.0.1      YES manual up        up"
    ],
    "stdout_lines": [           # Same output, split into lines — easier to work with
        [
            "Interface              IP-Address      OK? Method Status    Protocol",
            "GigabitEthernet1       10.10.10.1      YES manual up        up",
            "GigabitEthernet2       10.10.20.1      YES manual up        up",
            "Loopback0              10.255.0.1      YES manual up        up"
        ]
    ]
}

I access parts of this dictionary using dot notation or bracket notation in Jinja2

YAML
1
2
3
4
5
"{{ interface_brief.stdout[0] }}"           # Raw string — first command output
"{{ interface_brief.stdout_lines[0] }}"     # List of lines — first command output
"{{ interface_brief.stdout_lines[0][0] }}"  # First line of first command output
"{{ interface_brief.failed }}"              # Boolean — did the task fail?
"{{ interface_brief.changed }}"             # Boolean — did the task change anything?
Line 1:
Raw string
Line 2:
List of lines
Line 3:
First line of first command output
Line 4:
Boolean (did task fail)
Line 5:
Boolean (did task change anything)

Using debug Effectively

ansible.builtin.debug is the print() statement of playbooks. It outputs information during an run without making any changes. I use it constantly during development and for building reporting playbooks.


3 Forms

Form 1: msg - display a string or list of strings

YAML
- name: "Debug | Show a message"
  ansible.builtin.debug:
    msg: "The hostname is {{ ansible_net_hostname }}"

Form 2: var - deump an entire variable’s contents

YAML
- name: "Debug | Dump variable contents"
  ansible.builtin.debug:
    var: interface_brief

Form 3: msg with a list - displays each item on its own line

YAML
- name: "Debug | Show multiple lines"
  ansible.builtin.debug:
    msg:
      - "Hostname: {{ ansible_net_hostname }}"
      - "Version:  {{ ansible_net_version }}"
      - "Uptime:   {{ ansible_net_all_ipv4_addresses | join(', ') }}"

Verbosity-Gated Debug Messages

I can set a verbosity: level on debug tasks so they only print when I run the playbook with -v or higher. This lets me leave development debug output in the playbook without cluttering normal runs.

YAML
- name: "Debug | Full interface_brief dump (dev only)"
  ansible.builtin.debug:
    var: interface_brief
    verbosity: 2

Normal ru: this task is silently skipped. With -vv: this task prints the full variable dump.


Adding a Configuration Change

Now I add the first configuration changing task to the playbook. This will set the hostname to match what’s in host_vars.

first_playbook.yml
---
- name: "Report and Configure | IOS-XE devices"
  hosts: cisco_ios
  gather_facts: false
  connection: network_cli
  become: true
  become_method: enable

  tasks:

    - name: "Facts | Gather IOS device facts"
      cisco.ios.ios_facts:
        gather_subset:
          - default
          - interfaces
          - hardware

    - name: "Facts | Display device summary"
      ansible.builtin.debug:
        msg:
          - "Device:    {{ inventory_hostname }}"
          - "Hostname:  {{ ansible_net_hostname }}"
          - "Version:   {{ ansible_net_version }}"
          - "Model:     {{ ansible_net_model | default('unknown') }}"

    - name: "Facts | Run show ip interface brief"
      cisco.ios.ios_command:
        commands:
          - show ip interface brief
      register: interface_brief

    - name: "Facts | Display interface brief output"
      ansible.builtin.debug:
        msg: "{{ interface_brief.stdout_lines[0] }}"

    # ── NEW: push hostname configuration ─────────────────────────────
    - name: "Config | Set hostname from inventory (device_hostname variable)"
      cisco.ios.ios_hostname:
        config:
          hostname: "{{ device_hostname }}"    # From host_vars/<hostname>.yml
        state: merged
      register: hostname_result

    - name: "Config | Report hostname change result"
      ansible.builtin.debug:
        msg: "Hostname change — changed: {{ hostname_result.changed }}"

Running Updated Playbook
Bash
ansible-playbook playbooks/report/first_playbook.yml

First run (hostname was already correct):

Expected Output
TASK [Config | Set hostname from inventory] ****************************
ok: [wan-r1]      ← ok means no change needed — already correct
ok: [wan-r2]

PLAY RECAP *************************************************************
wan-r1 : ok=6    changed=0    ...
wan-r2 : ok=6    changed=0    ...

If I manually set a wrong hostname on wan-r and run again:

Expected Output
TASK [Config | Set hostname from inventory] ****************************
changed: [wan-r1]   ← changed means it applied the correct hostname

PLAY RECAP *************************************************************
wan-r1 : ok=6    changed=1    ...

Idempotency

Idempotency is one of the most important concepts in Ansible. An operation is idempotent if running it multiple times produces the same result as running it once. If I run a playbook twice with the same inputs and the device is already in the desired state, the second run makes no changes.

Why Idempotency Matters

Without it automation is dangerous:

  • Run a playbook twice → duplicate VLAN entries in the config
  • Run a backup playbook twice → configuration gets applied twice, potentially corrupting state
  • Scheduled automation runs a playbook every hour → device slowly accumlates duplicate lines

With it I can:

  • Run a playbook any number of times safely
  • Use automation as continuous enforcement (run every hour to keep devices compliant)
  • Retry failed playbooks without worrying about partial apply side effects
Resource Modules

Resource modules are idempotent by design. They compare the desired state against the current state and only pushing changes when there’s a difference.

First run - hostname is wrong, module fixes it:

Bash
ansible-playbook playbooks/report/first_playbook.yml
Expected Output
TASK [Config | Set hostname from inventory]
changed: [wan-r1]    # ← Applied the correct hostname

PLAY RECAP: wan-r1 : ok=6  changed=1  failed=0

Second run - hostname is now correct, no change is made:

Expected Output
TASK [Config | Set hostname from inventory]
ok: [wan-r1]         # ← Already correct, nothing to do

PLAY RECAP: wan-r1 : ok=6  changed=0  failed=0

This false positive changed is a known limitation of ios_config. Every changed: true in a playbook is supposed to mean “I made a change”. False positives erode trust in the output, I stop believing that changed: 1 means something actually changed.


Comparison
Module TypeExampleIdempotent?How it Checks
Resource Moduleios_vlansYesStructured state comparison
Resource Moduleios_hostnameYesStructured state comparison
Resource moduleios_bgp_globalYesStrcutured state comparison
Config moduleios_configPartialText line matching
Command moduleios_commandAlways okRead-only, never changes

Rules to Follow

Use resource modules whenever they exist for what I’m trying to configure. Fall back to ios_config only when no resource module covers it

Resource module coverage for IOS: ios_vlans, ios_interfaces, ios_l2_interfaces, ios_l3_interfaces, ios_bgp_global, ios_ospf_interfaces, and more.

The list of resource modules grows with every collection release. When I need to configure something new I check ansible-doc cisco.ios.ios_<tab> to see if a resource module exists before reaching for ios_config.


When ios_config is necessary, I use the parents: parameter to narrow the comparison scope, and save_when: changed to only save when a change was actually made.

YAML
- name: "Config | Configure interface description (ios_config)"
  cisco.ios.ios_config:
    parents: "interface GigabithEthernet1"
    lines:
      - "description WAN | To FW-01 eth1"
    save_when: changed

Adding a Backup Task

I add the backup task to the growing playbook. This reinforces the register pattern and introduces delegate_to: localhost.

first_playbook.yml
---
- name: "Report, Configure, and Backup | IOS-XE devices"
  hosts: cisco_ios
  gather_facts: false
  connection: network_cli
  become: true
  become_method: enable

  vars:
    timestamp: "{{ lookup('pipe', 'date +%Y%m%d_%H%M%S') }}"

  tasks:

    - name: "Facts | Gather IOS device facts"
      cisco.ios.ios_facts:
        gather_subset:
          - default
          - interfaces
          - hardware

    - name: "Facts | Display device summary"
      ansible.builtin.debug:
        msg:
          - "Device: {{ inventory_hostname }}"
          - "Hostname: {{ ansible_net_hostname }}"
          - "Version: {{ ansible_net_version }}"
          - "Model: {{ ansible_net_model | default('unknown') }}"

    - name: "Facts | Run show ip interface brief"
      cisco.ios.ios_command:
        commands:
          - show ip interface brief
      register: interface_brief

    - name: "Facts | Display interface brief output"
      ansible.builtin.debug:
        msg: "{{ interface_brief.stdout_lines[0] }}"

    - name: "Config | Set hostname from inventory"
      cisco.ios.ios_hostname:
        config:
          hostname: "{{ device_hostname }}"
        state: merged
      register: hostname_result

    - name: "Config | Report hostname change result"
      ansible.builtin.debug:
        msg: "Hostname change — changed: {{ hostname_result.changed }}"

    # ── backup tasks ─────────────────────────────────────────────
    - name: "Backup | Create backup directory on control node"
      ansible.builtin.file:
        path: "backups/cisco_ios/{{ inventory_hostname }}"
        state: directory
        mode: '0755'
      delegate_to: localhost

    - name: "Backup | Gather running configuration"
      cisco.ios.ios_command:
        commands:
          - show running-config
      register: running_config

    - name: "Backup | Write configuration to file on control node"
      ansible.builtin.copy:
        content: "{{ running_config.stdout[0] }}"
        dest: "backups/cisco_ios/{{ inventory_hostname }}/{{ inventory_hostname }}_{{ timestamp }}.cfg"
        mode: '0644'
      delegate_to: localhost

    - name: "Backup | Confirm backup location"
      ansible.builtin.debug:
        msg: "Backup saved: backups/cisco_ios/{{ inventory_hostname }}/{{ inventory_hostname }}_{{ timestamp }}.cfg"

Delegate_to: localhost Pattern Explained

Every task in a play normally runs on the remote host (the network device). But creating directories and writing files needs to happen on the control nose. delegate_to: localhost tells Ansible to run that specific task on the control node instead of the remote host.

NORMAL TASK FLOWUbuntu VMSSHwan-r1TASK RUNS HEREWITH DELEGATE_TO: LOCALHOSTUbuntu VMruns locallyno SSHUbuntu VMTASK RUNS HERE

The inventory_hostname variable still refers to wan-r1 even when the task runs on localhost (this is what makes the backup file path backups/cisco_ios/wan-r1...cfg correct). The variable context stays attached to the device even though the task execution moved to localhost.


When Conditions

The when: keyword makes a task conditional. It evaluates Jinja2 expression and skips the task if the result is false. This is how I write playbooks that handle multiple platforms or make decisions based on device state.


Basic Pattern
YAML
- name: "Task name"
  module:
    parameter: value
  when: condition_expression

when Based on ansible_network_os

The most common use in multi-platform playbooks is to run different tasks depending on which platform is being targeted:

YAML
- name: "Config | Configure NTP (IOS-specific module)"
  cisco.ios.ios_ntp_global:
    config:
      servers:
        - server: "{{ ntp_servers[0] }}"
    state: merged
  when: ansible_network_os == 'cisco.ios.ios'

- name: "Config | Configure NTP (NX-OS specific module)"
  cisco.nxos.nxos_ntp_global:
    config:
      servers:
        - server: "{{ ntp_servers[0] }}"
    state: merged
  when: ansible_network_os == 'cisco.nxos.nxos'

If I run this against a group that has both IOS and NX-OS devices, each device only runs the task that matches its platform. The other task is skipped with skipping: [hostname] in the output.


when Based on a Registered Result

I can gate a task on the result of a previous task:

YAML
- name: "Facts | Check if BGP is running"
  cisco.ios.ios_command:
    commands:
      - show ip bgp summary
  register: bgp_check
  ignore_errors: true

- name: "Report | BGP is configured on this device"
  ansible.builtin.debug:
    msg: "BGP is active on {{ inventory_hostname }}"
  when: bgp_check.failed == false

- name: "Report | BGP is NOT configured on this device"
  ansible.builtin.debug:
    msg: "BGP is NOT running on {{ inventory_hostname }}"
  when: bgp_check.failed == true

when Based on a Fact Variable

YAML
- name: "Config | Apply IOS-XE 17.x specific configuration"
  cisco.ios.ios_config:
    lines:
      - "platform punt-keepalive disable-kernel-core"
  when: ansible_net_version is version ('17.0', '>=')

- name: "Config | CSR-specific tuning"
  cisco.ios.ios_config:
    lines:
      - "platform hardware throughput level MB 1000"
  when: "'CSR' in ansible_net_model"

when With The device_role Inventory Variable

This is the pattern I use most ofthen when using the device_role variable from host_vars to control which tasks run on which devices:

YAML
- name: "Config | Apply spine-specific BGP configuration"
  cisco.nxos.nxos_bgp_global:
    config:
      as_number: "{{ bgp_as }}"
    state: merged
  when: device_role == 'spine'

- name: "Config | Apply leaf-specific VLAN configuration"
  cisco.nxos.nxos_vlans:
    config: "{{ base_vlans }}"
    state: merged
  when: device_role == 'leaf'

Adding when to the Growing Playbook

I add a conditional that only runs the hostname task when the current hostname doesn’t match what’s in inventory:

first_playbook.yml
- name: "Config | Set hostname (only if it needs to change)"
      cisco.ios.ios_hostname:
        config:
          hostname: "{{ device_hostname }}"
        state: merged
      register: hostname_result
      when: ansible_net_hostname != device_hostname

Looping Over Tasks

Many network configuration tasks involve applying the same operation to a list of items (configuring multiple VLANs, multiple NTP servers, etc). Loops avoid writing repetitive tasks.


Monder Approach

loop is the current standard. It iterates over a list, running the task once for each item.

YAML
- name: "Config | Add multiple VLANs"
  cisco.nxos.nxos_vlans:
    config:
      - vlan_id: "{{ item.id }}"
        name: "{{ item.name }}"
    state: merged
  loop:
    - { id: 10, name: "MGMT" }
    - { id: 20, name: "APP_SERVERS" }
    - { id: 30, name: "DB_SERVERS" }

Or loop over a variable list from inventory:

YAML
- name: "Config | Configure all base VLANs from group_vars"
  cisco.nxos.nxos_vlans:
    config:
      - vlan_id: "{{ item.id }}"
        name: "{{ item.name }}"
    state: merged
  loop: "{{ base_vlans }}"
  loop_control:
    label: "VLAN {{item.id }} - {{ item.name }}"

Leaf_switches, group_vars defines base_vlans as a list. This loop read that list and configure each VLAN.


Loop Output

Without loop_control.label each iteration shows the full item dict in the output. With label I get clean readable output:

Without label

TASK [Config | Configure all base VLANs] ***
changed: [leaf-01] => (item={'id': 10, 'name': 'MGMT', 'subnet': '192.168.10.0/24'})
changed: [leaf-01] => (item={'id': 20, 'name': 'APP_SERVERS', 'subnet': '192.168.20.0/24'})

With label

TASK [Config | Configure all base VLANs] ***
changed: [leaf-01] => (item=VLAN 10 — MGMT)
changed: [leaf-01] => (item=VLAN 20 — APP_SERVERS)

Looping Over a Simple List

Loop over a flat list of strings:

YAML
- name: "Config | Enable required NX-OS features"
  cisco.nxos.nxos_features:
    feature: "{{ item }}"
    state: enabled
  loop:
    - bgp
    - ospf
    - interface-vlan
    - lacp
    - lldp
  loop_control:
    label: "feature: {{ item }}"

Looping Over bgp_neighbors from host_vars

BGP neighbors list in host_vars/wan-r1.yml drives the BGP neighbor configuration:

YAML
- name: "Config | Configure BGP neighbors from host_vars"
  cisco.ios.ios_bgp_address_family:
    config:
      as_number: "{{ bgp_as }}"
      address_family:
        - afi: ipv4
          neighbors:
            - neighbor_address: "{{ item.neighbor_ip }}"
              remote_as: "{{ item.remote_as }}"
              description: "{{ item.description }}"
    state: merged
  loop: "{{ bgp_neighbors }}"
  loop_control:
    label: "BGP neighbor {{ item.neighbor_ip }} (AS {{ item.remote_as }})"

Legacy Approach

with_items is the older loop syntax. It works identically to loop for simple lists.

Example:

YAML
- name: "Config | Enable features (legacy with_items)"
  cisco.nxos.nxos_feature:
    feature: "{{ item }}"
    state: enabled
  with_items:
    - bgp
    - ospf
    - interface-vlan

Same but with loop:

YAML
- name: "Config | Enable features (modern loop)"
  cisco.nxos.nxos_feature:
    feature: "{{ item }}"
    state: enabled
  loop:
    - bgp
    - ospf
    - interface-vlan

Key Differences
Featureloopwith_items
StatusCurrent standardLegacy
Nested listsDoesn’t flattenFlattens one level automatically
Complex iterationWorks with loop_controlLimited options
loop_control.labelSupportedNot avaiable
Reading old playbooksMay see with_itemsNeeds recognition

Complete Playbook

Here is the complete first_playbook.yml with all concepts integrated:

YAML
---

- name: "Part 10 | Gather, configure, and backup IOS-XE devices"
  hosts: cisco_ios
  gather_facts: false
  connection: network_cli
  become: true
  become_method: enable

  vars:
    timestamp: "{{ lookup('pipe', 'date +%Y%m%d_%H%M%S') }}"

  tasks:

    # ── SECTION 1: Gather Facts ───────────────────────────────────────
    - name: "Facts | Gather IOS device facts"
      cisco.ios.ios_facts:
        gather_subset:
          - default
          - interfaces
          - hardware

    - name: "Facts | Display device summary"
      ansible.builtin.debug:
        msg:
          - "======================================"
          - "Device:    {{ inventory_hostname }}"
          - "Hostname:  {{ ansible_net_hostname }}"
          - "Version:   {{ ansible_net_version }}"
          - "Model:     {{ ansible_net_model | default('unknown') }}"
          - "Serial:    {{ ansible_net_serialnum | default('unknown') }}"
          - "======================================"

    # ── SECTION 2: Register and Debug ────────────────────────────────
    - name: "Facts | Gather interface brief"
      cisco.ios.ios_command:
        commands:
          - show ip interface brief
      register: interface_brief

    - name: "Facts | Display interface brief"
      ansible.builtin.debug:
        msg: "{{ interface_brief.stdout_lines[0] }}"

    - name: "Facts | Full register dump (dev only — gated at -vv)"
      ansible.builtin.debug:
        var: interface_brief
        verbosity: 2    # Only shown with -vv or higher

    # ── SECTION 3: Conditional Hostname Config ────────────────────────
    - name: "Config | Set hostname (when inventory name differs from device)"
      cisco.ios.ios_hostname:
        config:
          hostname: "{{ device_hostname }}"
        state: merged
      register: hostname_result
      when: ansible_net_hostname != device_hostname

    - name: "Config | Confirm hostname (skipped if no change was needed)"
      ansible.builtin.debug:
        msg: "Hostname set to {{ device_hostname }} — changed: {{ hostname_result.changed }}"
      when: hostname_result is not skipped

    # ── SECTION 4: Loop — Configure NTP Servers ──────────────────────
    - name: "Config | Configure NTP servers from group_vars/all.yml"
      cisco.ios.ios_ntp_global:
        config:
          servers:
            - server: "{{ item }}"
              prefer: "{{ true if loop.index == 1 else false }}"
        state: merged
      loop: "{{ ntp_servers }}"
      loop_control:
        label: "NTP server: {{ item }}"

    # ── SECTION 5: Platform-Conditional Task ─────────────────────────
    - name: "Config | IOS-XE only — disable IP source routing"
      cisco.ios.ios_config:
        lines:
          - no ip source-route
      when: ansible_network_os == 'cisco.ios.ios'
      # This task would be skipped on NX-OS or PAN-OS if this play
      # were extended to target multiple platforms

    # ── SECTION 6: Backup ─────────────────────────────────────────────
    - name: "Backup | Create backup directory"
      ansible.builtin.file:
        path: "backups/cisco_ios/{{ inventory_hostname }}"
        state: directory
        mode: '0755'
      delegate_to: localhost

    - name: "Backup | Gather running configuration"
      cisco.ios.ios_command:
        commands:
          - show running-config
      register: running_config

    - name: "Backup | Write configuration to control node"
      ansible.builtin.copy:
        content: "{{ running_config.stdout[0] }}"
        dest: "backups/cisco_ios/{{ inventory_hostname }}/{{ inventory_hostname }}_{{ timestamp }}.cfg"
        mode: '0644'
      delegate_to: localhost

    - name: "Backup | Report backup location"
      ansible.builtin.debug:
        msg: "Saved: backups/cisco_ios/{{ inventory_hostname }}/{{ inventory_hostname }}_{{ timestamp }}.cfg"

Running the Playbook

Full run:

Bash
ansible-playbook playbooks/report/first_playbook.yml

Limit to 1 device:

Bash
ansible-playbook playbooks/report/first_playbook.yml -l wan-r1

Run only specific tagged sections:

Bash
ansible-playbook playbooks/report/first_playbook.yml --tags backup

Show developer debug output:

Bash
ansible-playbook playbooks/report/first_playbook.yml -vv

The core playbook concepts are now in place. Now onto Jinja2 templating.

Last updated on • Ernesto Diaz