Configuring NTP on Cisco IOS with Ansible

Andrea Dainese
November 04, 2023
Post cover

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:

The two approaches are radically different:

  • Using specific modules delegates all checks regarding idempotence, integrity, and cleanliness of the configuration to the module itself. While the module handles all the work, we need to ensure it behaves correctly (bug-free), supports all the settings we need, and especially, we cannot make any optimizations.
  • Using generic configuration modules allows us flexibility to configure any necessary parameter (very useful in complex configurations involving, for example, BGP, MPLS, QoS…), but requires us to correctly manage idempotence, cleanliness, and especially optimization.

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

Using the cisco.ios.ios_ntp_global module

When using modules that we are not familiar with, it is always good practice to:

  • review the module documentation;
  • perform tests to verify behavior in all possible scenarios;
  • ensure, after any upgrades, that the behavior has changed.

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.

Using the generic module

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:

  • Like in the previous case, it automatically executes the 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.
  • Unlike the previous case, the configuration can be read from the running_config variable, allowing us to optimize complex playbooks.
  • Due to how Cisco devices work, default commands are not shown in the configuration. Inserting, or better restoring a parameter to its default behavior means that Ansible will always declare the 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.
  • Due to how Cisco devices work, I cannot declare a list of NTP peers, but I have to add the missing ones and remove the necessary ones. Unlike the previous case, it is up to us to properly implement this behavior.

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:

  • add the NTP peers that are present in the list but not present in the device configuration;
  • remove the NTP peers that are present in the device configuration but not in the list.

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).

Saving the configuration

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.
  • Always save the configuration at the end of the playbook: this approach is the simplest, however, for each execution, the configuration will be saved, and we will never have visibility of when the router configuration actually needs to be saved.
  • Check if the 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.

Optimizing performance

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.

Conclusions

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.