Ansible Deep Dive Part 10 Lab: Automating a Cisco and FortiGate Fleet With Ansible
Part 9 automated servers — the domain Ansible was originally built for. This lab is the domain I actually work in day to day: network devices, where there’s no Python runtime to push a payload to, and the “managed node” is a CLI or a REST API rather than a general-purpose OS.
Why network modules are architecturally different
Every module in Part 9 — package, service, template — executes Python on the managed node itself. A Cisco IOS router or a FortiGate has nowhere for that Python to run. Network collections solve this by running entirely on the control node: the module builds the CLI commands (or API payload) locally, opens a connection to the device, sends them, and parses the response — with local as the effective connection type even though the play still targets a device “host” in inventory.
This is set via the ansible_connection variable per host, not a module argument:
# inventory/network.yml
all:
children:
cisco_routers:
hosts:
r1.lab.local:
ansible_host: 192.168.10.1
vars:
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.ios.ios
ansible_user: automation
ansible_password: "{{ vault_cisco_password }}"
fortigates:
hosts:
fw1.lab.local:
ansible_host: 192.168.10.254
vars:
ansible_connection: ansible.netcommon.httpapi
ansible_network_os: fortinet.fortios.fortios
ansible_httpapi_use_ssl: true
ansible_httpapi_validate_certs: false
ansible_user: automation
ansible_httpapi_pass: "{{ vault_fortigate_password }}"
Two different connection types for two different device families: network_cli drives Cisco IOS the way an engineer would over SSH, issuing and parsing CLI output; httpapi drives FortiOS’s REST API directly, no CLI parsing involved at all. Both credentials come from Vault (Part 6), same vault_ prefix convention as the web app lab.
Gathering facts from a Cisco fleet
# facts.yml
---
- name: Gather Cisco IOS facts
hosts: cisco_routers
gather_facts: false
tasks:
- name: Collect facts
cisco.ios.ios_facts:
gather_subset: [hardware, interfaces]
register: ios_facts_result
- name: Show model and IOS version
ansible.builtin.debug:
msg: "{{ inventory_hostname }}: {{ ansible_facts['net_model'] }} running {{ ansible_facts['net_version'] }}"
gather_facts: false at the play level, deliberately — the standard setup module Part 4 covered assumes a general-purpose OS with Python, which a router doesn’t have. cisco.ios.ios_facts is the network-aware equivalent, gathering the same kind of information (hardware, interfaces, version) through IOS’s own facilities instead.
Pushing configuration to Cisco IOS
# configure_routers.yml
---
- name: Configure Cisco routers
hosts: cisco_routers
gather_facts: false
tasks:
- name: Set NTP servers
cisco.ios.ios_ntp_global:
config:
servers:
- server: 192.168.1.1
prefer: true
- name: Configure loopback interface
cisco.ios.ios_l3_interfaces:
config:
- name: Loopback0
ipv4:
- address: "{{ loopback_ip }}/32"
state: merged
- name: Save running config to startup config
cisco.ios.ios_config:
save_when: modified
Notice there’s no Jinja2 template here — ios_ntp_global and ios_l3_interfaces are resource modules, taking structured data (a dict describing the desired NTP servers, the desired interface state) rather than a raw config block to push. Ansible translates that structured intent into the actual IOS CLI commands and, critically, only pushes the commands needed to close the gap between current and desired state — reading current config first, same idempotency contract as Part 3’s package module, just implemented against IOS syntax instead of a package manager.
ios_config: save_when: modified is the equivalent of service: state=reloaded from Part 3’s handler pattern, but the actual mechanism is different — it writes running-config to startup-config only if this run actually changed something, rather than firing unconditionally.
FortiGate: firewall policy as structured data
# configure_fortigate.yml
---
- name: Configure FortiGate firewall policy
hosts: fortigates
gather_facts: false
tasks:
- name: Create address object for the app subnet
fortinet.fortios.fortios_firewall_address:
vdom: root
firewall_address:
name: app-subnet
subnet: "10.20.0.0/24"
- name: Allow app subnet to reach the internet
fortinet.fortios.fortios_firewall_policy:
vdom: root
firewall_policy:
name: app-subnet-outbound
srcintf: [{name: internal}]
dstintf: [{name: wan1}]
srcaddr: [{name: app-subnet}]
dstaddr: [{name: all}]
action: accept
schedule: always
service: [{name: ALL}]
nat: enable
logtraffic: all
Same shape as the Cisco resource modules — a nested dict describing the address object and the policy, not a raw config firewall policy / edit .. / set .. / next / end block. If you’ve read my Jinja Orchestrator series, this is the same end goal — declarative, repeatable FortiGate config — reached through a different mechanism: Ansible’s structured module arguments here, rather than a Jinja2 template rendering raw CLI syntax there. Both are legitimate; which one fits depends on whether you’re already standardized on Ansible for everything else, or maintaining a template pipeline that predates it.
A config-drift check, run on a schedule
The pattern I actually run against my own lab fleet — read current state, don’t touch anything, alert on unexpected difference:
# drift_check.yml
---
- name: Check for configuration drift
hosts: cisco_routers:fortigates
gather_facts: false
tasks:
- name: Gather current Cisco config
cisco.ios.ios_config:
backup: true
when: inventory_hostname in groups['cisco_routers']
register: cisco_result
- name: Diff against last known-good backup
ansible.builtin.debug:
msg: "Drift detected on {{ inventory_hostname }}"
when: cisco_result.changed | default(false)
backup: true on ios_config saves a timestamped copy of running-config locally before making any change — run this playbook with --check and no actual config is touched, only the drift detection logic fires, and cisco_result.changed tells you whether the device’s actual state has silently diverged from what the playbook expects.
Running it
ansible-galaxy collection install cisco.ios fortinet.fortios ansible.netcommon
ansible-playbook -i inventory/network.yml facts.yml
ansible-playbook -i inventory/network.yml configure_routers.yml --check --diff
ansible-playbook -i inventory/network.yml configure_fortigate.yml --ask-vault-pass
Same discipline as Part 9’s web stack — inventory-driven, vault-protected credentials, check mode before anything touches production — applied to hardware that has no Python runtime of its own to receive a payload on. This is precisely the gap agentless architecture (Part 1) was built to close.
Part 11 goes back to something every playbook eventually needs regardless of what it’s targeting: what happens when a task in the middle of a run fails, and how block/rescue/always let you handle that deliberately instead of leaving a host in a half-configured state.