Martin Buchleitner, Senior IT-Consultant

Über den Autor

Martin Buchleitner ist ein Senior IT-Berater für Infralovers und für Commandemy. Twitter github LinkedIn

Alle Artikel dieses Autors sehen

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

data "aws_route53_zone" "target_zone" {
  name = var.domain
}

resource "aws_route53_record" "target_record" {
  depends_on = [null_resource.module_dependency]
  zone_id    = data.aws_route53_zone.target_zone.zone_id
  name       = "${var.subdomain}.${var.domain}"
  type       = var.record_type
  ttl        = var.record_ttl
  records    = [var.record_ip]
}

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

go mod init $(basename $PWD)
mkdir -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.

package test

import (
  "encoding/json"
  "fmt"
  "path"
  "testing"

  "github.com/gruntwork-io/terratest/modules/terraform"
  tfPlan "github.com/hashicorp/terraform/plans/planfile"
)


const domain = "testing.infralovers.com"

type awsRoute53 struct {
  subdomain     string
  record_type   string
  record_ip     string
}

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

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

  for expected, input := range testCases {
    // Specify the test case folder and "-var" options
    tfOptions := &terraform.Options{
      TerraformDir: "../",
      Vars: map[string]interface{}{
        "subdomain":    input.subdomain,
        "domain":       domain,
        "record_type":  input.record_type,
        "retcord_ip":   input.record_ip,
      },
    }

    // Terraform init and plan only
    tfPlanOutput := "terraform.tfplan"
    terraform.Init(t, tfOptions)
    terraform.RunTerraformCommand(t, tfOptions, terraform.FormatArgs(tfOptions, "plan", "-out="+tfPlanOutput)...)
    tfOptions.Vars = nil

      // Read and parse the plan output
    reader, err := tfPlan.Open(path.Join(tfOptions.TerraformDir, tfPlanOutput))
    if err != nil {
      t.Fatal(err)
    }
    defer reader.Close()
    plan, _ := reader.ReadPlan()
    if plan.Changes.Empty() {
      t.Fatal("Empty plan outcome")
      continue
    }
    fmt.Printf("Checking %s...", expected)
    for _, res := range plan.Changes.Resources {
      if res.ChangeSrc.Action.String() != "Create" {
        t.Errorf("Found an action which is not create: %s", res.ChangeSrc.Action.String())
        continue
      }
      if res.Addr.String() == "aws_route53_record.target_record" {
        // do some fancy checks ...
      }
    }
  }
}

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.

go 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

output "dns" {
  value = aws_route53_record.target_record.fqdn
}

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

package test

import (
  "encoding/json"
  "path"
  "testing"

  "github.com/gruntwork-io/terratest/modules/terraform"
  tfPlan "github.com/hashicorp/terraform/plans/planfile"
)

func getJsonMap(m map[string]interface{}, key string) map[string]interface{} {
  raw := m[key]
  sub, ok := raw.(map[string]interface{})
  if !ok {
    return nil
  }
  return sub
}

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

  for expected, input := range testCases {
    // Specify the test case folder and "-var" options
    tfOptions := &terraform.Options{
      TerraformDir: "../",
      Vars: map[string]interface{}{
        "subdomain":   input.subdomain,
        "domain":      domain,
        "target_type": input.record_type,
        "target_ip":   input.record_ip,
      },
    }

    // init and plan
    tfPlanOutput := "terraform.tfplan"
    terraform.Init(t, tfOptions)
    terraform.RunTerraformCommand(t, tfOptions, terraform.FormatArgs(tfOptions, "plan", "-out="+tfPlanOutput)...)
    tfOptions.Vars = nil

    // read the plan as json
    jsonplan, err := terraform.RunTerraformCommandAndGetStdoutE(t, tfOptions, terraform.FormatArgs(tfOptions, "show", "-json", tfPlanOutput)...)
    jsonMap := make(map[string]interface{})
    err = json.Unmarshal([]byte(jsonplan), &jsonMap)
    if err != nil {
      panic(err)
    }
    planned := getJsonMap(jsonMap, "planned_values")
    outputs := getJsonMap(planned, "outputs")
    dns := getJsonMap(outputs, "dns")
    actual := dns["value"]
    if expected != actual {
      t.Errorf("Planned dns output is not valid: %s, expected: %s", actual, expected)
    }
    // Read and parse the plan output
    reader, err := tfPlan.Open(path.Join(tfOptions.TerraformDir, tfPlanOutput))
    if err != nil {
      t.Fatal(err)
    }
    defer reader.Close()
    plan, _ := reader.ReadPlan()
    if plan.Changes.Empty() {
      t.Fatal("Empty plan outcome")
      continue
    }

    for _, res := range plan.Changes.Resources {
      if res.ChangeSrc.Action.String() != "Create" {
        t.Errorf("Found an action which is not create: %s", res.ChangeSrc.Action.String())
        continue
      }
      if res.Addr.String() == "aws_route53_record.target_record" {
        // do some fancy checks ...
      }
    }
  }
}

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

package test

import (
  "testing"

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

// Test the Terraform module in examples/complete using Terratest.
func TestIT_AWSRoute53(t *testing.T) {
  t.Parallel()

  for expected, input := range testCases {
    // Specify the test case folder and "-var" options
    tfOptions := &terraform.Options{
      TerraformDir: "../",
      Vars: map[string]interface{}{
        "subdomain":   input.subdomain,
        "domain":      domain,
        "target_type": input.dnstype,
        "target_ip":   input.target,
      },
    }

    defer terraform.Destroy(t, tfOptions)

    // Terraform init and plan only
    terraform.InitAndApply(t, tfOptions)

    actual := terraform.Output(t, tfOptions, "dns")

    if actual != expected {
      t.Errorf("Expect %v, but found %v", expected, actual)
    }

  }
}

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

go 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

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

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.

terratest:
  stage: test
  image:
    name: "hashicorp/terraform:full"
    entrypoint:
      - "/usr/bin/env"
      - "PATH=/go/bin:/usr/local/go/bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
  script:
    - go test ./test/ -run "TestUT_" # running unit tests first
    - 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.

stages:
  - validate
  - lint
  - test

validate:
  stage: validate
  image:
    name: "hashicorp/terraform:0.12.8"
    entrypoint:
      - "/usr/bin/env"
      - "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
  script:
    - terraform init
    - terraform validate
  artifacts:
    paths:
      - .terraform

scriptlint:
  stage: lint
  image:
    name: "koalaman/shellcheck-alpine"
  script:
    - shellcheck scripts/*

terralint:
  stage: lint
  image:
    name: "wata727/tflint"
    entrypoint:
      - "/usr/bin/env"
      - "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
  script:
    - tflint

terratest:
  stage: test
  image:
    name: "hashicorp/terraform:full"
    entrypoint:
      - "/usr/bin/env"
      - "PATH=/go/bin:/usr/local/go/bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
  script:
    - currdir=$(pwd)
    - apk add --no-cache gcc libc-dev bind-tools
    - go get -u -d github.com/magefile/mage
    - magedir=$(find /go/pkg/mod/ -name magefile -type d | grep -v cache)
    - cd $magedir/$(ls $magedir/)
    - go run bootstrap.go
    - cd $currdir
    - mage full

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

package main

import (
  "fmt"
  "os"
  "path/filepath"

  "github.com/magefile/mage/mg"
  "github.com/magefile/mage/sh"
)

// The default target when the command executes `mage` in Cloud Shell
var Default = Full

// A build step that runs Clean, Format, Unit and Integration in sequence
func Full() {
  mg.Deps(Unit)
  mg.Deps(Integration)
}

// A build step that runs unit tests
func Unit() error {
  mg.Deps(Clean)
  mg.Deps(Format)
  fmt.Println("Running unit tests...")
  return sh.RunV("go", "test", "./test/", "-run", "TestUT_", "-v")
}

// A build step that runs integration tests
func Integration() error {
  mg.Deps(Clean)
  mg.Deps(Format)
  fmt.Println("Running integration tests...")
  return sh.RunV("go", "test", "./test/", "-run", "TestIT_", "-v")
}

// A build step that formats both Terraform code and Go code
func Format() error {
  fmt.Println("Formatting...")
  if err := sh.RunV("terraform", "fmt", "."); err != nil {
    return err
  }
  return sh.RunV("go", "fmt", "./test/")
}

// A build step that removes temporary build and test files
func Clean() error {
  fmt.Println("Cleaning...")
  return filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
    if err != nil {
      return err
    }
    if info.IsDir() && info.Name() == "vendor" {
      return filepath.SkipDir
    }
    if info.IsDir() && info.Name() == ".terraform" {
      os.RemoveAll(path)
      fmt.Printf("Removed \"%v\"\n", path)
      return filepath.SkipDir
    }
    if !info.IsDir() && (info.Name() == "terraform.tfstate" ||
      info.Name() == "terraform.tfplan" ||
      info.Name() == "terraform.tfstate.backup") {
      os.Remove(path)
      fmt.Printf("Removed \"%v\"\n", path)
    }
    return nil
  })
}