This article serves as an example to explore different strategies for configuring a feature on network devices using Ansible . Specifically, we will focus on configuring NTP on Cisco IOS devices, although the discussions here can be generalized.
As mentioned, in general, there are two possible approaches:
cisco.ios.ios_ntp_global
;cisco.ios.ios_config
.The two approaches are radically different:
Let’s see in detail how to configure NTP on Cisco IOS in the two described modes, starting from a list of NTP peers defined at the configuration level:
ntp_peers:
- 0.pool.ntp.org
- 1.pool.ntp.org
- 2.pool.ntp.org
- 3.pool.ntp.org
- 4.pool.ntp.org
cisco.ios.ios_ntp_global module
When using modules that we are not familiar with, it is always good practice to:
Reviewing the documentation immediately reveals that the module wants the list of peers in a different format from ours. Since all parameters are standardized, from a design point of view, it makes sense to keep the list of NTP peers as seen previously, moving the “translation” of the format into the role:
- ansible.builtin.set_fact:
ntp_peers_config_list: "{{ ntp_peers_config_list|default([]) + [{'peer': item}] }}"
with_items: "{{ ntp_peers }}"
We will immediately notice that we are introducing a performance problem that we will address later.
At this point, we have a list of NTP peers in the correct format, all we have to do is invoke the module:
- cisco.ios.ios_ntp_global:
config:
peers: "{{ ntp_peers_config_list }}"
state: replaced
The only open point is the state
parameter, which can take different values:
merged
: the added configuration is added to what is already present (any existing NTP peers will not be removed).replaced
and overridden
: the added configuration replaces what is already present (any existing NTP peers will be removed).deleted
: the existing configuration will be removed, and nothing will be configured.In this case, the replaced
and overridden
modes are identical. In general, behavior should always be checked with the appropriate documentation, but we can say that:
replaced
: individual configurations present in the device and not expected are first removed and then correctly reinserted.overridden
: all the configuration present in the device is deleted and then correctly reinserted.If in the
cisco.ios.ios_config
module the behavior is identical, in the cisco.ios.ios_acls module the behavior differs because ACLs are composed of sub-blocks:
replaced
in this case removes individual ACEs (access control entries) from each ACL, and then inserts the correct ACEs.overridden
in this case deletes the ACLs and then reinserts them correctly.There are then three values that will not modify the state of the configuration and that can be used for other elaborations, such as debugging:
rendered
: the configuration that would be added is saved in output in the rendered
key.gathered
: the configuration present on the device is read and saved in output in JSON format using the gathered
key.parsed
: like gathered
but the input is not the device configuration, but what is present in the running_config
variable. The output is saved in JSON format using the parsed
key.Given the complexity of the configurations that can be performed on network devices, it should be clear why it is important to verify that the module behaves as expected, supports the configurations we need, and that the behavior remains the same in case of updates.
The configuration is not saved automatically at the end of the task: it is clear that in a playbook that invokes dozens of different modules, if the configuration were saved after each task, we would have a possible performance problem. Furthermore, we must bear in mind that, since we are effectively doing text scraping, the execution of each module involves reading the configuration using the show running-config
command: this can generate an additional performance problem.
The generic module
cisco.ios.ios_config
gives us complete freedom to add and remove any configuration we can think of. It remains up to us, if we deem it appropriate, to manage idempotence, correctness, and efficiency.
The
cisco.ios.ios_config
module:
show running-config
command. The output will be used to verify if certain commands already exist and thus heavily influences the changed
state. For example, if we configure int e0/0
, with each execution Ansible will re-enter the configuration because interface Ethernet0/0
is present instead in the configuration. We deduce that we must be extremely precise in the commands we execute. With each change, Ansible will notify us with the following message: To ensure idempotency and correct diff the input configuration lines should be similar to how they appear if present in the running configuration on the device.running_config
variable, allowing us to optimize complex playbooks.changed
state. This is because the inserted (default) command will never be checked as present in the configuration. Therefore, we must manually manage this event.A simplified approach that I call “blind” involves executing commands anyway, even if not necessary, such as removing a specific NTP peer. This approach certainly has the advantage of being simple and immediate, but requires anticipating all possible cases and always results in the changed
state, thus nullifying the possibility of verifying “compliance”.
That said, our playbook must:
The first part is extremely simple:
- cisco.ios.ios_config:
lines:
- "ntp peer {{ item }}"
replace: line
save_when: never
with_items: "{{ ntp_peers }}"
There are two parameters to pay attention to:
replace
: takes line
(default) or block
as a value and defines how the configuration is applied. With line, individual commands not present in the configuration are sent, with block
, all commands are sent even if one differs from the configuration.save_when
: defines when the configuration is saved, and the values can be: always
, never
(default), modified
, changed
.We now need to remove the no longer necessary NTP peers. To do this, we must first extract all configured NTP servers:
- ansible.builtin.set_fact:
current_ntp_peers: "{{ running_config | regex_findall('^ntp peer .*$', multiline=True) | regex_replace('ntp peer ', '')}}"
Finally, we can remove the unnecessary NTP servers, i.e., those that are configured but not present in the ntp_peers
list:
- cisco.ios.ios_config:
lines:
- "no ntp peer {{ item }}"
running_config: "{{ running_config }}"
replace: line
save_when: never
with_items: "{{ current_ntp_peers }}"
when: item not in ntp_peers
This approach allows us ample freedom but at the same time proves complex to manage nested block configurations (such as routing).
At this point, the devices will have the desired configuration, but it is not saved. To do this, we have three approaches:
handler
: which allows us to “trigger” a task if it results in the changed
state. This mode is the simplest but has a problem: if the playbook stops, there is no guarantee that the next execution will still report a changed
state and consequently trigger the handler
task.running
and startup
configurations are different and, in this case, actually save the configuration.Let’s see first how to save the configuration:
- cisco.ios.ios_command:
commands: write memory
We used write memory
instead of the modern copy running-config startup-config
because the former does not require confirmation, while the latter does: managing the confirmation would be an unnecessary complexity.
This task can be configured within the post_tasks
, or called as a handler
by all tasks that potentially modify the configuration.
The tasks become:
- cisco.ios.ios_config:
lines:
- "ntp peer {{ item }}"
replace: line
save_when: never
with_items: "{{ ntp_peers }}"
notify: SAVING CONFIGURATION
While the task to save the configuration must be configured as a handler
, preferably within the handlers/main.yml
file.
We must now optimize the configuration saving, only performing it if necessary. Procedurally, this means comparing the startup-config
with the running-config
, and, in case of differences, performing the save.
Let’s see step by step how to proceed. Initially, we need to read the updated startup-config
and running-config
:
- cisco.ios.ios_command:
commands: show startup-config
register: show_startup_config_output
- cisco.ios.ios_command:
commands: show running-config
register: show_running_config_output
We must then verify if the two configurations differ, net of some lines.
- ansible.utils.fact_diff:
before: "{{ show_startup_config_output.stdout[0] }}"
after: "{{ show_running_config_output.stdout[0] }}"
plugin:
vars:
skip_lines:
- "^Current configuration.*"
- "^Building configuration.*"
register: config_differ
We can then condition the saving of the configuration on the result of the previous task using config_differ.changed
.
A note of caution: the save process should only be done if the playbook is not in check mode (-C
). Finally, we must remember that the save task should also be executed if the playbook is run including only some tags
.
Despite everything, it is preferable to use specialized modules, if bug-free and if they implement all the features we need. This also applies if we encounter performance problems. However, in my experience, it will often be necessary to use the generic configuration module to configure specific details not foreseen.