Ansible Lesson 23 of 42

Ansible Network Automation, In Depth: Cisco IOS/NX-OS, Juniper Junos & Arista EOS with ansible.netcommon

In a nutshell

Automating servers and automating network gear feel similar but work in opposite directions. On a server, Ansible ships a small program to the machine and lets it run there — the server does the work. A switch or router won’t allow that: it has no Python, no shell you can drop files into, and it will hang up if you try. So Ansible flips the model. It keeps all the brains on the control node (your laptop or an automation server) and simply dials each device — over SSH CLI, over XML, or over a REST API — speaking whatever “language” that box understands, and pushes exactly the configuration lines that are missing.

Think of it like a multilingual switchboard operator sitting in a central control room. The operator never moves into the buildings; they phone each site, speak Cisco to the Cisco boxes, Junos to the Juniper boxes and eAPI to the Arista boxes, then read back the line to confirm the change landed. The vendor collections (cisco.ios, cisco.nxos, junipernetworks.junos, arista.eos) are the phrasebooks; the connection plugins (network_cli, netconf, httpapi) are the phone lines.

The one idea that trips up everyone new to this: network devices are not idempotent the way servers are. A server module can inspect state and decide to do nothing; a switch just accepts whatever config lines you paste at it. Ansible earns idempotence back by reading the current config, comparing it to your intent, and sending only the difference — that is exactly what resource modules do for you. Run a good network play twice and the second run says changed=0; that is the whole game.

Beginners should care because this is how modern network teams stop making changes by hand at 2 a.m., one device at a time. Once you can express “this interface should have MTU 9214 and this description” as YAML and let Ansible reconcile it across a hundred switches, network change becomes reviewable, testable and repeatable — the same discipline server teams have had for a decade.

Level: Advanced, with a beginner-friendly on-ramp · Time: ~40 min (plus ~30 min for the free Containerlab lab)

New to Ansible itself? Start with Ansible fundamentals and your first playbook, then come back — this lesson assumes you can already read a play.

A switch is not a server. It does not run Python, it does not run an SSH agent in the kernel sense, it does not let you write to /etc/, and most of them will hang up on you if you try to scp a file. And yet network teams have the same demand server teams do — declarative, idempotent, auditable change. Ansible network automation is the answer because Ansible’s agentless push model already worked over SSH, and because the heavy lifting can move off the device: the connection plugin runs the CLI/API session on the control node, structured modules render the right config and push exactly what is needed. The result is that a Cisco Catalyst, a Nexus 9000, an MX204, a Juniper SRX, and an Arista 7050 all become first-class targets for the same playbook → role → collection machinery you already know.

This lesson is the exhaustive tour. We start with why network is different: no Python on the box, no facts gathering by setup, no become, and connection plugins that proxy a CLI/NETCONF/REST session instead of executing modules locally. We then walk the four mainstream vendor collections — cisco.ios, cisco.nxos, junipernetworks.junos, arista.eos — and their resource module pattern, which is the modern, declarative way to model interfaces, VLANs, L3, OSPF, BGP and ACLs. We cover the seven canonical states (merged, replaced, overridden, deleted, parsed, gathered, rendered) so you understand exactly what a single state: keyword does to the running config, the difference between network_cli (screen-scraping CLI), netconf (XML/YANG over SSH) and httpapi (REST/eAPI), ansible.netcommon.cli_command and ansible.netcommon.cli_config for the dirty cases that don’t have a resource module yet, intent-based config drift detection with --diff and gathered+rendered, configuration backup with the network resource modules plus ansible.netcommon.cli_backup, and the production patterns that make multi-vendor automation tractable: a single inventory: per vendor, role-per-platform, group-vars-per-vendor for ansible_network_os, and Execution Environments built with the right collections so AAP can run network jobs at scale. Everything targets current Ansible (ansible-core 2.17+, the ansible.netcommon 6+, cisco.ios 6+, cisco.nxos 7+, junipernetworks.junos 8+, arista.eos 7+ collections, 2026), uses FQCN throughout, and ends with a free hands-on lab that uses the Containerlab project to stand up an Arista cEOS topology you can drive without owning hardware.

Learning objectives

After this lesson you can:

Prerequisites & where this fits

You should already be comfortable with playbooks, plays and tasks, variables and the precedence rules, Jinja templating, and roles and collections. The companion advanced lessons that compound here are delegation, run_once and serial — because most network change is “drain leaf-1, change it, validate, move on” — performance tuning (network connections are slow per device, so forks and gather_facts: false matter more), and Execution Environments (the supported way to ship a network EE with all four vendor collections plus the right Python deps). In the Ansible Zero-to-Hero programme this is the Network expert lesson and a textbook EX374 / RHCEoSA-grade topic. Real teams running this in production almost always do so from AAP Controller plus a network EE, with mesh execution nodes pinned per network segment.

Core concepts

Five mental models carry the whole lesson.

1. Modules don’t run on the device — they run for the device. A normal Ansible module is shipped to the target, executed under Python, and prints JSON home. A network module runs on the control node; the connection plugin proxies a CLI/NETCONF/HTTP session to the box. That is why network plays don’t need Python on the device, don’t use become, and don’t gather facts via setup (they use a vendor *_facts module instead). Internalise this and the rest of the rules stop feeling arbitrary.

2. Three connection plugins, three universes. network_cli is screen-scraping over SSH — it enables, sends commands, parses output. It works everywhere because every box has a CLI, but it is the slowest and most brittle. netconf speaks XML/YANG over SSH — Juniper’s native protocol, and the way to get structured data without parsing CLI text. httpapi speaks REST/JSON over HTTPS — Arista eAPI, Cisco IOS-XE REST, F5 iControl, Meraki Dashboard. Pick the one the box does best.

3. Resource modules are declarative; raw cli_config is imperative. A resource module (ios_interfaces, eos_l3_interfaces, junos_vlans, nxos_bgp_global) takes a desired state in YAML and produces exactly the diff needed. A raw CLI module (cli_command, cli_config) lets you push lines directly. Resource modules win every time you can use them — they are idempotent, support state: replaced/overridden/deleted, and produce structured gathered data. Use raw CLI only when the resource module doesn’t cover the feature yet.

4. The seven states are the API. Every modern resource module accepts the same seven state: values: merged (add/update — default), replaced (replace just this resource), overridden (replace all resources of this type — destructive!), deleted (remove just this resource), parsed (read a CLI text file and return structured data — offline, no device), gathered (query the device and return structured data — read-only), rendered (turn structured data into the CLI lines it would push, without touching the device). Once you know these seven you know every resource module.

5. Multi-vendor is a grouping problem, not a coding problem. Network fleets are mixed by definition. The shape that scales is: one inventory group per vendor (cisco_ios, cisco_nxos, juniper_junos, arista_eos), group_vars/<group>/all.yml setting ansible_network_os and ansible_connection, and a single playbook that includes a vendor-specific role per group via import_playbook or include_role. Trying to write one role that detects vendor at runtime is a trap — the resource modules are vendor-namespaced by design.

Keep these terms straight: connection plugin (the SSH/NETCONF/HTTP transport), ansible_network_os (the platform identifier the connection plugin uses to dispatch the right module pack), resource module (declarative, structured, per-feature), cli_command / cli_config (raw CLI escape hatch), state machine (the seven canonical state: values), fact gathering (vendor *_facts modules, NOT setup), become semantics on network (means enable mode, not sudo).

Why network is different

Internalise this table; everything else falls out of it.

Server target Network target
Has Python; modules ship and execute on the box. Almost never has Python; modules execute on the control node.
become: true runs sudo. become: true enters enable mode (and become_method: enable).
Facts are gathered by ansible.builtin.setup. Facts are gathered by cisco.ios.ios_facts, arista.eos.eos_facts, etc.
gather_facts: true is fine. Always set gather_facts: false at play level — setup will fail.
ansible_connection: ssh (default). ansible_connection: ansible.netcommon.network_cli / netconf / httpapi.
One module = one apt/yum/copy. One module = one feature (interfaces, VLANs, BGP), with seven states.
Idempotency from module logic on the box. Idempotency from gathered → diff → render on the control node.
Rolling updates use serial over hosts. Rolling updates use serial over devices, often run_once for spine-leaf coordination.

The two facts to never forget: gather_facts: false at the play level (otherwise setup runs against a switch and the play fails before your first task), and ansible_become_method: enable when you set become: true (because privilege escalation on a switch is enable, not sudo).

Connection plugins, in full

ansible.netcommon ships the three connection plugins network plays depend on. Each takes a slightly different set of vars.

ansible.netcommon.network_cli

Screen-scraping CLI over SSH. The lowest common denominator. Every IOS, NX-OS, Junos, EOS, IOS-XR and VyOS box supports it.

# group_vars/cisco_ios/all.yml
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.ios.ios
ansible_user: netadmin
ansible_password: "{{ vault_cisco_password }}"
ansible_become: true
ansible_become_method: enable
ansible_become_password: "{{ vault_cisco_enable }}"
ansible_command_timeout: 60
ansible_persistent_command_timeout: 60
ansible_persistent_connect_timeout: 30

The persistent timeouts matter: network_cli keeps the SSH session alive across tasks (that is the whole point — re-handshaking SSH per task on a TCAM-heavy switch is brutal). Tune ansible_persistent_connect_timeout up if your devices are slow to negotiate; the ansible_command_timeout covers the time a single CLI command may take to return.

ansible.netcommon.netconf

XML/YANG over SSH on port 830. The right answer for Juniper (NETCONF is native) and increasingly for IOS-XR. Returns structured data and supports candidate config + commit.

# group_vars/juniper_junos/all.yml
ansible_connection: ansible.netcommon.netconf
ansible_network_os: junipernetworks.junos.junos
ansible_user: netadmin
ansible_ssh_private_key_file: ~/.ssh/junos
ansible_port: 830

NETCONF gives you commit/rollback semantics for free — Junos always commits to candidate first.

ansible.netcommon.httpapi

HTTPS + JSON. The right answer for Arista eAPI, Cisco IOS-XE RESTCONF, Cisco NX-API, F5 BIG-IP iControl, Meraki Dashboard, Cisco Catalyst Center, and a long tail of REST-only platforms.

# group_vars/arista_eos/all.yml
ansible_connection: ansible.netcommon.httpapi
ansible_network_os: arista.eos.eos
ansible_httpapi_use_ssl: true
ansible_httpapi_validate_certs: false
ansible_httpapi_port: 443
ansible_user: admin
ansible_password: "{{ vault_eos_password }}"
ansible_become: true
ansible_become_method: enable

httpapi is by far the fastest of the three because every “task” is a single REST roundtrip — no PTY allocation, no enable prompt, no banner scraping.

Plugin Transport Best for Speed Structured data
network_cli SSH + PTY Universal fallback Slowest No (CLI text → parsers)
netconf SSH + NETCONF (830) Junos, IOS-XR Medium Yes (XML/YANG)
httpapi HTTPS + JSON Arista, IOS-XE REST, NX-API Fastest Yes (JSON)

The vendor collections

Four collections cover ~95% of enterprise network fleets:

Collection Platform Lead module families
cisco.ios Catalyst / IOS, IOS-XE ios_interfaces, ios_l2_interfaces, ios_l3_interfaces, ios_vlans, ios_ospfv2, ios_bgp_global, ios_acls, ios_facts, ios_command, ios_config
cisco.nxos Nexus / NX-OS nxos_interfaces, nxos_vlans, nxos_bgp_global, nxos_bgp_neighbors, nxos_ospfv2, nxos_facts, nxos_command
junipernetworks.junos Junos (MX, SRX, EX, QFX) junos_interfaces, junos_l2_interfaces, junos_l3_interfaces, junos_vlans, junos_ospfv2, junos_bgp_global, junos_facts, junos_command, junos_config
arista.eos EOS (7000-series) eos_interfaces, eos_l2_interfaces, eos_l3_interfaces, eos_vlans, eos_bgp_global, eos_ospfv2, eos_facts, eos_command, eos_config

Plus the cross-vendor toolkit:

Module Purpose
ansible.netcommon.cli_command Run a raw CLI command on any platform that has network_cli.
ansible.netcommon.cli_config Push raw config lines safely (with replace, match, before, after).
ansible.netcommon.cli_backup Save the running config to a local file.
ansible.netcommon.cli_parse Parse CLI text into structured data via TextFSM/NTC-Templates/PyATS/native.
ansible.netcommon.netconf_get / netconf_config Direct NETCONF read/write for non-Junos NETCONF boxes.

Install everything you need at once:

ansible-galaxy collection install -r requirements.yml
# requirements.yml
collections:
  - name: ansible.netcommon
  - name: cisco.ios
  - name: cisco.nxos
  - name: junipernetworks.junos
  - name: arista.eos

The resource module pattern

Resource modules are the headline feature. They work the same way across all four vendors. Here is the full mental model on cisco.ios.ios_interfaces:

- name: Declare interface intent on Cat9300
  cisco.ios.ios_interfaces:
    config:
      - name: GigabitEthernet1/0/1
        description: "uplink-to-spine-1"
        enabled: true
        mtu: 9216
      - name: GigabitEthernet1/0/2
        description: "server rack A"
        enabled: true
        mtu: 1500
    state: merged

What happens depends entirely on state:.

The seven states, in detail

merged (default) — combine the desired config with whatever is already there. Add interfaces not listed; update the ones that are; leave existing-but-not-listed interfaces alone. The least destructive state. Use for adding configuration.

replaced — for each listed interface, replace its config with what you wrote. Interfaces not listed are untouched. Use for “I want THIS interface to look exactly like this, and I don’t care what was there.”

overridden — replace all interfaces of this type with the listed set. Anything not listed is removed. Destructive — use with eyes open. Always preview with --check --diff first.

deleted — remove the configuration for the listed interfaces. Use for cleanup.

parsedrunning_config: takes a string of CLI text; the module returns the structured equivalent. Runs offline. Use for migrating from a Tcl/Expect script: parse the old box’s CLI dump and feed it forward.

gathered — query the device and return structured data. Read-only. Use for backups, drift, and reports.

renderedconfig: is given; the module returns the CLI lines it would push. Does not touch the device. Use to preview, to generate config for golden images, or to feed a build-then-push pipeline.

State Touches device? Touches config? Destructive? Typical use
merged yes yes no day-2 add
replaced yes yes per-resource day-2 update
overridden yes yes category-wide rebuild from intent
deleted yes yes yes decommission
parsed no no no migrate from text
gathered yes (read) no no drift / backup
rendered no no no preview / golden

The seven states are the same across cisco.ios.*, cisco.nxos.*, junipernetworks.junos.*, and arista.eos.*. Once you have learned them on ios_interfaces, you have learned them on eos_bgp_global and junos_l3_interfaces.

Drift detection in practice

The standard drift loop is gathered → diff → alert:

- name: Capture current interface state
  cisco.ios.ios_interfaces:
    state: gathered
  register: live

- name: Load intended state from inventory
  ansible.builtin.set_fact:
    intent: "{{ interfaces_intent }}"

- name: Render what we WOULD push
  cisco.ios.ios_interfaces:
    config: "{{ intent }}"
    state: rendered
  register: rendered

- name: Show the diff
  ansible.builtin.debug:
    msg: |
      LIVE  : {{ live.gathered }}
      INTENT: {{ intent }}
      WOULD PUSH: {{ rendered.rendered_config }}

Or use --check --diff against state: replaced to get a structured diff in --diff output without touching the device.

Multi-vendor inventory shape

The shape that scales is one group per vendor with vendor-pinned vars:

# inventory/hosts.ini
[cisco_ios]
edge-rtr-1.lab
edge-rtr-2.lab

[cisco_nxos]
spine-1.lab
spine-2.lab
leaf-1.lab
leaf-2.lab

[arista_eos]
border-leaf-1.lab
border-leaf-2.lab

[juniper_junos]
mx-edge-1.lab
mx-edge-2.lab

[network:children]
cisco_ios
cisco_nxos
arista_eos
juniper_junos
# group_vars/cisco_ios/all.yml
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.ios.ios
ansible_become: true
ansible_become_method: enable

# group_vars/cisco_nxos/all.yml
ansible_connection: ansible.netcommon.httpapi
ansible_network_os: cisco.nxos.nxos
ansible_httpapi_use_ssl: true
ansible_httpapi_validate_certs: false

# group_vars/arista_eos/all.yml
ansible_connection: ansible.netcommon.httpapi
ansible_network_os: arista.eos.eos
ansible_httpapi_use_ssl: true

# group_vars/juniper_junos/all.yml
ansible_connection: ansible.netcommon.netconf
ansible_network_os: junipernetworks.junos.junos
ansible_port: 830

# group_vars/network/all.yml — applies to ALL vendors
ansible_user: netadmin
ansible_password: "{{ vault_net_password }}"
gather_facts: false

Then a top-level playbook composes per-vendor plays:

# site.yml
- import_playbook: plays/cisco_ios.yml
- import_playbook: plays/cisco_nxos.yml
- import_playbook: plays/arista_eos.yml
- import_playbook: plays/juniper_junos.yml

Configuration backup

Two patterns. The simple one uses cli_backup:

- name: Back up running config to backups/
  ansible.netcommon.cli_backup:
    filename: "{{ inventory_hostname }}-{{ ansible_date_time.iso8601_basic_short }}.cfg"
    dir_path: "./backups/"

The structured one uses state: gathered per resource and dumps YAML — useful for git-tracked declarative backups:

- name: Capture interfaces intent
  cisco.ios.ios_interfaces:
    state: gathered
  register: ifs

- name: Save as YAML backup
  ansible.builtin.copy:
    content: "{{ ifs.gathered | to_nice_yaml }}"
    dest: "./intent/{{ inventory_hostname }}/interfaces.yml"
  delegate_to: localhost

Commit intent/ to git per nightly run, and you have a per-feature, structured, diff-friendly backup of every device.

The escape hatch: cli_command and cli_config

When the resource module doesn’t cover the feature (yet), drop to raw CLI. Use cli_command for show and exec commands:

- name: Show CDP neighbors and capture
  ansible.netcommon.cli_command:
    command: "show cdp neighbors detail"
  register: cdp

Use cli_config for configuration changes — it has the safety levers match, replace, before, after:

- name: Push raw config lines (idempotent via `match`)
  ansible.netcommon.cli_config:
    config: |
      ip access-list standard MGMT-IN
       permit 10.0.0.0 0.0.0.255
       deny any log
    replace: block
    match: line
    before:
      - "no ip access-list standard MGMT-IN"

Avoid cli_config when a resource module exists — you lose the seven-state semantics, the --diff is blind to it (it shows the lines, not the intent), and idempotence is your problem.

Jinja config templating

Resource modules cover the structured features — interfaces, VLANs, BGP, OSPF, ACLs. But plenty of device config is free-form text a resource module doesn’t model: login banners, NTP servers, SNMP, AAA/TACACS, syslog destinations, DNS. For those, the classic pattern is Jinja template → raw config push — the same Jinja2 templating you use for server config files, aimed at a switch.

You keep the intent as variables and the shape as a template:

{# templates/baseline.j2 #}
{% for server in ntp_servers %}
ntp server {{ server }}
{% endfor %}
{% for host in syslog_hosts %}
logging host {{ host }}
{% endfor %}
# group_vars/cisco_ios/all.yml (excerpt)
ntp_servers:
  - 10.0.0.1
  - 10.0.0.2
syslog_hosts:
  - 10.0.0.50

Two idiomatic ways to push the rendered text:

# A) render in the task with the template lookup, push with cli_config
- name: Apply NTP + syslog baseline (idempotent via line match)
  ansible.netcommon.cli_config:
    config: "{{ lookup('ansible.builtin.template', 'templates/baseline.j2') }}"

# B) let the vendor *_config module render the Jinja and diff it — with a backup
- name: Apply baseline via ios_config (renders src through Jinja)
  cisco.ios.ios_config:
    src: baseline.j2
    match: line
    backup: true
    backup_options:
      dir_path: ./backups/

The *_config modules (ios_config, nxos_config, eos_config, junos_config) are the middle ground between a resource module and raw cli_command: they render a Jinja src:, diff it against the running config with match: (line/strict/exact/none), push only what differs, and can take a backup: in the same task. They are line-idempotent (they won’t re-push a line that is already present) but they are not state-aware the way a resource module is — they cannot remove a line you stopped templating. That is the key limitation: templating is great for adding/asserting a baseline, but “delete everything not in my template” is a job for a resource module’s overridden state, not for ios_config.

Rule of thumb: structured feature → resource module; free-form text with no resource module → Jinja + *_config/cli_config. Keep the two out of the same feature so you never fight idempotence.

Hands-on free lab — Containerlab + cEOS

You will not buy a switch. The lab uses Containerlab, an open-source CLI that runs vendor virtual images (cEOS for Arista, vMX for Juniper, etc.) in Docker. Arista cEOS is free for personal use after registration. The whole exercise is done from your laptop.

# 1. install containerlab + docker (mac/linux)
brew install containerlab          # or curl -sL https://get.containerlab.dev | bash

# 2. pull arista cEOS (registration is free at arista.com/support)
docker import cEOS-lab.tar.xz ceos:4.32.0F

# 3. topology
mkdir -p netlab && cd netlab
cat > topo.clab.yml <<'EOF'
name: ansible-network
topology:
  nodes:
    leaf-1:
      kind: ceos
      image: ceos:4.32.0F
      mgmt-ipv4: 172.20.20.11
    leaf-2:
      kind: ceos
      image: ceos:4.32.0F
      mgmt-ipv4: 172.20.20.12
  links:
    - endpoints: ["leaf-1:eth1", "leaf-2:eth1"]
EOF

sudo containerlab deploy -t topo.clab.yml

You now have two real Arista EOS devices reachable on 172.20.20.11/12.

Set up an Ansible workspace:

mkdir -p ansible-net && cd ansible-net
ansible-galaxy collection install ansible.netcommon arista.eos

cat > inventory.ini <<'EOF'
[arista_eos]
leaf-1 ansible_host=172.20.20.11
leaf-2 ansible_host=172.20.20.12
EOF

mkdir -p group_vars/arista_eos
cat > group_vars/arista_eos/all.yml <<'EOF'
ansible_connection: ansible.netcommon.httpapi
ansible_network_os: arista.eos.eos
ansible_user: admin
ansible_password: admin
ansible_httpapi_use_ssl: true
ansible_httpapi_validate_certs: false
ansible_become: true
ansible_become_method: enable
EOF

Now the playbook — declare interface intent and assert it:

# site.yml
- name: Configure leaf interfaces
  hosts: arista_eos
  gather_facts: false
  tasks:
    - name: Interfaces intent
      arista.eos.eos_interfaces:
        config:
          - name: Ethernet1
            description: "to-leaf-peer"
            enabled: true
            mtu: 9214
        state: replaced
      register: change

    - name: Verify with gathered
      arista.eos.eos_interfaces:
        state: gathered
      register: live

    - name: Assert MTU landed
      ansible.builtin.assert:
        that:
          - "live.gathered | selectattr('name','equalto','Ethernet1') | map(attribute='mtu') | first | int == 9214"

Run it:

ansible-playbook -i inventory.ini site.yml --diff

You’ll see the --diff show exactly the two CLI lines pushed (description "to-leaf-peer" and mtu 9214), then changed=2. Run it again — changed=0. That is idempotent network change.

Tear down:

sudo containerlab destroy -t topo.clab.yml

Going deeper

Under the hood: the persistent connection

The first task against a device is slow and every task after it is fast — and that is by design. When a network play starts, Ansible forks a small helper process called ansible-connection that opens the SSH/NETCONF/HTTPS session once and holds it open behind a Unix-domain socket under ~/.ansible/pc/. Subsequent tasks reuse that warm socket instead of re-handshaking. This is the persistent connection, and it is why ansible_persistent_connect_timeout (how long to wait for the socket to come up) and ansible_persistent_command_timeout (how long a single command may run) exist as separate knobs.

Three plugin layers sit on top of that socket:

The Python dependencies that must live on your control node or in your Execution Environment follow from the transport: paramiko (or libssh) for network_cli, ncclient for netconf, and requests for httpapi. A “network EE” that is missing ncclient fails every Junos play with an import error, not a connection error — a classic first-day trap.

The resource-module pipeline

A resource module is really a facts + config pair with a four-stage pipeline:

  1. Gather — read the running config off the box (or accept CLI text for parsed).
  2. Parse to facts — turn that text/XML/JSON into a normalised structured model (the same shape you see under gathered).
  3. Diff — compare the have (facts) against the want (your config:), honouring the state:.
  4. Render + push — emit only the CLI lines needed to close the gap, and send them.

The seven states are just different exit points on that pipeline: gathered stops after step 2, rendered runs the render of step 4 against your intent without a device, parsed feeds step 2 from a text file instead of the box, and merged/replaced/overridden/deleted change how step 3 computes the diff. Understanding the pipeline is why “learn the states once, know every module” is true.

Idempotency and check-mode reality

On a server, idempotence lives in the module running on the box. On a network device there is no such helper, so idempotence is computed on the control node by the diff in step 3 — which means it is only as good as the resource module’s model. Raw cli_command has no model, so a show command is reported changed unless you set changed_when: false; raw cli_config diffs line by line, which is weaker than a resource module’s semantic diff. Always pair destructive change with check mode and --diff: --check --diff runs the pipeline through render and shows you the exact lines without pushing them. For resource modules the changed flag is trustworthy; for cli_command/cli_config it is your responsibility.

RESTCONF and gNMI

httpapi + eAPI/NX-API is the common REST path, but two standards-based options are worth knowing:

NAPALM — the cross-vendor abstraction

Before resource modules existed, NAPALM (Network Automation and Programmability Abstraction Layer with Multivendor support) was the way to write vendor-neutral automation. Its Ansible modules — napalm_get_facts, napalm_install_config, napalm_validate (from the community NAPALM collection) — give you a single API across IOS, NX-OS, Junos, EOS and IOS-XR: unified getters (get_facts, get_interfaces, get_bgp_neighbors) that return the same dict shape on every platform, plus a config replace/merge with commit-confirm rollback built in. Where it still shines is that unified getter dictionary and its compare_config diff. Where Ansible’s own resource modules now win is native state: semantics, --check --diff, and being first-class in AAP. Rule of thumb for 2026: reach for resource modules first; keep NAPALM in mind for multi-vendor getters and its rollback-on-commit safety net, especially on platforms where resource-module coverage is thin.

Performance and scale

Network connections are slow per device but cheaply parallel, so scale comes from fan-out, not from speeding up one session:

Version and collection caveats

Common mistakes & troubleshooting

gather_facts: true on a network play. setup runs against a switch, fails, the play aborts before your first task. Always gather_facts: false at the play level, then call *_facts modules explicitly.

Forgot become_method: enable. Tasks fail with “permission denied” or commands silently run from user mode. Set ansible_become_method: enable in group_vars/<vendor>/all.yml.

Wrong ansible_network_os FQCN. Old playbooks use ios / nxos; modern plays must use cisco.ios.ios / cisco.nxos.nxos. The short form still works but emits deprecation warnings.

Connection plugin mismatch. Setting ansible_connection: ssh on a network host gets you setup-style failures. The connection plugin must be ansible.netcommon.network_cli / netconf / httpapi.

Slow per-task latency. You forgot ansible_persistent_connect_timeout and the connection is being torn down between tasks. With network_cli, the persistent socket is what makes a 50-task play finish in seconds, not minutes.

overridden accidentally wipes interfaces you didn’t list. Read the state table again. Use replaced for per-resource updates; overridden is only for “rebuild the entire feature from intent.”

No --check --diff before destructive change. Always preview. The --diff output of a network resource module shows the exact CLI lines that would be pushed.

Trying to delegate_to: localhost for everything. Some cloud-managed APIs (Meraki, Cisco DNA Center, F5) really do want delegate_to: localhost — but most network plays should not delegate; the connection plugin already runs on the control node.

Mixing cli_config and resource modules for the same feature. You’ll fight idempotence forever. Pick one. Resource modules win.

Common beginner mistakes

These are conceptual traps — the wrong mental model — rather than the symptom→fix issues in the troubleshooting list above.

Best practices

Security notes

Practice challenges

Six graded exercises, beginner → advanced. Try each before opening the solution. Most assume the Containerlab cEOS lab from earlier, but the intent applies to any vendor. Everything is FQCN and safe to run with --check --diff first.

Challenge 1 — Wire up eAPI (beginner)

Write the group_vars/arista_eos/all.yml that makes an Arista EOS group reachable over eAPI (HTTPS), in enable mode.

<details> <summary>Solution</summary>

# group_vars/arista_eos/all.yml
ansible_connection: ansible.netcommon.httpapi
ansible_network_os: arista.eos.eos
ansible_user: admin
ansible_password: "{{ vault_eos_password }}"
ansible_httpapi_use_ssl: true
ansible_httpapi_validate_certs: false   # lab only — use a real CA in prod
ansible_become: true
ansible_become_method: enable

Why: eAPI is REST/JSON, so the connection plugin is httpapi (not network_cli); become_method: enable is what “privileged” means on a switch. </details>

Challenge 2 — Read, don’t change (beginner)

Capture the live interface configuration of every leaf as structured data, changing nothing.

<details> <summary>Solution</summary>

- name: Snapshot interface state (read-only)
  hosts: arista_eos
  gather_facts: false
  tasks:
    - name: Gather interfaces
      arista.eos.eos_interfaces:
        state: gathered
      register: live

    - name: Show it
      ansible.builtin.debug:
        var: live.gathered

Why: state: gathered runs the read side of the pipeline only — it queries the device and returns structured data without touching config. </details>

Challenge 3 — Add a VLAN and prove idempotence (intermediate)

Add VLAN 20 named app-tier on the Nexus group with merged, then show that a second run reports changed=0.

<details> <summary>Solution</summary>

- name: Ensure VLAN 20 exists
  cisco.nxos.nxos_vlans:
    config:
      - vlan_id: 20
        name: app-tier
    state: merged

Run it twice:

ansible-playbook -i inv site.yml            # changed=1
ansible-playbook -i inv site.yml            # changed=0

Why: merged adds/updates only what you list and leaves other VLANs alone; the second run finds no diff, so nothing is pushed — that is network idempotence in action. </details>

Challenge 4 — Preview for a change ticket (intermediate)

Produce the exact CLI lines a proposed interface change would push — to paste into a change record — without touching the device.

<details> <summary>Solution</summary>

- name: Render intended CLI offline
  cisco.ios.ios_interfaces:
    config:
      - name: GigabitEthernet1/0/1
        description: uplink-to-spine-1
        mtu: 9216
        enabled: true
    state: rendered
  register: preview

- name: Print the CLI
  ansible.builtin.debug:
    var: preview.rendered

Why: state: rendered runs the render stage against your intent with no device connection at all — perfect for change tickets and golden-config generation. </details>

Challenge 5 — Detect drift and fail loudly (advanced)

For the Arista leaves, assert that Ethernet1 really has mtu: 9214; make the play fail if a human changed it out-of-band.

<details> <summary>Solution</summary>

- name: Gather live interfaces
  arista.eos.eos_interfaces:
    state: gathered
  register: live

- name: Assert Ethernet1 MTU is 9214
  ansible.builtin.assert:
    that:
      - "live.gathered | selectattr('name','equalto','Ethernet1') | map(attribute='mtu') | first | int == 9214"
    fail_msg: "DRIFT: Ethernet1 MTU is not 9214 on {{ inventory_hostname }}"

Why: gathered gives you the live truth; assert turns “does reality match intent?” into a pass/fail gate you can run nightly. (Equivalently, state: replaced under --check --diff shows the exact drifting lines.) </details>

Challenge 6 — Safe change with backup + rollback (advanced)

Wrap an interface change so that it (a) backs up the running config first, (b) rolls back from that backup if anything in the change fails, and © touches one device at a time.

<details> <summary>Solution</summary>

- name: Safe fabric change
  hosts: arista_eos
  gather_facts: false
  serial: 1                      # one device at a time
  tasks:
    - name: Backup running-config first
      ansible.netcommon.cli_backup:
        filename: "{{ inventory_hostname }}-pre.cfg"
        dir_path: ./backups/

    - name: Change block with rollback
      block:
        - name: Apply interface intent
          arista.eos.eos_interfaces:
            config:
              - name: Ethernet1
                description: to-leaf-peer
                mtu: 9214
                enabled: true
            state: replaced
      rescue:
        - name: Roll back from the backup we just took
          ansible.netcommon.cli_config:
            config: "{{ lookup('ansible.builtin.file', './backups/' ~ inventory_hostname ~ '-pre.cfg') }}"
            replace: config

Why: block/rescue (error handling) gives you transactional safety the device doesn’t provide natively; serial: 1 bounds the blast radius to one device. Take the backup before the change, always. </details>

Interview & exam Q&A

Q1. Why do we set gather_facts: false on network plays? Because gather_facts calls ansible.builtin.setup, which runs Python on the target. Switches don’t have Python, so setup fails and the play aborts. Use cisco.ios.ios_facts, arista.eos.eos_facts, etc., explicitly when you need facts.

Q2. Difference between network_cli, netconf and httpapi? network_cli screen-scrapes CLI over SSH (universal but slowest). netconf is XML/YANG over SSH on port 830 (Juniper-native, structured, has commit/rollback). httpapi is HTTPS+JSON (fastest; Arista eAPI, IOS-XE REST, NX-API, F5 iControl). Pick the highest one the platform supports.

Q3. What does become: true mean on a switch? It enters enable mode. You must also set ansible_become_method: enable (not sudo), and ansible_become_password if your devices use an enable secret.

Q4. Walk me through the seven canonical resource-module states. merged adds/updates only what you list (default). replaced rewrites each listed resource. overridden rewrites every resource of that type (anything not listed is wiped). deleted removes listed resources. parsed reads CLI text offline and returns structured data. gathered queries the device and returns structured data (read-only). rendered turns intent into the CLI lines it would push, without touching the device.

Q5. When would you use cli_config instead of a resource module? Only when the feature you need isn’t yet covered by a resource module — for example a vendor-proprietary feature in a brand-new release. You give up idempotence guarantees and structured diffs in exchange for raw access. Code-review every such usage.

Q6. How do you detect drift on a fleet of 200 Cisco IOS routers? Run ios_interfaces, ios_l3_interfaces, ios_ospfv2, etc. with state: gathered against the fleet, compare against a per-host intent stored in YAML (group/host vars), and report diffs. Or run the same modules with state: replaced and --check --diff to see exactly which lines would change.

Q7. Why is httpapi so much faster than network_cli? network_cli allocates a PTY, deals with banners and enable prompts, and parses CLI text per command. httpapi is a stateless HTTPS request that returns JSON. No PTY, no parser, no terminal handshake.

Q8. What is the right way to store device credentials for a 500-device fleet? Don’t put them in group_vars plaintext. Either: (a) Vault-encrypt group_vars/<vendor>/vault.yml and decrypt with --vault-id; or (b) use AAP credential plugins that pull from CyberArk/Conjur/HashiCorp Vault at job runtime — credentials never sit on disk.

Q9. How does parsed state differ from gathered? parsed is offline — you give the module a CLI text dump (running_config: parameter) and it returns the structured equivalent. The device is not touched. gathered queries the live device and returns the same structured shape. Use parsed for migrations from a text dump; use gathered for live drift.

Q10. How do you do a rolling upgrade across a leaf-spine fabric? serial: 1 on the leaves, with a pre_tasks delegate that drains traffic (config nve withdraw, bgp graceful-shutdown, etc.) on the upstream spine, then the change runs on the leaf, then post_tasks re-advertises. Combine with any_errors_fatal: true so a failed leaf stops the rollout.

Q11. How do replaced and overridden differ in practice? On ios_interfaces with three interfaces in config:: replaced rewrites those three and leaves all other interfaces alone. overridden rewrites those three and removes the configuration of every other interface on the box. One is per-resource; the other is feature-wide.

Q12. What’s the production EE story for network automation? Build an Execution Environment containing ansible.netcommon, all the vendor collections you use, plus Python deps (paramiko, ncclient, requests, pyats if you parse CLI). Push to Private Automation Hub, sign it, and pin AAP job templates by digest. Run from a mesh execution node inside the management network so latency is sane.

Q13. Why must mesh execution nodes be inside the management network? A network_cli task is many round-trips per command. Across a 100-200ms WAN link, a 30-task play takes minutes per device. Inside the mgmt VLAN, the same play finishes in seconds.

Q14. Where do delegate_to: localhost patterns belong in network automation? For cloud-managed APIs that don’t accept incoming SSH/HTTPS — Meraki Dashboard, DNA Center, Cisco vManage, F5 BIG-IQ, Cloudflare. The “device” is a tenant on a cloud API, so the play runs locally and authenticates outbound.

Quick check

  1. What FQCN do you set as ansible_network_os for an Arista EOS box driven by eAPI?
  2. Which state: would you use to read interface config off a device into structured YAML, without changing anything?
  3. Why does become_method: enable matter on a Cisco IOS play?
  4. What is the difference between replaced and overridden on cisco.ios.ios_vlans?
  5. Which connection plugin gives you commit/rollback semantics natively?

(Answers: arista.eos.eos; gathered; because enable is the privilege-escalation mode on IOS, sudo doesn’t exist; replaced rewrites only the listed VLANs, overridden rewrites all VLANs on the device; netconf — Junos commits to candidate config first.)

Exercise

Stand up the Containerlab cEOS topology from the lab. Then:

  1. Write a site.yml that declares interface intent (Ethernet1 mtu=9214, Ethernet2 mtu=1500) on both leaves using arista.eos.eos_interfaces with state: replaced.
  2. Add a state: gathered task that registers the live state and a debug that prints the diff.
  3. Add a state: rendered task that prints the CLI lines that would be pushed if you applied the intent today (so you can paste them into a change ticket).
  4. Add a backup step at the top using ansible.netcommon.cli_backup.
  5. Wrap the change steps in a block with rescue that runs cli_config to roll back to the backup file if anything fails.
  6. Run with --check --diff. Then run for real. Then run again — assert changed=0.
  7. (Stretch) Add delegate_to: localhost plus serial: 1 so the leaves change one at a time.

Certification mapping

Cert Coverage
EX374 — Red Hat Certified Specialist in Ansible Automation Direct: network automation, resource modules, state: replaced/gathered, multi-vendor inventory, EE for network.
RHCE EX294 Indirect: connection plugins, FQCN, become_method, role layout.
Cisco DevNet Professional / Specialist (Network Automation) Direct: cisco.ios/cisco.nxos collections, NX-API and IOS-XE REST.
JNCIA-DevOps Direct: NETCONF, junipernetworks.junos.*, commit/rollback semantics.
Arista ACE-A / ACE-AS Direct: eAPI, arista.eos.* resource modules.

Glossary

Next steps

Now that you can drive switches and routers from a playbook, the cloud-target lessons follow. Move on to Ansible for AWS, Ansible for Azure, and Ansible for GCP for the cloud control-plane equivalents — same agentless model, very different module shapes. After that, Ansible for Windows covers the third major OS family, and Ansible for Kubernetes rounds out the platform tour. The Specialist-tier lessons (CI/CD, compliance, scale) all assume you can drive any of these targets.

ansiblenetworkcisco-ioscisco-nxosjuniper-junosarista-eosnetwork_clinetconfEX374
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments