terratest_terraform

17 février 2022

Si vous avez déjà utilisé Terraform, vous vous êtes sûrement déjà demandé comment tester ce que vous faites. Terratest est peut-être la réponse à cette question ! Nous allons tenter de comprendre comment fonctionne cet outil et comment il peut s’intégrer au cycle de développement de nos modules.

Qu’est-ce que Terratest ?

Terratest est une librairie Open Source écrite en Go, qui permet entre autres d’automatiser les tests d’infrastructure as code. Elle est maintenue par gruntwork.io, une société spécialisée dans l’infra as code, également à l’origine de Terragrunt.

terragrunt

À la base, Terratest était une librairie pour tester du code Terraform — d’où son nom peut être 😀 . Aujourd’hui elle supporte : Terraform, Packer, Docker, Kubernetes, AWS, GCP, et d’autres.

Pour tester du Terraform il faudra toujours le client Terraform installé sur la machine, de même pour Packer. Par contre, pour Docker, GCP ou Kubernetes elle s’appuie sur les librairies Go de ces services. L’autre gros avantage est que les systèmes d’authentification natifs des cloud providers sont utilisés : pas besoin de gérer un énième fichier de variables d’environnement ou de configuration dédié 🍾.

Terratest ne va pas nous aider à tester unitairement le “code” Terraform, comme on l’aurait fait pour un autre langage, mais plutôt à réaliser des tests fonctionnels : il va nous aider à vérifier que les ressources créées par Terraform existent et sont fonctionnelles, que les outputs sont valides et ont le bon format...

Une librairie en Go présente beaucoup d’avantages : portabilité, langage simple à appréhender, documentation... Mais aussi tout le set de fonction associé aux tests natifs au langage.

La documentation de terratest et plus spécifiquement le module terraform de la librairie sont accessibles et mettent en avant quelques exemples bien commentés.

Mise en place et écriture de son premier test

Commençons par un exemple simple : un module terraform qui définit un certificat auto-signé et qui output le certificat créé au format PEM.

C’est un cas basique d’utilisation, facile à tester, car il ne demande aucun accès à un cloud provider.

Commençons par définir nos ressources avec Terraform :

terraform {
  required_version = "~> 1.1"
}

locals {
	domain_name = "example.padok.fr"
}

resource "tls_private_key" "example" {
  algorithm = "RSA"
  rsa_bits  = 2048
}

resource "tls_self_signed_cert" "example" {
  key_algorithm   = tls_private_key.example.algorithm
  private_key_pem = tls_private_key.example.private_key_pem

  # Certificate expires after 12h
  validity_period_hours = 12

  # Reasonable set of uses for a server SSL certificate.
  allowed_uses = [
    "key_encipherment",
    "digital_signature",
  ]

  dns_names = [local.domain_name]

  subject {
    common_name  = local.domain_name
    organization = "Padok"
  }
}

output "example_cert_pem" {
  value = tls_self_signed_cert.example.cert_pem
}

Ici rien de très compliqué, nous générons un certificat tls_self_signed_cert.example pour le domaine “example.padok.fr” et une clé tls_private_key.example qui nous servira à signer notre certificat.

Reste maintenant à écrire le Terratest. On commence par créer le répertoire dédié aux tests :

mkdir -p tests

Puis, on va initialiser l’environnement pour Go :

go mod init example.net/padok/certificate
go mod tidy -go=1.16

Normalement à ce moment-là, 2 nouveaux fichiers sont présents : go.mod et go.sum (ces fichiers seront utiles pour la compilation et la récupération des librairies par golang).

Puis dans le nouveau répertoire (tests), on va écrire la base du fichier de tests :

package test

import (
	"testing"

	"github.com/gruntwork-io/terratest/modules/terraform"
)

func TestCertExample(t *testing.T) {
	// Définit l'environnement de test pour Terratest
	terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "../",
	})

	// Détruit les resources terraform à la fin du test (= `terraform destroy`)
	// Le mot clé 'defer' permet de déléguer l'execution d'une function à la fin de l'execution de la fonction courante.
	defer terraform.Destroy(t, terraformOptions)

	// Lance un init + apply du module
	terraform.InitAndApply(t, terraformOptions)
}

Pour le moment le test ne fait pas grand-chose : il va lancer un terraform init plus terraform apply de notre module, et à la fin un terraform destroy.

On peux déjà faire un premier test, voir si notre terraform s’exécute correctement :

$ go test -count 1 cert_example_test.go
TestCertExample 2022-02-02T15:02:17+01:00 retry.go:91: terraform [init -upgrade=false]
TestCertExample 2022-02-02T15:02:17+01:00 logger.go:66: Running command terraform with args [init -upgrade=false]
TestCertExample 2022-02-02T15:02:18+01:00 logger.go:66: 
TestCertExample 2022-02-02T15:02:18+01:00 logger.go:66: Initializing the backend...
...
TestCertExample 2022-02-02T15:02:19+01:00 logger.go:66: tls_private_key.example: Destruction complete after 0s
TestCertExample 2022-02-02T15:02:19+01:00 logger.go:66: 
TestCertExample 2022-02-02T15:02:19+01:00 logger.go:66: Destroy complete! Resources: 2 destroyed.
TestCertExample 2022-02-02T15:02:19+01:00 logger.go:66: 
PASS
ok      example.com/padok       2.030s

Tout est ok ! Continuons... avec un premier test simple : on va vérifier que l’output example_cert_pem est bien présent. S’il est vide, le test échouera.

package test

import {
	"testing"

	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
}

func TestCertExample(t *testing.T) {
	// Define environment for Terraform
	terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "../",
	})

	// Destroy terraform resources at the end of the execution (= `terraform destroy`)
	defer terraform.Destroy(t, terraformOptions)

	// Execute `terraform init` then `apply`
	terraform.InitAndApply(t, terraformOptions)

	// Get only the output 'example_cert_pem'
	output := terraform.Output(t, terraformOptions, "example_cert_pem")
	// Check that output is not empty
	assert.NotEmpty(t, output, "Output is empty")
}

Et on relance :

$ go test -count 1 cert_example_test.go
TestCertExample 2022-02-02T15:02:17+01:00 retry.go:91: terraform [init -upgrade=false]
TestCertExample 2022-02-02T15:02:17+01:00 logger.go:66: Running command terraform with args [init -upgrade=false]
TestCertExample 2022-02-02T15:02:18+01:00 logger.go:66: 
TestCertExample 2022-02-02T15:02:18+01:00 logger.go:66: Initializing the backend...
...
TestCertExample 2022-02-02T15:02:19+01:00 logger.go:66: tls_private_key.example: Destruction complete after 0s
TestCertExample 2022-02-02T15:02:19+01:00 logger.go:66: 
TestCertExample 2022-02-02T15:02:19+01:00 logger.go:66: Destroy complete! Resources: 2 destroyed.
TestCertExample 2022-02-02T15:02:19+01:00 logger.go:66: 
--- PASS: TestCertExample (1.64s)
PASS
ok      example.com/padok       1.646s

PASS ! On est maintenant sûr que notre module est valide, on peut ajouter des tests, ajouter des steps, sortir les rapports de coverage...

On aurait eu moins de chance si dans notre fichier main.tf il n’y avait pas eu ces lignes:

output "example_cert_pem" {
  value = tls_self_signed_cert.example.cert_pem
}

Le test aurait échoué de cette manière :

$ go test -count 1 cert_example_test.go
TestCertExample 2022-02-02T15:02:17+01:00 retry.go:91: terraform [init -upgrade=false]
TestCertExample 2022-02-02T15:02:17+01:00 logger.go:66: Running command terraform with args [init -upgrade=false]
TestCertExample 2022-02-02T15:02:18+01:00 logger.go:66: 
TestCertExample 2022-02-02T15:02:18+01:00 logger.go:66: Initializing the backend...
...
TestCertExample 2022-02-02T15:30:32+01:00 logger.go:66: Destroy complete! Resources: 2 destroyed.
TestCertExample 2022-02-02T15:30:32+01:00 logger.go:66: 
--- FAIL: TestCertExample (1.24s)
    output.go:19: 
                Error Trace:    output.go:19
                                                        cert_example_test.go:24
                Error:          Received unexpected error:
                                FatalError{Underlying: error while running command: exit status 1; 
                                Error: Output "example_cert_pem" not found
                            
                                The output variable requested could not be found in the state file. If you
                                recently added this to your configuration, be sure to run `terraform apply`,
                                since the state won't be updated with new output variables until that command
                                is run.}
                Test:           TestCertExample
FAIL
exit status 1
FAIL    example.com/padok       1.247s

Le site de Terratest est également bien fourni en exemples, qui couvrent bien toutes les fonctionnalités de la librairie.

 

Tester un module terraform existant

On ne va pas pouvoir couvrir tout le spectre des possibilités de cette librairie dans un seul article 🤷‍♂️. Nous allons donc regarder un peu plus en détail un cas concret d’utilisation.

Chez Padok nous avons une librairie de module Terraform que nous utilisons dans différents projets pour déployer les briques simples de nos infrastructures. Nous allons regarder quelles sont les problématiques que l’on peut rencontrer sur ces modules, notamment le nommage des ressources unique, les stages, les jobs longs...

Le module pour lequel nous avons voulu ajouter des tests est terraform-google-staticfrontend. Ce module permet de créer un bucket GCS pour héberger un site statique sur GCP.

Plutôt que de tester directement le module nous allons tester ces exemples. Il y en a 2 dans ces modules :

  • Déployer un loadbalancer devant un bucket ;
  • Déployer un loadbalancer devant plusieurs buckets.

Comme pour l’exemple précédent, on ajoute la structure et on initialise l’environnement Go, puis on crée le fichier de test :

package test

import (
	"testing"
)

func TestMultipleFrontend(t *testing.T) {
	t.Parallel()

	// The folder where we have our Terraform code
	workingDir := "../examples/multiple_frontends"
}

func TestSimpleFrontend(t *testing.T) {
	t.Parallel()

	// The folder where we have our Terraform code
	workingDir := "../examples/simple_frontend"
}

test/staticfrontend_test.go

Dans ces exemples, on a plusieurs problématiques :

  • Les noms de buckets doivent être uniques comme décrit dans la documentation ;
  • Les déploiements des certificats de Load Balancer sur GCP sont très longs ;
  • On veut tester la fonctionnalité de bout en bout : interroger le site à travers le LoadBalancer en HTTPS.

Prenons les problèmes un par un, tout d’abord, le nommage des buckets, notre module permet de les nommer comme on le souhaite on va donc permettre aux exemples de prendre en paramètre un identifiant fourni par le Terratest :

variable "namespace" {
	type    = string
}

module "frontend" {
  source        = "../.."
  name          = "${var.namespace}-staticfrontend"
  location      = "europe-west1"
  force_destroy = true
}

examples/simple_frontend/main.tf

Dans le test, il va falloir passer une valeur pour namespace:

import (
	"testing"

	"github.com/gruntwork-io/terratest/modules/random"
	"github.com/gruntwork-io/terratest/modules/terraform"
	test_structure "github.com/gruntwork-io/terratest/modules/test-structure"
)

func TestSimpleFrontend(t *testing.T) {
	t.Parallel()

	// To skip step, uncomment corresponding lines
	// os.Setenv("SKIP_destroy", "true")
	// os.Setenv("SKIP_build", "true")

	// The folder where we have our Terraform code
	workingDir := "../examples/simple_frontend"

	// destroy all resources at the end
	defer test_structure.RunTestStage(t, "destroy", func() {
		// Load the Terraform Options saved by the earlier 'build' stage
		terraformOptions := test_structure.LoadTerraformOptions(t, workingDir)

		terraform.Destroy(t, terraformOptions)
	})

	// Build stage: configure, init and apply terraform
	test_structure.RunTestStage(t, "build", func() {
		// create namespace
		uniqueId := random.UniqueId()

		// init terraform context
		terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
			TerraformDir: workingDir,
			Vars: map[string]interface{}{
				// pass terraform vars: terraform apply -var namespace=abcd12
				"namespace": strings.ToLower(uniqueId),
			},
		})

		// Save the Terraform Options struct so future test stages can use it
		test_structure.SaveTerraformOptions(t, workingDir, terraformOptions)

		// Apply
		terraform.InitAndApply(t, terraformOptions)
	})
}

On a introduit une autre notion ici, c’est la possibilité de créer des “stages”. Ces blocs d’exécution peuvent être contrôlés. Par exemple on pourra forcer l’exécution de certains stages, et bloquer l’exécution d’autres (documentation sur les test-stage).

Jouer avec les stages permet surtout, lorsqu’on est en cours de développement, de pouvoir tester rapidement un seul step sans avoir à tout détruire ou déployer à chaque essai.

Par exemple si on veut lancer le build sans le détruire à la fin du test, on peut ajouter SKIP_destroy :

SKIP_destroy=true go test -count 1 -p 1 ./...

Maintenant que notre structure est prête, on peut s’attaquer au test de la fonctionnalité. C’est-à-dire vérifier que le site statique est fonctionnel.

Terratest nous met à disposition plusieurs modules : pour vérifier que le bucket est présent, que la connexion HTTP fonctionne...

Commençons donc par vérifier que le bucket est présent et par ajouter l’index de test dans un nouveau stage :

import (
	...
	// import GCP module from terratest library
	"github.com/gruntwork-io/terratest/modules/gcp"
)

func TestSimpleFrontend(t *testing.T) {
	t.Parallel()

	// To skip step, uncomment corresponding lines
	// os.Setenv("SKIP_destroy", "true")
	// os.Setenv("SKIP_build", "true")
	// os.Setenv("SKIP_validate", "true")

	...
	
	test_structure.RunTestStage(t, "validate", func() {
		// Load the Terraform Options saved by the earlier 'build' stage
		terraformOptions := test_structure.LoadTerraformOptions(t, workingDir)
	
		// check output from example
		bucketName, err := terraform.OutputE(t, terraformOptions, "bucket_name")
		assert.NoError(t, err, "bucket_name cannot be output")
	
		// check bucket exists
		err := gcp.AssertStorageBucketExistsE(t, bucketName)
		assert.NoErrorf(t, err, "bucket '%s' does not exists", bucketName)
	
		// upload default index file
		index := `
	 <!DOCTYPE html>
	<html>
	<head>
	  <meta charset='utf-8'>
	  <title>Sample test</title>
	</head>
	<body>
	  Sample test
	</body>
	</html>`
	
		// upload our index file to the bucket
		_, err = gcp.WriteBucketObjectE(t, bucketName, "index.html", strings.NewReader(index), "text/html")
		assert.NoErrorf(t, err, "cannot upload index.html to bucket '%s'", bucketName)
	
		// defer empty storage bucket (for deletion)
		defer gcp.EmptyStorageBucket(t, bucketName)
	})
}

Une fois lancé, le test devrait afficher ces quelques lignes supplémentaires :

$ go test -count 1 -p 1 -timeout 5m ./...
TestSimpleFrontend 2022-02-09T14:42:43+01:00 test_structure.go:27: The 'SKIP_build' environment variable is not set, so executing stage 'build'.
TestSimpleFrontend 2022-02-09T14:42:43+01:00 save_test_data.go:188: Storing test data in ../examples/simple_frontend/.test-data/TerraformOptions.json so it can be reused later
...
TestSimpleFrontend 2022-02-09T14:43:33+01:00 logger.go:66: 
TestSimpleFrontend 2022-02-09T14:43:33+01:00 storage.go:190: Finding bucket lhiiad-staticfrontend
TestSimpleFrontend 2022-02-09T14:43:34+01:00 storage.go:108: Writing object to bucket lhiiad-staticfrontend using path index.html and content type text/html
TestSimpleFrontend 2022-02-09T14:43:34+01:00 storage.go:146: Emptying storage bucket lhiiad-staticfrontend
TestSimpleFrontend 2022-02-09T14:43:34+01:00 storage.go:173: Deleting storage bucket object index.html
...
TestSimpleFrontend 2022-02-09T14:44:17+01:00 logger.go:66: 
TestSimpleFrontend 2022-02-09T14:44:17+01:00 logger.go:66: Destroy complete! Resources: 16 destroyed.
TestSimpleFrontend 2022-02-09T14:44:17+01:00 logger.go:66: 
PASS
ok      github.com/padok-team/terraform-google-staticfrontend   94.012s

Maintenant on peut tester la connexion au bucket.

import (
	...
	// import http_helper module from terratest library
	http_helper "github.com/gruntwork-io/terratest/modules/http-helper"
)

func TestSimpleFrontend(t *testing.T) {
	t.Parallel()

	...

	test_structure.RunTestStage(t, "validate", func() {	

		...

		url, err := gcp.WriteBucketObjectE(t, bucketName, "index.html", rd, "text/html")
		require.NoErrorf(t, err, "cannot upload index.html to bucket '%s'", bucketName)

		// get domain name from terraform output
		domainName, err := terraform.OutputE(t, terraformOptions, "domain_name")
		assert.NoError(t, err, "domain_name cannot be output")	
	
		// test public access on bucket
		_, _, err = http_helper.HttpGetE(t, url, &tls.Config{})
		assert.NoError(t, err, "cannot read '%s'", url)
	})
}

Dans les logs on peut voir l’appel au bucket :

...
TestSimpleFrontend 2022-02-09T14:51:15+01:00 storage.go:190: Finding bucket how4na-staticfrontend
TestSimpleFrontend 2022-02-09T14:51:16+01:00 storage.go:108: Writing object to bucket how4na-staticfrontend using path index.html and content type text/html
TestSimpleFrontend 2022-02-09T14:51:16+01:00 http_helper.go:32: Making an HTTP GET call to URL https://storage.googleapis.com/how4na-staticfrontend/index.html
TestSimpleFrontend 2022-02-09T14:51:16+01:00 storage.go:146: Emptying storage bucket how4na-staticfrontend
TestSimpleFrontend 2022-02-09T14:51:17+01:00 storage.go:173: Deleting storage bucket object index.html
...

Maintenant, testons la connexion au bucket à travers le LoadBalancer.

Le souci ici va être la validation du certificat TLS par GCP. Cette étape est passablement longue (~20 min) et si notre test se lance immédiatement après la création de la ressource par Terraform il échouera à coup sûr.

Nous allons donc utiliser les fonctions de retry du module http_helper. Cette fonction apporte 2 nouveautés :

  • Faire des tentatives d’appel HTTP à intervalle régulier, pendant un temps donné ;
  • Vérifier que le contenu récupéré avec la requête HTTP correspond au contenu présent dans le bucket.
// test public access on bucket
_, content, err := http_helper.HttpGetE(t, url, &tls.Config{})
assert.NoError(t, err, "cannot read '%s'", url)

// test public access on lb, retry every minute, 30 times
lbURL := fmt.Sprintf("https://%s", domainName)
err = http_helper.HttpGetWithRetryE(t, lbURL, &tls.Config{
	InsecureSkipVerify: true,
}, 200, content, 30, time.Minute)
require.NoError(t, err, "cannot read '%s'", lbURL)

Mais ça ne va pas suffire. En effet, par défaut le time out des tests en go est assez bas (30s), il va donc falloir modifier un peu la manière dont on lance nos tests.

$ go test -count 1 -p 1 -timeout 60m ./...
...
TestSimpleFrontend 2022-02-09T14:56:57+01:00 http_helper.go:32: Making an HTTP GET call to URL https://storage.googleapis.com/l3r5tl-staticfrontend/index.html
TestSimpleFrontend 2022-02-09T14:56:57+01:00 retry.go:91: HTTP GET to URL https://l3r5tl-staticfrontend.k8s-training.padok.cloud
TestSimpleFrontend 2022-02-09T14:56:57+01:00 http_helper.go:32: Making an HTTP GET call to URL https://l3r5tl-staticfrontend.k8s-training.padok.cloud
TestSimpleFrontend 2022-02-09T14:56:57+01:00 retry.go:103: HTTP GET to URL https://l3r5tl-staticfrontend.k8s-training.padok.cloud returned an error: Get "https://l3r5tl-staticfrontend.k8s-training.padok.cloud": EOF. Sleeping for 1m0s and will try again.
TestSimpleFrontend 2022-02-09T14:57:57+01:00 retry.go:91: HTTP GET to URL https://l3r5tl-staticfrontend.k8s-training.padok.cloud
TestSimpleFrontend 2022-02-09T14:57:57+01:00 http_helper.go:32: Making an HTTP GET call to URL https://l3r5tl-staticfrontend.k8s-training.padok.cloud
TestSimpleFrontend 2022-02-09T14:57:57+01:00 retry.go:103: HTTP GET to URL https://l3r5tl-staticfrontend.k8s-training.padok.cloud returned an error: Get "https://l3r5tl-staticfrontend.k8s-training.padok.cloud": EOF. Sleeping for 1m0s and will try again.
...

⚠️ De manière générale, les tests avec terratest prennent rarement 30s... il faudra donc penser à ajouter le paramètre à chaque lancement.

Évidemment on ne réduit pas le temps de déploiement du certificat par GCP, mais nous avons un moyen d’être sûr qu’une fois déployé il sera fonctionnel !

Conclusion

J’espère que cet article vous aura permis de mieux comprendre comment et pourquoi utiliser Terratest dans le cadre de développement Terraform.

Pour aller plus loin, une des applications possible au-delà d’une utilisation locale, serait d’intégrer ces tests à un pipeline de CI/CD, ce qui permettrai de valider les fonctionnalités d’un module, d’un update... Mais c’est une autre histoire 😄