Tests d'intégration SaltStack avec Docker

SaltStack est un outil d’infrastructure as code de nouvelle génération au même titre qu’Ansible par exemple. Le code Salt est écrit sous forme de fichier YAML décrivant les états désirés d’un système. Je l’utilise au quotidien pour gérer l’installation des machines par rôle d’infrastructure. Chaque rôle comprend une base commune chargée de partitionner les disques, configurer les services de base comme le serveur ssh et la gestion des utilisateurs. La partie spécifique décrit l’installation, la configuration et l’exécution du service associé à un rôle. Il y a par exemple des rôles pour Graphite, Elasticsearch, RabbitMQ, Redis, Nginx et bien d’autres. La base de code Salt contient plus de 200 fichiers sls. Afin d’éviter les régressions et de faciliter le développement de nouveaux rôles, nous avons besoin d’une solution qui nous permette de facilement tester notre code et de vérifier qu’il rend bien le service attendu. Je me suis naturellement tourné vers Docker pour industrialiser une solution d’intégration continue de notre code SaltStack. Les buts sont de limiter les coûts d’instances Amazon EC2 et d’éviter les quelques minutes de démarrage imposé par AWS. Dans cet article, je vous propose de découvrir notre socle d’intégration continue pour le développement SaltStack.

Organisation des tests

Le principe est de lancer via des tests Python, un docker salt-master et de lui lier un docker salt-minion configuré avec les grains (dictionnaire YAML spécialisant le minion) fournis pour un rôle donné. Chaque rôle à tester est représenté par un répertoire contenant les grains et fichier d’assertion en code Salt nommé assert.sls.

salt-docker-tests

  1. Lancement d’un conteneur Salt-master en lui fournissant la base de code Salt sous la forme d’un volume monté au démarrage.
  2. Génération d’un test Python pour chaque répertoire de rôle fourni.
  3. Lancement d’un conteneur Salt-minion lié à l’ip du Salt-master docker en lui injectant les grains du rôle testé.
  4. Application du provisionning complet du minion via l’exécution d’une commande salt state.highstate depuis le master.
  5. Exécution des assertions sur le minion via l’exécution d’une commande salt state.sls depuis le master.

A chaque étape, le résultat des commandes lancées est vérifié sous forme d’assertion Python. Chaque test se termine par la destruction du minion. Pour finir la suite de test, le docker master est supprimé à son tour. Nous utilisons nose2 pour exécuter les tests, ce qui nous permet de générer un rapport junit-xml facilement intégrable dans un build Jenkins.

Voyons maintenant étape par étape comment, mettre en place cette solution sur votre infrastructure.

Construire son image salt-minion

La première étape pour obtenir une solution Docker fonctionnelle est de construire une image contenant le démon salt-minion capable de s’exécuter et provisionner le conteneur dans lequel il s’exécutera. Même si cela ne correspond pas à l’état de l’art de l’utilisation de Docker, notre conteneur exécutera plusieurs processus lancés par systemd. Dans les states nous configurons beaucoup de services pour lesquels SaltStack nous garantit qu’ils seront bien lancés et actifs. Notre image doit donc contenir :

  • la base du système cible en CentOS 7 dans mon cas
  • une installation fonctionnelle de systemd
  • un salt-minion configurable au runtime

Voici le Dockerfile que nous utilisons pour construire notre image :

FROM centos:7.0.1406  
MAINTAINER "slemesle" <seven.lemesle@wescale.fr>  
ENV container docker  
RUN yum -y swap -- remove fakesystemd -- install systemd systemd-libs  
RUN (cd /lib/systemd/system/sysinit.target.wants/; for i in *; do [ $i == systemd-tmpfiles-setup.service ] || rm -f $i; done); \  
rm -f /lib/systemd/system/multi-user.target.wants/*;\  
rm -f /etc/systemd/system/*.wants/*;\  
rm -f /lib/systemd/system/local-fs.target.wants/*; \  
rm -f /lib/systemd/system/sockets.target.wants/*udev*; \  
rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \  
rm -f /lib/systemd/system/basic.target.wants/*;\  
rm -f /lib/systemd/system/anaconda.target.wants/*; \  
yum install -y curl sysvinit-tools \  
initscripts sudo crontabs cronie cronie-anacron  
RUN curl -L https://bootstrap.saltstack.com -o install_salt.sh  
RUN sh install_salt.sh -F -Z -P -X stable  
RUN mkdir -p /etc/salt/minion.d  
RUN mkdir -p /app/cd-factory; touch /etc/sysctl.conf  
RUN systemctl enable salt-minion.service;  
ENV NOTVISIBLE "in users profile"

RUN echo "export VISIBLE=now" >> /etc/profile  
CMD ["/usr/sbin/init"]  

Dans l’ordre, nous remplaçons fakesystemd livré dans l’image de base centos 7 par le vrai systemd. Nous supprimons ensuite les services standards inutiles dans un contexte Docker. Pour finir, nous installons et configurons le minion salt dans l’image. Le fichier custom.conf fournit simplement la configuration souhaitée du minion (log, identifiant, dépôt de state, …).

Notez bien que nous définissons les volumes requis :

  • /sys/fs/cgroup : montage nécessaire pour le fonctionnement de systemd
  • /srv/salt : répertoire monté pour fournir les states et pillars au minion

Pour construire l’image, il suffit alors de se placer dans le répertoire contenant le Dockerfile et le fichier custom.conf, puis de lancer :

$ docker build -t salt-minion . 

Pour vérifier le bon fonctionnement de votre image il vous suffit alors de la lancer :

$ docker run -td --privileged --name="salt-minion"-v /sys/fs/cgroup:/sys/fs/cgroup:ro salt-minion

Vous devez obtenir l’affichage du démarrage des services systemd pour vérifier que le minion est bien fonctionnel, il vous suffit de lancer :

$ docker exec-ti salt-minion salt-call --local test.ping 

Cela permet d’exécuter une commande de ping du minion, depuis lui-même. Notre image est maintenant prête à l’emploi, nous pouvons donc passer à la prochaine étape.

Construire son image salt-master

Nos tests utilisent un conteneur Salt-master pour assurer l’application des configurations. Nous allons donc construire une image docker contenant uniquement le serveur salt-master capable de recevoir les connexions des minions et chargé de porter le référentiel de code salt.

Voici le Dockerfile que nous utilisons pour construire notre image:

FROM centos:7.0.1406  
MAINTAINER "slemesle" <seven.lemesle@wescale.fr>  
RUN yum -y swap -- remove fakesystemd -- install systemd systemd-libs  
RUN yum -y install curl  
RUN curl -L https://bootstrap.saltstack.com -o /install_salt.sh  
RUN sh install_salt.sh -F -Z -P -M -N -X stable  
RUN mkdir -p /etc/salt/master.d  
RUN mkdir -p /app/cd-factory  
ADD ./master.conf /etc/salt/master.d/master.conf  
ADD run.sh /usr/local/bin/run.sh  
VOLUME ["/srv/salt"]

EXPOSE 4505 4506  
CMD ["/usr/local/bin/run.sh"]  

Dans ce fichier nous commençons par remplacer fakesystemd par le package systemd officiel car le rpm Salt-master en dépend. Nous installons ensuite curl et le Salt-master en utilisant le bootstrap fournit par salt. Pour finir, nous ajoutons le fichier de configuration master.conf pour configurer le salt-master, et le script ‘run.sh’ chargé de lancer le salt-master. L’image Docker définit le volume /srv/salt pour récupérer la base de code salt, elle expose les ports 4505 et 4506 qui sont ouvert par le master pour communiquer avec les minions. Au lancement, le conteneur exécutera le script run.sh qui se contente de lancer le salt-master.

Pour construire l’image, il suffit alors de se placer dans le répertoire contenant le Dockerfile, puis de lancer :* *

$ docker build -t salt-master . 

Pour vérifier le bon fonctionnement de votre image il vous suffit alors de la lancer :

$ docker run -td --name="salt-master"-v .:/srv/salt salt-master 

Vous pouvez enfin, vérifier le bon fonctionnement de votre master en lançant :

$ docker exec-ti salt-master salt-run manage.versions 

Cela permet d’exécuter une commande de vérification des versions salt installées sur le parc du salt-master. Notre image est maintenant prête à l’emploi, nous pouvons donc passer à la prochaine étape.

Créer des tests salt

Pour nos tests, nous allons appliquer un highstate sur le minion en lui fournissant au préalable des grains définissant le rôle d’infrastructure à appliquer. Si le highstate s’applique avec succès nous allons ensuite exécuter un state d’assertions permettant de vérifier que le service que nous avons configuré est bien fonctionnel.

Notre test doit donc fournir:

  • un fichier de grains pour spécialiser le minion
  • un state d’assertions asserts.sls pour vérifier le bon fonctionnement du système après configuration via highstate

Le test doit faire parti de l’arborescence de state salt du projet pour que l’on puisse appliquer le state asserts.sls.

Le plus simple est de créer un répertoire test dans l’arborescence des state salt et d’y ajouter ensuite un sous-répertoire pour chaque test que l’on voudra lancer.

Prenons l’exemple de redis, les grains à fournir pour le rôle redis sont:

# salt/states/test/redis/test_grains
 roles:
   - redis

Ce fichier de grains définit uniquement le rôle redis.

Pour les assertions, voici un exemple d’asserts.sls:

# salt/states/test/redis/asserts.sls
# Redis server pid
{%- set redis_pid = salt['cmd.run']('pgrep -f redis-server') -%}

# check redis is listening as needed
test.docker-roles::redis::listen:  
 cmd.run:
 - name: lsof -iTCP@0.0.0.0:{{ salt['pillar.get']('redis:port', '6379') }} -a -p {{ redis_pid }}

# Insert a key inside the running redis server
test.docker-roles::redis::insert:  
 redis.string:
   - value: Test string data in redis
   - host: localhost
   - port: {{ salt['pillar.get']('redis:port', '6379') }}
   - db: 0

Ce fichier retrouve le PID du processus redis-server, à l’aide d’une commande pgrep. Nous vérifions ensuite que le serveur est bien en écoute TCP sur le port souhaité, avec la commande lsof.
Le dernier test consiste à injecter une clé avec une valeur par défaut dans la base de données redis. Nous avons utilisé ici le module redis fournit par SaltStack.

Pour exécuter un test manuellement, il suffira alors de lancer tout d’abord le highstate :

$ docker run -td --privileged --name="salt-minion"-v /sys/fs/cgroup:/sys/fs/cgroup:ro -v /srv/salt:/srv/salt salt-minion 

Ici /srv/salt définit le chemin par défaut vers la base de code salt, vous devrez donc remplacer ce chemin par celui qui vous correspond.

$ docker exec-ti salt-minion salt-call --local state.highstate 

Si l’application du highstate se déroule sans erreur, vous pourrez passer à la partie vérification en appliquant le state d’assertions :

$ docker exec-ti salt-minion salt-call --local state.sls test.monrole.asserts

Le fichier d’assertions devra exécuter des commandes permettant de valider que le service installé se comporte comme souhaité.
Dans ce premier exemple, nous avons réalisé le test sans salt-master, c’est le rôle du paramètre ‘–local’ que nous avons utilisé. Voyons maintenant comment faire la même chose avec notre salt-master :
Lancez le master comme vu ci-dessus en prenant soin de monter le répertoire code salt dans le conteneur, puis récupérez son adresse IP :

$ docker inspect salt-master --format '{{ .NetworkSettings.IPAddress }}'

Générez un fichier de configuration pour le minion (minion.conf) :

# /etc/salt/minion.d/minion.conf
id: test-minion  
master: XXX.X.X.X # IP du master  

Lancez votre conteneur minion :
$ docker run –tid —privileged —name=”salt–minion“–v /sys/fs/cgroup:/sys/fs/cgroup:ro –v minion.conf:/etc/salt/minion.d/minion.conf -v grains:/etc/salt/grains salt–minion

Vérifiez qu’il s’est bien enregistré auprès du master :

$ docker exec-ti salt-master salt ‘*’ test.ping 

Le minion ‘test-minion’ doit bien répondre au ping. Vous pouvez maintenant lancer le provisioning du minion via le salt-master :

$ docker exec-ti salt-master salt ‘test-minion’--local state.highstate 

Le state d’assertion peut maintenant être appliqué sur le minion via le salt-master.

$ docker exec-ti salt-master salt ‘test-minion’--local state.sls test.monrole.asserts

Vous avez exécuté manuellement le test de votre rôle d’infrastructure.

Automatiser les tests Python

A ce stade, il devient nécessaire d’automatiser le processus, pour éliminer les taches manuelles sources d’erreurs. Pour cette étape, nous avons choisi d’utiliser un script Python de tests unitaires exécutés avec nosetests ou nose2. L’avantage de cette méthode est de permettre de générer des rapports de tests complets et de permettre de paralléliser leur exécution.

Le script récolte les répertoires de tests et lance les commandes docker dans le bon ordre en interprétant les résultats de chaque exécution. Il se charge aussi de générer les fichiers de configuration.

Je ne recopierai pas ici le script de test, mais il est disponible sur le dépôt Github https://github.com/WeScale/salt-docker-integration qui accompagne cet article.

Pour lancer les tests sur un système disposant de docker et de python, il vous suffira de lancer le script [run-all-tests.sh](https://github.com/WeScale/salt-docker-integration/blob/master/run-all-tests.sh) disponible sur le dépôt Github.

Vous pouvez consulter directement le script testsaltroles.py directement.

Sources de l’article

Comme indiqué ci-dessus, cet article est accompagné du dépôt de sources Github https://github.com/WeScale/salt-docker-integration fournissant un exemple complet. Pour illustrer le fonctionnement du système de test d’intégration automatisé, j’ai choisi de définir un rôle redis déployant donc un serveur redis. Vous y trouverez donc la base de code salt permettant de déployer le serveur redis ainsi que le test du dit rôle.

Les dockerfiles sont bien sûr eux aussi fournis avec script permettant de faciliter leur construction.* *

Pour conclure

Cette technique de test facilite grandement le développement et la non régression d’une base de code salt. En utilisant Vagrant, nous sommes capables de développer et d’exécuter nos tests depuis un poste de développement.

Notre but n’est pas de tester Salt mais bel et bien de valider en continu notre base de states. L’outil est facilement intégrable dans un serveur d’intégration continue comme Jenkins ce qui permet de mettre en place un build wall supervisant le résultat des tests.

La couverture de test assurée n’est pas parfaite car certaines parties du système ne peuvent pas être configurées dans nos conteneurs :

  • Le formatage et le partitionnement des disques
  • La configuration des interfaces réseaux
  • Les policies SELinux qui ne peuvent être appliquées dans un Docker

Nous avons donc certaines parties du code Salt qui ne sont volontairement pas appliquées sur les conteneurs. Pour régler ce problème, une bonne solution est de créer des tests d’intégration sur des machines virtuelles permettant l’application des configurations non adressées dans Docker. Vous pourrez ainsi tester en continu votre gestion de configuration.