DevOps und Cloud-Reifegradmodelle verstehen: Ein Leitfaden zur Verbesserung Ihrer IT-Strategie
Im heutigen schnelllebigen Technologiebereich sind DevOps- und Cloud-Praktiken entscheidend, um die Software-Bereitstellung zu beschleunigen und
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 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:
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.
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}
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.
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.
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.
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
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}
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