Skip to content

13 - Roles

This project 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. 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.

Part 13: Ansible Roles β€” Reusable Automation

Parts 8–12 produced playbooks that work. This part produces playbooks that scale. The difference is roles. Without roles, every playbook that configures a Cisco IOS device duplicates the same tasks β€” the same NTP configuration, the same hostname logic, the same syslog setup. Change one detail and it needs updating in a dozen files. Roles collect all of that into a single, self-contained unit that any playbook can call with one line. This part builds two complete roles for the lab, covers every component of the role directory structure, and establishes the role patterns used for the rest of this guide.


13.1 β€” What Roles Are and Why They Matter

A role is a self-contained, reusable unit of Ansible automation. It packages tasks, variables, handlers, templates, and files together in a standardized directory structure that any playbook can consume with a single roles: reference.

The Problem Roles Solve

Without roles, the deploy_base_config.yml playbook from Part 8 contains the full IOS base configuration logic. The deploy_ios_config.yml from Part 11 duplicates parts of it. A backup_and_reconfigure.yml playbook duplicates more. When the NTP server addresses change, I update three playbooks. When the SNMP community string format changes, I update five.

With roles:

# Any playbook that needs IOS base config just calls the role
- name: "Deploy | Base configuration"
  hosts: cisco_ios
  gather_facts: false
  roles:
    - cisco_ios_base    # One line β€” runs the complete base config role

The NTP logic, SNMP logic, hostname logic, syslog logic β€” all of it lives in the role. When it changes, it changes in one place.

Roles vs Playbooks vs Tasks

Playbook  β€” orchestrates what runs and in what order
  └── Role β€” a self-contained, reusable automation unit
        └── Tasks β€” the individual module calls
              └── Handlers β€” triggered by task changes, run once at play end

A playbook calls roles. Roles contain tasks. Tasks trigger handlers. These are three distinct layers, each with a clear purpose.

### 🏒 Real-World Scenario

A network automation team I know built their entire Ansible project without roles for the first year β€” 60+ playbooks, all standalone. When they needed to add TACACS+ authentication to every device, they discovered the base configuration logic was spread across 23 different playbooks. Adding TACACS+ took three days and introduced inconsistencies because several playbooks were missed. After they refactored to roles β€” one cisco_ios_base role, one cisco_nxos_base role β€” the same change took two hours: update the role, run a single playbook. The refactor itself took two weeks, but they never regretted it.


13.2 β€” The Role Directory Structure

Every role follows an identical directory structure. Ansible knows what each directory contains and loads it automatically:

roles/
└── cisco_ios_base/              ← Role name (matches how it's called from playbooks)
    β”œβ”€β”€ tasks/
    β”‚   └── main.yml             ← Entry point β€” the task list that runs
    β”œβ”€β”€ handlers/
    β”‚   └── main.yml             ← Handlers triggered by notify: in tasks
    β”œβ”€β”€ templates/
    β”‚   └── *.j2                 ← Jinja2 templates used by this role
    β”œβ”€β”€ files/
    β”‚   └── *                    ← Static files copied to devices or control node
    β”œβ”€β”€ vars/
    β”‚   └── main.yml             ← Role variables β€” HIGH precedence, rarely overridden
    β”œβ”€β”€ defaults/
    β”‚   └── main.yml             ← Role defaults β€” LOW precedence, easily overridden
    β”œβ”€β”€ meta/
    β”‚   └── main.yml             ← Role metadata: dependencies, author, platform info
    └── README.md                ← Role documentation (generated by ansible-galaxy)

What Each Directory Does

tasks/main.yml β€” The entry point. When a role is called, Ansible reads this file first and executes the tasks within it. Tasks can include_tasks: other files in tasks/ to break long task lists into sections.

handlers/main.yml β€” Handlers are tasks that only run when explicitly triggered by notify: in another task, and only when that task reports changed. They run once at the end of the play regardless of how many tasks notified them. This is where “save configuration” and “reload service” logic lives.

templates/ β€” Jinja2 template files (.j2) used by ansible.builtin.template tasks within this role. Templates in a role’s templates/ directory are found automatically β€” no path prefix needed.

files/ β€” Static files deployed by ansible.builtin.copy tasks. Like templates, files in a role’s files/ directory are found automatically.

vars/main.yml β€” Variables that are internal to the role and should not be overridden. High precedence (level 13 in the 18-level chain). Use for constants the role depends on β€” things that would break the role if changed from outside.

defaults/main.yml β€” Default values for variables the role uses. Low precedence (level 1 β€” lowest of all). These are overridden by group_vars, host_vars, play vars, or -e. Use for configurable parameters with sensible defaults.

meta/main.yml β€” Role metadata: dependencies on other roles, author info, supported platforms, minimum Ansible version. Dependencies listed here are automatically executed before this role runs.


13.3 β€” Creating Roles with ansible-galaxy role init

ansible-galaxy role init generates the complete directory skeleton:

cd ~/projects/ansible-network

# Create the IOS base configuration role
ansible-galaxy role init roles/cisco_ios_base

# Create the NX-OS VLAN management role
ansible-galaxy role init roles/cisco_nxos_vlans

# Verify the structure was created correctly
tree roles/cisco_ios_base
roles/cisco_ios_base/
β”œβ”€β”€ README.md
β”œβ”€β”€ defaults/
β”‚   └── main.yml
β”œβ”€β”€ files/
β”œβ”€β”€ handlers/
β”‚   └── main.yml
β”œβ”€β”€ meta/
β”‚   └── main.yml
β”œβ”€β”€ tasks/
β”‚   └── main.yml
β”œβ”€β”€ templates/
β”œβ”€β”€ tests/
β”‚   β”œβ”€β”€ inventory
β”‚   └── test.yml
└── vars/
    └── main.yml

Cleaning Up the Skeleton

ansible-galaxy role init generates more than I need for a network automation role. I remove the tests/ directory (I’ll use a separate testing approach covered in Part 30) and the generated boilerplate inside each main.yml:

# Remove the generated tests directory
rm -rf roles/cisco_ios_base/tests roles/cisco_nxos_vlans/tests

# The generated main.yml files have boilerplate comments β€” I'll replace them
# with actual content as I build each role

13.4 β€” Role defaults vs vars β€” When to Use Each

This is the most commonly misunderstood aspect of roles. The distinction is simple once I understand what each is for.

defaults/main.yml β€” Configurable Parameters

Defaults are variables the role user is expected to override. They represent the role’s configurable interface β€” the knobs someone turning can adjust when calling the role. They have the lowest precedence of any variable, meaning anything in host_vars, group_vars, or -e overrides them.

# roles/cisco_ios_base/defaults/main.yml
---
# =============================================================
# Configurable defaults β€” override in group_vars or host_vars
# =============================================================

# SSH session timeouts (seconds)
ios_exec_timeout_minutes: 10
ios_exec_timeout_seconds: 0

# Terminal settings
ios_terminal_length: 0
ios_terminal_width: 512

# Service settings
ios_service_password_encryption: true
ios_ip_source_route: false

# Logging settings
ios_logging_buffer_size: 16384
ios_logging_severity: informational

# Archive/configuration archive settings
ios_archive_enabled: false
ios_archive_path: "disk0:/archive"
ios_archive_maximum: 14

These are defaults that work fine out of the box for most devices but can be overridden when a specific device or group needs different values. For example, if my WAN routers need a longer exec timeout, I add ios_exec_timeout_minutes: 30 to group_vars/wan.yml and the default is overridden only for that group.

vars/main.yml β€” Internal Role Constants

Vars are values the role depends on internally and doesn’t expect users to change. They have high precedence (level 13) β€” higher than host_vars β€” so they’re very difficult to override accidentally. Use these for constants that define the role’s behavior, not its configuration.

# roles/cisco_ios_base/vars/main.yml
---
# =============================================================
# Internal role constants β€” not intended to be overridden
# These define how the role operates, not what it configures
# =============================================================

# Supported platforms for this role β€” used in assert tasks
_ios_base_supported_platforms:
  - cisco.ios.ios

# Required variables β€” checked by the role's pre-flight assertions
_ios_base_required_vars:
  - device_hostname
  - ntp_servers
  - syslog_server

# Internal state tracking (used across task files)
_ios_base_config_changed: false

The leading _ underscore prefix is a community convention for internal role variables β€” it signals “this is not part of the role’s public interface.”

The Practical Rule

Question: Should a user of this role be able to change this value?

YES β†’ defaults/main.yml    (low precedence, easily overridden)
NO  β†’ vars/main.yml        (high precedence, internal to role)

Concrete examples from the IOS base role:

VariableLocationReason
ios_exec_timeout_minutesdefaultsDifferent device groups may need different timeouts
ios_logging_severitydefaultsSome devices may need debug logging temporarily
ios_terminal_lengthdefaultsOverride to 24 for devices with pagination issues
_ios_base_supported_platformsvarsInternal check β€” never changes based on inventory
_ios_base_required_varsvarsDefines the role contract β€” not user-configurable

13.5 β€” Building the cisco_ios_base Role β€” Complete

This is the most important role in the project. It configures every standard element that every IOS device should have β€” the foundation that all other roles build on.

roles/cisco_ios_base/defaults/main.yml

cat > ~/projects/ansible-network/roles/cisco_ios_base/defaults/main.yml << 'EOF'
---
# =============================================================
# cisco_ios_base role defaults
# Override any of these in group_vars or host_vars
# =============================================================

# --- Terminal and session settings ---
ios_exec_timeout_minutes: 10
ios_exec_timeout_seconds: 0
ios_terminal_length: 0
ios_terminal_width: 512

# --- Security hardening ---
ios_service_password_encryption: true
ios_ip_source_route: false
ios_ip_proxy_arp: false
ios_cdp_enabled: true          # Set false to disable CDP globally

# --- Logging ---
ios_logging_buffer_size: 16384
ios_logging_severity: informational
ios_logging_console: false     # Disable console logging (reduces CPU on busy devices)

# --- Archive (configuration versioning on device) ---
ios_archive_enabled: false
ios_archive_path: "disk0:/archive"
ios_archive_maximum: 14

# --- Banner ---
ios_banner_enabled: true
ios_banner_text: |
  **************************************************************************
  *  Authorized access only. All activity is monitored and logged.         *
  *  Disconnect immediately if you are not an authorized user.             *
  **************************************************************************

# --- Save behavior ---
ios_save_on_change: true       # Write memory when configuration changes
EOF

roles/cisco_ios_base/vars/main.yml

cat > ~/projects/ansible-network/roles/cisco_ios_base/vars/main.yml << 'EOF'
---
# =============================================================
# cisco_ios_base role internal variables
# Not intended to be overridden from outside the role
# =============================================================

_ios_base_supported_platforms:
  - cisco.ios.ios

_ios_base_required_vars:
  - device_hostname
  - ntp_servers
  - syslog_server
  - domain_name
EOF

roles/cisco_ios_base/tasks/main.yml

Rather than one enormous task file, I break the role into logical task files and include them from main.yml. This keeps each section navigable and makes it easy to run specific sections with tags:

cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/main.yml << 'EOF'
---
# =============================================================
# cisco_ios_base β€” main task entry point
# Includes task files in configuration order
# =============================================================

- name: "IOS Base | Pre-flight checks"
  ansible.builtin.include_tasks: preflight.yml
  tags: always    # Pre-flight always runs regardless of other tags

- name: "IOS Base | Gather device facts"
  ansible.builtin.include_tasks: gather_facts.yml
  tags:
    - facts
    - always

- name: "IOS Base | Configure device identity"
  ansible.builtin.include_tasks: identity.yml
  tags:
    - identity
    - base

- name: "IOS Base | Configure global services"
  ansible.builtin.include_tasks: services.yml
  tags:
    - services
    - base

- name: "IOS Base | Configure NTP"
  ansible.builtin.include_tasks: ntp.yml
  tags:
    - ntp
    - base

- name: "IOS Base | Configure logging"
  ansible.builtin.include_tasks: logging.yml
  tags:
    - logging
    - base

- name: "IOS Base | Configure SNMP"
  ansible.builtin.include_tasks: snmp.yml
  tags:
    - snmp
    - base

- name: "IOS Base | Configure login banner"
  ansible.builtin.include_tasks: banner.yml
  tags:
    - banner
    - base
EOF

roles/cisco_ios_base/tasks/preflight.yml

cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/preflight.yml << 'EOF'
---
# Pre-flight: verify required variables exist before any config is pushed

- name: "IOS Base | Pre-flight | Verify required variables are defined"
  ansible.builtin.assert:
    that:
      - device_hostname is defined
      - ntp_servers is defined
      - ntp_servers | length > 0
      - syslog_server is defined
      - domain_name is defined
    fail_msg: >
      Role cisco_ios_base is missing required variables for {{ inventory_hostname }}.
      Ensure host_vars/{{ inventory_hostname }}.yml defines:
      device_hostname, ntp_servers, syslog_server, domain_name.
    success_msg: "Pre-flight passed for {{ inventory_hostname }}"

- name: "IOS Base | Pre-flight | Verify platform is supported"
  ansible.builtin.assert:
    that:
      - ansible_network_os in _ios_base_supported_platforms
    fail_msg: >
      cisco_ios_base role does not support platform: {{ ansible_network_os }}.
      Supported platforms: {{ _ios_base_supported_platforms | join(', ') }}
    success_msg: "Platform check passed: {{ ansible_network_os }}"
EOF

roles/cisco_ios_base/tasks/gather_facts.yml

cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/gather_facts.yml << 'EOF'
---
- name: "IOS Base | Facts | Gather IOS device facts"
  cisco.ios.ios_facts:
    gather_subset:
      - default
      - hardware

- name: "IOS Base | 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') }}"
    verbosity: 1    # Only shown with -v
EOF

roles/cisco_ios_base/tasks/identity.yml

cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/identity.yml << 'EOF'
---
- name: "IOS Base | Identity | Set hostname"
  cisco.ios.ios_hostname:
    config:
      hostname: "{{ device_hostname }}"
    state: merged
  notify: "Save IOS configuration"

- name: "IOS Base | Identity | Set IP domain name"
  cisco.ios.ios_config:
    lines:
      - "ip domain-name {{ domain_name }}"
  notify: "Save IOS configuration"
EOF

roles/cisco_ios_base/tasks/services.yml

cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/services.yml << 'EOF'
---
- name: "IOS Base | Services | Enable password encryption"
  cisco.ios.ios_config:
    lines:
      - "service password-encryption"
  when: ios_service_password_encryption | bool
  notify: "Save IOS configuration"

- name: "IOS Base | Services | Disable IP source routing"
  cisco.ios.ios_config:
    lines:
      - "no ip source-route"
  when: not ios_ip_source_route | bool
  notify: "Save IOS configuration"

- name: "IOS Base | Services | Set console and VTY exec-timeout"
  cisco.ios.ios_config:
    lines:
      - "exec-timeout {{ ios_exec_timeout_minutes }} {{ ios_exec_timeout_seconds }}"
    parents: "line con 0"
  notify: "Save IOS configuration"

- name: "IOS Base | Services | Set VTY exec-timeout and transport"
  cisco.ios.ios_config:
    lines:
      - "exec-timeout {{ ios_exec_timeout_minutes }} {{ ios_exec_timeout_seconds }}"
      - "transport input ssh"
      - "transport output none"
    parents: "line vty 0 15"
  notify: "Save IOS configuration"

- name: "IOS Base | Services | Set terminal width and length"
  cisco.ios.ios_config:
    lines:
      - "terminal width {{ ios_terminal_width }}"
      - "terminal length {{ ios_terminal_length }}"
  notify: "Save IOS configuration"
EOF

roles/cisco_ios_base/tasks/ntp.yml

cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/ntp.yml << 'EOF'
---
- name: "IOS Base | NTP | Configure NTP servers"
  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 }}"
  notify: "Save IOS configuration"

- name: "IOS Base | NTP | Configure NTP source interface"
  cisco.ios.ios_config:
    lines:
      - "ntp source {{ ntp_source_interface }}"
  when: ntp_source_interface is defined
  notify: "Save IOS configuration"
EOF

roles/cisco_ios_base/tasks/logging.yml

cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/logging.yml << 'EOF'
---
- name: "IOS Base | Logging | Configure syslog host"
  cisco.ios.ios_logging_global:
    config:
      hosts:
        - hostname: "{{ syslog_server }}"
          severity: "{{ ios_logging_severity }}"
      buffered:
        size: "{{ ios_logging_buffer_size }}"
        severity: "{{ ios_logging_severity }}"
    state: merged
  notify: "Save IOS configuration"

- name: "IOS Base | Logging | Disable console logging"
  cisco.ios.ios_config:
    lines:
      - "no logging console"
  when: not ios_logging_console | bool
  notify: "Save IOS configuration"
EOF

roles/cisco_ios_base/tasks/snmp.yml

cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/snmp.yml << 'EOF'
---
- name: "IOS Base | SNMP | Configure SNMP community"
  cisco.ios.ios_config:
    lines:
      - "snmp-server community {{ snmp_community_ro }} RO"
  no_log: true    # snmp_community_ro is sensitive
  notify: "Save IOS configuration"

- name: "IOS Base | SNMP | Configure SNMP location"
  cisco.ios.ios_config:
    lines:
      - "snmp-server location {{ snmp_location }}"
  when: snmp_location is defined
  notify: "Save IOS configuration"

- name: "IOS Base | SNMP | Configure SNMP contact"
  cisco.ios.ios_config:
    lines:
      - "snmp-server contact {{ snmp_contact }}"
  when: snmp_contact is defined
  notify: "Save IOS configuration"
EOF

roles/cisco_ios_base/tasks/banner.yml

cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/banner.yml << 'EOF'
---
- name: "IOS Base | Banner | Configure login banner"
  cisco.ios.ios_banner:
    banner: login
    text: "{{ ios_banner_text }}"
    state: present
  when: ios_banner_enabled | bool
  notify: "Save IOS configuration"
EOF

13.6 β€” Handlers: The Deep Dive

Handlers are tasks that run in response to notify: calls from regular tasks. They solve a specific and important problem in network automation β€” the “save configuration” problem.

The Problem Without Handlers

Without handlers, every task that changes configuration needs to save it:

# ❌ Every task saves β€” if 8 tasks change, 8 write memory commands execute
- name: Set hostname
  cisco.ios.ios_hostname:
    config:
      hostname: "{{ device_hostname }}"
    state: merged
  register: hostname_result

- name: Save after hostname
  cisco.ios.ios_command:
    commands: [write memory]
  when: hostname_result.changed

- name: Configure NTP
  cisco.ios.ios_ntp_global:
    ...
  register: ntp_result

- name: Save after NTP
  cisco.ios.ios_command:
    commands: [write memory]
  when: ntp_result.changed

This creates two problems: multiple write memory commands on one device in a single play (wasteful and potentially disruptive on loaded hardware), and complex dependency tracking between tasks. If I add a new task and forget to add a corresponding save task, the configuration is lost on reload.

The Real Reload Loop Scenario

This problem becomes severe with operations that require a reload β€” like changing a device’s management VRF or enabling OSPF process configuration. Consider this without handlers:

# ❌ Without handlers β€” a reload triggers 3 times if 3 tasks all change config
- name: Task 1 β€” change OSPF router-id
  cisco.ios.ios_config:
    lines: ["router-id 10.255.0.1"]
    parents: "router ospf 1"
  when: ospf_changed
  # Reload here...

- name: Task 2 β€” change BGP router-id
  ...
  # Reload here again...

- name: Task 3 β€” change NTP
  ...
  # Reload here a third time...

With handlers, the operation happens exactly once, at the end of all tasks, after it’s clear everything has been configured. The handler doesn’t care how many tasks notified it β€” it runs once.

How Handlers Work

Task 1 β†’ changed β†’ notify: "Save IOS configuration"    ┐
Task 2 β†’ ok      β†’ (no notify)                         β”œβ”€β”€ Handler queue
Task 3 β†’ changed β†’ notify: "Save IOS configuration"    β”˜   builds up
Task 4 β†’ changed β†’ notify: "Save IOS configuration"
...all tasks complete...
                                                        ↓
                    Handler runs ONCE: "Save IOS configuration"

Key rules:

  • A handler is triggered only when a task reports changed (not ok or skipped)
  • Multiple notifies of the same handler result in one handler execution
  • Handlers run at the end of the play by default (after all tasks complete)
  • If the play fails before reaching the handler execution phase, handlers don’t run
  • Handlers run in the order they’re defined in handlers/main.yml, not the order they’re notified

roles/cisco_ios_base/handlers/main.yml

cat > ~/projects/ansible-network/roles/cisco_ios_base/handlers/main.yml << 'EOF'
---
# =============================================================
# cisco_ios_base handlers
# Triggered by notify: in tasks, run once at end of play
# =============================================================

- name: "Save IOS configuration"
  cisco.ios.ios_command:
    commands:
      - write memory
  listen: "Save IOS configuration"
  # This handler runs once at play end, regardless of how many
  # tasks notified it. Prevents multiple write memory calls.

- name: "Reload IOS device"
  cisco.ios.ios_command:
    commands:
      - reload in 1    # Scheduled reload β€” gives time to abort if needed
  listen: "Reload IOS device"
  # Only used when a configuration change requires a reload
  # Example: changing the management VRF requires a reload on some platforms
  # No tasks in cisco_ios_base currently use this handler β€”
  # it's here for extension when needed
EOF

flush_handlers β€” When I Need Handler to Run Mid-Play

Sometimes I need a handler to run before the play ends β€” for example, to save config before running a verification task that depends on the saved state:

# In a playbook or role tasks file
- name: "Configure all base settings"
  # ... lots of tasks that notify "Save IOS configuration" ...

- name: "Force handler execution now (before verification)"
  ansible.builtin.meta: flush_handlers
  # Runs all pending handlers at this point
  # After this, the "Save IOS configuration" handler has already run

- name: "Verify configuration was saved"
  cisco.ios.ios_command:
    commands:
      - show startup-config | include hostname
  register: startup_check
  # This task only makes sense AFTER write memory β€” flush_handlers ensures that

### πŸ’‘ Tip

Handlers have a subtle failure mode: if a play fails partway through, handlers never run β€” even if tasks before the failure notified them. If the hostname task succeeded and notified “Save IOS configuration” but a later NTP task failed and stopped the play, write memory never executes. The unsaved hostname change is lost on the next reload. The fix is force_handlers: true at the play level, which ensures handlers run even when the play fails:

- name: Configure IOS devices
  hosts: cisco_ios
  force_handlers: true    # ← Handlers run even if play fails
  roles:
    - cisco_ios_base

I use this in all plays that push configuration. A failed play still saves whatever it successfully changed.


13.7 β€” roles/cisco_ios_base/meta/main.yml

The meta file documents the role and defines dependencies. Role dependencies are automatically executed before the role itself runs β€” no explicit call needed in the playbook.

cat > ~/projects/ansible-network/roles/cisco_ios_base/meta/main.yml << 'EOF'
---
galaxy_info:
  role_name: cisco_ios_base
  author: "Your Name"
  description: >
    Base configuration for Cisco IOS and IOS-XE devices.
    Configures hostname, domain, NTP, syslog, SNMP, banner,
    and global security settings.
  license: MIT
  min_ansible_version: "2.15"
  platforms:
    - name: IOS
      versions:
        - all
    - name: IOS-XE
      versions:
        - all

# Role dependencies β€” these roles run BEFORE cisco_ios_base
# Example: if all network devices need a 'common_preflight' role first
# dependencies:
#   - role: common_preflight
#   - role: cisco_ios_facts
#     vars:
#       gather_subset: default

# No dependencies for now β€” cisco_ios_base is self-contained
dependencies: []
EOF

Using Role Dependencies

Dependencies are powerful when I have common setup logic that multiple roles share. For example, if cisco_ios_bgp and cisco_ios_vlans both need facts gathered first, I create a cisco_ios_facts role and list it as a dependency in both:

# roles/cisco_ios_bgp/meta/main.yml
dependencies:
  - role: cisco_ios_base          # Always run base config first
  - role: cisco_ios_facts         # Always gather facts first
    vars:
      gather_subset: default

When a playbook calls cisco_ios_bgp, Ansible automatically runs cisco_ios_base β†’ cisco_ios_facts β†’ cisco_ios_bgp in that order. The playbook only needs to reference the final role.


13.8 β€” The Role README

A well-written README is what makes a role usable by other engineers:

cat > ~/projects/ansible-network/roles/cisco_ios_base/README.md << 'EOF'
# cisco_ios_base

Base configuration role for Cisco IOS and IOS-XE devices.

## What This Role Does

Configures the foundational settings every IOS device should have:
- Hostname and IP domain name
- Global services (password encryption, SSH-only VTY, exec timeout)
- NTP servers with prefer flag on first server
- Syslog to centralized server
- SNMP read-only community
- Login banner
- Security hardening (no ip source-route, no proxy-arp)

## Requirements

- Cisco IOS or IOS-XE device reachable via SSH
- `ansible_network_os: cisco.ios.ios` set in group_vars
- `ansible_connection: network_cli` set in group_vars
- `cisco.ios` collection installed

## Required Variables

These must be defined in `host_vars/<device>.yml` or `group_vars/`:

| Variable | Description |
|---|---|
| `device_hostname` | Device hostname to configure |
| `ntp_servers` | List of NTP server IPs |
| `syslog_server` | IP address of syslog server |
| `domain_name` | IP domain name |
| `snmp_community_ro` | SNMP read-only community (vault this) |

## Configurable Defaults

Override any of these in `group_vars/` or `host_vars/`:

| Variable | Default | Description |
|---|---|---|
| `ios_exec_timeout_minutes` | `10` | Exec timeout minutes |
| `ios_exec_timeout_seconds` | `0` | Exec timeout seconds |
| `ios_service_password_encryption` | `true` | Enable service password-encryption |
| `ios_ip_source_route` | `false` | Allow IP source routing |
| `ios_logging_severity` | `informational` | Logging severity level |
| `ios_logging_buffer_size` | `16384` | Local log buffer size |
| `ios_banner_enabled` | `true` | Configure login banner |
| `ios_save_on_change` | `true` | Write memory on change |

## Usage

```yaml
- name: Apply IOS base configuration
  hosts: cisco_ios
  gather_facts: false
  connection: network_cli
  become: true
  become_method: enable
  force_handlers: true
  roles:
    - cisco_ios_base

Tags

TagWhat It Runs
baseAll configuration tasks
identityHostname and domain
ntpNTP configuration
loggingSyslog and buffer
snmpSNMP community and location
bannerLogin banner
factsFact gathering only
alwaysPre-flight and facts (always runs)
EOF

---

## 13.9 β€” Calling Roles from Playbooks

There are three ways to call a role from a playbook. I'll show the practical pattern used throughout this guide and explain the tradeoffs.

### The Primary Pattern: `roles:` Key

```yaml
---
- name: "Deploy | IOS base configuration"
  hosts: cisco_ios
  gather_facts: false
  connection: network_cli
  become: true
  become_method: enable
  force_handlers: true    # ← Always use with configuration roles

  roles:
    - cisco_ios_base      # Role name matches directory name in roles/

This is the primary pattern for this guide. roles: runs before tasks: (if any exist in the same play), which is the desired order β€” roles set up the device state, then any play-level tasks can run afterward.

Passing Variables to a Role

roles:
  - role: cisco_ios_base
    vars:
      ios_exec_timeout_minutes: 30     # Override the role default for this play
      ios_banner_enabled: false        # Disable banner for lab devices

Multiple Roles in Sequence

roles:
  - cisco_ios_base          # Runs first β€” base configuration
  - cisco_ios_interfaces    # Runs second β€” interface configuration
  - cisco_ios_routing       # Runs third β€” routing configuration

import_role and include_role β€” When They’re Needed

# import_role β€” static, processed at parse time
# Use when: the role always needs to run, tags and --list-tasks work correctly
tasks:
  - name: "Apply base config"
    ansible.builtin.import_role:
      name: cisco_ios_base

# include_role β€” dynamic, processed at runtime
# Use when: the role is called conditionally or inside a loop
tasks:
  - name: "Apply platform-specific role"
    ansible.builtin.include_role:
      name: "cisco_{{ device_platform }}_base"    # Dynamic role name from variable
    when: device_platform is defined

  - name: "Apply role for each device type"
    ansible.builtin.include_role:
      name: "{{ item }}"
    loop:
      - cisco_ios_base
      - cisco_ios_interfaces

The Pattern for This Guide

I use the roles: key in play headers for the rest of this guide because:

  • It’s the most readable and conventional approach
  • Tags work correctly across all roles in the play
  • --list-tasks shows the complete task list
  • force_handlers: true at the play level covers all roles correctly

include_role is only used when I genuinely need dynamic role selection β€” which happens in Part 19 (multi-platform playbooks) and Part 25 (network-wide compliance enforcement).


13.10 β€” Building the cisco_nxos_vlans Role

With the full cisco_ios_base pattern established, I build the NX-OS VLAN role using the same structure. This role manages VLAN configuration on all NX-OS switches, using the data model from host_vars and group_vars.

Role Overview

cisco_nxos_vlans/
β”œβ”€β”€ tasks/main.yml
β”œβ”€β”€ tasks/preflight.yml
β”œβ”€β”€ tasks/base_vlans.yml      ← VLANs from group_vars/leaf_switches.yml
β”œβ”€β”€ tasks/local_vlans.yml     ← VLANs from host_vars/<device>.yml
β”œβ”€β”€ tasks/svi.yml             ← Layer 3 VLAN interfaces (SVIs)
β”œβ”€β”€ handlers/main.yml
β”œβ”€β”€ defaults/main.yml
β”œβ”€β”€ vars/main.yml
└── meta/main.yml

defaults/main.yml

cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/defaults/main.yml << 'EOF'
---
# Configurable defaults for cisco_nxos_vlans role

# Whether to configure SVIs (Layer 3 VLAN interfaces)
nxos_vlans_configure_svi: true

# Default SVI state (up/down)
nxos_vlans_svi_state: up

# Whether to remove VLANs not in the data model (use with caution)
nxos_vlans_purge_undefined: false

# VLAN state for all configured VLANs
nxos_vlans_state: active
EOF

vars/main.yml

cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/vars/main.yml << 'EOF'
---
_nxos_vlans_supported_platforms:
  - cisco.nxos.nxos

_nxos_vlans_required_vars:
  - base_vlans    # From group_vars/leaf_switches.yml
EOF

tasks/main.yml

cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/tasks/main.yml << 'EOF'
---
- name: "NX-OS VLANs | Pre-flight"
  ansible.builtin.include_tasks: preflight.yml
  tags: always

- name: "NX-OS VLANs | Configure base VLANs (group-wide)"
  ansible.builtin.include_tasks: base_vlans.yml
  tags:
    - vlans
    - base_vlans

- name: "NX-OS VLANs | Configure local VLANs (device-specific)"
  ansible.builtin.include_tasks: local_vlans.yml
  when: local_vlans is defined
  tags:
    - vlans
    - local_vlans

- name: "NX-OS VLANs | Configure SVIs"
  ansible.builtin.include_tasks: svi.yml
  when: nxos_vlans_configure_svi | bool
  tags:
    - vlans
    - svi
EOF

tasks/preflight.yml

cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/tasks/preflight.yml << 'EOF'
---
- name: "NX-OS VLANs | Pre-flight | Verify platform"
  ansible.builtin.assert:
    that:
      - ansible_network_os in _nxos_vlans_supported_platforms
    fail_msg: "cisco_nxos_vlans does not support {{ ansible_network_os }}"

- name: "NX-OS VLANs | Pre-flight | Verify required variables"
  ansible.builtin.assert:
    that:
      - base_vlans is defined
      - base_vlans | length > 0
    fail_msg: "base_vlans must be defined in group_vars/leaf_switches.yml"
EOF

tasks/base_vlans.yml

cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/tasks/base_vlans.yml << 'EOF'
---
# Configure VLANs that all leaf switches should have (from group_vars)
# base_vlans is defined in group_vars/leaf_switches.yml

- name: "NX-OS VLANs | Base | Configure standard VLANs"
  cisco.nxos.nxos_vlans:
    config:
      - vlan_id: "{{ item.id }}"
        name: "{{ item.name }}"
        state: "{{ nxos_vlans_state }}"
    state: merged
  loop: "{{ base_vlans }}"
  loop_control:
    label: "VLAN {{ item.id }} β€” {{ item.name }}"
  notify: "Save NX-OS configuration"
EOF

tasks/local_vlans.yml

cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/tasks/local_vlans.yml << 'EOF'
---
# Configure VLANs specific to this device (from host_vars)
# local_vlans is defined in host_vars/<device>.yml
# This task file is only included when local_vlans is defined

- name: "NX-OS VLANs | Local | Configure device-specific VLANs"
  cisco.nxos.nxos_vlans:
    config:
      - vlan_id: "{{ item.id }}"
        name: "{{ item.name }}"
        state: "{{ nxos_vlans_state }}"
    state: merged
  loop: "{{ local_vlans }}"
  loop_control:
    label: "VLAN {{ item.id }} β€” {{ item.name }}"
  notify: "Save NX-OS configuration"
EOF

tasks/svi.yml

cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/tasks/svi.yml << 'EOF'
---
# Configure SVIs (Layer 3 VLAN interfaces) for local_vlans
# Only VLANs with svi_ip defined get an SVI configured

- name: "NX-OS VLANs | SVI | Enable interface-vlan feature"
  cisco.nxos.nxos_feature:
    feature: interface-vlan
    state: enabled
  notify: "Save NX-OS configuration"

- name: "NX-OS VLANs | SVI | Configure SVI interfaces"
  cisco.nxos.nxos_l3_interfaces:
    config:
      - name: "Vlan{{ item.id }}"
        ipv4:
          - address: "{{ item.svi_ip }}/{{ item.svi_prefix }}"
    state: merged
  loop: "{{ local_vlans | selectattr('svi_ip', 'defined') | list }}"
  loop_control:
    label: "Vlan{{ item.id }} β€” {{ item.svi_ip }}/{{ item.svi_prefix }}"
  notify: "Save NX-OS configuration"

- name: "NX-OS VLANs | SVI | Bring SVI interfaces up"
  cisco.nxos.nxos_interfaces:
    config:
      - name: "Vlan{{ item.id }}"
        enabled: "{{ true if nxos_vlans_svi_state == 'up' else false }}"
    state: merged
  loop: "{{ local_vlans | selectattr('svi_ip', 'defined') | list }}"
  loop_control:
    label: "Vlan{{ item.id }}"
  notify: "Save NX-OS configuration"
EOF

handlers/main.yml

cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/handlers/main.yml << 'EOF'
---
- name: "Save NX-OS configuration"
  cisco.nxos.nxos_command:
    commands:
      - copy running-config startup-config
  listen: "Save NX-OS configuration"
EOF

meta/main.yml

cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/meta/main.yml << 'EOF'
---
galaxy_info:
  role_name: cisco_nxos_vlans
  author: "Your Name"
  description: "VLAN and SVI management for Cisco NX-OS switches"
  license: MIT
  min_ansible_version: "2.15"
  platforms:
    - name: NX-OS
      versions:
        - all

dependencies: []
EOF

13.11 β€” The Juniper and PAN-OS Role Pattern

Juniper and Palo Alto follow the identical role structure β€” the same directories, the same defaults/vars/tasks/handlers/meta layout. The only differences are the module names and connection types.

Juniper junos_interfaces Role Skeleton

ansible-galaxy role init roles/juniper_junos_interfaces
# roles/juniper_junos_interfaces/defaults/main.yml
---
junos_interface_state: enabled
junos_commit_confirm: 60    # Commit confirm timeout β€” auto-rollback if not confirmed

# roles/juniper_junos_interfaces/tasks/main.yml
---
- name: "Junos Interfaces | Pre-flight"
  include_tasks: preflight.yml
  tags: always

- name: "Junos Interfaces | Configure interfaces"
  include_tasks: configure.yml
  tags: interfaces
# Key difference: Juniper uses junos_config with candidate config + commit
# roles/juniper_junos_interfaces/tasks/configure.yml
- name: "Junos | Configure interface descriptions"
  junipernetworks.junos.junos_interfaces:
    config:
      - name: "{{ item.key }}"
        description: "{{ item.value.description }}"
        enabled: "{{ not item.value.shutdown | default(false) }}"
    state: merged
  loop: "{{ interfaces | dict2items }}"
  notify: "Commit Junos configuration"

# handlers/main.yml
- name: "Commit Junos configuration"
  junipernetworks.junos.junos_command:
    commands:
      - "commit confirmed {{ junos_commit_confirm }}"
  listen: "Commit Junos configuration"

The key Juniper difference: commit confirmed <seconds> applies the configuration with an automatic rollback timer. I must run a commit to confirm within the timeout window, or the device automatically reverts. This is a safety feature β€” covered fully in the Juniper section (Part 24).

Palo Alto panos_security_policy Role Skeleton

ansible-galaxy role init roles/panos_security_policy
# Key difference: PAN-OS requires an explicit commit after all changes
# This is the critical handler difference from IOS/NX-OS

# roles/panos_security_policy/handlers/main.yml
- name: "Commit PAN-OS configuration"
  paloaltonetworks.panos.panos_commit_firewall:
    description: "Committed by Ansible β€” {{ lookup('pipe', 'date') }}"
  listen: "Commit PAN-OS configuration"
  # PAN-OS changes are staged in candidate config until committed
  # Without this handler, configuration changes are visible but not active
# roles/panos_security_policy/tasks/configure.yml
- name: "PAN-OS | Configure security policy rules"
  paloaltonetworks.panos.panos_security_rule:
    rule_name: "{{ item.name }}"
    source_zone: "{{ item.source_zone }}"
    destination_zone: "{{ item.destination_zone }}"
    action: "{{ item.action }}"
    state: present
  loop: "{{ panos_security_rules }}"
  notify: "Commit PAN-OS configuration"    # Triggers commit once, at play end

The critical PAN-OS difference: changes are staged in candidate configuration until a panos_commit_firewall runs. Without the commit handler, every task would change things visibly but they’d never take effect. Using the handler ensures exactly one commit per play, after all rule changes are staged.


13.12 β€” Downloading Roles from Ansible Galaxy

Ansible Galaxy hosts community-contributed roles. For common tasks like managing ACLs, configuring BGP, or auditing device compliance, a community role may already exist and be better tested than one I’d write myself.

# Search for network-related roles
ansible-galaxy role search cisco ios --author

# View role information before installing
ansible-galaxy role info geerlingguy.ntp

# Install a role from Galaxy
ansible-galaxy role install geerlingguy.ntp

# Install to the project roles/ directory (recommended β€” keeps it with the project)
ansible-galaxy role install geerlingguy.ntp -p roles/

# Install roles from a requirements file (the correct production approach)
cat > roles/requirements.yml << 'EOF'
---
roles:
  - name: geerlingguy.ntp
    version: "2.3.0"    # Always pin versions in production

  - name: some_vendor.network_base
    src: https://github.com/vendor/ansible-role-network-base
    version: v1.2.0
EOF

ansible-galaxy role install -r roles/requirements.yml -p roles/

### ℹ️ Info

Community roles from Ansible Galaxy vary wildly in quality and maintenance. Before installing any Galaxy role, I check: when was it last updated (stale roles may not support current collection APIs), how many downloads and GitHub stars it has, whether it has CI tests, and whether the author maintains it actively. For critical network configuration, I generally prefer writing and owning the role rather than taking a dependency on a community role I can’t control. Galaxy roles are most useful for well-understood infrastructure tasks (NTP, syslog, package management on Linux hosts) where the risk of a bad role is low.


13.13 β€” Running the Roles from a Playbook

nano ~/projects/ansible-network/playbooks/deploy/deploy_base_config.yml
---
# =============================================================
# deploy_base_config.yml β€” Role-based base configuration
# Replaces the task-based version from Part 8
# =============================================================

- name: "Deploy | IOS-XE base configuration"
  hosts: cisco_ios
  gather_facts: false
  connection: network_cli
  become: true
  become_method: enable
  force_handlers: true    # Save config even if play fails partway through

  roles:
    - cisco_ios_base

- name: "Deploy | NX-OS VLAN configuration (leaf switches only)"
  hosts: leaf_switches
  gather_facts: false
  connection: network_cli
  force_handlers: true

  roles:
    - cisco_nxos_vlans

Running with Tags

# Run everything
ansible-playbook playbooks/deploy/deploy_base_config.yml

# Run only NTP configuration
ansible-playbook playbooks/deploy/deploy_base_config.yml --tags ntp

# Run only banner configuration on a single device
ansible-playbook playbooks/deploy/deploy_base_config.yml \
    --tags banner --limit wan-r1

# Run everything except SNMP
ansible-playbook playbooks/deploy/deploy_base_config.yml \
    --skip-tags snmp

# List all tasks that would run (dry run view)
ansible-playbook playbooks/deploy/deploy_base_config.yml \
    --list-tasks

13.14 β€” Common Gotchas in This Section

### πŸͺ² Gotcha β€” Handler name must match exactly across role files

The string in notify: must be exactly identical to the handler name: in handlers/main.yml β€” including capitalization and spacing:

# tasks/ntp.yml
notify: "Save IOS configuration"    # ← exact string

# handlers/main.yml
- name: "Save IOS configuration"    # ← must match exactly
  cisco.ios.ios_command:
    commands: [write memory]

If there’s a mismatch, the handler silently fails to register β€” no error, no save. I test by adding a debug task after a change and checking whether the handler ran.

### πŸͺ² Gotcha β€” include_tasks with tags doesn’t work the same as import_tasks

# With include_tasks β€” tags on the include don't propagate to tasks inside
- name: "Include NTP tasks"
  ansible.builtin.include_tasks: ntp.yml
  tags: ntp    # ← This tag applies to the INCLUDE task, not to tasks inside ntp.yml

# The tasks inside ntp.yml need their OWN tags for --tags ntp to filter them
# OR use import_tasks instead (static β€” tags propagate through)
- name: "Import NTP tasks"
  ansible.builtin.import_tasks: ntp.yml
  tags: ntp    # ← With import_tasks, this tag DOES propagate to tasks inside

For this guide, I use include_tasks in role main.yml and add tags to the individual task files themselves. This requires tasks in each file to have their own tags, but makes dynamic inclusion possible (the option to conditionally skip sections based on when:).

### πŸͺ² Gotcha β€” Role defaults in group_vars with the same name causes silent override

If group_vars/all.yml defines ios_exec_timeout_minutes: 30 and the role’s defaults/main.yml also defines ios_exec_timeout_minutes: 10, the group_vars value wins β€” silently. This is correct behavior (group_vars has higher precedence than role defaults), but it’s confusing when I can’t figure out why a role default isn’t taking effect.

# Debug: check what value a variable has for a specific host
ansible-inventory --host wan-r1 | grep ios_exec_timeout
# If it shows a value, it's coming from inventory (group_vars/host_vars)
# If it doesn't appear, the role default will be used

13.15 β€” Committing the Roles to Git

cd ~/projects/ansible-network

git add roles/cisco_ios_base/
git add roles/cisco_nxos_vlans/
git add playbooks/deploy/deploy_base_config.yml

git commit -m "feat(roles): implement cisco_ios_base and cisco_nxos_vlans roles

cisco_ios_base role:
- Configures hostname, domain, services, NTP, logging, SNMP, banner
- Split into task files per configuration domain
- Handler: Save IOS configuration (write memory once at play end)
- force_handlers: true on playbook prevents unsaved config on failure
- Preflight assertions verify required variables before pushing config

cisco_nxos_vlans role:
- Manages base VLANs (from group_vars) and local VLANs (from host_vars)
- Configures SVIs for VLANs with svi_ip defined
- Handler: Save NX-OS configuration

Both roles:
- defaults/main.yml for user-overridable parameters
- vars/main.yml for internal constants with _ prefix
- meta/main.yml with platform info and empty dependencies list
- README.md with required vars, defaults table, and usage example"

Two production-quality roles are built and running. The pattern is clear β€” every future platform role follows the same structure. Part 14 takes the automation further into real network configuration use cases: deploying VLANs, interfaces, and routing protocols using resource modules.

Last updated on β€’ Ernesto Diaz