TDD Ansible - systemd daemons

Se sei qui, è perché stai usando Infrastructure-as-Code (IaC), giusto? Hai mai sentito parlare di Test-Driven Development…forse starai pensando: “TDD quella roba da sviluppatori maledetti”. Ottimo! Il fatto è che quando fai IaC tu stai realmente scrivendo del codice, quindi è molto importante scrivere anche i test e TDD è probabilmente il metodo più efficace per scrivere codice di qualità con una buona copertura di test case!

Introduzione

Ansible è uno strumento agent-less di orchestrazione IT scritto in Python. Esso semplifica l’automazione e il deploy dell’infrastruttura IT, dei pacchetti software e la loro configurazione.

Le caratteristiche di Ansible sembrano davvero interessanti, ma voglio fare un passo in avanti nel mio viaggio sulla via “DevOps” e applicare il concetto di “Infrastructure as Code”. Come posso fare? Di certo non basta prendere questi file e metterli in un repository condiviso… devo trovare un modo per verificare quello che scrivo, magari usare un linter, eseguire dei “test veri” della mia automazione e, infine, dato che sto scrivendo codice (usando un DSL), perché non applicare TDD o BDD? Quali strumenti possono aiutarmi?

Il codice completo scritto durante questo tutorial e altri esempi di playbook Ansible scritti in TDD li potete trovare nel seguente repsitory: https://github.com/bombo82/tdd-ansible

Obiettivo

Installare, abilitare ed eseguire un servizio che usa systemd su macchine CentOS.

In questo tutorial userò il servizio Apache2/HTTPD e lo useremo per servire un file HTML statico.

Non mi dilungo nel raccontarvi cosa sia TDD o su come scrivere dei buoni test… per questi argomenti vi rimando ai seguenti link: Wikipedia-TDD, Agile Book - TDD, Kent Beck - Programmer Test Principles

Prima di iniziare

Vi consiglio di leggere e magari seguire il tutorial TDD Ansible - primi passi in cui trovate una descrizione dei pre-requisiti e degli strument strumenti utilizzati, oltre a una breve trattazione relativa alla creazione dello scheletro del ruolo e dello scenario di test.

Iniziamo

Dato che useremo TDD è necessario configurare e impostare l’ambiente di test e solo in seguito possiamo procedere scrivendo del codice, ma prima ancora dobbiamo creare lo scheletro del nostro ruolo e dello scenario di test.

1. Creazione dello scheletro

L’approccio migliore per farlo è quello di creare prima il ruolo Ansible usando il comando ansible-galaxy init e poi aggiungiamo lo scenario di test mediante molecule init.

ansible-galaxy init apache-systemd
cd apache-systemd
molecule init scenario --scenario-name default --role-name apache-systemd

2. Configurazione test framework e molecule

Innanzitutto, dobbiamo verificare ed eventualmente ridefinire il file di configurazione predefinito di Molecule. Esso viene generato dal comando molecule init e contiene il driver, le piattaforme, i verifier e la sequenza di test (se non vogliamo utilizzare quella predefinita) per il nostro scenario.


---
dependency:
  name: galaxy
driver:
  name: docker
lint:
  name: yamllint
platforms:
  - name: instance
    image: centos:7
provisioner:
  name: ansible
  lint:
    name: ansible-lint
verifier:
  name: testinfra
  lint:
    name: flake8

Sopra trovate il file <role_name>/molecule/molecule.yml creato dal comando molecule init ed è praticamente perfetto.

Se eseguissimo in questo momento i test con il comando molecule test avremmo un errore perchè il linter dei ruoli Ansible ci segnala che il file <role_name>/meta/main.yml contiene alcuni valori di default che andrebbero modificati e l’indicazione delle platforms è mancante. Potete correggere i metadata oppure eliminare il file… questo file è indispensabile solo quando il ruolo viene pubblicato e condiviso su Ansible Galaxy o un altro repository per i ruoli.

Sistemato il file <role_name>/meta/main.yml tutti i test saranno verdi e possiamo procedere a scrivere il codice.

3. Assicurarsi che il pacchetto apache2 sia installato

Scriviamo il test che verifica la presenza del pacchetto apache, quindi editiamo il file <role_name>/molecule/default/test_default.py, rimuovendo il test case di default e inseriamo il seguente codice:

def test_apache2_package(host):
    assert host.package('httpd').is_installed

Dopo aver lanciato il test con il comando molecule test e verificato che esso fallisce, possiamo procedere scrivendo la parte di playbook Ansible che installa il pacchetto apache. Editate il file <role_name>/task/main.yml e inserite il seguente codice:


---
- name: ensure Apache2 is installed
  yum:
    name: httpd
    state: present

A questo punto il test sarà verde e abbiamo terminato la parte d’installazione del pacchetto. Abbiamo scritto così poco codice che non è necessario fare refactor.

4. Lanciare il servizio e avviarlo in automatico

Scriviamo il test per verificare se il servizio è stato avviato ed è messo in avvio automatico. Potremmo scrivere un solo test case che effettua 2 differenti assert, come nell’esempio sotto, oppure 2 differenti test case.

def test_apache_is_running_and_enabled(host):
    service = host.service('httpd')

    assert service.is_running
    assert service.is_enabled

Ci aspettiamo che le assert presenti nel nostro test non siano verificate (non abbiamo ancora scritto l’automazione), ma come potete vedere dai log sotto, è proprio il test che fallisce malamente con un errore :-(

Questo errore è dovuto al fatto che i servizi in CentOS utilizzano systemd come “System and Service Manager”, ma i container Docker ufficiali di tutte le distribuzioni non hanno al loro interno alcun “System and Service Manager” configurato e funzionante. La spiegazione della scelta di non inserire un “System and Service Manager” nei container Docker esula dal presente tutorial, quindi prendetelo come un dato di fatto e passiamo direttamente a come risolvere questo inconveniente!

Abilitare systemd all’interno di un container Docker

Se durante l’esecuzione dei test trovate il seguente errore ValueError: cannot find "service" command significa che testinfra non è riuscito a trovare il gestore dei servizi. Sotto è riportato il log per intero:

    host = <testinfra.host.Host object at 0x7fbd978184e0>
    
        def test_apache_is_running_and_enabled(host):
            service = host.service('httpd')
        
    >       assert service.is_running
    
    tests/test_default.py:17:
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
    ~/.local/lib64/python3.6/site-packages/testinfra/modules/service.py:110: in is_running
        return super(SystemdService, self).is_running
    ~/.local/lib64/python3.6/site-packages/testinfra/modules/service.py:92: in is_running
        self._service_command, self.name).rc == 0
    ~/.local/lib64/python3.6/site-packages/testinfra/utils/__init__.py:44: in __get__
        value = obj.__dict__[self.func.__name__] = self.func(obj)
    ~/.local/lib64/python3.6/site-packages/testinfra/modules/service.py:80: in _service_command
        return self.find_command('service')
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
    
    self = <testinfra.host.Host object at 0x7fbd978184e0>, command = 'service'
    extrapaths = ('/sbin', '/usr/sbin')
    
        def find_command(self, command, extrapaths=('/sbin', '/usr/sbin')):
            """Return path of given command
        
            raise ValueError if command cannot be found
            """
            out = self.run_expect([0, 1, 127], "command -v %s", command)
            if out.rc == 0:
                return out.stdout.rstrip('\r\n')
            for basedir in extrapaths:
                path = os.path.join(basedir, command)
                if self.exists(path):
                    return path
    >       raise ValueError('cannot find "%s" command' % (command,))
    E       ValueError: cannot find "service" command
    
    ~/.local/lib64/python3.6/site-packages/testinfra/host.py:46: ValueError

Se invece riscontrate il seguente errore mentre eseguite il playbook… come diceva una vecchia pubblicità: DevOps fai da te? No TDD? Ahiahiahihahihaihiiiiiiii

Failed to get D-Bus connection: Operation not permitted

La causa è sempre quella… la mancanza di un “System and Service Manager”. Possiamo risolvere questo inconveniente in svariati modi, ma i principali sono:

  1. creare noi alcune immagini custom con systemd installato e funzionante
  2. usare delle immagini custom create da un’altra persona che le ha rese disponibili tramite Docker Registry

Personalmente preferisco la seconda opzione e solitamente utilizzo le immagini preparare da Jeff Geerling, perché sono state create proprio per essere utilizzate con molecule. Esse sono fatte in modo professionale, stabili, testate e aggiornate con un ritmo ragionevole.

Purtroppo per utilizzare systemd non basta cambiare immagine, ma è indispensabile che l’host sia una macchina GNU/Linux con systemd! Non preoccupatevi… normalmente non c’è niente da fare anche se non utilizzate GNU/Linux come host! Distinguiamo i vari casi per sistema operativo che usate:

  • macOS: Docker installa di nascosto una VM che usa come host per i container, quindi siete a posto;
  • MS Windows 10 fa cose magiche e sfrutta il WSL, quindi dovrebbe andare tutto… basta avere fede in Bill Gates;
  • GNU/Linux la maggior parte delle distribuzioni usa systemd, quindi quasi nessuno ha problemi.

Nel caso in cui la vostra distribuzione GNU/Linux non usi systemd oppure riscontrate problemi con macOS o MS Windows potete creare una macchina virtuale ad-hoc da eseguire come host per i container Docker usati durante i test. Nella cartella vagrant di questo repsitory https://github.com/bombo82/tdd-ansible trovate un possibile modo per creare la vostra VM da usare come host.

Immagini già pronte che supportano systemd

Modifichiamo la sezione platforms del file <role_name>/molecule/default/molecule.yml come segue:

platforms:
  - name: centos7
    image: geerlingguy/docker-centos7-ansible:latest
    command: ""
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:ro
    privileged: true
    pre_build_image: true

Ora eseguendo il comando molecule test l’errore relativo a systemd dovrebbe essere sparito e dovreste trovare il log della assert fallita. Sotto trovare un esempio:

    host = <testinfra.host.Host object at 0x7f80d8ee3400>
    
        def test_apache_is_running_and_enabled(host):
            service = host.service('httpd')
        
    >       assert service.is_running
    E       assert False
    E        +  where False = <service httpd>.is_running
    
    tests/test_default.py:17: AssertionError

Infine scriviamo l’automazione

A questo punto non ci resta che assicurarci che Apache sia avviato e messo in avvio automatico. Aggiungiamo il seguente codice al task del playbook:

- name: ensure apache is running and enabled
  service:
    name: httpd
    state: started
    enabled: true

5. Servire un file HTML statico

Questo punto dovrebbe andar via liscio senza alcun inconveniente. Ci bastano veramente poche righe per il test e per l’implementazione! Come al solito partiamo dal test… al file statico diamo casualmente il nome index.html e il test si limiterà a verificare che esso esiste all’interno del container Docker e che sia nella folder corretta.

def test_index_html_exists(host):
    index_html = host.file("/var/www/html/index.html")

    assert index_html.exists

Creare il file statico index.html e automatizzare il suo deploy… ehm, copiarlo nella cartella radice del sito. Procedendo con ordine, partiamo con creare un file index.html all’interno della folder <role_name>/files e, anche se il contenuto del file non viene controllato dal test, scriviamo al suo interno del codice HTML valido, per esempio:

<html>
    <head>
        <title>My Page</title>
    </head>
    <body>
        <h1>YEAAAAAH</h1>
        <p>This is just a simple html page</p>
    </body>
</html>

Ora aggiungiamo al task l’istruzione che copia il file, notate che in src abbiamo messo solo il nome del file… per convenzione di Ansible i file usati all’interno dei playbook si trovano nella folder <role_name>/files ed essa viene considerata come radice dei path quando usiamo questi file.

- name: ensure index.html is present
  copy:
    src: index.html
    dest: "/var/www/html/index.html"

Eseguiamo molecule test e visto che tutto funziona e abbiamo finito!