Tester son code d'infrastructure avec Terratest

le 19/12/2018 par Edouard Perret
Tags: Cloud & Platform

Avec l’essor des outils d’Infrastructure As Code, (Ansible, Puppet, Heat ou Terraform) de ces dernières années, on aimerait tirer parti de toutes les bonnes pratiques de Software Craftsmanship pour garantir la qualité du code qui décrit nos infrastructures.  Tout développeur qui se respecte sait que pour avoir un code de qualité, il doit être testé. L’une des pratiques qui en découle est le TDD, le Test Driven Development.

Pour rappel, le TDD consiste à : commencer par poser un test ; vérifier qu’il échoue ; écrire le code associé pour le faire passer ; relancer le test, vérifier qu’il passe ainsi que tous les précédents et enfin remanier le code avant de réitérer.

Les trois étapes principale d'un développement en TDD

L’intérêt de cette pratique est d’avoir une boucle de feed-back courte et ainsi détecter les bugs le plus tôt possible. Elle permet aussi de répondre à un besoin avec une complexité minimale et donc d’avoir un code mieux conçu.

Si aujourd’hui les outils permettant aux développeurs de faire du TDD sont arrivés à maturité dans la majorité des langages, ce n’est pas le cas pour les outils d’Infra as Code. On a tout de même pu lire dans le très bon billet “The Wizard” qu’aujourd’hui, on pouvait écrire du code Ansible en TDD efficacement...

...mais en ce qui concerne les outils de provisioning comme Terraform, c’est différent.

Les douleurs du test manuel

Terraform, outil d’HashiCorp, nous permet de définir une infrastructure dans un langage  haut niveau puis de la créer sur un cloud provider tel qu’Amazon Web Services ou Google Cloud Platform.

Aujourd’hui quand nous voulons tester ce code, nous exécutons les tests à la main. Pas de localhost, impossible de tester l’installation d’un VPC sur sa propre machine. Nous testons directement dans nos environnements cloud.

Ces tests prennent de longues minutes, comprennent souvent plusieurs étapes qui demandent parfois des actions de notre part, ce qui nous oblige soit à attendre, soit à context-switcher régulièrement : cela rallonge la boucle de feed-back. Ensuite, nous devons valider que l’infrastructure générée corresponde bien à nos attentes. Pour cela, nous nous aidons de la console web ou d’une interface en ligne de commande. Parfois encore, nous nous connectons aux machines pour vérifier la présence d’un fichier. Puis les détruisons et réitérons…

Ce qu’on aurait fait à la main, sans doute plusieurs fois pour corriger nos erreurs, Terratest nous aide à l’automatiser.

Terratest

Terratest c’est quoi ? C’est une librairie Go permettant d’écrire et d’automatiser des tests pour notre Infra as Code écrite en Terraform et en Packer sur les IaaS d’Amazon et de Google ou encore sur un cluster Kubernetes.

Terratest est développé par Gruntwork, une société américaine partenaire de HashiCorp qui a open-sourcé plus de 300 000 lignes de code d’infrastructure et qui se sert de Terratest pour maintenir cette base de code. Terratest est disponible depuis avril 2018 sur Github, et si vous voulez jouer c’est par ici.

Pourquoi utiliser Terratest ?

On peut trouver de nombreux avantages à tester son code avec une librairie comme Terratest, en voici une liste non-exhaustive :

  • Tester des comportements d’infrastructure complexes
  • Tests de bout en bout
  • Documentation de l’infrastructure
  • Plus besoin pour les ops d’avoir une infrastructure iso-prod permanente, servant à tester les modifications de celle-ci
  • Boucle de feed-back rapide permettant une correction des bugs efficace
  • Capacité à lancer régulièrement et rapidement ces tests
  • Résistance aux montées de versions des nombreux outils que l’on utilise
  • Tests des scripts types cloud-init
  • Validations des AMIs déployées

Qui plus est, Terratest propose dans son dépôt Git une importante quantité d’exemples, permettant de prendre en main la librairie plus facilement.

Comment ça marche ?

Pour écrire son code Terraform en TDI, on procède par étapes :

  • On commence par écrire le test en Go dans un fichier en *_test.go, comme par exemple instance_test.go. On veille à poser dans ses tests des assertions vraies ou fausses. Suivant le principe TDD, on va d’abord vérifier que le test ne passe pas en exécutant la commande “go test instance_test.go”.
  • Puis ont écrit le code Terraform qui décrit notre infrastructure, en essayant de garder une structure modulaire du projet.
  • On relance Terratest pour monter cette fois l’infrastructure sur votre IaaS préféré. Attention Terratest construit vraiment l’infrastructure. Il fait un terraform apply, cela peut évidemment entraîner des coûts.
  • Terratest exécute les tests. Pour valider la conformité de notre infrastructure, la librairie peut faire des appels HTTP, se connecter aux machines en SSH, y exécuter des commandes, télécharger des fichiers à un endroit donné, requêter les APIs du cloud provider et lire les outputs de terraform, etc...
  • Enfin, l’infrastructure de tests est détruite à l’aide d’un terraform destroy.
  • Le résultat de ces tests sont affichés dans la console.

Mise en pratique

Objectif : afin d’explorer un peu cet outil, nous allons tenter de tester un script d’initialisation d’instance en se connectant directement à la machine pour y vérifier le contenu.

Installation

Pour découvrir la librairie Terratest on peut cloner son repository Git. Le dépôt de code contient à la fois les modules et beaucoup d’exemples. Les exemples donnent une bonne idée de ce que peut faire l’outil et constituent une bonne base pour commencer.

Terratest étant une librairie Go, il est évidemment nécessaire de l’avoir installé sur sa machine. Pour installer les modules de la librairie Terratest il est préférable d’utiliser un gestionnaire de dépendance comme dep.

On peut donc installer le module qui servira à utiliser Terraform de cette façon :

dep ensure -add github.com/gruntwork-io/terratest/modules/terraform

Ou comme ceci avec go get :

go get github.com/gruntwork-io/terratest/modules/terraform

Il est conseillé de décrire l’ensemble des dépendances dans fichier Gopkg.toml, cela permet également de fixer la version des dépendances.

Assurez-vous ensuite d’avoir les accès nécessaires à votre cloud provider, dans notre cas, nous utilisons AWS et chargeons les variables d’environnement AWS_ACCESS_KEY_ID et AWS_SECRET_ACCESS_KEY. Nous nous assurons aussi d’avoir un couple clé privée / clé publique pour se connecter aux machines créées et avoir notre clé publique dans AWS. Pour cet exemple, nous avons nommé la clé “terratest_key”

Enfin, nous créons un projet avec des fichiers vides structurés de cette façon :

Structure du projet

Dépendances

Pour commencer, nous utilisons le package de test Go bien nommé “test” et importons les modules dont nous avons besoin dans un fichier instance_test.go :

package test
import (
   "testing"
   "fmt"
   "time"
   "github.com/stretchr/testify/assert"
   "github.com/gruntwork-io/terratest/modules/terraform"
   "github.com/gruntwork-io/terratest/modules/aws"
   "github.com/gruntwork-io/terratest/modules/ssh"
   "github.com/gruntwork-io/terratest/modules/retry"
)

Premier test : La clé SS****H

Puis, nous déclarons notre première fonction de test, qui doit être nommée comme ceci : func TestXxx(*testing.T) pour pouvoir être utilisée avec la commande go test. Nous commençons par tester qu’une machine est bien créée et avec une clé SSH.

func TestInstanceSshKey(t *testing.T) {}

Maintenant que nous avons écrit la partie de “configuration”, place à l’action. Nous voulons initialiser notre répertoire de travail Terraform et appliquer notre code (terraform init + terraform apply). C’est la méthode InitAndApply qui lancera cette création. Nous voulons aussi détruire l’ensemble de nos machines à la fin des tests (terraform destroy). Le mot clé defer permet d’ajouter la méthode Destroy à la liste des actions à effectuer lors du retour de la fonction.

defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)

Et enfin on récupère l’output de terraform dans lequel on trouvera le nom de la clé SSH de l’instance. Cette clé nous permettra de nous connecter avec Terratest à la machine. Nous allons donc mettre une première assertion afin que cette clé soit définie.

instanceSshKey := terraform.Output(t, terraformOptions, "instance_key")
assert.Equal(t, "terratest_key", instanceSshKey)

Voilà notre première fonction de test complète:

func TestInstanceSshKey(t *testing.T) {
    terraformOptions := configureTerraformOptions(t)
    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)
    instanceSshKey := terraform.Output(t, terraformOptions, "instance_key")
    assert.Equal(t, "terratest_key", instanceSshKey)
}
  • Lancement du test

La commande suivante :

go test instance_test.go

...lance le test en question et vous gratifiera d’un joyeux :

FAIL: TestInstanceIp (0.27s)

Très peu verbeux par défaut, on relance la commande avec l’option -v pour avoir plus d’informations**.**

En remontant un peu les logs on comprend vite que terratest n’a rien créé, c’est normal, on a encore rien implémenté. En revanche on remarque dans les logs qu’il a bien initialisé le répertoire de travail (terraform init) et on y trouve maintenant les fichiers .tfstate de Terraform.

  • Implémentation

Nous voulons maintenant que Terraform construise l’instance EC2. Dans le fichier main.tf nous déclarons ceci :

resource "aws_instance" "example" {
    ami           = "ami-5026902d"
    instance_type = "t2.micro"
    key_name = "terratest_key"
}

Ce bout de code, extrêmement basique décrit une instance centos 7 (décrit par la clé ”ami”), t2.micro. Et l’ajout d’une clé SSH dans l’instance, cette clé existe déjà dans AWS, nous l’avions créée et déposée lors de l’installation).

Ajoutons maintenant dans le fichier output.tf dans lequel nous indiquons la sortie du code, le nom de la clé de l’instance :

output "instance_key" {
    value = "${aws_instance.example.key_name}"
}
  • Nouvelle tentative

On relance les tests avec l’option -v qui nous permettra d’avoir un rapport plus complet.

On voit dans les logs l’étape d’initialisation (init) de Terraform, puis l’application (apply). On voit aussi l’output demandé : le nom de la clé de l’instance. On observe ensuite la destruction de la machine.

Et enfin, la délivrance :

--- PASS: TestInstanceSshKey (73.80s)
PASS
ok      command-line-arguments  73.812s

Il y a un cache donc si on lance 2 fois la même commande sans changements dans le code Go la réponse est instantanée mais les tests ne sont pas rejoués. On peut tout de même forcer l'exécution en valorisant la variable d’environnement suivante : GOCACHE=off**.**

Pas de refactoring à faire, notre code est très simple.

Si nous nous connectons à la console, nous observons que notre instance a déjà été détruite. Le test a duré un peu plus d’une minute et surtout sans aucune intervention de notre part. En revanche nous n’avons rien testé d’intéressant, si ce n’est que Terraform faisait bien son boulot et que nous récupérons un output de sa part.

Deuxième test: L’IP publique

Ecrivons maintenant notre deuxième test. Notre but final est de nous connecter à une machine pour vérifier la présence d’un fichier. Nous devons donc nous assurer que l’instance est accessible publiquement. Commençons par poser le test.

Pour gagner en modularité, nous allons rédiger le test dans une fonction indépendante. Cela nous permet de lancer chaque test indépendamment des autres, mais réduit la lisibilité des logs de sortie. D’autre part, cela demande la création et la destruction d’une nouvelle instance pour chaque test, opération très chronophage.

Créons la nouvelle fonction de test : TestInstanceIp. La structure de notre fichier instance_test.go, ressemble désormais à ceci :

func configureTerraformOptions(t *testing.T) *terraform.Options {...}
func TestInstanceSshKey(t *testing.T) {...}
func TestInstanceIp(t *testing.T) {...}

Nous voulons nous assurer que notre instance possède bien une IP publique. Et nous souhaitons pour cela utiliser le module Terratest aws. En effet celui-ci permet de récupérer les IPs des instances en passant par l’API AWS.Comme dans le premier test, ajoutons les méthodes de création et de destruction de notre petite architecture :

terraformOptions := configureTerraformOptions(t)
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)

Assurons-nous d’abord que le test est rouge. Pour obtenir l’IP avec AWS, nous avons besoin de l’ID de l’instance. L’ID est un paramètre de sortie de Terraform, passons le test et l’implémentation de sa récupération, identique et celle de la clé SSH.

Nous obtenons l’assertion suivante :

instanceID := terraform.Output(t, terraformOptions, "instance_id")
instanceIPFromInstance := aws.GetPublicIpOfEc2Instance(t, instanceID, awsRegion)
assert.Equal(t, “fake_ip”, instanceIPFromInstance)

Vérifions que notre test est bien rouge : (Il y a beaucoup de logs entre les deux résultats)

--- PASS: TestInstanceSshKey (45.02s)
=== RUN   TestInstanceIp
---
--- FAIL: TestInstanceIp (52.22s)
instance_test.go:50:
Error Trace: instance_test.go:50
Error: Not equal:
expected: "fake_ip"
actual : "35.180.230.122"

Effectivement, l’IP de la machine n’est pas “fake_ip”. Notre test échoue, nous pouvons nous y fier.

  • Implémentation

Nous nous rendons alors compte que par défaut, les instances AWS sont créées avec une IP publique; Nous allons vérifier que l’IP retournée par l’output de Terraform est bien identique à celle de Terratest.

Ajoutons l’output suivant dans output.tf:

output "instance_id" {
    value = "${aws_instance.example.id}"
}
output "instance_public_ip" {
    value = "${aws_instance.example.public_ip}"
}

Et modifions notre fonction de test TestInstanceIp() pour comparer les deux valeurs.

La fonction entière :


func TestInstanceIp(t *testing.T) {
    terraformOptions := configureTerraformOptions(t)
    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)
    instanceIP := terraform.Output(t, terraformOptions, "instance_public_ip")
    instanceID := terraform.Output(t, terraformOptions, "instance_id")
    instanceIPFromInstance := aws.GetPublicIpOfEc2Instance(t, instanceID, awsRegion)
    assert.Equal(t, instanceIP, instanceIPFromInstance)
}

On relance les tests.

--- PASS: TestInstanceIp (79.52s)
PASS
ok      command-line-arguments  124.562s

Victoire, c’est vert !

C’est vert d’accord, mais le détail n’est pas très explicite.

Troisième test : Contenu du fichier

Nous avons vérifié que l’instance est créée avec notre clé SSH et une adresse IP publique. Nous allons nous appuyer sur ces tests pour maintenant tester l’écriture dans un fichier par un script lancé à l’initialisation de la machine.

Nous voulons vérifier que le fichier “/tmp/salut” contient la chaîne de caractère “Hello World”.

Nous utilisons le package ssh et la fonction CheckSshCommandE() pour exécuter la commande “cat /tmp/salut” sur la machine et la comparons avec la chaîne de caractère.

Ce qui nous donne l’assertion:

expectedText := "Hello, World"
command := fmt.Sprintf("cat /tmp/salut") // Commande effectuée sur la machine cible
actualText, err := ssh.CheckSshCommandE(t, publicHost, command)
assert.Equal(t, expectedText, actualText)

A présent nous devons indiquer à Terratest comment se connecter à la machine en question, dont nous récupérons l’adresse en output. Nous utilisons le user par défaut ‘ec2-user’ et la clé SSH de notre agent.

publicIP := terraform.Output(t, terraformOptions, "instance_public_ip")
publicHost := ssh.Host{ Hostname:  publicIP, SshUserName: "ec2-user", SshAgent: true, }

On demande donc 30 essais, 1 toutes les 5 secondes, et ne remontons pas les erreurs pour pouvoir continuer.Enfin, comme l’instance peut mettre jusqu’à quelques minutes pour démarrer, nous devons nous assurer que Terratest essaiera plusieurs fois de joindre la machine avant de déclarer un échec.

maxRetries := 30
timeBetweenRetries := 5 * time.Second
description := fmt.Sprintf("SSH to public host %s", publicInstanceDNS)
retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) {
    actualText, err := ssh.CheckSshCommandE(t, publicHost, command)
    assert.Equal(t, expectedText, actualText)
    return "", err
})

On obtient le test TestFileContent() suivant :

func TestFileContent(t *testing.T) {
    terraformOptions := configureTerraformOptions(t)
    terraform.InitAndApply(t, terraformOptions)
    defer terraform.Destroy(t, terraformOptions)
    publicIP := terraform.Output(t, terraformOptions, "instance_public_ip")
    publicHost := ssh.Host{
        Hostname:  publicIP,
        SshUserName: "ec2-user",
        SshAgent: true,
    }
    maxRetries := 30
    timeBetweenRetries := 5 * time.Second
    description := fmt.Sprintf("SSH to public host %s", publicIP)
    expectedText := "Hello, World"
    command := fmt.Sprintf("cat /tmp/salut")
    retry.DoWithRetry(t, description, maxRetries, timeBetweenRetries, func() (string, error) {
        actualText, err := ssh.CheckSshCommandE(t, publicHost, command)
        assert.Equal(t, expectedText, actualText)
        return "", err
    })
}

C’est assez laborieux et demande des connaissances de programmation en Go. Par contre nous arrivons à un fonctionnement proche de ce que l’on ferait à la main (cat sur le fichier) et nous l'automatisons.

  • L’oubli

On lance le test : go test -v instance_test.go -run TestFileContent

Running command cat /tmp/salut on ec2-user@35.180.190.131:22
"returned an error: dial tcp 35.180.190.131:22: i/o timeout. Sleeping for 5s and will try again."

Oupss, le port sur l’instance n’est pas ouvert…

Nous implémentons et affectons à notre instance un security group permettant de se connecter à la machine depuis n’importe quelle IP en SSH dans le fichier main.tf :

resource "aws_security_group" "ssh" {
    ingress {
        from_port = "22"
        to_port   = "22"
        protocol  = "tcp"
        cidr_blocks = ["0.0.0.0/0"]
    }
}

On recommence : go test -v instance_test.go -run TestFileContent

Nouvelle erreur :

Running command cat /tmp/salut on ec2-user@35.180.190.131:22
"returned an error: Process exited with status 1. Sleeping for 5s and will try again."

Le message n’est pas très parlant, mais on sait qu’on n’a pas créé le fichier. On finit donc notre main.tf en ajoutant un script qui écrit dans le fichier /tmp/salut  à la création de la machine :

resource "aws_instance" "example" {
    ami           = "ami-5026902d"
    instance_type = "t2.micro"
    key_name = "terratest_key"
    vpc_security_group_ids = ["${aws_security_group.ssh.id}]"
    user_data = <<-EOF
    #!/bin/bash
    echo 'Hello, World!' > /tmp/salut
    EOF
}

Voilà !” :

Running command cat /tmp/salut on ec2-user@35.180.208.120 --- PASS: TestFileContent (0.61s) PASS ok      command-line-arguments  0.623s

Nous avons implémenté et testé l’écriture dans un fichier par un script d’initialisation. Dans cet exercice, le script n’est pas testé dans son intégralité (On pourrait encore vérifier les permissions etc …), mais cela donne déjà une idée de ce qu’on peut accomplir avec cet outil.

Aller plus loin

Ces tests non exhaustifs nous ont permis de voir certains aspects de Terratest. En fouillant un peu dans le repo, on trouve des exemples de tests plus complexes permettant de valider les comportements de son architecture sur la durée comme par exemple un déploiement sans interruption de service.

On y trouve également comment séparer ses tests en étapes en se servant de variables d’environnements pour éviter la création et la destruction systématique des instances entre les tests. De plus, Terraform génère un tfstate, un fichier .json qui décrit l’état de l’infrastructure, nous pouvons nous baser dessus pour relancer plusieurs fois un test sans reconstruire ni déconstruire les instances .

Toujours dans ce repo, on trouvera des mécanismes permettant de rendre aléatoire les régions dans lesquelles sont créées les infrastructures pour s’assurer que la création est possible dans toutes. On trouvera aussi comment rendre aléatoire le nom des machines pour éviter les conflits.

Il peut être intéressant d’intégrer Terratest dans une plateforme d’intégration continue, et de jouer des tests à chaque update du code d’infrastructure. Construire des environnements uni****quement pour la durée des tests est plus économique que d’avoir d’en avoir des environnements dédiés en permanence.

Enfin, on trouvera dans ce même repo des exemples de tests et des modules couvrant d’autres services AWS comme S3, ARDS, CloudWatch, IAM ou encore les VPCs.

Sans oublier que Terratest couvre d’autres outils : Packer, GCP et K8

Bonus : Cloud Nuke

Un risque identifié par Gruntwork est d’avoir des ressources conservées après des séries de test avortées et donc d’avoir des instances stagnantes parmis celles qui sont vraiment utilisés. Cela représentait un certain coût chez eux : ~85%. Ils ont donc développé un outil, Cloud Nuke qui nettoie régulièrement l’environnement de ces instances, launch configurations, load-balancers et EIPs perdues. Est considéré comme perdu tout ce qui a été créé il y a plus d’une heure, durée supérieure à l'exécution de tous les tests. Leur environnement de test utilise un compte AWS indépendant des autres pour éviter les risques de destruction de machines d’autres environnements.

Réserves

  • Contrairement à d’autres outils de test d’infra as code comme kitchen ou molecule, la librairie n’abstrait pas la logique de création, de test ou de destruction de son environnement. Il en découle que c’est à l’ops d’implémenter certain mécanisme comme le “retry” dans ses tests.
  • Encore une fois : attention, l’environnement cloud dans lequel Terratest construit les infrastructures de test doit être cloisonné et séparé des autres environnements. Ce serait dommage de détruire des machines de production en voulant tester son infra…
  • Troisièmement, pour que Terratest interagit avec les services cloud à tester, il doit en avoir les droits adéquats. Ce qui implique potentiellement de devoir gérer un nouvel utilisateur ou rôle puissant et de lui donner les accréditations.
  • Le rapport en sortie de test est soit trop concis : une ligne FAIL/PASS en mode non-verbeux, soit trop verbeux, affichant tous les détails de création et de destructions de terraform qui noient l’information.
  • La librairie est peu fournie sur les services AWS moins “classiques” mais qui sont, pour sa défense, extrêmement nombreux.

Et les autres dans tout ça ?

Il existe d’autres outils de tests d’infrastructure, on citera kitchen-terraform, un set de plugins test-kitchen écrit en Ruby, le très jeune rspec-terraform en ruby aussi, ou encore le framework de test de Terraform.

Terratest se concentre sur l’aspect fonctionnel de l’infrastructure globale plutôt que sur les propriétés individuelles de ces composants. La librairie privilégie l’automatisation de tâches validant un comportement plutôt que de l’observer. Par exemple, on préférera faire de vrais appels http et analyser le code retour plutôt que de vérifier que le service httpd tourne sur le serveur.

Conclusion

On a vu qu’il est possible, bien que compliqué, de poser des tests écrit en Go pour garantir les propriétés d’une infrastructure produite par du code Terraform.

Terratest remplit bien sa promesse de générations d’environnements éphémères et d’automatisation de test. Il garantit qu’en fin d'exécution les machines seront détruites. Il permet par ailleurs de tester un grand nombre de paramètres sur les instances EC2 d’AWS.

On constate que l’outil mérite de gagner en simplicité d’utilisation, de s’enrichir sur ses services cibles, de gagner en lisibilité sur les rapports et de permettre une meilleure utilisation à l’échelle. On peut penser que le TDI va gagner en maturité rapidement et que l’arrivée de ces nouveaux outils vont démocratiser cette pratique et que bientôt les outils de provisioning seront testables facilement. Avec ces librairies interagissant en langages impératifs sur de l’infrastructure on peut même penser que le code d’infrastructure se dirige vers un tel paradigme.

Sources

https://github.com/gruntwork-io/terratest https://blog.gruntwork.io/open-sourcing-terratest-a-swiss-army-knife-for-testing-infrastructure-code-5d883336fcd5 https://blog.gruntwork.io/cloud-nuke-how-we-reduced-our-aws-bill-by-85-f3aced4e5876 https://blog.octo.com/tdi-ou-test-driven-infrastructure/