Fiche technique : Automatisation des tests d’infrastructure avec Puppet

D2SI_Blog_Image_Automate_Infrastucture
Le rapprochement entre Dev et Ops, ou DevOps, a donné naissance au concept d’Infrastructure As Code : il s’agit notamment d’automatiser l’infrastructure, et à cet effet on a besoin d’outils dont la fonction primaire est la gestion des configurations systèmes.
Aujourd’hui, les plus connus sont Puppet, Chef, Salt et Ansible. Cet article n’ayant pas vocation à être un comparatif, nous parlerons ici de Puppet, sous l’angle pratique et technique de l’automatisation des tests.

Puppet est un outil permettant de simplifier la configuration d’un serveur, mais pas uniquement : la conformité et la sécurité entre également dans le domaine d’application de Puppet. Ainsi, on peut partir d’un OS vierge, et définir l’état dans lequel il devra se trouver (comptes de services, règles firewall, monitoring, logiciels…).

Une infrastructure comporte généralement des centaines voire des milliers de serveurs, il n’est donc guère envisageable de tester la bonne conformité sur tous ces serveurs comprenant des rôles différents. Il faut donc écrire des tests :

  • Unitaire : une procédure permettant de vérifier le bon fonctionnement d’une partie d’un programme, d’un bout de code. Chaque module Puppet doit contenir un répertoire « tests », qui reprend l’ensemble des manifests du module avec des valeurs fixes
  • Fonctionnel : une description formelle du comportement d’un programme, généralement exprimée sous la forme d’un exemple ou d’un scénario. Chaque module doit contenir un répertoire « spec », reprenant chaque classe, type… du module. Ainsi on vérifie la présence des classes et le comportement des fonctions…

Exemple de test avec rspec :

[code lang=text]
describe 'myclass', :type => :class do
it { should contain_class('foo') }

end

[/code]

  • D’intégration: un test regroupant l’ensembles des tests unitaires, dans le but de vérifier le bon fonctionnement de tous les éléments.

Nous parlerons ici de tests d’intégrations, et pour les réaliser, il faut disposer d’une plateforme de test. Cela signifie installation et configuration des serveurs, le tout à la main, car l’automatisation est en plein développement.

On parle alors d’automatisation des tests d’infrastructure, où le workflow ressemble fortement à celui d’une application : développement, commit du code sur un système de versionning, exécution des tests unitaires par un serveur d’intégration continue…

La seule différence étant qu’il s’agit de provisionner / déprovisionner des machines au lieu de déployer des applicatifs dans un environnement quasi-statique.

L’idée est donc de tester un système complet et pas seulement une application :

1. Création d’une ou plusieurs machines virtuelles
2. Configuration des machines virtuelles
3. Déploiement des applications
4. Execution des tests
5. Reporting des tests
6. Destruction des machines virtuelles

Une des étapes importante après les étapes 2 et 3, ce sont les tests : il s’agit de passer des assertions sur les machines afin de valider un scénario, ou le rôle d’un serveur. Une assertion est une expression qui doit être évaluée à vrai. Ce qui est intéressant dans cette étape, c’est l’écriture des tests, durant laquelle il faut réfléchir à :

  • Ce que l’on veut tester
  • Comment on va tester
  • Comment on valide ce que l’on a testé

Il est également important de conserver une homogénéité concernant le langage entre les outils de gestion de configuration système automatisés et les outils de tests unitaires et d’intégration. C’est pourquoi je ne citerai que ces deux outils :

  • Beaker (Puppet)
  • Test Kitchen (Chef)

D2SI_Blog_Image_Automatisation_Tests_Puppet_beaker_workflow

L’environnement de test déployé est ainsi identique à celui de la production.

L’automatisation des tests d’infrastructure permet de gagner du temps et de limiter le risque d’erreur humaine, elle apporte fiabilité et productivité ce qui permet de se concentrer sur le Continous Delivery et ce jusqu’à la production.

Partie Technique

Place à la technique et au code ! Pour illustrer ce raisonnement, je vous propose de réaliser le scénario suivant :

Gestion d’un serveur web avec Puppet :

Le business demande aux développeurs l’implémentation d’une nouvelle fonctionnalité : l’activation de SSL sur les vhosts d’Apache. Le développeur effectue les changements, réalise des tests unitaires, et pousse son code sur le gestionnaire de contrôle de version (git ou svn). A partir de là, le développeur attend le résultat des tests d’intégration sur la plateforme de tests. En cas de succès, la fonctionnalité est automatiquement déployée en production, sinon en cas d’erreur retour au développement.

Pour simuler le scénario ci-dessus, je vais utiliser:

  • Un outil de gestion de configuration : Puppet
  • Un gestionnaire de contrôle de versions : Gitlab
  • Un logiciel d’intégration continue : Jenkins
  • Un outil de création de tests d’intégration : Beaker
  • Un hyperviseur : AWS
Préparation de la plateforme de tests

Amazon Web Services (AWS)

J’ai choisi AWS comme « hyperviseur » pour les raisons suivantes :

  • Facilité de déploiement d’un environnement de test
  • Ressources à la demande
  • Accès depuis n’importe où

Pré-requis AWS :

  • Un compte IAM avec access/secret key et les droits sur le service EC2 (import de clé, creation de SG, d’instances)
  • Un vpc et un subnet public
  • Une instance
  • Configurer le security group de l’instance avec en input le SSH et les ports de vos applications, ici 80,8080.
Type Protocol Port Range Source
Custom TCP Rule TCP 8080 0.0.0.0/0
HTTP TCP 80 0.0.0.0/0
SSH TCP 22 0.0.0.0/0

Note: Pour plus de sécurité vous pouvez limiter le trafic source à votre IP publique.

Gitlab, Jenkins & Beaker

Pour gérer le code et le versionning, il faut un gestionnaire de contrôle de versions: Gitlab outil open-source avec une interface web claire.

Pour l’intégration continue, j’ai choisi Jenkins, connaissant l’outil, il fait ce dont on a besoin de manière simple.

Pour la partie tests d’intégration, c’est Beaker. Développé par Alice Nodelman (Puppetlabs), il permet de provisionner des machines virtuelles, les configurer, et d’exécuter des tests avant de quitter et de supprimer ou conserver les machines. Beaker lance des tests écrits en Ruby qui reposent sur l’API DSL (Domain-specific language).

Beaker’s virtualization options
Amazon Elastic Compute Cloud
Google Compute Engine
OpenStack
Docker
Vsphere
Fusion
Vagrant

Je vais donc déployer ces 3 outils sur AWS, et exploiter la puissance de Docker afin de répondre à mes objectifs :

  • Avoir plusieurs applications sur une seule machine
  • Conserver la portabilité des applications d’un environnement à un autre
  • Ne pas changer les ports par défaut de l’application
  • Garder un OS propre
Installation des outils

1/ Connectez-vous sur l’instance aws et clonez le repo demo-puppet-beaker

[code lang=text]
git clone https://github.com/d2si/demo-puppet-beaker
[/code]

Ce repo contient des scripts pour créer, déployer et gérer les conteneurs, mais aussi les sources pour créer un repo basique avec les rôles et profils Puppet, les fichiers de tests pour Beaker…tout ce qui est nécessaire pour l’automatisation des tests d’une infrastructure avec Puppet sur AWS.

Le script bash deploy_beaker_env.sh permet de builder et déployer deux conteneurs :

  • Un conteneur gitlab, version community edition, build à partir des sources de Gitlab
  • Un conteneur Jenkins, build personnalisé, inspiré des sources de Jenkins

Je préfère créer un Docker file avec pour image source, l’image de base (Ubuntu, Debian..) et ajouter le contenu pour builder Jenkins, plutôt que créer un Docker file avec comme image source celle de Jenkins ou autre (from java..). De cette façon, je suis capable de reconstruire mon application à partir de la base.

Pour ne pas multiplier les outils, je suis rester sur un simple script. J’invite les puristes à forker le repo, et a « figguer » le script.  Fig est un orchestrateur pour Docker, il permet de builder, déployer, démarrer automatiquement plusieurs conteneurs avec un seul fichier yaml.

Ce qui nous donne le setup suivant :

D2SI_Blog_Image_Automatisation_Tests_Puppet_aws_setup

2/ Renseignez l’access key/secret key de l’utilisateur IAM dans le fichier **aws_auth**. Ce fichier permettra à Beaker de s’authentifier pour le provisionning des VM.

[code lang=text]
:default:
:aws_access_key_id: xxxxxxxxxxxx
:aws_secret_access_key: xyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxy
[/code]

3/ Exécutez le script deploy_beaker_env.sh

Configuration des outils

Gitlab

Les sources du build Docker sont ici.

  1. Modifiez le mot de passe de l’utilisateur « root« , par défaut 5iveL!fe
  2. Créer un nouveau groupe « puppet »
  3. Créer un nouveau project « puppet » dans le groupe « puppet » et poussez tous les fichiers du dossier « gitlab-sample-project »
  4. Ajouter un web hook sur le project cette structure d’url: http://your-jenkins-server/gitlab/build_now

Jenkins

La version de Jenkins a été fixée à la 1.596 et le Dockerfile a été créé pour integrer l’installation de Beaker.

Les sources du build Docker sont ici.

  1. Installez le plugin gitlab-hook.
  2. Créez le job jenkins « puppet-beaker » avec :
  • build paramétré avec la string BRANCH.
  • le repo git

Configuration de Puppet

Je pourrais utiliser les fonctions de Beaker pour installer un puppetmaster et déployer mes modules sur mon serveur, mais ce n’est pas ce que je souhaite. Il me faut reproduire la procédure telle qu’elle le serait sur mon environnement de production, c’est à dire avoir un puppet master avec plusieurs environnements.

Pour gérer ces environnements dynamiques, j’utilise r10k. Il peut créer de nouveaux environnements à partir de branches git et déployer un ensemble de modules dans cet environnement.

Pour en savoir plus sur r10k et les directory environments, je vous invite à lire cet excellent article.

1/ Modifiez l’adresse du project gitlab précédemment créer dans **bootstrap_puppetmaster.sh** à la ligne 125

[code lang=text]
[…]
:cachedir: /var/cache/r10k
:sources:
:local:
remote: http://192.168.59.103/puppet/puppet.git
basedir: /etc/puppet/environments
[/code]

Pour gérer mes modules, j’utilise la méthode des rôles et des profils. Cela me permet d’assigner un rôle à un noeud et dans ce rôle un ou plusieurs profils. Par exemple, pour gérer un serveur web, j’assigne le rôle web_server, qui contient le profil apache_server.

Je vous invite à regarder cette présentation ou à lire cet article.

2/ Créez deux autres projects « roles » and « profiles » avec les fichiers dans le répertoire modules

3/ Modifiez les adresses des projects dans Puppetfile aux lignes 28-31

[code lang=text]
[…]
mod "roles",
:git => "http://192.168.59.103/puppet/roles.git"

mod "profiles",
:git => "http://192.168.59.103/puppet/profiles.git"
[/code]

Configuration de Beaker

Maintenant que Puppet est prêt, il faut décrire le cas de test que Beaker va exécuter. Dans notre cas, il s’agit du test de la fonctionnalité d’un serveur Apache. J’ai donc créé un rôle app_server avec un profil apache_server. Il faut également préciser la plateforme de notre serveur apache, ici ce sera une debian wheezy. Il faut récupérer l’ID de de l’AMI (l’image du serveur à déployer) en fonction de la région AWS.

Fichier ec2.yaml – (config/image_template)

[code lang=text]
AMI:
debian-wheezy-amd64-west:
:image:
:foss: ami-3d9cc00d
:region: us-west-2
ubuntu-14.04-amd64-west:
:image:
:foss: ami-3d50120d
:region: us-west-2
[/code]

Chaque noeud est défini dans un fichier de configuration au format Yaml. Il peut y avoir un ou plusieurs rôles, il n’y a ici aucune relation avec les rôles Puppet.

Pour exécuter les tests sur les noeuds, il faut respecter la règle suivante :

  • le noeud puppetmaster doit avoir au minimum, le rôle master
  • les autres noeuds doivent avoir au minimum, le rôle agent

Ces rôles permettent de définir l’environnement et d’exécuter le script de bootstrap en fonction du rôle du noeud, master ou agent.

Fichier app_server.cfg – (spec/acceptance/nodesets)

[code lang=text]
HOSTS:
puppet:
roles:
– master
platform: debian-wheezy-amd64-west
user: admin
subnet_id: subnet-XXXXXXX
vpc_id: vpc-XXXXXXX
amisize: t2.small
hypervisor: ec2
snapshot: foss
app-server-1:
roles:
– agent
– app_server
platform: debian-wheezy-amd64-west
user: admin
subnet_id: subnet-XXXXXXX
vpc_id: vpc-XXXXXXX
amisize: t2.small
hypervisor: ec2
snapshot: foss
[/code]

Le fichier beaker_helper.rb permet de provisionner les noeuds à partir du script bootstrap, et d’éxécuter des opérations de pré-déploiements.

Fichier beaker_helper.rb – (spec/acceptance)

[code lang=text]
# Used for pre-suite tests
test_name "Hosts provisionning" do

if ENV['RS_PROVISION'] == 'no' or ENV['BEAKER_provision'] == 'no'
skip_test(msg="Provisionning option is set to 'no'")
else
domain = "us-west-2.compute.internal"
puppet_version = "3.5.1"
puppet_env = ENV['BEAKER_git_branch']

# Provisionning is based on node's role defined in hosts file
step 'PuppetMaster Node provisionning' do
scp_to master, "bootstrap_puppetmaster.sh", "/home/vagrant/bootstrap_puppetmaster.sh"
on master, "sed -i 's/#{master}/#{master}.#{domain} #{master}/g' /etc/hosts"
on master, "chmod +x /home/vagrant/bootstrap_puppetmaster.sh"
on master, "/home/vagrant/bootstrap_puppetmaster.sh #{puppet_version} #{puppet_env}", :acceptable_exit_codes => [0,2]
end

step 'Nodes provisionning' do
agents = hosts_as :agent
agents.each do |agent|
on agent, "sed -i 's/#{agent}/#{agent}.#{domain} #{agent}/g' /etc/hosts"
scp_to agent, "bootstrap_client.sh", "/home/vagrant/bootstrap_client.sh"
on agent, "chmod +x /home/vagrant/bootstrap_client.sh"
on agent, "/home/vagrant/bootstrap_client.sh #{puppet_version} #{puppet_env}", :acceptable_exit_codes => [0,2]
on agent, "puppet agent -t", :acceptable_exit_codes => [0,2]
end
end

end
end
[/code]

Ecriture d’un test pour un rôle

Le répertoire beaker (spec/acceptance/beaker) contient tous les tests, un rôle par fichier. Lors de l’exécution de Beaker, si aucun test n’est spécifié alors chaque fichier de ce répertoire est parcouru. Si aucun noeud ne contient le rôle présent dans le fichier alors le test est ignoré et passe au suivant.

[code lang=text]
# Test used to validate app server role.
test_name "App Server Role" do

# Return true if app_server role is found
app_server_role = any_hosts_as?(:app_server)

if app_server_role == false
skip_test(msg="This test is not applicable because no role app_server was found in hosts file")
else
app_server_hosts = hosts_as :app_server
app_server_hosts.each do |app_server|

# Commands to execute on the target system.
apache_status="service apache2 status"

# Output result expected
state_expected="running"

# Checking services status
on(app_server, apache_status) { assert_match(state_expected, stdout, 'Not the state expected for apache service') }
end
end
end
[/code]

Et Jenkins, dans tout ça ?

J’ai développé un script pour surveiller des répertoires dans lesquels des modifications ont été faites. Le web hook git activé sur le repo puppet permet de déclencher le job jenkins puppet-beaker. Ce job exécute le script jenkins-job.sh qui est à la racine du repo puppet.

Le script effectue les étapes suivantes :

  • Vérification des modifications dans hieradata/tier_n/roles
  • Vérification des modifications dans profils
  • Récupération des rôles impactés
  • Installation des dépendances
  • Vérification de la branche git pour déterminer s’il s’agit d’un use-case
  • Tests Beaker avec chaque rôle identifié

L’idée de ce script est de pouvoir identifier grâce au nom de la branche git, s’il d’agit d’un scénario, d’un use case avec plusieurs machines, ou juste d’un rôle à tester. Il y aura toujours au minimum 2 machines, le puppetmaster et un serveur avec un role précis. De cette facon, on s’assure d’avoir un puppetmaster toujours bien configuré et au plus proche de celui de la production.

On peut ainsi tester des scénarii plus complexes comme un puppetmaster, deux load balancer haproxy, deux web servers apache et un client, le but étant de vérifier le bon fonctionnement de chaque machine. Pour cela, on écrit un test, le client se connecte sur le frontend du loadbalancer, récupère une page web et vérifie que le contenu correspond avec celui attendu. Si le client ne récupère pas la page web demandée cela signifie qu’il y a un problème de communication entre les machines, ou une erreur lors du déploiement de l’application.

[code lang=text]
#!/bin/bash

# Constants and Arrays
regex_use_case="featureUseCase"
data_profile=()
data_role=()
base_path="modules"

echo "============== Checking modifications============"

# Check if hieradata were changed
for tier in hieradata/*; do
if [[ -d ${tier} ]]; then
hieradata_modified=$(git show –pretty="format:" –name-only — "${tier}/roles")

if [[ -z ${hieradata_modified} ]]; then
echo "No hieradata were changed in ${tier}"
else
# String format to have role name
role=$(echo ${hieradata_modified} | xargs -n 1 basename | cut -d "." -f 1)
echo ${role} "has changed in ${tier}"

#Check if role is already in array
if [[ ${data_role[*]} != ${role} ]]; then
data_role+=(${role})
fi
fi
fi
done

# Check if profiles were changed
profile_modified=$(git show –pretty="format:" –name-only — "${base_path}/profiles/manifests")

if [[ -z ${profile_modified} ]]; then
echo "No profiles were changed"
else
# Save profiles in array
data_profile+=(${profile_modified})

for profile in ${data_profile[@]}; do

# String format to have profile name
profile=$(echo ${profile} | xargs -n 1 basename | cut -d "." -f 1)
echo "Profile:" ${profile} "has changed"

# Get all roles whose profile has changed
role=$(grep -r -l -E "profile::${profile}$" "${base_path}/roles/manifests")

if [[ -z ${role} ]]; then
echo "No role was found for ${profile} profile"
else
role=$(echo ${role} | xargs -n 1 basename | cut -d "." -f 1)
echo "Role:" ${role} "has changed"

#Check if role is already in array
if [[ ${data_role[*]} != ${role} ]]; then
data_role+=(${role})
fi
fi
done
fi

echo "================================================="

if [[ -z ${profile_modified} ]] && [[ -z ${hieradata_modified} ]] ; then
echo "No profiles/hieradata were changed"
else
#Get dependencies
bundle install

# Check if current branch is a use case
branch_name=$(git symbolic-ref –short -q HEAD | tr -d 'n')
use_case=$(echo ${branch_name} | grep -E '${regex_use_case}')

if [[ -z ${use_case} ]]; then
# Get the use case name
use_case_name=$(echo ${branch_name} | sed "s/^${regex_use_case}//gI")
echo "Testing" ${use_case_name} "use case"
BEAKER_PROJECT=beaker-aws BEAKER_DEPARTMENT=DEV BEAKER_CREATED_BY=theowner BEAKER_BUILD_URL="http://myjenkinsurl:8080/job/puppet-beaker" BEAKER_validate=no BEAKER_setfile=spec/acceptance/nodesets/${use_case_name}.cfg rake beaker:test;
else
echo "============== Testing with roles ==============="
for role in ${data_role[@]}; do
echo $role
done
echo "================================================="

# Roles testing
for role in ${data_role[@]}; do
echo "Testing" $role "role"
BEAKER_PROJECT=beaker-aws BEAKER_DEPARTMENT=DEV BEAKER_CREATED_BY=theowner BEAKER_BUILD_URL="http://myjenkinsurl:8080/job/puppet-beaker" BEAKER_test_file=${role}.rb BEAKER_validate=no BEAKER_setfile=spec/acceptance/nodesets/${role}.cfg rake beaker:test;
done
fi
fi
[/code]

Résultats des tests

Pour analyser le résultat des tests, Beaker crée deux répertoires qui contiennent les informations de chaque run.

[code lang=text]
junit
├── 2015-02-09_12_08_30
│   ├── beaker_junit.xml
│   └── junit.xsl
[/code]

[code lang=text]
log
├── 2015-02-09_12_30_45
│   ├── pre_suite-run.log
│   ├── pre_suite-summary.txt
│   ├── tests-run.log
│   └── tests-summary.txt
[/code]

Test
  1. Modifiez le profil apache_server
  2. Poussez les modifications dans gitlab
  3. Surveillez l’exécution du job jenkins puppet-beaker
  4. Récupérez les résultats des test
Documentation Externe

Présentation de Beaker

Beaker Wiki

Beaker Ruby Doc

Commentaires :

A lire également sur le sujet :