Ansible Network Automation for Cisco, Juniper and Arista - 夜莺博客

Ansible Network Automation for Cisco, Juniper and Arista

Ansible's network modules let one playbook talk to Cisco IOS, Juniper Junos, Arista EOS and many other network operating systems, which makes it the fastest way to standardize show-command collection and configuration delivery in a multi-vendor environment. The tricky part for newcomers is the inventory: network devices need ansible_connection and ansible_network_os set per host, and each platform has its own command module. This article shows a working multi-vendor inventory, playbooks using both platform-specific modules and the platform-independent cli_command module, and a configuration backup pattern you can copy.

Building the Inventory

Group devices by platform so playbooks can target them selectively. Each host needs the connection type and network OS declared:

[switches:children]
eos
ios
junos

[eos]
eos01.example.net

[ios]
ios01.example.net

[junos]
junos01.example.net

[switches:vars]
ansible_connection=ansible.netcommon.network_cli
ansible_user=admin
ansible_ssh_private_key_file=~/.ssh/id_rsa

[eos:vars]
ansible_network_os=arista.eos.eos
[ios:vars]
ansible_network_os=cisco.ios.ios
[junos:vars]
ansible_network_os=junipernetworks.junos.junos

Running Commands with Platform Modules

The classic pattern registers the command output and prints it, using a when condition per platform:

---
- hosts: switches
  gather_facts: false
  tasks:
    - name: Run show command on Arista EOS
      arista.eos.eos_command:
        commands: show ip interface brief
      register: result
      when: ansible_network_os == 'arista.eos.eos'

    - name: Run show command on Cisco IOS
      cisco.ios.ios_command:
        commands: show ip interface brief
      register: result
      when: ansible_network_os == 'cisco.ios.ios'

    - name: Run show command on Junos
      junipernetworks.junos.junos_command:
        commands: show interfaces terse
      register: result
      when: ansible_network_os == 'junipernetworks.junos.junos'

    - name: Display collected output
      debug:
        var: result.stdout_lines

Simplifying with the Platform-Independent cli_command

When the command syntax is identical across platforms, drop the per-vendor modules and use ansible.netcommon.cli_command with a single task:

---
- hosts: switches
  gather_facts: false
  connection: ansible.netcommon.network_cli
  tasks:
    - name: Collect interface status from all platforms
      ansible.netcommon.cli_command:
        command: show interfaces status
      register: result
      when: ansible_network_os in ['arista.eos.eos', 'cisco.ios.ios', 'junipernetworks.junos.junos']

    - name: Display results
      debug:
        var: result.stdout_lines

Backing Up Running Configurations

Gather facts and save each device's running configuration locally - the standard backup pattern:

---
- hosts: switches
  gather_facts: false
  tasks:
    - name: Backup config from each platform
      cisco.ios.ios_config:
        backup: yes
      when: ansible_network_os == 'cisco.ios.ios'

    - name: Backup Junos configuration
      junipernetworks.junos.junos_config:
        backup: yes
      when: ansible_network_os == 'junipernetworks.junos.junos'

Backups land in the playbook's backup/ directory with hostname and timestamp in the filename.

Configuration Changes: cli_config and Resource Modules

For configuration delivery, ansible.netcommon.cli_config applies a config snippet over network_cli, while vendor resource modules (cisco.ios.ios_vlans, arista.eos.eos_vlans, junipernetworks.junos.junos_vlans) declare desired state and let Ansible compute the diff - the safest model for idempotent VLAN, interface, SNMP and NTP management:

---
- hosts: cisco
  gather_facts: false
  tasks:
    - name: Manage VLANs with a resource module
      cisco.ios.ios_vlans:
        config:
          - name: servers
            vlan_id: 10
        state: merged

Verify Your Playbook

$ ansible-playbook -i inventory.ini collect_show.yml
$ ansible-playbook -i inventory.ini --syntax-check backup.yml

Installing the Collections Before Anything Else

Ansible ships the core engine, but the vendor modules live in collections that must be installed before the playbooks above will even parse. A fresh control node has none of them:

$ ansible-galaxy collection install cisco.ios arista.eos junipernetworks.junos ansible.netcommon
$ ansible-galaxy collection list | grep -E "cisco|arista|juniper|netcommon"

Pin versions in a requirements.yml and install with ansible-galaxy collection install -r requirements.yml so a colleague's laptop and your CI runner resolve identical module signatures. The ansible.netcommon collection is the one that actually implements network_cli, cli_command and cli_config; the vendor collections depend on it, and version skew between the two is behind most "module not found" failures.

Tuning ansible.cfg for Network Devices

Network devices are slower to connect than Linux hosts (SSH banner, AAA round trips, paging) and the shipped defaults are tuned for servers. A small ansible.cfg next to the playbook removes the most common friction:

[defaults]
host_key_checking = False
gather_timeout = 60
retry_files_enabled = False
forks = 20

[persistent_connection]
connect_timeout = 60
command_timeout = 120
persistent_connect_timeout = 120

Raising connect_timeout is what stops a playbook failing on its first task against a device that takes thirty seconds to present a login prompt, and gather_timeout gives a slow show command room to finish. Keep forks modest: embedding three hundred access switches simultaneously will overwhelm the management plane and the AAA server long before it helps runtime.

Organising Variables with group_vars and host_vars

Once the inventory grows past a handful of devices, credentials and platform details belong in group_vars/ rather than inline inventory vars. Create a directory tree beside the inventory file:

inventory.ini
group_vars/
  all.yml
  switches.yml
  ios.yml
  junos.yml
host_vars/
  ios01.example.net.yml

group_vars/all.yml holds the shared credentials and connection type, while group_vars/ios.yml and friends override the platform-specific pieces:

# group_vars/all.yml
ansible_connection: ansible.netcommon.network_cli
ansible_user: "{{ vault_network_user }}"
ansible_password: "{{ vault_network_password }}"

# group_vars/ios.yml
ansible_network_os: cisco.ios.ios
ansible_become: true
ansible_become_method: enable

Ansible merges group variables by precedence - ios.yml beats switches.yml beats all.yml - so the per-platform ansible_network_os only needs to be declared once. Adding a new device then means adding its hostname to one group and nothing else. The flat [ios:vars] style used in the inventory example at the top of this article still works, but it stops scaling the moment you have per-site passwords or a second platform group.

Handling Credentials Securely

Never commit a password into group_vars in plain text. Two options are worth the five minutes they cost. The first uses ansible-vault:

$ ansible-vault create group_vars/all/vault.yml
$ ansible-vault encrypt_string 'SuperSecret' --name 'vault_network_password'
$ ansible-playbook -i inventory.ini site.yml --ask-vault-pass

The second uses SSH key authentication, which removes the password entirely and is the preferred route on IOS, EOS and Junos alike once the key is installed on each device. Whichever you pick, use a dedicated automation account with its own privilege level, so that an accidental --check run or a runaway loop cannot modify production configuration with a human engineer's credentials.

Using Command Output Instead of Just Printing It

The register result holds raw text in result.stdout_lines, which is fine for a report but awkward to assert on. Reach for the parsed form when you need to test something:

- name: Collect interface status
  ansible.netcommon.cli_command:
    command: show interfaces status
  register: intf

- name: Fail when any access port is not connected
  assert:
    that:
      - "'notconnect' not in (intf.stdout | join('\n'))"
    fail_msg: "One or more access ports are down"

For genuinely structured data prefer the vendor facts modules - cisco.ios.ios_facts, arista.eos.eos_facts, junipernetworks.junos.junos_facts - which return dictionaries you can index directly instead of pattern-matching text. Those fact keys are covered in detail in Ansible network modules: ios_config, facts and config backup.

Idempotency, --check and --diff

Resource modules are idempotent: run cisco.ios.ios_vlans twice with the same state: merged configuration and the second run reports changed=0. Take advantage of that before touching production by previewing the diff:

$ ansible-playbook -i inventory.ini vlans.yml --check --diff
$ ansible-playbook -i inventory.ini vlans.yml --limit ios01.example.net

--check shows what the module would change without applying it, --diff prints the configuration lines it would touch, and --limit scopes the run to a single device as a canary. That trio is what turns a network playbook from a script into a change-controlled deployment, and it is the reason resource modules are worth the extra learning curve over raw cli_config strings.

Backing Up with cli_config and the common Modules

The per-vendor backup: yes pattern shown earlier works, but the platform-independent equivalent is shorter and picks up every host in the group:

---
- hosts: switches
  gather_facts: false
  tasks:
    - name: Back up running config to a timestamped file
      ansible.netcommon.cli_config:
        backup: yes
        backup_options:
          dir_path: "backups/{{ inventory_hostname }}"
          filename: "{{ lookup('pipe', 'date +%Y%m%d-%H%M%S') }}.cfg"

Storing backups under one directory per host keeps the diffs readable. If you would rather not manage retention yourself, the dedicated tooling in Oxidized network configuration backup polls the same devices on a schedule and commits each revision to git, which is a better fit for long-term history than a pile of flat files.

Scaling with Dynamic Inventory

Static inventory files are fine to about fifty devices; beyond that, generate the inventory from your source of truth. The NetBox inventory plugin builds groups straight from the IPAM database, so the platform group and the ansible_network_os value come from data that already exists:

# inventory/netbox.yml
plugin: netbox.netbox.nb_inventory
api_endpoint: https://netbox.example.net
token: "{{ lookup('env', 'NETBOX_TOKEN') }}"
group_by:
  - device_role
  - platform
compose:
  ansible_network_os: platform.slug

The payoff is that a device decommissioned in NetBox disappears from the next playbook run, so there are no stale hostnames to prune and no risk of a script pushing configuration to a switch that was removed from the estate a month ago. The same principle - let a database be the single source of truth and generate the automation inputs from it - is what separates a collection of one-off playbooks from a maintainable automation platform.

Rolling Deployment Pattern

Applying a configuration change to every device at once is how a small syntax mistake becomes a site outage. Serial execution with a fail-fast health check contains the blast radius:

---
- hosts: switches
  gather_facts: false
  serial: 5
  max_fail_percentage: 0
  tasks:
    - name: Apply the change
      ansible.netcommon.cli_config:
        config: "{{ lookup('file', 'snippets/ntp.txt') }}"

    - name: Verify the device is still healthy
      ansible.netcommon.cli_command:
        command: show ntp status
      register: ntp

    - name: Abort the rollout if NTP did not synchronise
      assert:
        that:
          - "'synchronized' in (ntp.stdout | join(' '))"

serial: 5 batches the fleet five hosts at a time, and max_fail_percentage: 0 stops the entire run the moment one device fails an assertion. That combination means the fifth batch never starts if the first device already proved the change is bad.

Common Errors and How to Fix Them

Three failures account for most first attempts. "unable to open shell" almost always means ansible_network_os is missing or points at the wrong collection. "timed out waiting for privilege escalation" means the enable password is not set via ansible_become_password. And a playbook that hangs on the first task is calling the default fact gathering, which tries to run Python on a device that has none - set gather_facts: false on every network play. Testing against a container-based lab rather than live kit, as described in Containerlab multi-vendor network labs, shortens that debugging loop considerably.

When to Move Beyond cli_command

cli_command is the right tool for ad-hoc collection, but it is a text pipe: it cannot compute a diff or guarantee idempotency, and it has no idea what the device's desired state should be. For configuration management move to vendor resource modules, for generated configuration move to Jinja2 templating, and for declarative state across many devices consider Terraform's network providers. Ansible network automation playbook examples covers the middle ground, and Terraform for network automation shows where the declarative model fits better than Ansible's imperative one.

Related reading: Ansible network facts and config backup examples and Ansible Cisco network automation playbooks.

Original article: Ansible Network Examples (official docs) | Juniper: Ansible for Junos modules overview