Running commands with configuration management: Ansible

Exam topic 5.5 · AI, and Network Operations and Management · 10% of the exam

Why this matters

Sooner or later every network team needs to run the same thing on a lot of devices: collect show version from every router before an upgrade, back up every switch's configuration before a change window, or add the same two NTP lines to fifty branches. Doing it by hand takes hours and invites mistakes. A configuration management tool does it in minutes, the same way every time. Ansible is the tool most network teams reach for first, and a junior network engineer is often the person asked to write or run the playbook.

Ansible also changes what can go wrong. A playbook with the wrong host group can change devices nobody meant to touch, and a password typed into a playbook can end up in a shared repository. Reading a playbook, knowing which module does what, and understanding what "ok" and "changed" mean in the output lets you use the tool safely, and spot when someone else's run did something unexpected.

The lesson

Running commands on many devices with Ansible

Ansible is an open-source automation and configuration management tool. Lesson 26 places it in the automation-based approach, and as the engine that applies changes in infrastructure as code. Three properties define it for network work.

  • Agentless. Nothing is installed on the routers and switches. No agent, no extra runtime on flash. Ansible needs only what the device already has: SSH access and a user account (lesson 17).
  • Push model. A control node, the Linux machine or container where Ansible runs, connects out to each device and pushes the work when a playbook runs. Devices do not poll a central server for changes.
  • YAML. Playbooks and, commonly, inventories are written in YAML, a plain-text format that uses indentation for structure.

Compare Puppet and Chef. Both traditionally use an agent on each managed system that pulls its configuration from a central master or server. Chef recipes are written in Ruby; Puppet manifests use Puppet's own language. Ansible's agentless push design is a big reason it fits network devices, most of which cannot run arbitrary agents.

The pieces you need to know:

Piece What it is
Inventory The list of managed hosts, arranged in groups, with variables such as how to connect
Playbook A YAML file of one or more plays
Play Maps a host or group from the inventory to an ordered list of tasks
Task One call to a module, with its arguments
Module The unit of work, such as running commands or pushing configuration lines
Collection A packaged set of modules and plugins, such as cisco.ios

The inventory

The inventory lists the routers and switches to manage and organises them into groups that plays can target, such as core, access or branch_routers. It also carries variables, including how Ansible connects.

# hosts.yml
all:
  vars:
    ansible_connection: ansible.netcommon.network_cli
    ansible_network_os: cisco.ios.ios
    ansible_user: ansible-svc
  children:
    branch_routers:
      hosts:
        br1-rtr1:
          ansible_host: 10.30.1.1
        br2-rtr1:
          ansible_host: 10.30.2.1
    access_switches:
      hosts:
        br1-sw1:
          ansible_host: 10.30.1.11

Two variables matter for Cisco devices.

  • ansible_connection: ansible.netcommon.network_cli makes Ansible open an SSH session to the device's command line, the same CLI you use by hand. Other connection plugins exist: ansible.netcommon.netconf speaks NETCONF with XML payloads instead of using the CLI, ansible.builtin.local runs on the control node itself, and ansible.builtin.winrm is for Windows hosts.
  • ansible_network_os: cisco.ios.ios tells the network_cli plugin it is talking to IOS or IOS XE, so it knows the prompts and how to enter enable and configuration mode.

The password is not in the file. It is supplied at run time with --ask-pass, or stored encrypted with ansible-vault.

Running show commands: cisco.ios.ios_command

The cisco.ios.ios_command module sends exec-mode commands, such as show commands, and returns their output. It is the module for "run this and tell me what it says". It does not change configuration.

This playbook runs show ip interface brief on the branch routers and saves each router's output to a file on the control node:

# show-brief.yml
- name: Collect interface status from branch routers
  hosts: branch_routers
  gather_facts: false
  tasks:
    - name: Run show ip interface brief
      cisco.ios.ios_command:
        commands:
          - show ip interface brief
      register: result

    - name: Save the output to a file
      ansible.builtin.copy:
        content: "{{ result.stdout[0] }}"
        dest: "backups/{{ inventory_hostname }}-ip-int-brief.txt"
      delegate_to: localhost

Reading it line by line:

  1. The play targets the group branch_routers from the inventory.
  2. The first task calls ios_command. register: result stores what the module returned in a variable named result. result.stdout is a list with one entry per command, so result.stdout[0] is the output of the first command.
  3. The second task writes that output to a file named after the host. delegate_to: localhost makes the copy happen on the control node, not on the router.

Running the playbook

The ansible-playbook command runs a playbook, and -i names the inventory:

$ ansible-playbook -i hosts.yml show-brief.yml

PLAY [Collect interface status from branch routers] ****************************

TASK [Run show ip interface brief] *********************************************
ok: [br1-rtr1]
ok: [br2-rtr1]

TASK [Save the output to a file] ***********************************************
changed: [br1-rtr1]
changed: [br2-rtr1]

PLAY RECAP *********************************************************************
br1-rtr1                   : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
br2-rtr1                   : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The show command task reports ok: it ran and changed nothing on the routers. The copy task reports changed, because it created files on the control node. The recap shows nothing unreachable (no SSH connection) and nothing failed (a task errored). The saved file for br1-rtr1 holds normal IOS XE output:

Interface              IP-Address      OK? Method Status                Protocol
GigabitEthernet0/0/0   10.30.1.1       YES NVRAM  up                    up
GigabitEthernet0/0/1   203.0.113.10    YES NVRAM  up                    up
GigabitEthernet0/0/2   unassigned      YES NVRAM  administratively down down

Do not mix up the other Ansible commands, which share the prefix:

  • ansible-galaxy installs and manages collections and roles, for example ansible-galaxy collection install cisco.ios.
  • ansible-vault encrypts and decrypts secrets.
  • ansible-inventory displays the inventory as Ansible understands it, useful for checking groups.
  • ansible on its own runs a single module ad hoc, without a playbook: ansible branch_routers -i hosts.yml -m cisco.ios.ios_command -a "commands='show version'".

Pushing configuration: cisco.ios.ios_config

The cisco.ios.ios_config module pushes configuration lines. It is the module for "make the running configuration contain these lines".

# ntp.yml
- name: Set NTP servers on branch routers
  hosts: branch_routers
  gather_facts: false
  tasks:
    - name: Add the NTP servers
      cisco.ios.ios_config:
        lines:
          - ntp server 10.0.0.6
          - ntp server 10.0.0.7
        save_when: modified

save_when: modified copies the running configuration to the startup configuration only when the running configuration differs from the saved startup configuration (save_when: changed saves only when the task itself made a change).

Worked example: idempotence. Run the playbook twice against a router that has neither line.

$ ansible-playbook -i hosts.yml ntp.yml --limit br1-rtr1
TASK [Add the NTP servers] *****************************************************
changed: [br1-rtr1]
PLAY RECAP *********************************************************************
br1-rtr1                   : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

$ ansible-playbook -i hosts.yml ntp.yml --limit br1-rtr1
TASK [Add the NTP servers] *****************************************************
ok: [br1-rtr1]
PLAY RECAP *********************************************************************
br1-rtr1                   : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
  1. On the first run, ios_config compares the requested lines with the running configuration, finds both missing, sends them, and reports changed.
  2. On the second run, it compares again, finds both lines already present, sends nothing, and reports ok.
  3. It does not fail on "duplicates", and the session is not "unreachable". This behaviour, where repeating a run with no other changes produces no further change, is called idempotence. Ansible's network modules aim for it, which is what makes a playbook safe to rerun.

Before a change window, add --check --diff to see what would change without changing it. --limit narrows a run to one host or group, a sensible first step before running against everything.

A third module: cisco.ios.ios_facts

cisco.ios.ios_facts gathers structured information about each device, such as its model, software version, serial number and interfaces, and makes it available as variables for later tasks. Use it when you want data you can test in a playbook, such as "only upgrade routers running an older version", rather than raw show output to save.

Several commands, and checking the result

ios_command accepts a list, and returns one output per command in the same order. A pre-change snapshot often gathers several at once:

    - name: Pre-change snapshot
      cisco.ios.ios_command:
        commands:
          - show version
          - show ip route summary
          - show ip ospf neighbor
      register: snap

Here snap.stdout[0] is the show version output, snap.stdout[1] the route summary and snap.stdout[2] the OSPF neighbours. snap.stdout_lines holds the same output split into lines, which is easier to read in a debug task. The module can also wait for a condition with wait_for, for example result[2] contains FULL, and fail the task if the condition is not met in time. That turns a show command into a simple check: the playbook stops before a change if the OSPF neighbours are not up.

Working safely with playbooks

  • Start read-only. Run ios_command playbooks against a new inventory first. They gather evidence and change nothing.
  • Use a dedicated account for Ansible, such as ansible-svc above, so device logs show which changes came from automation.
  • Keep secrets out of files. Use --ask-pass or ansible-vault, never a plain-text password in the inventory or a repository.
  • Narrow, then widen. Run with --check --diff, then --limit one device, then the whole group.
  • Back up first. A task that saves the running configuration before ios_config runs gives you something to roll back to (lesson 18 covers copying files off devices).

What the exam is really asking

  • Nothing is installed on the device. Ansible is agentless and connects over SSH. An agent pulling from a master is Puppet or Chef.
  • YAML, not JSON, XML or Ruby. Playbooks are YAML. Ruby is Chef's language.
  • Inventory vs playbook. The inventory lists devices and groups; the playbook lists tasks.
  • Which module. Show commands: cisco.ios.ios_command. Configuration lines: cisco.ios.ios_config. Structured device data: cisco.ios.ios_facts.
  • Which connection. CLI over SSH is ansible.netcommon.network_cli, with ansible_network_os: cisco.ios.ios. NETCONF is ansible.netcommon.netconf.
  • Which command. ansible-playbook -i <inventory> <playbook> runs a playbook.
  • ok vs changed. A rerun with nothing to do reports ok. Changed means the task altered something: the device's configuration or, for a task such as the copy above, a file on the control node.
  • Push. The control node pushes changes when the playbook runs.

Practise what you just read

1. A team starts using Ansible to manage Cisco IOS XE routers. What must be installed on each router before Ansible can manage it?

Select one

  1. Nothing; Ansible connects over SSH
  2. An Ansible agent running on the router
  3. A Python runtime on the router’s flash
  4. A Puppet agent registered to a master
Show answer

A. Ansible is agentless: the control node connects to each router over SSH and does the work from there, so nothing extra is installed on the device. A Puppet agent registered to a master is the agent-based pull model that Ansible avoids.

2. An engineer is writing an Ansible playbook that backs up the running configuration of twenty switches. In which format is the playbook written?

Select one

  1. JSON
  2. XML
  3. YAML
  4. Ruby
Show answer

C. Ansible playbooks are written in YAML, listing plays, the hosts each play targets and the tasks to run in order. JSON is a data format Ansible can read and return, but playbooks are authored as YAML files, and Ruby is the language of Chef recipes.

3. Which Ansible file lists the routers and switches to manage, organised into groups such as core and access, for plays to target?

Select one

  1. The playbook
  2. The inventory
  3. The role
  4. The vault
Show answer

B. The inventory lists the managed hosts and arranges them into groups, along with variables such as the connection type, and each play names a host or group from it. The playbook holds the tasks to run, not the list of devices.

5 more questions on this exam topic are part of the full practice bank.

Practise the full question bank in the exam simulator

This is an independent study companion for the Cisco CCNA 200-301 v2.0 exam and is not produced by or endorsed by Cisco. Cisco, CCNA and IOS are trademarks of Cisco Systems, Inc.