Ansible Playbook — Automate Cisco IOS Configuration

Manually logging into ten switches to configure VLANs takes thirty minutes of repetitive, error-prone work. An Ansible playbook does the same job in under sixty seconds, applies identical configuration to every device simultaneously, and produces a log of exactly what changed — with zero risk of a typo on device seven that you don't catch until the following morning. Ansible is the most widely adopted network automation tool in enterprise environments and the one most likely to appear in both job descriptions and CCNA/DevNet certification requirements. For a broader overview of network automation tools and concepts see Ansible Overview and Network Automation Overview.

Ansible works agentlessly over SSH — there is nothing to install on the Cisco devices themselves. You write a playbook (a YAML file describing what you want the network to look like), point it at an inventory of your devices, and Ansible handles the SSH connections, command translation, and verification. The cisco.ios Ansible collection provides purpose-built modules for IOS: ios_config for sending raw IOS commands, ios_vlans for declarative VLAN management, ios_interfaces for interface configuration, and ios_command for running show commands and capturing output. For Python-based alternatives see Python Networking Overview, Python Netmiko, and Python NAPALM Multi-Vendor. For RESTCONF/NETCONF-based automation see NETCONF & RESTCONF Overview and Cisco RESTCONF Basics.

This guide builds a complete Ansible project from scratch — from installing Ansible and the Cisco collection, through writing an inventory file, to running a multi-play playbook that configures hostnames, VLANs, and interfaces across an entire lab network. Every file shown here is production-ready and can be used directly on a real network with minor variable changes.

1. Ansible Architecture for Network Automation

How Ansible Communicates with Cisco IOS

Unlike server automation where Ansible pushes a Python agent to the target, network automation works differently — most network operating systems (including IOS) cannot run arbitrary Python. Ansible handles this with network connection plugins that run entirely on the control node (your Linux machine), using SSH to send CLI commands and parse the responses:

  ┌─────────────────────────────────────────────────────────────────┐
  │  CONTROL NODE (Linux/macOS — where you run ansible-playbook)    │
  │                                                                  │
  │  ansible-playbook site.yml                                       │
  │       │                                                          │
  │       ├── Reads inventory/hosts.yml  (which devices to target)  │
  │       ├── Reads group_vars/          (variables per group)       │
  │       └── Reads playbooks/site.yml   (what to configure)        │
  │                                                                  │
  │  Connection plugin: network_cli (for IOS)                        │
  │  Runs entirely on control node — NO Python on the IOS device     │
  └──────────────────┬──────────────────────────────────────────────┘
                     │  SSH (port 22)
          ┌──────────┴──────────────────────────────────┐
          │                                              │
   NetsTuts_SW1 (192.168.1.10)            NetsTuts_SW2 (192.168.1.11)
   NetsTuts_R1  (192.168.1.1)             NetsTuts_R2  (192.168.1.2)
          │                                              │
   Ansible SSHes in, runs                Ansible SSHes in, runs
   IOS commands, reads output,           same commands simultaneously,
   parses results, reports changes       reports changes

  KEY CONCEPTS:
  ┌─────────────────┬──────────────────────────────────────────────┐
  │ Control Node    │ The machine running Ansible (your workstation │
  │                 │ or a jump host). Must have Python + Ansible.  │
  ├─────────────────┼──────────────────────────────────────────────┤
  │ Managed Node    │ The Cisco device being configured. No agent   │
  │                 │ needed — only SSH + privilege access.         │
  ├─────────────────┼──────────────────────────────────────────────┤
  │ Inventory       │ List of managed nodes and their groupings.    │
  │                 │ Defines which devices a playbook targets.     │
  ├─────────────────┼──────────────────────────────────────────────┤
  │ Playbook        │ YAML file describing tasks to run. Tasks map  │
  │                 │ to modules (ios_config, ios_vlans, etc.).     │
  ├─────────────────┼──────────────────────────────────────────────┤
  │ Module          │ The unit of work — a Python class that knows  │
  │                 │ how to translate YAML into IOS commands.      │
  ├─────────────────┼──────────────────────────────────────────────┤
  │ Collection      │ A packaged set of modules. cisco.ios is the   │
  │                 │ official Cisco collection for IOS/IOS-XE.     │
  ├─────────────────┼──────────────────────────────────────────────┤
  │ Variable        │ Reusable values stored in vars files or the   │
  │                 │ inventory. Device-specific (host_vars) or     │
  │                 │ group-wide (group_vars).                      │
  └─────────────────┴──────────────────────────────────────────────┘
  

Key cisco.ios Modules — Reference

Module Purpose Approach Best Used For
cisco.ios.ios_config Send raw IOS configuration commands Imperative — you specify the exact IOS commands Complex configurations, one-off commands, any feature not covered by a dedicated module
cisco.ios.ios_vlans Manage VLANs declaratively Declarative — you describe the desired VLAN state; Ansible figures out the commands VLAN database management across switches — idempotent by default
cisco.ios.ios_interfaces Configure physical interface properties Declarative — description, enabled/disabled, speed, duplex, MTU Enabling/disabling interfaces, setting descriptions and physical parameters
cisco.ios.ios_l2_interfaces Configure Layer 2 interface properties Declarative — access VLAN, trunk mode, allowed VLANs, native VLAN VLAN assignment on switchports — access and trunk configuration
cisco.ios.ios_l3_interfaces Configure Layer 3 interface properties Declarative — IPv4/IPv6 address assignment on routed interfaces Assigning IP addresses to router interfaces and SVIs
cisco.ios.ios_command Run show commands and capture output Imperative — runs any IOS command and returns the output as a variable Gathering facts, verification steps, pre/post-change snapshots
cisco.ios.ios_facts Gather IOS device facts automatically Read-only — retrieves hostname, interfaces, version, serial number etc. Pre-change inventory, building dynamic configuration from discovered state
cisco.ios.ios_banner Configure MOTD and login banners Declarative — sets banner text without the IOS delimiter complexity Standardising login banners across all devices from one playbook
Idempotency: A task is idempotent if running it multiple times produces the same result as running it once — and it makes no changes if the device is already in the desired state. Declarative modules like ios_vlans are naturally idempotent. The ios_config module achieves idempotency through the lines + parents approach — Ansible checks if the exact lines already exist in the running-config before pushing them. Building idempotent playbooks is the key discipline in production network automation: you should be able to run the same playbook every night as a compliance check without it causing disruption. See show running-config for how to verify applied configuration, and Saving & Managing Cisco Configurations for config persistence best practices.

2. Lab Setup — Installing Ansible and the Cisco Collection

Control Node Requirements

  Control Node (Linux/macOS/WSL2 on Windows):
    Python 3.9 or later
    pip (Python package manager)
    SSH client (standard on Linux/macOS)
    Network access to all managed Cisco devices on port 22

  Cisco Managed Nodes (IOS/IOS-XE):
    SSH enabled (ip ssh version 2)
    Local user with privilege 15 (or AAA with enable fallback)
    VTY lines configured to accept SSH
    No additional software required

  Lab Topology for This Guide:
    Control Node:  192.168.1.100 (Ubuntu 22.04 LTS)
    NetsTuts_SW1:  192.168.1.10  (Cisco Catalyst — IOS 15.x)
    NetsTuts_SW2:  192.168.1.11  (Cisco Catalyst — IOS 15.x)
    NetsTuts_R1:   192.168.1.1   (Cisco ISR — IOS-XE 16.x)
    NetsTuts_R2:   192.168.1.2   (Cisco ISR — IOS-XE 16.x)
  

Step 1 — Prepare the Cisco Devices for SSH

Before Ansible can connect, each Cisco device needs SSH enabled with a local user. Run these commands manually once — Ansible will handle everything after this. For a full SSH setup guide see SSH Configuration and Console & VTY Line Configuration:

! ── On every Cisco device — one-time SSH preparation ─────────────
NetsTuts_SW1(config)#hostname NetsTuts-SW1
NetsTuts-SW1(config)#ip domain-name netstuts.local
NetsTuts-SW1(config)#crypto key generate rsa modulus 2048
! ── Generates RSA keys — required for SSH v2 ─────────────────────

NetsTuts-SW1(config)#ip ssh version 2
NetsTuts-SW1(config)#ip ssh time-out 60
NetsTuts-SW1(config)#ip ssh authentication-retries 3

! ── Create the Ansible management user ───────────────────────────
NetsTuts-SW1(config)#username ansible privilege 15 secret AnsibleP@ss2026
!                                   ^^^^^^^^^^^^ priv 15 = no enable needed

! ── Configure VTY lines to accept SSH ────────────────────────────
NetsTuts-SW1(config)#line vty 0 15
NetsTuts-SW1(config-line)#transport input ssh
NetsTuts-SW1(config-line)#login local
NetsTuts-SW1(config-line)#exec-timeout 5 0
NetsTuts-SW1(config-line)#exit

! ── Verify SSH is working ────────────────────────────────────────
NetsTuts-SW1#show ip ssh
SSH Enabled - version 2.0
Authentication timeout: 60 secs; Authentication retries: 3
  
The privilege 15 on the local user gives Ansible immediate access to global configuration mode without needing a separate enable password. This is the recommended approach for automation accounts — Ansible's IOS modules use network_cli connection type which needs to enter config mode, and a priv-15 user eliminates the need for ansible_become and an enable secret in the vars. If your security policy requires privilege 1 users, configure ansible_become: yes and ansible_become_password in your group_vars instead. For hostname and banner configuration see Hostname, Banner & Password Configuration. For login security hardening see Login Security & Brute-Force Protection.

Step 2 — Install Ansible on the Control Node

# Update package index
$ sudo apt update && sudo apt install -y python3-pip python3-venv

# Create a dedicated virtual environment (recommended — keeps Ansible isolated)
$ python3 -m venv ~/ansible-venv
$ source ~/ansible-venv/bin/activate
(ansible-venv) $

# Install Ansible (includes ansible-core and CLI tools)
(ansible-venv) $ pip install ansible

# Verify installation
(ansible-venv) $ ansible --version
ansible [core 2.17.x]
  python version = 3.11.x
  jinja version = 3.1.x

# Install the Cisco IOS collection from Ansible Galaxy
(ansible-venv) $ ansible-galaxy collection install cisco.ios

# Install the required Python library for network_cli connections
(ansible-venv) $ pip install paramiko

# Verify the collection is installed
(ansible-venv) $ ansible-galaxy collection list | grep cisco
cisco.ios        5.x.x
cisco.nxos       8.x.x   ← also installed as a dependency
  
Using a Python virtual environment keeps Ansible and its dependencies isolated from the system Python — recommended whenever you manage multiple automation tools on the same workstation. The cisco.ios collection is the official Cisco-maintained collection hosted on Ansible Galaxy — it is updated frequently and supports IOS, IOS-XE, and IOS-XR devices through separate module namespaces. paramiko is the Python SSH library that the network_cli connection plugin uses to establish SSH sessions to the Cisco devices.

3. Ansible Project Structure

A well-structured Ansible project uses a standard directory layout that separates inventory, variables, and playbooks. This layout scales from a two-device lab to a thousand-device production network without restructuring:

  netstuts-ansible/              ← Project root
  ├── ansible.cfg                ← Project-wide Ansible settings
  ├── inventory/
  │   └── hosts.yml              ← Device inventory (groups + IPs)
  ├── group_vars/
  │   ├── all.yml                ← Variables for ALL devices
  │   ├── switches.yml           ← Variables for the 'switches' group
  │   └── routers.yml            ← Variables for the 'routers' group
  ├── host_vars/
  │   ├── NetsTuts-SW1.yml       ← Variables unique to SW1
  │   ├── NetsTuts-SW2.yml       ← Variables unique to SW2
  │   ├── NetsTuts-R1.yml        ← Variables unique to R1
  │   └── NetsTuts-R2.yml        ← Variables unique to R2
  └── playbooks/
      ├── site.yml               ← Master playbook (calls others)
      ├── configure_hostnames.yml
      ├── configure_vlans.yml
      ├── configure_interfaces.yml
      └── gather_facts.yml
  
Variable precedence in Ansible (lowest to highest): group_vars/all → group_vars/[groupname] → host_vars/[hostname] → playbook vars → task vars. This means a setting in host_vars/NetsTuts-SW1.yml overrides the same setting in group_vars/switches.yml — device-specific variables always win. This precedence makes it easy to set defaults for the whole network in group_vars and override them for individual devices in host_vars.

Step 1 — Create the Project and ansible.cfg

# Create the project directory structure
(ansible-venv) $ mkdir -p netstuts-ansible/{inventory,group_vars,host_vars,playbooks}
(ansible-venv) $ cd netstuts-ansible
  

Create ansible.cfg in the project root:

# ansible.cfg
[defaults]
inventory          = inventory/hosts.yml
collections_path   = ~/.ansible/collections
host_key_checking  = False          # Disable SSH host key check for lab
timeout            = 30
gathering          = explicit       # Don't auto-gather facts (slow on network devices)
stdout_callback    = yaml           # Cleaner output format

[persistent_connection]
connect_timeout    = 30
command_timeout    = 30
  
host_key_checking = False is acceptable in a lab environment where you control all devices. In production, set this to True and populate the control node's ~/.ssh/known_hosts with the device SSH keys. gathering = explicit prevents Ansible from automatically running the facts module on every play — on network devices, auto-gathering adds 5–10 seconds per device. We gather facts manually only when needed.

Step 2 — Create the Inventory File

Create inventory/hosts.yml:

---
# inventory/hosts.yml
# Defines all managed devices, their groups, and connection parameters

all:
  children:

    switches:                        # Group: all Cisco switches
      hosts:
        NetsTuts-SW1:
          ansible_host: 192.168.1.10
        NetsTuts-SW2:
          ansible_host: 192.168.1.11

    routers:                         # Group: all Cisco routers
      hosts:
        NetsTuts-R1:
          ansible_host: 192.168.1.1
        NetsTuts-R2:
          ansible_host: 192.168.1.2

    core:                            # Sub-group: core devices only
      hosts:
        NetsTuts-SW1:
        NetsTuts-R1:
  
The inventory hostname (e.g., NetsTuts-SW1) is the name Ansible uses internally — it does not need to match the device's actual IOS hostname. The ansible_host variable provides the actual IP address Ansible SSHes to. Groups can contain other groups (nested groups) — the core group here references two hosts that also belong to switches and routers respectively. Playbooks can target any group, any individual host, or combinations like switches:!NetsTuts-SW2 (all switches except SW2).

Step 3 — Create Group Variables

Create group_vars/all.yml (applies to every device):

---
# group_vars/all.yml
# Connection settings that apply to ALL managed devices

ansible_user:               ansible
ansible_password:           "AnsibleP@ss2026"
ansible_network_os:         cisco.ios.ios
ansible_connection:         ansible.netcommon.network_cli
ansible_become:             false      # priv 15 user — no enable needed

# NTP servers applied to all devices
ntp_servers:
  - 192.168.1.100
  - pool.ntp.org

# Standard MOTD banner text for all devices
motd_banner: |
  ******************************************
  *  NetsTuts Campus Network               *
  *  Unauthorised access is prohibited.    *
  *  All sessions are monitored.           *
  ******************************************
  

Create group_vars/switches.yml (applies to all switches). For VLAN concepts see VLANs and VLAN Creation & Management. For spanning tree background see STP Overview and RSTP Configuration:

---
# group_vars/switches.yml
# Variables specific to all switches in the 'switches' group

# VLANs that should exist on ALL switches
common_vlans:
  - vlan_id: 10
    name: Management
  - vlan_id: 20
    name: Voice
  - vlan_id: 30
    name: Engineering
  - vlan_id: 40
    name: IoT-Lab
  - vlan_id: 99
    name: Native-Trunk

# Default spanning-tree mode for all switches
spanning_tree_mode: rapid-pvst
  

Create group_vars/routers.yml (applies to all routers). For OSPF background see OSPF Overview and OSPF Single-Area Configuration:

---
# group_vars/routers.yml
# Variables specific to all routers in the 'routers' group

# OSPF settings for all routers
ospf_process_id:  1
ospf_area:        0

# IP routing enabled on all routers (should always be on)
ip_routing: true
  

Step 4 — Create Host Variables

Create host_vars/NetsTuts-SW1.yml:

---
# host_vars/NetsTuts-SW1.yml
# Variables unique to NetsTuts-SW1

device_hostname: NetsTuts-SW1

# Management interface (SVI for VLAN 10)
mgmt_vlan:       10
mgmt_ip:         192.168.1.10
mgmt_prefix:     24
mgmt_gateway:    192.168.1.1

# SW1-specific VLANs (in addition to common_vlans from group_vars)
extra_vlans:
  - vlan_id: 100
    name: Servers
  - vlan_id: 110
    name: DMZ

# Interface configurations for SW1
interfaces:
  - name: GigabitEthernet0/1
    description: "Uplink to NetsTuts-R1 Gi0/1"
    mode: trunk
    native_vlan: 99
    allowed_vlans: "10,20,30,40,99,100,110"

  - name: FastEthernet0/1
    description: "PC_MGMT — VLAN 10"
    mode: access
    access_vlan: 10
    portfast: true

  - name: FastEthernet0/2
    description: "IP Phone — VLAN 20"
    mode: access
    access_vlan: 30
    voice_vlan: 20
    portfast: true

  - name: FastEthernet0/3
    description: "Server — VLAN 100"
    mode: access
    access_vlan: 100
    portfast: false
  

Create host_vars/NetsTuts-SW2.yml:

---
# host_vars/NetsTuts-SW2.yml

device_hostname: NetsTuts-SW2
mgmt_vlan:       10
mgmt_ip:         192.168.1.11
mgmt_prefix:     24
mgmt_gateway:    192.168.1.1

extra_vlans: []   # No extra VLANs on SW2 beyond common_vlans

interfaces:
  - name: GigabitEthernet0/1
    description: "Uplink to NetsTuts-SW3"
    mode: trunk
    native_vlan: 99
    allowed_vlans: "10,20,30,40,99"

  - name: FastEthernet0/1
    description: "ENG1 — VLAN 30"
    mode: access
    access_vlan: 30
    portfast: true

  - name: FastEthernet0/2
    description: "ENG2 — VLAN 30"
    mode: access
    access_vlan: 30
    portfast: true

  - name: FastEthernet0/3
    description: "ENG3 — VLAN 40"
    mode: access
    access_vlan: 40
    portfast: true
  

4. Playbook 1 — Configure Hostnames and Banners

Create playbooks/configure_hostnames.yml. This playbook uses ios_config to set device hostnames and ios_banner to push the standard MOTD banner across all devices simultaneously:

---
# playbooks/configure_hostnames.yml
# Sets device hostname and MOTD banner on all managed devices

- name: Configure Hostnames and Banners
  hosts: all                         # Target every device in inventory
  gather_facts: false                # Explicit — don't auto-gather facts

  tasks:

    - name: Set device hostname
      cisco.ios.ios_config:
        lines:
          - "hostname {{ device_hostname }}"
      register: hostname_result      # Capture the task result

    - name: Show hostname change status
      debug:
        msg: "Hostname set to {{ device_hostname }} — changed: {{ hostname_result.changed }}"

    - name: Configure MOTD banner
      cisco.ios.ios_banner:
        banner: motd
        text: "{{ motd_banner }}"
        state: present

    - name: Configure NTP servers
      cisco.ios.ios_config:
        lines:
          - "ntp server {{ item }}"
      loop: "{{ ntp_servers }}"      # Loop over each NTP server in the list

    - name: Disable HTTP server (security hardening)
      cisco.ios.ios_config:
        lines:
          - no ip http server
          - no ip http secure-server

    - name: Save configuration to NVRAM
      cisco.ios.ios_config:
        save_when: modified          # Only write memory if something changed
  
The register keyword captures the task result into a variable (hostname_result). Every Ansible task returns a result object with a changed boolean — true if Ansible made a change, false if the device was already in the desired state. The loop keyword iterates the task once per item in the list — in this case, once per NTP server. For NTP configuration details see NTP Configuration. The save_when: modified option on the final ios_config task is critically important — it runs write memory only if any earlier task made a change, preventing unnecessary flash writes. See Saving & Managing Cisco Configurations for more.

Run the Hostname Playbook

# Dry run first — --check mode shows what would change without making changes
(ansible-venv) $ ansible-playbook playbooks/configure_hostnames.yml --check

PLAY [Configure Hostnames and Banners] ************************************

TASK [Set device hostname] ************************************************
changed: [NetsTuts-SW1]
changed: [NetsTuts-SW2]
changed: [NetsTuts-R1]
changed: [NetsTuts-R2]

TASK [Configure MOTD banner] **********************************************
changed: [NetsTuts-SW1]
changed: [NetsTuts-SW2]
...

PLAY RECAP ****************************************************************
NetsTuts-SW1   : ok=3  changed=2  unreachable=0  failed=0
NetsTuts-SW2   : ok=3  changed=2  unreachable=0  failed=0
NetsTuts-R1    : ok=3  changed=2  unreachable=0  failed=0
NetsTuts-R2    : ok=3  changed=2  unreachable=0  failed=0

# Run for real — remove --check to apply the changes
(ansible-venv) $ ansible-playbook playbooks/configure_hostnames.yml

# Run again — should show ok=N changed=0 (idempotent)
(ansible-venv) $ ansible-playbook playbooks/configure_hostnames.yml

PLAY RECAP ****************************************************************
NetsTuts-SW1   : ok=3  changed=0  unreachable=0  failed=0   ← no changes needed
NetsTuts-SW2   : ok=3  changed=0  unreachable=0  failed=0
  
Always run with --check first in production — this is Ansible's dry-run mode where it connects to the devices and evaluates what would change without actually making any changes. The PLAY RECAP at the end summarises results per device: ok = tasks evaluated, changed = tasks that made a change, unreachable = SSH failures, failed = task errors. A second run showing changed=0 confirms the playbook is idempotent — the desired state was applied and maintained.

5. Playbook 2 — Configure VLANs Across All Switches

Create playbooks/configure_vlans.yml. This playbook uses the declarative ios_vlans module to ensure all required VLANs exist on every switch. The common_vlans from group_vars are merged with each switch's extra_vlans from host_vars. For VLAN fundamentals see VLANs, VLAN Tagging (802.1Q), and VLAN Creation & Management. For trunk configuration see Trunk Port Configuration and show vlan:

---
# playbooks/configure_vlans.yml
# Creates and names VLANs on all switches — idempotent

- name: Configure VLANs on Switches
  hosts: switches
  gather_facts: false

  tasks:

    - name: Merge common and device-specific VLAN lists
      set_fact:
        all_vlans: "{{ common_vlans + (extra_vlans | default([])) }}"
      # Combines group_vars VLANs with any host_vars extra_vlans
      # Result: [vlan10, vlan20, vlan30, vlan40, vlan99, ...extras]

    - name: Display VLANs to be configured on this device
      debug:
        msg: "{{ inventory_hostname }}: configuring {{ all_vlans | map(attribute='vlan_id') | list }}"

    - name: Configure VLANs using ios_vlans module
      cisco.ios.ios_vlans:
        config:
          - vlan_id: "{{ item.vlan_id }}"
            name: "{{ item.name }}"
            state: active
        state: merged         # merged = add/update only; never remove existing VLANs
      loop: "{{ all_vlans }}"
      loop_control:
        label: "VLAN {{ item.vlan_id }} — {{ item.name }}"  # Clean loop output

    - name: Verify VLANs exist on device
      cisco.ios.ios_command:
        commands:
          - show vlan brief
      register: vlan_output

    - name: Display VLAN verification output
      debug:
        var: vlan_output.stdout_lines

    - name: Save configuration
      cisco.ios.ios_config:
        save_when: modified
  
The ios_vlans module with state: merged is additive — it creates VLANs that don't exist and updates names of VLANs that do, but never deletes VLANs that exist on the device but aren't in the playbook. Using state: replaced instead would remove any VLAN not in the list — powerful but dangerous in production. The loop_control.label replaces the full loop item dump in the output with a human-readable label, making the playbook output much cleaner when iterating long lists.

Understanding ios_vlans State Options

State Behaviour When to Use Risk in Production
merged Adds/updates specified VLANs. Never removes VLANs not in the list Adding new VLANs to existing switches — safe default Low — additive only
replaced Replaces the entire VLAN configuration with exactly what is in the playbook. VLANs not in the list are removed Greenfield deployments, compliance enforcement where unmanaged VLANs are not permitted High — can remove VLANs in use
overridden Like replaced but applies globally across all VLANs — more aggressive Full VLAN standardisation across all switches simultaneously Very high — only use in complete greenfield or with full change control
deleted Removes specified VLANs from the device Decommissioning specific VLANs Medium — only removes what you specify
gathered Read-only — retrieves current VLAN configuration as structured data Auditing, pre-change snapshots, building config from discovered state None — read-only

Run the VLAN Playbook

# Target only switches (routers don't have VLANs in this playbook)
(ansible-venv) $ ansible-playbook playbooks/configure_vlans.yml

PLAY [Configure VLANs on Switches] ****************************************

TASK [Merge common and device-specific VLAN lists] ************************
ok: [NetsTuts-SW1]
ok: [NetsTuts-SW2]

TASK [Configure VLANs using ios_vlans module] *****************************
changed: [NetsTuts-SW1] => (item=VLAN 10 — Management)
changed: [NetsTuts-SW1] => (item=VLAN 20 — Voice)
changed: [NetsTuts-SW1] => (item=VLAN 30 — Engineering)
changed: [NetsTuts-SW1] => (item=VLAN 40 — IoT-Lab)
changed: [NetsTuts-SW1] => (item=VLAN 99 — Native-Trunk)
changed: [NetsTuts-SW1] => (item=VLAN 100 — Servers)
changed: [NetsTuts-SW1] => (item=VLAN 110 — DMZ)
changed: [NetsTuts-SW2] => (item=VLAN 10 — Management)
...

TASK [Verify VLANs exist on device] ***************************************
ok: [NetsTuts-SW1]
ok: [NetsTuts-SW2]

TASK [Display VLAN verification output] ***********************************
ok: [NetsTuts-SW1] =>
  vlan_output.stdout_lines:
  - - 'VLAN Name             Status    Ports'
    - '---- ----------------- --------- ------'
    - '1    default           active    ...'
    - '10   Management        active'
    - '20   Voice             active'
    - '30   Engineering       active    Fa0/1, Fa0/2'
    - '40   IoT-Lab           active    Fa0/3'
    - '99   Native-Trunk      active'
    - '100  Servers           active    Fa0/3'
    - '110  DMZ               active'

PLAY RECAP ****************************************************************
NetsTuts-SW1   : ok=5  changed=2  unreachable=0  failed=0
NetsTuts-SW2   : ok=5  changed=2  unreachable=0  failed=0
  

6. Playbook 3 — Configure Interfaces

Create playbooks/configure_interfaces.yml. This is the most complex playbook — it uses multiple modules together to configure interface descriptions, access/trunk VLAN assignments, and PortFast on each interface defined in host_vars. For interface configuration background see Basic Interface Configuration, Trunk Port Configuration, and Assigning VLANs to Switch Ports. For PortFast and BPDU Guard see PortFast & BPDU Guard. For voice VLAN see Voice VLAN Configuration:

---
# playbooks/configure_interfaces.yml
# Configures interface descriptions, VLAN assignments, and PortFast
# Uses host_vars/[hostname].yml 'interfaces' list for device-specific config

- name: Configure Switch Interfaces
  hosts: switches
  gather_facts: false

  tasks:

    - name: Configure interface descriptions
      cisco.ios.ios_interfaces:
        config:
          - name: "{{ item.name }}"
            description: "{{ item.description }}"
            enabled: true            # Ensure interface is not shutdown
        state: merged
      loop: "{{ interfaces }}"
      loop_control:
        label: "{{ item.name }}: {{ item.description }}"

    - name: Configure access port VLAN assignments
      cisco.ios.ios_l2_interfaces:
        config:
          - name: "{{ item.name }}"
            mode: access
            access:
              vlan: "{{ item.access_vlan }}"
        state: merged
      loop: "{{ interfaces | selectattr('mode', 'equalto', 'access') | list }}"
      loop_control:
        label: "{{ item.name }} → access VLAN {{ item.access_vlan }}"
      # selectattr filter: only loop over interfaces where mode == 'access'

    - name: Configure voice VLAN on IP phone ports
      cisco.ios.ios_config:
        parents: "interface {{ item.name }}"
        lines:
          - "switchport voice vlan {{ item.voice_vlan }}"
      loop: "{{ interfaces | selectattr('voice_vlan', 'defined') | list }}"
      loop_control:
        label: "{{ item.name }}: voice VLAN {{ item.voice_vlan }}"
      # ios_l2_interfaces doesn't support voice VLAN — use ios_config instead

    - name: Configure trunk port VLAN settings
      cisco.ios.ios_l2_interfaces:
        config:
          - name: "{{ item.name }}"
            mode: trunk
            trunk:
              native_vlan: "{{ item.native_vlan }}"
              allowed_vlans: "{{ item.allowed_vlans }}"
        state: merged
      loop: "{{ interfaces | selectattr('mode', 'equalto', 'trunk') | list }}"
      loop_control:
        label: "{{ item.name }}: trunk (native {{ item.native_vlan }})"

    - name: Enable PortFast on access ports
      cisco.ios.ios_config:
        parents: "interface {{ item.name }}"
        lines:
          - spanning-tree portfast
          - spanning-tree bpduguard enable
      loop: "{{ interfaces | selectattr('portfast', 'equalto', true) | list }}"
      loop_control:
        label: "{{ item.name }}: PortFast + BPDU Guard enabled"

    - name: Save configuration
      cisco.ios.ios_config:
        save_when: modified
  
The selectattr Jinja2 filter is essential for targeted looping — selectattr('mode', 'equalto', 'access') returns only the interfaces where the mode attribute equals 'access'. This means the access VLAN task only runs for access ports, the trunk task only runs for trunk ports, and voice VLAN is only configured on ports that have voice_vlan defined in their host_vars. The parents parameter in ios_config sets the configuration context — parents: "interface GigabitEthernet0/1" is equivalent to navigating into that interface's config mode before running the lines commands. For Jinja2 templating for configuration generation see Jinja2 Config Generation.

Router Interface Configuration

Create playbooks/configure_router_interfaces.yml to configure IP addresses on router interfaces using the declarative ios_l3_interfaces module. For OSPF configuration see OSPF Overview, OSPF Single-Area Configuration, and OSPF Multi-Area Configuration:

---
# playbooks/configure_router_interfaces.yml

- name: Configure Router Interfaces
  hosts: routers
  gather_facts: false

  tasks:

    - name: Configure WAN interface IP addresses
      cisco.ios.ios_l3_interfaces:
        config:
          - name: GigabitEthernet0/0
            ipv4:
              - address: "{{ wan_ip }}/{{ wan_prefix }}"
        state: merged

    - name: Configure Loopback0 (OSPF Router ID)
      cisco.ios.ios_l3_interfaces:
        config:
          - name: Loopback0
            ipv4:
              - address: "{{ loopback_ip }}/32"
        state: merged

    - name: Enable all configured interfaces
      cisco.ios.ios_interfaces:
        config:
          - name: GigabitEthernet0/0
            enabled: true
          - name: Loopback0
            enabled: true
        state: merged

    - name: Configure OSPF process
      cisco.ios.ios_config:
        lines:
          - "router ospf {{ ospf_process_id }}"
          - " network {{ loopback_ip }} 0.0.0.0 area {{ ospf_area }}"
          - " network {{ wan_network }} {{ wan_wildcard }} area {{ ospf_area }}"
          - " passive-interface Loopback0"

    - name: Save configuration
      cisco.ios.ios_config:
        save_when: modified
  

7. The Master Playbook — Orchestrate Everything

Create playbooks/site.yml — the master playbook that calls all other playbooks in the correct order. Running a single command now configures the entire network:

---
# playbooks/site.yml
# Master playbook — runs all configuration playbooks in order
# Usage: ansible-playbook playbooks/site.yml

- name: "Phase 1 — Base Configuration (all devices)"
  import_playbook: configure_hostnames.yml

- name: "Phase 2 — VLAN Configuration (switches only)"
  import_playbook: configure_vlans.yml

- name: "Phase 3 — Interface Configuration (switches)"
  import_playbook: configure_interfaces.yml

- name: "Phase 4 — Router Interface and OSPF Configuration"
  import_playbook: configure_router_interfaces.yml
  

Run the Full Site Deployment

# Full dry run of the entire network configuration
(ansible-venv) $ ansible-playbook playbooks/site.yml --check

# Apply to the entire network — all devices simultaneously
(ansible-venv) $ ansible-playbook playbooks/site.yml

# Target only switches (skip router plays)
(ansible-venv) $ ansible-playbook playbooks/site.yml --limit switches

# Target a single device
(ansible-venv) $ ansible-playbook playbooks/site.yml --limit NetsTuts-SW1

# Show verbose output (useful for debugging connection issues)
(ansible-venv) $ ansible-playbook playbooks/site.yml -vvv

# Run only tasks tagged with 'vlans' (see next section for tags)
(ansible-venv) $ ansible-playbook playbooks/site.yml --tags vlans

# List all tasks without running them
(ansible-venv) $ ansible-playbook playbooks/site.yml --list-tasks
  
The --limit flag is one of the most useful Ansible options in production — it restricts which inventory hosts a playbook runs against, without changing the playbook itself. This lets you test a change on one device first, verify it, then run again without --limit to apply to all. The -vvv flag enables three levels of verbosity — it shows the exact SSH commands being sent and responses received, essential for diagnosing connection failures or unexpected command output.

8. Gather Facts and Verify — Post-Change Validation

Create playbooks/gather_facts.yml to run show commands across all devices and save the output as structured reports. This is the automated post-change verification step. For the equivalent manual show commands see show ip interface brief, show vlan, show running-config, and show ip route:

---
# playbooks/gather_facts.yml
# Runs verification show commands and saves output as reports

- name: Gather and Verify Network State
  hosts: all
  gather_facts: false

  tasks:

    - name: Gather IOS facts (hostname, version, interfaces)
      cisco.ios.ios_facts:
        gather_subset:
          - hardware
          - interfaces
          - config
      register: device_facts

    - name: Display device summary
      debug:
        msg:
          - "Hostname:  {{ ansible_net_hostname }}"
          - "IOS Ver:   {{ ansible_net_version }}"
          - "Serial:    {{ ansible_net_serialnum }}"
          - "Uptime:    {{ ansible_net_stacked_models | default('N/A') }}"

    - name: Run verification show commands
      cisco.ios.ios_command:
        commands:
          - show ip interface brief
          - show version | include uptime
          - show running-config | include hostname
      register: verify_output

    - name: Run switch-specific verification
      cisco.ios.ios_command:
        commands:
          - show vlan brief
          - show interfaces trunk
          - show spanning-tree summary
      register: switch_verify
      when: inventory_hostname in groups['switches']  # Only on switches

    - name: Run router-specific verification
      cisco.ios.ios_command:
        commands:
          - show ip route
          - show ip ospf neighbor
          - show ip interface brief
      register: router_verify
      when: inventory_hostname in groups['routers']   # Only on routers

    - name: Save verification output to file
      copy:
        content: |
          ====== {{ inventory_hostname }} Verification Report ======
          Generated: {{ ansible_date_time.iso8601 | default('N/A') }}

          --- show ip interface brief ---
          {{ verify_output.stdout[0] }}

          {% if inventory_hostname in groups['switches'] %}
          --- show vlan brief ---
          {{ switch_verify.stdout[0] }}

          --- show interfaces trunk ---
          {{ switch_verify.stdout[1] }}
          {% endif %}

          {% if inventory_hostname in groups['routers'] %}
          --- show ip route ---
          {{ router_verify.stdout[0] }}

          --- show ip ospf neighbor ---
          {{ router_verify.stdout[1] }}
          {% endif %}
        dest: "/tmp/ansible-verify-{{ inventory_hostname }}.txt"
      delegate_to: localhost                           # Write file on control node

    - name: Check for any interfaces that are down
      cisco.ios.ios_command:
        commands:
          - show ip interface brief
      register: intf_check

    - name: Alert on any down interfaces
      debug:
        msg: "WARNING — down interface detected on {{ inventory_hostname }}"
      when: "'down' in intf_check.stdout[0] | lower"
  
The when conditional restricts a task to specific hosts — when: inventory_hostname in groups['switches'] runs the task only on hosts in the switches group. The delegate_to: localhost directive causes a task to execute on the control node rather than the managed device — used here to write the verification report to the local filesystem. Jinja2 {% if %} blocks inside the file content generate device-appropriate report sections.

Troubleshooting Common Ansible–IOS Connection Errors

Error Message Cause Fix
UNREACHABLE! — Connection timed out SSH not reachable — firewall, wrong IP, or SSH disabled on device Verify ansible_host IP, confirm SSH is enabled (ip ssh version 2), check VTY line config
Invalid credentials Wrong username or password in group_vars/all.yml Verify ansible_user and ansible_password match the IOS local user. Test with manual SSH first
unable to open shell ansible_network_os or ansible_connection is missing or wrong Ensure ansible_network_os: cisco.ios.ios and ansible_connection: ansible.netcommon.network_cli are set in group_vars/all.yml
prompt detection failed IOS prompt not matching expected pattern — often caused by banner text containing # characters Remove # from MOTD banner text, or add terminal_prompt_checksum workaround
no module named 'paramiko' paramiko not installed in the Ansible virtual environment pip install paramiko in the active venv
command timeout triggered A command took longer than command_timeout to return Increase command_timeout in ansible.cfg — write memory can be slow on large configs
Task shows changed every run (not idempotent) Using ios_config with lines that IOS reformats — Ansible can't match them Use the dedicated declarative module (ios_vlans, ios_l2_interfaces) which handles idempotency correctly, or use check_running_config option
For end-to-end troubleshooting methodology on the network side see End-to-End Troubleshooting Scenario and Troubleshooting Methodology. For OSPF-specific issues that may surface during router automation see Troubleshooting OSPF Neighbour Adjacency. For SSH connectivity issues see SSH Configuration.

9. Production Best Practices and Ansible Vault

Protecting Secrets with Ansible Vault

Never store passwords in plain text in group_vars files that are committed to version control. Ansible Vault encrypts sensitive variables so they can be safely stored in Git:

# Create an encrypted vars file for secrets
(ansible-venv) $ ansible-vault create group_vars/all/vault.yml
New Vault password: (enter a strong vault password)
Confirm New Vault password:
# This opens a text editor — enter your secrets:

vault_ansible_password: "AnsibleP@ss2026"
vault_enable_secret:    "EnableS3cret!"

# In group_vars/all.yml, reference vault variables instead of plain text:
ansible_password:       "{{ vault_ansible_password }}"

# Run playbooks with vault password
(ansible-venv) $ ansible-playbook playbooks/site.yml --ask-vault-pass

# Or store vault password in a file (permissions 600, gitignored)
(ansible-venv) $ echo "MyVaultPassword" > ~/.vault_pass
(ansible-venv) $ chmod 600 ~/.vault_pass
# In ansible.cfg: vault_password_file = ~/.vault_pass

# Encrypt just a single string value (useful for inline secrets)
(ansible-venv) $ ansible-vault encrypt_string "AnsibleP@ss2026" --name ansible_password
ansible_password: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          62333532353961313033396133626636...
  

Using Tags for Selective Execution

---
# Adding tags to tasks enables selective execution

    - name: Configure VLANs using ios_vlans module
      cisco.ios.ios_vlans:
        config: ...
        state: merged
      loop: "{{ all_vlans }}"
      tags:
        - vlans
        - layer2

    - name: Configure trunk port VLAN settings
      cisco.ios.ios_l2_interfaces:
        ...
      tags:
        - trunks
        - layer2

# Run only VLAN-related tasks across all devices
$ ansible-playbook playbooks/site.yml --tags vlans

# Run everything EXCEPT the save task (useful in check mode)
$ ansible-playbook playbooks/site.yml --skip-tags save
  

Production Playbook Best Practices

Practice Implementation Why It Matters
Always --check first Run ansible-playbook playbook.yml --check before any production change Shows exactly what will change without making changes — prevents surprises
Use --diff with check ansible-playbook playbook.yml --check --diff Shows exact config lines that would be added or removed — the network equivalent of a git diff
Use Ansible Vault for all passwords Encrypt all credentials — never commit plain-text passwords to Git Security compliance — credentials in version control is a critical vulnerability
Use save_when: modified Add to every play's final ios_config task Saves only when changes were made — prevents unnecessary NVRAM writes and flash wear
Use --limit for staged rollouts Test on one device → verify → run on all Reduces blast radius of a misconfiguration — a problem on SW1 doesn't affect SW2–SW10
Tag all tasks Add meaningful tags (vlans, layer2, ospf) to every task Enables surgical re-runs of specific configuration sections without running the whole playbook
Store playbooks in Git Use git commit with meaningful messages for every playbook change Full audit trail of configuration changes — who changed what, when, and why
Gather pre/post snapshots Run gather_facts.yml before and after every change window Enables diff comparison to verify the change had exactly the intended effect
Next Steps: With playbooks automating IOS configuration, explore related automation tools and patterns. For Python-based network automation as an alternative or complement to Ansible see Python Netmiko, Python NAPALM Multi-Vendor, and NETCONF with ncclient & Python. For RESTCONF-based configuration management see Cisco RESTCONF Basics. For the Jinja2 templating that powers variable-driven configs see Jinja2 Config Generation. For structured data formats (JSON, XML, YANG) used by automation APIs see JSON, XML & YANG. For direct IOS troubleshooting without automation, see End-to-End Troubleshooting and OSPF Neighbour Troubleshooting.

TEST WHAT YOU LEARNED

1. An Ansible playbook targeting four Cisco switches runs successfully. On the second run, all tasks show ok with changed=0 for every device. What does this indicate, and why is it desirable?

Correct answer is C. Idempotency is a fundamental concept in infrastructure-as-code automation. An idempotent operation produces the same result whether it is run once or a hundred times — and critically, it makes no changes when the system is already in the desired state. In Ansible, ok means the task was evaluated (Ansible connected, checked the device state, compared it to the desired state), and changed=0 means every evaluation concluded that the device was already correct. This is the ideal state for scheduled compliance playbooks: the playbook runs every night, detects no drift, makes no changes, and reports changed=0. If someone manually changes a VLAN name on a switch during the day, the next scheduled run detects the drift and reports changed=1 for that device, automatically correcting it. Declarative modules like ios_vlans and ios_l2_interfaces are inherently idempotent. The ios_config module achieves idempotency by checking the running-config before pushing lines — it only sends commands if the target lines are absent from the current config.

2. What is the difference between ios_vlans with state: merged versus state: replaced? Which should be used when adding new VLANs to production switches that already have existing VLANs?

Correct answer is A. The distinction between merged and replaced is critically important for safe production automation. state: merged is an additive operation — it only modifies the specific VLANs you define, leaving all other VLANs exactly as they are. If a production switch has 50 VLANs and you run a merged playbook with 5 new VLANs, you end up with 55 VLANs — the original 50 unchanged plus 5 new ones. state: replaced makes the device configuration match your playbook exactly — if your playbook defines 5 VLANs and the switch has 50, replaced will delete the other 45. Any hosts on those deleted VLANs immediately lose connectivity. The appropriate use of replaced is in greenfield deployments (brand-new switches being configured for the first time with no traffic) or in strict compliance environments where unmanaged VLANs are explicitly prohibited by policy — and even then, always with a preceding --check run to verify what will be deleted. state: overridden is even more aggressive than replaced, applying the operation globally across the entire VLAN database rather than per-specified-VLAN.

3. A playbook task runs successfully but is not idempotent — every run shows changed=1 even when no actual configuration change is needed. The task uses ios_config with a specific IOS command. What is the most common cause and how is it resolved?

Correct answer is D. This is one of the most common Ansible-IOS frustrations for engineers new to network automation. The ios_config module achieves idempotency by searching for the exact lines strings in the running-config output. If IOS normalises, reformats, or stores the command differently from how you typed it, the string comparison fails and Ansible incorrectly concludes the configuration needs to be applied again. Common examples: IOS may store ip access-group ACL_NAME in but you typed it without consistent spacing; banner text with special characters may be stored with escape sequences; encrypted password hashes differ from the original input. Solutions: (1) Use dedicated declarative modules (ios_vlans, ios_acls, ios_banner) which handle the canonical format internally — these are idempotent by design; (2) Run the playbook once, then use ios_command to retrieve the relevant section of the running-config and adjust your lines to match exactly what IOS stores; (3) Use ios_config with diff_against: running to compare your desired config against the current config and identify format mismatches.

4. What is the purpose of delegate_to: localhost in a task within a playbook that targets Cisco switches, and what would happen if it were omitted?

Correct answer is B. In Ansible, every task by default runs against the current managed host — when the playbook targets Cisco switches, every task attempts to execute on those switches. For tasks that need to run on the control node instead (writing files, querying a local database, calling a REST API, running a local script), delegate_to: localhost redirects that specific task's execution to the Ansible control node while still using the current managed host's variables (like inventory_hostname, host_vars, etc.). In the gather_facts playbook example, the copy module with delegate_to: localhost creates a file on the control node's filesystem using data gathered from the Cisco device — the best of both: device-specific data (hostname, show output) used in a control-node action (file creation). Without the delegation, Ansible would try to write a file on the Cisco switch using IOS's file system commands, which would fail with a module execution error because Cisco IOS cannot run the Python copy module. Common uses of delegate_to: localhost include saving reports, calling configuration management databases (CMDB) APIs, triggering CI/CD pipelines, and running local validation scripts.

5. An Ansible playbook fails with the error unable to open shell when trying to connect to a Cisco switch. The SSH connection itself succeeds (verified with manual SSH). What is the most likely cause?

Correct answer is C. The "unable to open shell" error is one of the most common Ansible network automation errors, and it almost always means the connection plugin is not correctly configured. Ansible has fundamentally different connection types for servers (SSH to bash) versus network devices (SSH to a CLI with prompt-based interaction). When ansible_network_os and ansible_connection are not set, Ansible uses its default connection (paramiko or OpenSSH to a Linux shell) and attempts to detect a bash prompt — which never arrives because the Cisco device is presenting an IOS CLI prompt instead. The fix is ensuring these two variables are set in group_vars/all.yml or the specific group's vars file. Note that even with the cisco.ios collection installed, these connection variables must still be explicitly set — the collection installation alone does not configure the connection type. A useful debugging technique is to add -vvv to the ansible-playbook command — at maximum verbosity, you can see the exact SSH negotiation and the specific prompt Ansible is looking for versus what the device is sending.

6. Why should the Ansible user account on Cisco IOS be configured with privilege 15, and what is the alternative if your organisation's security policy prohibits privilege 15 for service accounts?

Correct answer is D. IOS privilege levels work on a 0–15 scale where 15 is the highest (equivalent to enable/privileged exec). When Ansible's ios_config or other configuration modules need to make changes, they must enter global configuration mode with the configure terminal command — this is only possible from privilege 15 (the enable prompt). A user logging in at privilege 1 (the default) cannot enter config mode without first running enable and providing the enable secret. Configuring the Ansible service account at privilege 15 eliminates this two-step authentication. The alternative using ansible_become is functionally equivalent but requires additional variable configuration: ansible_become: true, ansible_become_method: enable, and ansible_become_password: "{{ vault_enable_secret }}" in group_vars (with the enable secret encrypted in Ansible Vault). From a security standpoint, both approaches have merit — privilege 15 accounts are simpler to configure but represent a higher-privilege credential if stolen; become accounts require two credentials (login + enable) but add defence-in-depth. Either approach works correctly with Ansible's cisco.ios collection.

7. In an Ansible playbook, what is the difference between import_playbook and include_playbook, and which is used in the master site.yml?

Correct answer is B. The static versus dynamic distinction has practical consequences. import_playbook (static): Ansible reads and processes all imported playbooks before execution begins — like pre-compiling. This means ansible-playbook site.yml --list-tasks shows all tasks from all imported playbooks. Tags applied at the import level propagate down to all individual tasks. Conditional (when) statements on imports are evaluated at parse time. include_playbook (dynamic): the playbook is loaded at runtime when that line is reached in execution. This allows dynamic filename construction using variables (e.g., include_playbook: "{{ environment }}_config.yml"), but means the included playbook's tasks are not visible to --list-tasks until execution reaches that point. For the master site.yml in this lab — where the playbooks to include are fixed and known in advance — import_playbook is the correct choice. It provides better tooling support (list-tasks, check mode, tag propagation) and simpler mental model. Use include_playbook only when you genuinely need runtime-conditional playbook selection.

8. The selectattr Jinja2 filter is used in the interface configuration playbook. Given this interfaces list in host_vars, what does interfaces | selectattr('mode', 'equalto', 'trunk') | list return?

interfaces:
  - {name: Gi0/1, mode: trunk,  description: "Uplink"}
  - {name: Fa0/1, mode: access, description: "PC port"}
  - {name: Fa0/2, mode: access, description: "PC port"}
  - {name: Fa0/3, mode: trunk,  description: "Uplink2"}
Correct answer is C. selectattr is a Jinja2 filter that selects items from a list where a specified attribute matches a test condition. The syntax is list | selectattr('attribute_name', 'test_name', 'test_value'). In this case: iterate through the interfaces list, keep only items where the mode attribute equals (using the equalto test) the string 'trunk'. The | list at the end converts the selectattr generator object into an actual Python list that Ansible's loop keyword can iterate over. The result is a list of the two trunk dictionaries — Gi0/1 and Fa0/3. This approach is essential for writing clean, reusable playbooks where all interface configurations are stored in host_vars and the playbook intelligently routes each interface through the appropriate configuration tasks based on its attributes. Without selectattr, you would need separate variables for trunk interfaces and access interfaces, duplicating data and making host_vars maintenance harder.

9. Ansible Vault is used to encrypt the ansible_password variable in group_vars/all/vault.yml. An engineer runs ansible-playbook playbooks/site.yml without any vault flags. What happens?

Correct answer is A. Ansible Vault encryption is transparent to the playbook — you write tasks using variable names like {{ ansible_password }} exactly as you would without vault. At runtime, Ansible detects that the variable's value is vault-encrypted (identified by the !vault | YAML tag and the $ANSIBLE_VAULT;1.1;AES256 header). To decrypt it, Ansible needs the vault password. The three ways to provide it: (1) --ask-vault-pass flag on the command line — prompts interactively; (2) --vault-password-file /path/to/file flag — reads the password from a file; (3) vault_password_file = ~/.vault_pass in ansible.cfg — automatically uses the specified file. If none of these is configured when encrypted variables are encountered, Ansible raises a decryption error and the playbook fails before making any changes. This is the correct security behaviour — it prevents accidental execution with undecrypted (garbled) credentials. In CI/CD pipelines, the vault password is typically injected as an environment variable (ANSIBLE_VAULT_PASSWORD_FILE) or stored as a pipeline secret.

10. An engineer needs to add VLAN 50 to only NetsTuts-SW1 and run the VLAN playbook without affecting NetsTuts-SW2. She also wants to dry-run it first. Write the complete sequence of commands she should run.

Correct answer is D. This question tests the correct workflow for a targeted, safe production change using Ansible. Step 1 — add the VLAN to SW1's host_vars: since extra_vlans is a SW1-specific variable in host_vars/NetsTuts-SW1.yml, adding VLAN 50 there means it will only be configured on SW1. SW2 does not have extra_vlans or has an empty list, so the playbook's merged VLAN list for SW2 will not include VLAN 50. Step 2 — dry-run with --limit: the --limit flag restricts playbook execution to only the specified hosts or groups without modifying the inventory. This is the correct tool for targeting a subset — never modify the inventory file temporarily. The --check flag confirms what would change without making changes. Step 3 — verify check output: confirm the dry run shows the expected change (VLAN 50 added to SW1) and no changes on SW2 (which wasn't targeted). Step 4 — apply: run without --check to apply the change to SW1 only. Step 5 — verify: use gather_facts.yml or a quick manual check to confirm VLAN 50 is now active on SW1. Option A (modifying the inventory) is an anti-pattern that introduces risk of leaving the inventory in a broken state. Option C has incorrect syntax — the flag is --limit not --host.