dstester/dstester.go (426 lines of code) (raw):
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package dstester is a collection of tools to make testing Terraform
// resources created for DeployStack easier.
package dstester
import (
"fmt"
"net/http"
"os/exec"
"reflect"
"strings"
"testing"
"time"
)
var (
gcloud = ""
terraform = ""
)
func init() {
gcloud = which("gcloud")
terraform = which("terraform")
}
func which(command string) string {
cmd := exec.Command("which")
cmd.Args = append(cmd.Args, command)
result, _ := cmd.Output()
return strings.TrimSpace(string(result))
}
// ErrorURLFail is thrown when a http poll fails
var ErrorURLFail = fmt.Errorf("the url did not return 200 after time allotted")
// ErrorCheckFail is thrown when a check fails
var ErrorCheckFail = fmt.Errorf("there was an issue with the poll")
// Terraform is a resource for calling Terraform with a consistent set of
// variables.
type Terraform struct {
Dir string // directory containing .tf files
Vars map[string]string // collection of vars passed into terraform call
}
func (tf Terraform) exec(command string, opt ...string) (string, error) {
cmd := tf.cmd(command, opt...)
dat, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("error: '%s'", string(dat))
}
return string(dat), nil
}
func (tf Terraform) cmd(command string, opt ...string) *exec.Cmd {
cmd := exec.Command("terraform")
cmd.Args = append(cmd.Args, fmt.Sprintf("-chdir=%s", tf.Dir))
cmd.Args = append(cmd.Args, command)
if command == "apply" || command == "destroy" {
cmd.Args = append(cmd.Args, "-auto-approve")
for i, v := range tf.Vars {
cmd.Args = append(cmd.Args, "-var")
cmd.Args = append(cmd.Args, fmt.Sprintf("%s=%s", i, v))
}
}
if command == "output" {
for _, v := range opt {
cmd.Args = append(cmd.Args, v)
}
}
return cmd
}
func (tf Terraform) string(command string, opt ...string) string {
cmd := tf.cmd(command, opt...)
return cmd.String()
}
// Output extracts a terraform output variable from the terraform state
func (tf Terraform) Output(variable string) (string, error) {
return tf.exec("output", variable)
}
// Init runs a terraform init command
func (tf Terraform) Init() (string, error) {
return tf.exec("init")
}
// Apply runs a terraform apply command passing in the variables to the command
func (tf Terraform) Apply() (string, error) {
return tf.exec("apply")
}
// Destroy runs a terraform destroy command
func (tf Terraform) Destroy() (string, error) {
return tf.exec("destroy")
}
// InitApplyForTest runs terraform init and apply and can output extra
// information if debug is set to true
func (tf Terraform) InitApplyForTest(test *testing.T, debug bool) {
out, err := tf.Init()
if err != nil {
test.Fatalf("expected no error, got: '%v'", err)
}
if debug {
test.Logf("init: %s\n", out)
}
out2, err := tf.Apply()
if err != nil {
test.Fatalf("expected no error, got: '%v'", err)
}
if debug {
test.Logf("apply: %s\n", out2)
}
return
}
// DestroyForTest runs terraform destroy and can output extra information if
// debug is set to true
func (tf Terraform) DestroyForTest(test *testing.T, debug bool) {
out3, err := tf.Destroy()
if err != nil {
test.Fatalf("expected no error, got: '%v'", err)
}
if debug {
test.Logf("destroy: %s\n", out3)
}
return
}
// Resources is a list of resources
type Resources struct {
Items []Resource
Project string
}
// Init runs through the items in the list and sets some prereqs
func (gs *Resources) Init() {
for i, v := range gs.Items {
if v.Project == "" {
v.Project = gs.Project
gs.Items[i] = v
}
}
}
// Resource represents a resource in Google Cloud that we want to check
// to see if it exists. In most cases a gcloud command will be built using
// this content
type Resource struct {
Product string // Portion of the gcloud command between gcloud and describe
Name string // The name of the resource to describe
Field string // The field to use in format directive. Defaults to 'name'
Append string // A string of content to be added to the end of the command string
Project string // The GCP project to use for the gcloud command
Expected string // The exepcted value that will be checking for. Defaults to vaule of Name
Arguments map[string]string // A set of key value pairs that will be added to the end of the gcloud command
}
func (g *Resource) desc() *exec.Cmd {
cmd := exec.Command("gcloud")
for _, v := range strings.Split(g.Product, " ") {
cmd.Args = append(cmd.Args, v)
}
cmd.Args = append(cmd.Args, "describe", g.Name)
if len(g.Append) > 0 {
for _, v := range strings.Split(g.Append, " ") {
cmd.Args = append(cmd.Args, v)
}
}
for i, v := range g.Arguments {
cmd.Args = append(cmd.Args, fmt.Sprintf("--%s", i), v)
}
if g.Project != "" {
cmd.Args = append(cmd.Args, fmt.Sprintf("--project=%s", g.Project))
}
if g.Field == "" {
g.Field = "name"
}
cmd.Args = append(cmd.Args, fmt.Sprintf("--format=value(%s)", g.Field))
return cmd
}
func (g *Resource) delete() *exec.Cmd {
cmd := exec.Command("gcloud")
for _, v := range strings.Split(g.Product, " ") {
cmd.Args = append(cmd.Args, v)
}
cmd.Args = append(cmd.Args, "delete", g.Name)
if len(g.Append) > 0 {
for _, v := range strings.Split(g.Append, " ") {
cmd.Args = append(cmd.Args, v)
}
}
for i, v := range g.Arguments {
cmd.Args = append(cmd.Args, fmt.Sprintf("--%s", i), v)
}
if g.Project != "" {
cmd.Args = append(cmd.Args, fmt.Sprintf("--project=%s", g.Project))
}
cmd.Args = append(cmd.Args, "-q")
return cmd
}
// Exists runs a gcloud describe call to ensure that the resource exists
func (g *Resource) Exists() (string, error) {
cmd := g.desc()
dat, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("error: '%s'", string(dat))
}
out := strings.TrimSpace(string(dat))
return out, nil
}
// existsString gives the string for a gcloud describe call to ensure that
// the resource exists
func (g *Resource) existsString() string {
cmd := g.desc()
for i, v := range cmd.Args {
if strings.Contains(v, "--format=value") {
v = fmt.Sprintf("--format=\"value(%s)\"", g.Field)
cmd.Args[i] = v
break
}
}
return cmd.String()
}
func (g *Resource) deleteString() string {
cmd := g.delete()
return cmd.String()
}
// TextExistence runs through and tests for the existence of each of the
// GCPResources
func TextExistence(t *testing.T, items []Resource) {
t.Logf("Testing for existence of GCP resources")
testsExists := map[string]struct {
input Resource
want string
}{}
for _, v := range items {
if v.Expected == "" {
v.Expected = v.Name
}
testsExists[fmt.Sprintf("Test %s %s exists", v.Product, v.Name)] = struct {
input Resource
want string
}{v, v.Expected}
}
for name, tc := range testsExists {
t.Run(name, func(t *testing.T) {
got, err := tc.input.Exists()
if err != nil {
debug := strings.ReplaceAll(tc.input.existsString(), gcloud, "gcloud")
if strings.Contains(err.Error(), "was not found") {
t.Fatalf("expected item to exist, it did not\n To debug:\n %s", debug)
}
t.Fatalf("expected no error, got: '%v' To debug:\n %s", err, debug)
}
if !reflect.DeepEqual(tc.want, got) {
// artifact registry call leaks stuff into stderr
if strings.Contains(got, "Repository Size") {
if strings.Contains(got, tc.want) {
return
}
}
t.Fatalf("expected: '%v', got: '%v'", tc.want, got)
}
})
}
}
// TextNonExistence runs through and tests for the lack of existence of each of
// the GCPResources
func TextNonExistence(t *testing.T, items []Resource) {
t.Logf("Testing for non-existence of GCP resources")
testsNotExists := map[string]struct {
input Resource
}{}
for _, v := range items {
testsNotExists[fmt.Sprintf("Test %s %s does not exist", v.Product, v.Name)] = struct {
input Resource
}{v}
}
for name, tc := range testsNotExists {
t.Run(name, func(t *testing.T) {
_, err := tc.input.Exists()
if err == nil {
t.Fatalf("expected error, got no error")
}
})
}
}
// TestOperations Cycles through the operations and runs them.
func TestOperations(t *testing.T, operations Operations, tf Terraform) {
if len(operations.Items) == 0 {
return
}
t.Logf(operations.Label)
testsPolls := map[string]struct {
input Operation
}{}
for _, v := range operations.Items {
testsPolls[fmt.Sprintf("Operation %s %s", operations.Key, v.Type)] = struct {
input Operation
}{v}
}
for name, tc := range testsPolls {
t.Run(name, func(t *testing.T) {
ok, err := tc.input.Do(tf)
if err != nil {
t.Fatalf("expected no error, got: '%v'", err)
}
if !ok {
t.Fatalf("operation failed")
}
})
}
}
// httpPoll polls a url attempts number of times with a delay of interval
// between attempts
func httpPoll(url, query string, interval, attempts int) (bool, error) {
urlToUse := strings.ReplaceAll(url, "\"", "")
urlToUse = strings.TrimSpace(urlToUse)
client := http.Client{
Timeout: 2 * time.Second,
}
for i := 0; i < attempts; i++ {
resp, _ := client.Get(urlToUse)
if resp != nil && resp.StatusCode == http.StatusOK {
return true, nil
}
time.Sleep(time.Duration(interval) * time.Second)
}
return false, fmt.Errorf("%s debug: %s", ErrorURLFail, urlToUse)
}
// customCheck allows for a custom bash command to be run as set in "custom"
func customCheck(command string) (bool, error) {
sl := strings.Split(command, " ")
cmd := exec.Command(sl[0])
cmd.Args = append(cmd.Args, sl[1:]...)
dat, err := cmd.CombinedOutput()
if err != nil {
return false, fmt.Errorf("error: '%s'", string(dat))
}
return true, nil
}
// sleep is an operation that allows for delays in operations
func sleep(interval int) (bool, error) {
time.Sleep(time.Duration(interval) * time.Second)
return true, nil
}
// Operation represents an intersitial check to be run between terraform apply and
// terraform destroy
type Operation struct {
Output string
Type string
Attempts int
Interval int
Query string
Custom string
}
// Do performs the operation that the check is supposed to run
func (o Operation) Do(tf Terraform) (bool, error) {
i := o.Interval
a := o.Attempts
if a == 0 {
a = 50
}
if i == 0 {
i = 5
}
val, err := tf.Output(o.Output)
if err != nil {
return false, err
}
switch o.Type {
case "httpPoll":
return httpPoll(val, o.Query, a, i)
case "sleep":
return sleep(i)
case "customCheck":
return customCheck(o.Custom)
}
return false, ErrorCheckFail
}
// Operations are a set of operations to perform and certain times in the lifecycle
// of a test
type Operations struct {
Items []Operation
Label string
Key string
}
// Add an operation to the list of operations
func (os *Operations) Add(o Operation) {
os.Items = append(os.Items, o)
}
// OperationsSets are the whole collection of all of the pre and post operations
type OperationsSets map[string]Operations
// Add an operation to the underlying set of Operations.
func (os *OperationsSets) Add(target string, o Operation) {
tmp := (*os)[target]
tmp.Add(o)
(*os)[target] = tmp
}
// NewOperationsSet returns the default set of operation sets
func NewOperationsSet() OperationsSets {
ops := OperationsSets{}
ops["preTest"] = Operations{
Key: "preTest",
Label: "Operations to be run before any tests",
}
ops["preApply"] = Operations{
Key: "preApply",
Label: "Operations to be run after terraform init and before terraform apply",
}
ops["postApply"] = Operations{
Key: "postApply",
Label: "Operations to be run after terraform apply",
}
ops["preDestroy"] = Operations{
Key: "preDestroy",
Label: "Operations to be run after terraform apply and before terraform destroy",
}
ops["postDestroy"] = Operations{
Key: "postDestroy",
Label: "Operations to be run after terraform destroy",
}
ops["postTest"] = Operations{
Key: "postTest",
Label: "Operations to be after any tests",
}
return ops
}
// TestStack runs the test for an entire Deploystack test given the right inputs
func TestStack(t *testing.T, tf Terraform, resources Resources, ops OperationsSets, debug bool) {
TestOperations(t, ops["preTest"], tf)
resources.Init()
TestOperations(t, ops["preApply"], tf)
tf.InitApplyForTest(t, debug)
TestOperations(t, ops["postApply"], tf)
TextExistence(t, resources.Items)
TestOperations(t, ops["preDestroy"], tf)
tf.DestroyForTest(t, debug)
TestOperations(t, ops["postDestroy"], tf)
TextNonExistence(t, resources.Items)
TestOperations(t, ops["postTest"], tf)
}
// DebugCommands will spit out all of the executables that the framework calls
// under the covers for debugging purposes
func DebugCommands(t *testing.T, tf Terraform, resources Resources) {
fmt.Printf("gcloud describe commands \n")
for _, v := range resources.Items {
output := strings.ReplaceAll(v.existsString(), gcloud, "gcloud")
fmt.Printf("%s\n", output)
}
fmt.Println("")
fmt.Printf("gcloud delete commands \n")
for _, v := range resources.Items {
cmd := v.delete().String()
output := strings.ReplaceAll(cmd, gcloud, "gcloud")
fmt.Printf("%s\n", output)
}
fmt.Println("")
fmt.Printf("terraform commands \n")
cmds := []string{"init", "apply", "destroy"}
for _, v := range cmds {
output := tf.string(v)
output = strings.ReplaceAll(output, terraform, "terraform")
fmt.Printf("%s\n", output)
}
}
// Clean calls the deletion version of all of the gcloud commands to wipe out
// all of the resources in the project
func Clean(t *testing.T, tf Terraform, resources Resources) {
for _, v := range resources.Items {
cmd := v.delete()
// Big issue is that storage buckets needs to be emptied before they are
// deleted
if strings.Contains(cmd.String(), "alpha storage buckets") {
rm := exec.Command("gcloud")
rm.Args = append(rm.Args, "alpha", "storage", "rm", "-r")
for _, v := range cmd.Args {
if strings.Contains(v, "gs://") {
rm.Args = append(rm.Args, fmt.Sprintf("%s/**", v))
}
}
dat, err := rm.CombinedOutput()
if err != nil {
t.Logf("bucket removal issue: %s", string(dat))
}
}
dat, err := cmd.CombinedOutput()
if err != nil {
t.Logf("delete issue: %s", string(dat))
}
}
}