Terraform mit Terratest in einer Gitlab Pipeline


Bicycle

Terraform mit Terratest in einer Gitlab Pipeline

Mit der Einführung von Terraform Cloud bzw. Terrform Enterprise kann man in einer Organization oder Team nun auch Terraform Module untereinander teilen. Wenn nun solche Module verwendet werden, muss sicher gestellt sein, dass sich Änderungen am Modul, die sich durch Weiterentwicklung oder auch Fehlerbehebungen ergeben, nicht negativ auswirken. Hierzu kommt dann Terratest ins Spiel.

Terratest

Terratest ist eine Go Bibliothek, die es vereinfacht automatisierte Tests für Infrastrukturcode zu schreiben. Terratest wurde von Gruntwork entwickelt, um die deren Infrastruktur Code Bibliothek zu warten, welche in Terraform, Go, Pytho nund Bash geschrieben ist. Terratest ist selbst in Go geschrieben, somit müssen auch alle Tests in Go geschrieben werden.

Terratest stellt eine Sammlung von Hilfsfunktionen und Munstern für gemeinsame Aufgaben von Infrastrukturtests, wie das Absetzen von HTTP Anfragen oder SSH Zugriff auf spezielle virtuelle Maschinen, zur Verfügung.

Im folgenden sind einige der Vorteile von Terratest aufgelistet:

  • Es stellt praktische Helfer bereit um Infrastruktur zu prüfen Diese Besonderheit ist sehr nützlich, um reale Infrastruktur in einer echten Umgebung zu verifizieren
  • Die Ordner Struktur ist klar organisiert Die Tests sind klar organisiert und folgen der Terraform Modul Struktur
  • Alle Tests sind in Go geschrieben Die meisten Entwickler, welche Terraform verwenden, sind auch Go Entwickler. Die einzigen Abhängigkeiten, um Terratest Tests auszuführen sind Terraform und Go.
  • Die Infrastruktur ist erweiterbar Jeder kann zustäzliche Funktionen implementieren

Terratest wurde entwickelt für Integrationstests. Zu diesem Zweck provisioniert Terratest reale Resourcen in einer Umgebung. Manchmal können diese Integrationstests sehr groß werden, dementsprechend lange dauern diese Tests dann auch!

Man sollte hier immer beachten, dass man diese Terraform Tests in einer isolierten Umgebung, sprich einem oder mehrerer Test Accounts, ausführt, so dass keine Umgebungen von der Entwicklungsarbeit des Terraform Codes bzw. vor allem der Terratest Tests beeinflußt werden können.

Beispiel AWS Route53 Modul

In diesem Beitrag werden wir nun ein kleines Modul entwickeln, anhand dessen wir Terraform Unit-Tests und Integrationstests erklären werden

 1data "aws_route53_zone" "target_zone" {
 2  name = var.domain
 3}
 4
 5resource "aws_route53_record" "target_record" {
 6  depends_on = [null_resource.module_dependency]
 7  zone_id    = data.aws_route53_zone.target_zone.zone_id
 8  name       = "${var.subdomain}.${var.domain}"
 9  type       = var.record_type
10  ttl        = var.record_ttl
11  records    = [var.record_ip]
12}

Unit Tests mit Terratest

Dank der Flexibilität von Terratest können wir Unit-Tests entwickeln. Unit-Tests sind lokal ausgeführte Tests (obwohl ein Internetzugang erforderlich ist). Wir können hierzu die terraform plan Funktionalität verwenden und auf das eigentliche apply verzichten.

Unit-Tests führen also nur die Befehle terraform init und terraform plan aus, um die Ausgabe des Terraform-Plans zu analysieren und nach den zu vergleichenden Attributwerten zu suchen.

Um mit Terratest zu beginnen, müss man den Terraform-Modulpfad als Go-Modul mit dem aktuellen Pfad initialisieren und einen Pfad test für Ihre bevorstehenden Tests erstellen

1go mod init $(basename $PWD)
2mkdir -p test

Im folgenden Unit-Test werden wir nun testen, ob eine Subdomain erstellt und nichts zerstört wird. Der Zoneneintrag sollte hier nur als Datenquelle geladen werden und niemals eine Ressource in unserem Modul sein.

Der Zoneneintrag muss in einem anderen Modul angelegt und gepflegt werden.

 1package test
 2
 3import (
 4  "encoding/json"
 5  "fmt"
 6  "path"
 7  "testing"
 8
 9  "github.com/gruntwork-io/terratest/modules/terraform"
10  tfPlan "github.com/hashicorp/terraform/plans/planfile"
11)
12
13
14const domain = "testing.infralovers.com"
15
16type awsRoute53 struct {
17  subdomain     string
18  record_type   string
19  record_ip     string
20}
21
22// Test cases for storage account name conversion logic
23var testCases = map[string]awsRoute53{
24  "terratest.testing.infralovers.com": awsRoute53{subdomain: "terratest", record_type: "A", record_ip: "127.0.0.1"},
25}
26
27func TestUT_AWSRoute53(t *testing.T) {
28  t.Parallel()
29
30  for expected, input := range testCases {
31    // Specify the test case folder and "-var" options
32    tfOptions := &terraform.Options{
33      TerraformDir: "../",
34      Vars: map[string]interface{}{
35        "subdomain":    input.subdomain,
36        "domain":       domain,
37        "record_type":  input.record_type,
38        "retcord_ip":   input.record_ip,
39      },
40    }
41
42    // Terraform init and plan only
43    tfPlanOutput := "terraform.tfplan"
44    terraform.Init(t, tfOptions)
45    terraform.RunTerraformCommand(t, tfOptions, terraform.FormatArgs(tfOptions, "plan", "-out="+tfPlanOutput)...)
46    tfOptions.Vars = nil
47
48      // Read and parse the plan output
49    reader, err := tfPlan.Open(path.Join(tfOptions.TerraformDir, tfPlanOutput))
50    if err != nil {
51      t.Fatal(err)
52    }
53    defer reader.Close()
54    plan, _ := reader.ReadPlan()
55    if plan.Changes.Empty() {
56      t.Fatal("Empty plan outcome")
57      continue
58    }
59    fmt.Printf("Checking %s...", expected)
60    for _, res := range plan.Changes.Resources {
61      if res.ChangeSrc.Action.String() != "Create" {
62        t.Errorf("Found an action which is not create: %s", res.ChangeSrc.Action.String())
63        continue
64      }
65      if res.Addr.String() == "aws_route53_record.target_record" {
66        // do some fancy checks ...
67      }
68    }
69  }
70}

Go Entwickler werden möglicherweise bemerken, dass der Unit Test die gleiche Signatur wie klassische Go Test Funktionien aufweist in dem er einen Argumententyp *testing.T akzeptiert.

Mit diesem Code kann man nun mit dem folgenden Kommando prüfen, ob alle Resourcen nur generiert werden und keinerlei andere Resourcen verändert oder zerstört werden.

1go test ./test/

Der Test sollte nun ohne Fehler durchlaufen, aber er berücksichtigt noch nicht, ob der DNS Eintrag auch in korrekter Form generiert wird. Um dies machen zu können wird der unten stehende Code noch dem Terraform Modul hinzugefügt

1output "dns" {
2  value = aws_route53_record.target_record.fqdn
3}

Und wir müssen auch den Test noch modifizieren, so dass dieser auch die Ausgabe des Moduls verifiziert

 1package test
 2
 3import (
 4  "encoding/json"
 5  "path"
 6  "testing"
 7
 8  "github.com/gruntwork-io/terratest/modules/terraform"
 9  tfPlan "github.com/hashicorp/terraform/plans/planfile"
10)
11
12func getJsonMap(m map[string]interface{}, key string) map[string]interface{} {
13  raw := m[key]
14  sub, ok := raw.(map[string]interface{})
15  if !ok {
16    return nil
17  }
18  return sub
19}
20
21func TestUT_AWSRoute53(t *testing.T) {
22  t.Parallel()
23
24  for expected, input := range testCases {
25    // Specify the test case folder and "-var" options
26    tfOptions := &terraform.Options{
27      TerraformDir: "../",
28      Vars: map[string]interface{}{
29        "subdomain":   input.subdomain,
30        "domain":      domain,
31        "target_type": input.record_type,
32        "target_ip":   input.record_ip,
33      },
34    }
35
36    // init and plan
37    tfPlanOutput := "terraform.tfplan"
38    terraform.Init(t, tfOptions)
39    terraform.RunTerraformCommand(t, tfOptions, terraform.FormatArgs(tfOptions, "plan", "-out="+tfPlanOutput)...)
40    tfOptions.Vars = nil
41
42    // read the plan as json
43    jsonplan, err := terraform.RunTerraformCommandAndGetStdoutE(t, tfOptions, terraform.FormatArgs(tfOptions, "show", "-json", tfPlanOutput)...)
44    jsonMap := make(map[string]interface{})
45    err = json.Unmarshal([]byte(jsonplan), &jsonMap)
46    if err != nil {
47      panic(err)
48    }
49    planned := getJsonMap(jsonMap, "planned_values")
50    outputs := getJsonMap(planned, "outputs")
51    dns := getJsonMap(outputs, "dns")
52    actual := dns["value"]
53    if expected != actual {
54      t.Errorf("Planned dns output is not valid: %s, expected: %s", actual, expected)
55    }
56    // Read and parse the plan output
57    reader, err := tfPlan.Open(path.Join(tfOptions.TerraformDir, tfPlanOutput))
58    if err != nil {
59      t.Fatal(err)
60    }
61    defer reader.Close()
62    plan, _ := reader.ReadPlan()
63    if plan.Changes.Empty() {
64      t.Fatal("Empty plan outcome")
65      continue
66    }
67
68    for _, res := range plan.Changes.Resources {
69      if res.ChangeSrc.Action.String() != "Create" {
70        t.Errorf("Found an action which is not create: %s", res.ChangeSrc.Action.String())
71        continue
72      }
73      if res.Addr.String() == "aws_route53_record.target_record" {
74        // do some fancy checks ...
75      }
76    }
77  }
78}

Im obigen Code wird der Plan als JSON-Datei gelesen, da die interne Verarbeitung des Terraform-Plans über die Go Bibliothek go-cty vorgenommen wird, wodurch einige Typen erstellt werden, die erst konvertiert werden müssten.

Integrationstests mit Terratest

Beim Integrationstest geht der Test noch einen Schritt weiter und erstellt wirkliche Ressourcen - und zerstört diese auch danach.

Der tatsächliche Testcode ist kürzer, da wir jetzt die Ausgabe des Terraform-angewendeten Codes lesen können, um den generierten DNS-Datensatz zu überprüfen

 1package test
 2
 3import (
 4  "testing"
 5
 6  "github.com/gruntwork-io/terratest/modules/terraform"
 7)
 8
 9// Test the Terraform module in examples/complete using Terratest.
10func TestIT_AWSRoute53(t *testing.T) {
11  t.Parallel()
12
13  for expected, input := range testCases {
14    // Specify the test case folder and "-var" options
15    tfOptions := &terraform.Options{
16      TerraformDir: "../",
17      Vars: map[string]interface{}{
18        "subdomain":   input.subdomain,
19        "domain":      domain,
20        "target_type": input.dnstype,
21        "target_ip":   input.target,
22      },
23    }
24
25    defer terraform.Destroy(t, tfOptions)
26
27    // Terraform init and plan only
28    terraform.InitAndApply(t, tfOptions)
29
30    actual := terraform.Output(t, tfOptions, "dns")
31
32    if actual != expected {
33      t.Errorf("Expect %v, but found %v", expected, actual)
34    }
35
36  }
37}

Beim Ausführen dieses Tests müssen die AWS Umgebungsvariablen definiert sein!

1go test ./test/ -run "TestIT_"

Dieses Mal werden nur Integrationstests mit der obigen Befehlszeile ausgeführt, und es sollten DNS-Datensätze erstellt und durch den Aufruf "terraform.Destroy()" zerstört werden, nachdem der vollständige Test durchgelaufen ist.

Jetzt sind unsere Tests in vollständig und die Funktion des Terraform-Modul Codes überprüft.

Fehldesign des Integrationstests

Wenn wir nun noch einen Testfall in die Definition hinzufügen, wird der erste Durchlauf funktionieren, jeder weitere fehlschlägt

1// Test cases for storage account name conversion logic
2var testCases = map[string]awsRoute53{
3  "terratest.testing.infralovers.com": awsRoute53{subdomain: "terratest", record_type: "A", record_ip: "127.0.0.1"},
4  "cnamtest.testing.infralovers.com": awsRoute53{subdomain: "cnamtest", record_type: "CNAME", record_ip: "terratest.testing.infralovers.com"},
5}

Mit dem aktuellen Testcode werden Ressourcen erstellt, die nicht sauber zerstört werden, da beim Zerstörungsprozess nur der zuletzt erstellte Terraform-Status verwendet wird! In unserem Beispiel wird jetzt nur der CNAME-Eintrag korrekt entfernt, der A-Eintrag ist noch vorhanden. Dies ist natürlich kein Problem von Terraform oder Terratest, es ist der einfache Beispielcode, der dieses Verhalten erzeugt!

Man sollte immer im Kopf behalten, dass Integrationstests wieder alles aufgeräumt hinterlassen, Ansonsten werden diese Tests fälschlicherweise fehlschlagen oder sogar Kosten produzieren, da Resourcen belegt bleiben.

Terratest in einer Gitlab Pipeline

Typischerweise möchte man diese Tests auch in einer Pipeline ausführen. Wir betrachten nun Gitlab mit Gitlab-CI.

 1terratest:
 2  stage: test
 3  image:
 4    name: "hashicorp/terraform:full"
 5    entrypoint:
 6      - "/usr/bin/env"
 7      - "PATH=/go/bin:/usr/local/go/bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
 8  script:
 9    - go test ./test/ -run "TestUT_" # running unit tests first
10    - go test ./test/ -run "TestIT_" # and afterwards integration tests

Ein volles Beispiel einer Gitlab Pipeline mit Terraform und Terratest

Im folgenden Code Beispiel ist eine Gitlab Pipeline, die sowohl Terraform Code validiert als auch ein Linting des Terraform sowie Shell Codes macht.

In diesem Beispiel wird

verwendet.

Hier werden die Tests in einer weiterentwickelten Version mittels mage gestartet.

 1stages:
 2  - validate
 3  - lint
 4  - test
 5
 6validate:
 7  stage: validate
 8  image:
 9    name: "hashicorp/terraform:0.12.8"
10    entrypoint:
11      - "/usr/bin/env"
12      - "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
13  script:
14    - terraform init
15    - terraform validate
16  artifacts:
17    paths:
18      - .terraform
19
20scriptlint:
21  stage: lint
22  image:
23    name: "koalaman/shellcheck-alpine"
24  script:
25    - shellcheck scripts/*
26
27terralint:
28  stage: lint
29  image:
30    name: "wata727/tflint"
31    entrypoint:
32      - "/usr/bin/env"
33      - "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
34  script:
35    - tflint
36
37terratest:
38  stage: test
39  image:
40    name: "hashicorp/terraform:full"
41    entrypoint:
42      - "/usr/bin/env"
43      - "PATH=/go/bin:/usr/local/go/bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
44  script:
45    - currdir=$(pwd)
46    - apk add --no-cache gcc libc-dev bind-tools
47    - go get -u -d github.com/magefile/mage
48    - magedir=$(find /go/pkg/mod/ -name magefile -type d | grep -v cache)
49    - cd $magedir/$(ls $magedir/)
50    - go run bootstrap.go
51    - cd $currdir
52    - mage full

Um die Tests zu starten wird hier mage mit dem folgenden magefile verwendet:

 1package main
 2
 3import (
 4  "fmt"
 5  "os"
 6  "path/filepath"
 7
 8  "github.com/magefile/mage/mg"
 9  "github.com/magefile/mage/sh"
10)
11
12// The default target when the command executes `mage` in Cloud Shell
13var Default = Full
14
15// A build step that runs Clean, Format, Unit and Integration in sequence
16func Full() {
17  mg.Deps(Unit)
18  mg.Deps(Integration)
19}
20
21// A build step that runs unit tests
22func Unit() error {
23  mg.Deps(Clean)
24  mg.Deps(Format)
25  fmt.Println("Running unit tests...")
26  return sh.RunV("go", "test", "./test/", "-run", "TestUT_", "-v")
27}
28
29// A build step that runs integration tests
30func Integration() error {
31  mg.Deps(Clean)
32  mg.Deps(Format)
33  fmt.Println("Running integration tests...")
34  return sh.RunV("go", "test", "./test/", "-run", "TestIT_", "-v")
35}
36
37// A build step that formats both Terraform code and Go code
38func Format() error {
39  fmt.Println("Formatting...")
40  if err := sh.RunV("terraform", "fmt", "."); err != nil {
41    return err
42  }
43  return sh.RunV("go", "fmt", "./test/")
44}
45
46// A build step that removes temporary build and test files
47func Clean() error {
48  fmt.Println("Cleaning...")
49  return filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
50    if err != nil {
51      return err
52    }
53    if info.IsDir() && info.Name() == "vendor" {
54      return filepath.SkipDir
55    }
56    if info.IsDir() && info.Name() == ".terraform" {
57      os.RemoveAll(path)
58      fmt.Printf("Removed \"%v\"\n", path)
59      return filepath.SkipDir
60    }
61    if !info.IsDir() && (info.Name() == "terraform.tfstate" ||
62      info.Name() == "terraform.tfplan" ||
63      info.Name() == "terraform.tfstate.backup") {
64      os.Remove(path)
65      fmt.Printf("Removed \"%v\"\n", path)
66    }
67    return nil
68  })
69}
Zurück Unsere Trainings entdecken

Wir sind für Sie da

Sie interessieren sich für unsere Trainings oder haben einfach eine Frage, die beantwortet werden muss? Sie können uns jederzeit kontaktieren! Wir werden unser Bestes tun, um alle Ihre Fragen zu beantworten.

Hier kontaktieren