Configurazione di NTP su dispositivi Cisco IOS con Ansible

Andrea Dainese
4 Novembre 2023
Post cover

Questo articolo serve di esempio per vedere le diverse strategie per configurare una feature su dispositivi di rete utilizzando Ansible. In particolare ci occuperemo di configurare NTP su dispositivi Cisco IOS, tuttavia i ragionamenti che faremo qui possono essere generalizzati.

Come abbiamo accennato, in generale, ci sono due modalità possibili:

I due approcci sono radicalmente diversi:

  • L’uso di moduli specifici demanda al modulo stesso tutte le verifiche relative all’idempotenza, all’integrità e alla pulizia della configurazione. Se da una parte il modulo stesso fa tutto il lavoro, dall’altra dobbiamo fare attenzione che si comporti correttamente (bug free), che supporti tutte le impostazioni che ci servono, e sopratutto non possiamo effettuare ottimizzazioni di sorta.
  • L’uso di moduli di configurazione generica da una parte ci permette ampia flessibilità di configurare qualsiasi parametro serve (molto utile in configurazioni complesse che coinvolgano, ad esempio BGP, MPLS, QoS…), dall’altra ci richiede di gestire correttamente idempotenza, pulizia e sopratutto ottimizzazione.

Vediamo nel dettaglio come configurate NTP su Cisco IOS nelle due modalità descritte, partendo da una lista di peer NTP definita a livello di configurazione:

ntp_peers:
  - 0.pool.ntp.org
  - 1.pool.ntp.org
  - 2.pool.ntp.org
  - 3.pool.ntp.org
  - 4.pool.ntp.org

Utilizzo del modulo cisco.ios.ios_ntp_global

Quando utilizziamo moduli che non conosciamo, è sempre buona norma:

  • rivedere la documentazione del modulo;
  • effettuare dei test per verificare il comportamento in tutti i vari scenari possibili;
  • assicurarsi, dopo eventuali upgrade, che il comportamento sia cambiato.

Rivedendo la documentazione vediamo subito che il modulo vuole la lista dei peer in un formato differente dal nostro. Poiché tutti i parametri sono standardizzati, da un punto di vista di design ha senso mantenere la lista dei peer NTP così come visto precedentemente, spostando la “traduzione” del formato nel ruolo:

- ansible.builtin.set_fact:
    ntp_peers_config_list: "{{ ntp_peers_config_list|default([]) + [{'peer': item}] }}"
  with_items: "{{ ntp_peers }}"

Ci accorgeremo immediatamente che stiamo introducendo un problema di performance che tratteremo in seguito.

A questo punto abbiamo una lista dei peer NTP nel formato corretto, non ci rimane che invocare il modulo:

- cisco.ios.ios_ntp_global:
    config:
      peers: "{{ ntp_peers_config_list }}"
    state: replaced

L’unico punto aperto è il parametro state che può assumere diversi valori:

  • merged: la configurazione aggiunta va ad aggiungersi a quanto già presente (eventuali peer NTP esistenti non verranno rimossi).
  • replaced e overridden: la configurazione aggiunta va a sostituire quanto già presente (eventuali peer NTP esistenti verranno rimossi).
  • deleted: la configurazione esistente verrà rimossa e nulla verrà configurato.

In questo caso le modalità replaced e overridden sono identiche. In generale il comportamento va sempre verificato con l’apposita documentazione, ma possiamo dire che in generale:

  • replaced: singole configurazioni presenti nel dispositivo e non previste vengono prima rimosse e poi reinserite correttamente.
  • overridden: tutta la configurazione presente nel dispositivo viene cancellata e poi reinserita.

Se nel modulo cisco.ios.ios_config il comportamento è identico, nel modulo cisco.ios.ios_acls il comportamento differisce poiché le ACL sono composte da sottoblocchi:

  • replaced in questo caso toglie le singole ACE (access control entry) da ciascuna ACL, per poi inserire le ACE corrette.
  • overridden in questo caso cancella le ACL per poi reinserirle corrette.

Esistono poi tre valori che non modificheranno lo stato della configurazione e che possono essere usati per altre elaborazioni, come il debugging:

  • rendered: la configurazione che verrebbe aggiunta viene salvata in output nella chiave rendered.
  • gathered: la configurazione presente nel dispositivo viene letta e salvata in output nel formato JSON usando la chiave gathered.
  • parsed: come gathered ma l’input non è la configurazione del dispositivo, ma quanto presente nella variabile running_config. L’output viene salvato nel formato JSON usando la chiave parsed.

Data la complessità delle configurazioni che è possibile effettuare su dispositivi di rete, dovrebbe apparire chiaro perché è importante verificare che il modulo si comporti come ci aspettiamo, che supporti le configurazioni che ci servono e che il comportamento rimanga il medesimo in caso di aggiornamenti.

La configurazione non viene salvata automaticamente al termine del task: appare chiaro che in un playbook che invoca decine di moduli diversi, se la configurazione fosse salvata ad ogni task, avremmo un possibile problema di performance. Inoltre dobbiamo tener presente che, poiché stiamo di fatto facendo text scraping, l’esecuzione di ciascun modulo prevede di leggere la configurazione tramite il comando show running-config: questo può generare un ulteriore problema di performance.

Utilizzo del modulo generico

Il modulo generico cisco.ios.ios_config ci da piena libertà di aggiungere e togliere qualsiasi configurazione ci venga in mente. Rimane anche a noi, se lo riteniamo opportuno, l’onere di gestire idempotenza, correttezza ed efficienza.

Il modulo cisco.ios.ios_config :

  • Come nel caso precedente, esegue automaticamente il comando show running-config. L’output verrà usato per verificare se determinati comandi già esistono e influenza quindi pesantemente lo stato changed. Ad esempio se configuriamo int e0/0, ad ogni esecuzione Ansible immetterà nuovamente la configurazione perché nella configurazione è presente invece interface Ethernet0/0. Deduciamo quindi che dobbiamo essere estremamente precisi nei comandi che andiamo ad eseguire. Ad ogni change Ansible ci avviserà con il seguente messaggio: To ensure idempotency and correct diff the input configuration lines should be similar to how they appear if present in the running configuration on device.
  • Diversamente dal caso precedente, la configurazione può essere letta dalla variabile running_config, il che ci permette di ottimizzare playbook complessi.
  • Per come funzionano i dispositivi Cisco, i comandi di default non sono mostrati nella configurazione. Inserire, o meglio riportare un parametro al suo default comporta che Ansible dichiarerà sempre lo stato changed. Questo perché il comando inserito (default) non verrà mai verificato come presente nella configurazione. Dobbiamo quindi gestire manualmente questo evento.
  • Per come funzionano i dispositivi Cisco, non posso dichiarare una lista di peer NTP, ma devo aggiungere i mancanti e togliere i necessari. Diversamente dal caso precedente, sta a noi l’onere di implementare correttamente questo comportamento.

Un approccio semplificato che chiamo “blind” (cieco), prevede di eseguire comunque dei comandi anche se non necessari, come as esempio la rimozione di uno specifico peer NTP. Questo approccio ha sicuramente il vantaggio di essere semplice e immediato, ma richiede di prevedere in anticipo tutti i casi possibili e di avere sempre lo stato changed, vanificando quindi la possibilità di verificare la “compliance”.

Detto questo, il nostro playbook deve:

  • aggiungere i peer NTP che sono presenti nella lista ma non presenti nella configurazione del dispositivo;
  • togliere i peer NTP che sono presenti nella configurazione del dispositivo ma non nella lista.

La prima parte è estremamente semplice:

- cisco.ios.ios_config:
    lines:
      - "ntp peer {{ item }}"
    replace: line
    save_when: never
  with_items: "{{ ntp_peers }}"

Ci sono due parametri da vedere con attenzione:

  • replace: prende come valore line (default) o block e definisce come la configurazione viene applicata. Con line vengono inviati i singoli comandi non presenti nella configurazione, con block vengono inviati tutti i comandi anche se uno solo differisce dalla configurazione.
  • save_when: definisce quando la configurazione viene salvata e i valori possono essere: always, never (default), modified, changed.

Occorre ora rimuovere i peer NTP non più necessari. Per far questo dobbiamo prima estrarre tutti i server NTP configurati:

- ansible.builtin.set_fact:
    current_ntp_peers: "{{ running_config | regex_findall('^ntp peer .*$', multiline=True) | regex_replace('ntp peer ', '')}}"

Infine possiamo rimuovere i server NTP non necessari, ossia quelli che sono configurati ma non presenti nella lista ntp_peers:

- 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

Questo approccio ci permette ampia libertà ma allo stesso tempo risulta complesso gestire configurazioni a blocchi innestati (come il routing).

Salvare la configurazione

A questo punto i dispositivi avranno la configurazione desiderata, ma essa non è salvata. Per fare ciò abbiamo tre approcci:

  • handler: che ci permette di “attivare” un task se esso risulta nello stato changed. Questa modalità è la più semplice ma ha un problema: se il playbook si interrompe, non è detto che la successiva esecuzione riporti ancora uno stato changed e di conseguenza attivi il task di handler.
  • Salvare sempre la configurazione alla fine del playbook: questo approccio è il più semplice, tuttavia per ogni esecuzione la configurazione verrà salvata e non avremo mai visibilità di quando la configurazione del router necessità effettivamente di essere salvata.
  • Verificare se le configurazioni running e di startup sono diverse e, in questo caso, salvare effettivamente la configurazione.

Vediamo prima come salvare la configurazione:

- cisco.ios.ios_command:
    commands: write memory

Abbiamo usato write memory invece del moderno copy running-config startup-config perché il primo non chiede conferma, mentre il secondo si: gestire la conferma sarebbe una complessità inutile.

Questo task può essere configurato all’interno dei post_tasks, o richiamato come handler da tutti i task che potenzialmente modificano la configurazione.

I task diventano quindi:

- cisco.ios.ios_config:
    lines:
      - "ntp peer {{ item }}"
    replace: line
    save_when: never
  with_items: "{{ ntp_peers }}"
  notify: SAVING CONFIGURATION

Mentre il task per salvare la configurazione deve essere configurato come handler, preferibilmente all’interno del file handlers/main.yml.

Ottimizzare le performance

Dobbiamo ora ottimizzare il salvataggio della configurazione, effettuandolo solo se necessario. Da un punto di vista procedurale questo significa comparare la startup-config con la running-config, e, in caso di differenze, effettuare il salvataggio.

Vediamo quindi passo passo come operare. Inizialmente occorre leggere la startup-config e la running-config aggiornate:

- 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

Dobbiamo quindi verificare se le due configurazioni differiscono, al netto però di alcune righe.

- 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

Possiamo quindi condizionare il salvataggio della configurazione dal risultato del task precedente usando config_differ.changed.

Un’accortezza: il processo di salvataggio andrebbe fatto solo se il playbook non è in check mode (-C). Infine dobbiamo ricordarci che il task di salvataggio andrebbe eseguito anche se il playbook viene eseguito includendo solo alcuni tags.

Conclusioni

Nonostante tutto è da preferire l’uso di moduli specializzati, se bug free e se implementano tutte le funzionalità che ci servono. Questo vale anche se dovessimo incontrare problemi di performance. Tuttavia nella mia esperienza sarà spesso necessario utilizzare anche il modulo di configurazione generica per configurare specifici dettagli non previsti.