infra/blueprint-test/pkg/tft/terraform.go (545 lines of code) (raw):

/** * Copyright 2021-2024 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 tft provides a set of helpers to test Terraform modules/blueprints. package tft import ( b64 "encoding/base64" "fmt" "os" "path" "path/filepath" "strings" gotest "testing" "time" "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/discovery" "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/gcloud" "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/utils" "github.com/alexflint/go-filemutex" "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/hashicorp/terraform-config-inspect/tfconfig" "github.com/mitchellh/go-testing-interface" "github.com/stretchr/testify/assert" "github.com/tidwall/gjson" ) const ( setupKeyOutputName = "sa_key" tftCacheMutexFilename = "bpt-tft-cache.lock" planFilename = "plan.tfplan" ) var ( CommonRetryableErrors = map[string]string{ // Project deletion is eventually consistent. Even if google_project resources inside the folder are deleted there maybe a deletion error. ".*FOLDER_TO_DELETE_NON_EMPTY_VIOLATION.*": "Failed to delete non empty folder.", // API activation is eventually consistent. Even if the google_project_service resource is reconciled there maybe an activation error. ".*SERVICE_DISABLED.*": "Required API not enabled.", } ) // TFBlueprintTest implements bpt.Blueprint and stores information associated with a Terraform blueprint test. type TFBlueprintTest struct { discovery.BlueprintTestConfig // additional blueprint test configs name string // descriptive name for the test saKey string // optional setup sa key tfDir string // directory containing Terraform configs tfEnvVars map[string]string // variables to pass to Terraform as environment variables prefixed with TF_VAR_ backendConfig map[string]interface{} // backend configuration for terraform init retryableTerraformErrors map[string]string // If Terraform apply fails with one of these (transient) errors, retry. The keys are a regexp to match against the error and the message is what to display to a user if that error is matched. maxRetries int // Maximum number of times to retry errors matching RetryableTerraformErrors timeBetweenRetries time.Duration // The amount of time to wait between retries migrateState bool // suppress user confirmation in a migration in terraform init setupDir string // optional directory containing applied TF configs to import outputs as variables for the test policyLibraryPath string // optional absolute path to directory containing policy library constraints terraformVetProject string // optional a valid existing project that will be used when a plan has resources in a project that still does not exist. vars map[string]interface{} // variables to pass to Terraform as flags logger *logger.Logger // custom logger sensitiveLogger *logger.Logger // custom logger for sensitive logging t testing.TB // TestingT or TestingB init func(*assert.Assertions) // init function plan func(*terraform.PlanStruct, *assert.Assertions) // plan function apply func(*assert.Assertions) // apply function verify func(*assert.Assertions) // verify function teardown func(*assert.Assertions) // teardown function setupOutputOverrides map[string]interface{} // override outputs from the Setup phase tftCacheMutex *filemutex.FileMutex // Mutex to protect Terraform plugin cache parallelism int // Set the parallelism setting for Terraform } type tftOption func(*TFBlueprintTest) func WithName(name string) tftOption { return func(f *TFBlueprintTest) { f.name = name } } func WithSetupSaKey(saKey string) tftOption { return func(f *TFBlueprintTest) { f.saKey = saKey } } func WithFixtureName(fixtureName string) tftOption { return func(f *TFBlueprintTest) { // when a test is invoked for an explicit blueprint fixture // expect fixture path to be ../../fixtures/fixtureName tfModFixtureDir := path.Join("..", "..", discovery.FixtureDir, fixtureName) f.tfDir = tfModFixtureDir } } func WithTFDir(tfDir string) tftOption { return func(f *TFBlueprintTest) { f.tfDir = tfDir } } func WithEnvVars(envVars map[string]string) tftOption { return func(f *TFBlueprintTest) { tfEnvVars := make(map[string]string) loadTFEnvVar(tfEnvVars, envVars) f.tfEnvVars = tfEnvVars } } func WithBackendConfig(backendConfig map[string]interface{}) tftOption { return func(f *TFBlueprintTest) { f.backendConfig = backendConfig f.migrateState = true } } func WithRetryableTerraformErrors(retryableTerraformErrors map[string]string, maxRetries int, timeBetweenRetries time.Duration) tftOption { return func(f *TFBlueprintTest) { f.retryableTerraformErrors = retryableTerraformErrors f.maxRetries = maxRetries f.timeBetweenRetries = timeBetweenRetries } } func WithSetupPath(setupPath string) tftOption { return func(f *TFBlueprintTest) { f.setupDir = setupPath } } func WithPolicyLibraryPath(policyLibraryPath, terraformVetProject string) tftOption { return func(f *TFBlueprintTest) { f.policyLibraryPath = policyLibraryPath f.terraformVetProject = terraformVetProject } } func WithVars(vars map[string]interface{}) tftOption { return func(f *TFBlueprintTest) { f.vars = vars } } func WithLogger(logger *logger.Logger) tftOption { return func(f *TFBlueprintTest) { f.logger = logger } } func WithSensitiveLogger(logger *logger.Logger) tftOption { return func(f *TFBlueprintTest) { f.sensitiveLogger = logger } } // WithSetupOutputs overrides output values from the setup stage func WithSetupOutputs(vars map[string]interface{}) tftOption { return func(f *TFBlueprintTest) { f.setupOutputOverrides = vars } } func WithParallelism(p int) tftOption { return func(f *TFBlueprintTest) { f.parallelism = p } } // NewTFBlueprintTest sets defaults, validates and returns a TFBlueprintTest. func NewTFBlueprintTest(t testing.TB, opts ...tftOption) *TFBlueprintTest { var err error tft := &TFBlueprintTest{ name: fmt.Sprintf("%s TF Blueprint", t.Name()), tfEnvVars: make(map[string]string), t: t, } // initiate tft cache file mutex tft.tftCacheMutex, err = filemutex.New(filepath.Join(os.TempDir(), tftCacheMutexFilename)) if err != nil { t.Fatalf("tft lock file <%s> could not created: %v", filepath.Join(os.TempDir(), tftCacheMutexFilename), err) } // default TF blueprint methods tft.init = tft.DefaultInit // No default plan function, plan is skipped if no custom func provided. tft.apply = tft.DefaultApply tft.verify = tft.DefaultVerify tft.teardown = tft.DefaultTeardown // apply options for _, opt := range opts { opt(tft) } // if no custom logger, set default based on test verbosity if tft.logger == nil { tft.logger = utils.GetLoggerFromT() } // If no custom sensitive logger, use discard logger. if tft.sensitiveLogger == nil { tft.sensitiveLogger = logger.Discard } // if explicit tfDir is provided, validate it else try auto discovery if tft.tfDir != "" { _, err := os.Stat(tft.tfDir) if os.IsNotExist(err) { t.Fatalf("TFDir path %s does not exist", tft.tfDir) } } else { tfdir, err := discovery.GetConfigDirFromTestDir(utils.GetWD(t)) if err != nil { t.Fatalf("unable to detect TFDir :%v", err) } tft.tfDir = tfdir } // discover test config tft.BlueprintTestConfig, err = discovery.GetTestConfig(path.Join(tft.tfDir, discovery.DefaultTestConfigFilename)) if err != nil { t.Fatal(err) } // setupDir is empty, try known setupDir paths if tft.setupDir == "" { setupDir, err := discovery.GetKnownDirInParents(discovery.SetupDir, 2) if err != nil { t.Logf("Setup dir not found, skipping loading setup outputs as fixture inputs: %v", err) } else { tft.setupDir = setupDir } } // load setup sa Key if tft.saKey != "" { gcloud.ActivateCredsAndEnvVars(tft.t, tft.saKey) } // load TFEnvVars from setup outputs if tft.setupDir != "" { tft.logger.Logf(tft.t, "Loading env vars from setup %s", tft.setupDir) outputs := tft.getOutputs(tft.sensitiveOutputs(tft.setupDir)) loadTFEnvVar(tft.tfEnvVars, tft.getTFOutputsAsInputs(outputs)) if credsEnc, exists := tft.tfEnvVars[fmt.Sprintf("TF_VAR_%s", setupKeyOutputName)]; tft.saKey == "" && exists { if credDec, err := b64.StdEncoding.DecodeString(credsEnc); err == nil { gcloud.ActivateCredsAndEnvVars(tft.t, string(credDec)) } else { tft.t.Fatalf("Unable to decode setup sa key: %v", err) } } else { tft.logger.Logf(tft.t, "Skipping credential activation %s output from setup", setupKeyOutputName) } } // Load env vars to supplement/override setup tft.logger.Logf(tft.t, "Loading setup from environment") if tft.setupOutputOverrides == nil { tft.setupOutputOverrides = make(map[string]interface{}) } for k, v := range extractFromEnv("CFT_SETUP_") { tft.setupOutputOverrides[k] = v } tftVersion := gjson.Get(terraform.RunTerraformCommand(tft.t, tft.GetTFOptions(), "version", "-json"), "terraform_version") tft.logger.Logf(tft.t, "Running tests TF configs in %s with version %s", tft.tfDir, tftVersion) return tft } // sensitiveOutputs returns a map of sensitive output keys for module in dir. func (b *TFBlueprintTest) sensitiveOutputs(dir string) map[string]bool { mod, err := tfconfig.LoadModule(dir) if err != nil { b.t.Fatalf("error loading module in %s: %v", dir, err) } sensitiveOP := map[string]bool{} for _, op := range mod.Outputs { if op.Sensitive { sensitiveOP[op.Name] = true } } return sensitiveOP } // getOutputs returns all output values. func (b *TFBlueprintTest) getOutputs(sensitive map[string]bool) map[string]interface{} { // allow only parallel reads as Terraform plugin cache isn't concurrent safe rUnlockFn := b.rLockFn() defer rUnlockFn() outputs := terraform.OutputAll(b.t, &terraform.Options{TerraformDir: b.setupDir, Logger: b.sensitiveLogger, NoColor: true}) for k, v := range outputs { _, s := sensitive[k] if s { b.sensitiveLogger.Logf(b.t, "output key %q: %v", k, v) } else { b.logger.Logf(b.t, "output key %q: %v", k, v) } } return outputs } // GetTFOptions generates terraform.Options used by Terratest. func (b *TFBlueprintTest) GetTFOptions() *terraform.Options { newOptions := terraform.WithDefaultRetryableErrors(b.t, &terraform.Options{ TerraformDir: b.tfDir, EnvVars: b.tfEnvVars, Vars: b.vars, Logger: b.logger, BackendConfig: b.backendConfig, MigrateState: b.migrateState, RetryableTerraformErrors: b.retryableTerraformErrors, NoColor: true, Parallelism: b.parallelism, }) if b.maxRetries > 0 { newOptions.MaxRetries = b.maxRetries } if b.timeBetweenRetries > 0 { newOptions.TimeBetweenRetries = b.timeBetweenRetries } return newOptions } // getTFOutputsAsInputs computes a map of TF inputs from outputs map. func (b *TFBlueprintTest) getTFOutputsAsInputs(o map[string]interface{}) map[string]string { n := make(map[string]string) // TF requires complex values to be an HCL expression passed literally. // However, Terratest only exposes a way to format strings as HCL expressions to be used as var flags. // Var flags requires the root module to declare a variable of that name. // Hence, we extract the HCL formated string from the var arg slice of form [-var, key1=value1, -var, key2={"complex"="data"}...] for _, v := range terraform.FormatTerraformVarsAsArgs(o) { if v == "-var" { continue } parsedKey, parsedVal, err := getKVFromOutputString(v) if err != nil { b.t.Logf("Unable to parse output from setup: %v", err) continue } n[parsedKey] = parsedVal } return n } // getKVFromOutputString parses string kv pairs of form k=v func getKVFromOutputString(v string) (string, string, error) { // v of format key1=value1 kv := strings.SplitN(v, "=", 2) if len(kv) < 2 { return "", "", fmt.Errorf("error parsing %s", v) } return kv[0], kv[1], nil } // GetStringOutput returns TF output for a given key as string. // It fails test if given key does not output a primitive. func (b *TFBlueprintTest) GetStringOutput(name string) string { // allow only parallel reads as Terraform plugin cache isn't concurrent safe rUnlockFn := b.rLockFn() defer rUnlockFn() return terraform.Output(b.t, b.GetTFOptions(), name) } // GetStringOutputList returns TF output for a given key as list. // It fails test if given key does not output a primitive. // // Deprecated: Use GetJsonOutput instead. func (b *TFBlueprintTest) GetStringOutputList(name string) []string { // allow only parallel reads as Terraform plugin cache isn't concurrent safe rUnlockFn := b.rLockFn() defer rUnlockFn() return terraform.OutputList(b.t, b.GetTFOptions(), name) } // GetJsonOutput returns TF output for key as gjson.Result. // An empty string for key can be used to return all values. // It fails test on invalid JSON. func (b *TFBlueprintTest) GetJsonOutput(key string) gjson.Result { // allow only parallel reads as Terraform plugin cache isn't concurrent safe rUnlockFn := b.rLockFn() defer rUnlockFn() jsonString := terraform.OutputJson(b.t, b.GetTFOptions(), key) if !gjson.Valid(jsonString) { b.t.Fatalf("Invalid JSON: %s", jsonString) } return gjson.Parse(jsonString) } // GetTFSetupOutputListVal returns TF output from setup for a given key as list. // It fails test if given key does not output a list type. func (b *TFBlueprintTest) GetTFSetupOutputListVal(key string) []string { if v, ok := b.setupOutputOverrides[key]; ok { if listval, ok := v.([]string); ok { return listval } else { b.t.Fatalf("Setup Override %s is not a list value", key) } } if b.setupDir == "" { b.t.Fatal("Setup path not set") } // allow only parallel reads as Terraform plugin cache isn't concurrent safe rUnlockFn := b.rLockFn() defer rUnlockFn() return terraform.OutputList(b.t, &terraform.Options{TerraformDir: b.setupDir, Logger: b.logger, NoColor: true}, key) } // GetTFSetupStringOutput returns TF setup output for a given key as string. // It fails test if given key does not output a primitive or if setupDir is not configured. func (b *TFBlueprintTest) GetTFSetupStringOutput(key string) string { if v, ok := b.setupOutputOverrides[key]; ok { return v.(string) } if b.setupDir == "" { b.t.Fatal("Setup path not set") } // allow only parallel reads as Terraform plugin cache isn't concurrent safe rUnlockFn := b.rLockFn() defer rUnlockFn() return terraform.Output(b.t, &terraform.Options{TerraformDir: b.setupDir, Logger: b.logger, NoColor: true}, key) } // GetTFSetupJsonOutput returns TF setup output for a given key as gjson.Result. // An empty string for key can be used to return all values. // It fails test if given key does not output valid JSON or if setupDir is not configured. func (b *TFBlueprintTest) GetTFSetupJsonOutput(key string) gjson.Result { if v, ok := b.setupOutputOverrides[key]; ok { if !gjson.Valid(v.(string)) { b.t.Fatalf("Invalid JSON in setup output override: %s", v) } return gjson.Parse(v.(string)) } if b.setupDir == "" { b.t.Fatal("Setup path not set") } // allow only parallel reads as Terraform plugin cache isn't concurrent safe rUnlockFn := b.rLockFn() defer rUnlockFn() jsonString := terraform.OutputJson(b.t, &terraform.Options{TerraformDir: b.setupDir, Logger: b.logger, NoColor: true}, key) if !gjson.Valid(jsonString) { b.t.Fatalf("Invalid JSON: %s", jsonString) } return gjson.Parse(jsonString) } // loadTFEnvVar adds new env variables prefixed with TF_VAR_ to an existing map of variables. func loadTFEnvVar(m map[string]string, new map[string]string) { for k, v := range new { m[fmt.Sprintf("TF_VAR_%s", k)] = v } } // extractFromEnv parses environment variables with the given prefix, and returns a key-value map. // e.g. CFT_SETUP_key=value returns map[string]string{"key": "value"} func extractFromEnv(prefix string) map[string]interface{} { r := make(map[string]interface{}) for _, s := range os.Environ() { k, v, ok := strings.Cut(s, "=") if !ok { // skip malformed entries in os.Environ continue } // For env vars with the prefix, extract the key and value if setupvar, ok := strings.CutPrefix(k, prefix); ok { r[setupvar] = v } } return r } // ShouldSkip checks if a test should be skipped func (b *TFBlueprintTest) ShouldSkip() bool { return b.BlueprintTestConfig.Spec.Skip } // shouldRunTerraformVet checks if terraform vet should be executed func (b *TFBlueprintTest) shouldRunTerraformVet() bool { return b.policyLibraryPath != "" } // AutoDiscoverAndTest discovers TF config from examples/fixtures and runs tests. func AutoDiscoverAndTest(t *gotest.T) { configs := discovery.FindTestConfigs(t, "./") for testName, dir := range configs { t.Run(testName, func(t *gotest.T) { nt := NewTFBlueprintTest(t, WithTFDir(dir)) nt.Test() }) } } // DefineInit defines a custom init function for the blueprint. func (b *TFBlueprintTest) DefineInit(init func(*assert.Assertions)) { b.init = init } // DefinePlan defines a custom plan function for the blueprint. func (b *TFBlueprintTest) DefinePlan(plan func(*terraform.PlanStruct, *assert.Assertions)) { b.plan = plan } // DefineApply defines a custom apply function for the blueprint. func (b *TFBlueprintTest) DefineApply(apply func(*assert.Assertions)) { b.apply = apply } // DefineVerify defines a custom verify function for the blueprint. func (b *TFBlueprintTest) DefineVerify(verify func(*assert.Assertions)) { b.verify = verify } // DefineTeardown defines a custom teardown function for the blueprint. func (b *TFBlueprintTest) DefineTeardown(teardown func(*assert.Assertions)) { b.teardown = teardown } // DefaultTeardown runs TF destroy on a blueprint. func (b *TFBlueprintTest) DefaultTeardown(assert *assert.Assertions) { terraform.Destroy(b.t, b.GetTFOptions()) } // DefaultVerify asserts no resource changes exist after apply. func (b *TFBlueprintTest) DefaultVerify(assert *assert.Assertions) { e := terraform.PlanExitCode(b.t, b.GetTFOptions()) // exit code 0 is success with no diffs, 2 is success with non-empty diff // https://www.terraform.io/docs/cli/commands/plan.html#detailed-exitcode assert.NotEqual(1, e, "plan after apply should not fail") assert.NotEqual(2, e, "plan after apply should have no diff") } // DefaultInit runs TF init and validate on a blueprint. func (b *TFBlueprintTest) DefaultInit(assert *assert.Assertions) { terraform.Init(b.t, b.GetTFOptions()) // if vars are set for common options, this seems to trigger -var flag when calling validate // using custom tfOptions as a workaround terraform.Validate(b.t, terraform.WithDefaultRetryableErrors(b.t, &terraform.Options{ TerraformDir: b.tfDir, Logger: b.logger, NoColor: true, })) } // Vet runs TF plan, TF show, and gcloud terraform vet on a blueprint. func (b *TFBlueprintTest) Vet(assert *assert.Assertions) { jsonPlan, _ := b.PlanAndShow() filepath, err := utils.WriteTmpFileWithExtension(jsonPlan, "json") assert.NoError(err) defer func() { if err := os.Remove(filepath); err != nil { b.t.Fatalf("Could not remove plan json: %v", err) } }() results := gcloud.TFVet(b.t, filepath, b.policyLibraryPath, b.terraformVetProject).Array() assert.Empty(results, "Should have no Terraform Vet violations") } // DefaultApply runs TF apply on a blueprint. func (b *TFBlueprintTest) DefaultApply(assert *assert.Assertions) { if b.shouldRunTerraformVet() { b.Vet(assert) } terraform.Apply(b.t, b.GetTFOptions()) } // Init runs the default or custom init function for the blueprint. func (b *TFBlueprintTest) Init(assert *assert.Assertions) { // allow only single write as Terraform plugin cache isn't concurrent safe if err := b.tftCacheMutex.Lock(); err != nil { b.t.Fatalf("Could not acquire lock: %v", err) } defer func() { if err := b.tftCacheMutex.Unlock(); err != nil { b.t.Fatalf("Could not release lock: %v", err) } }() b.init(assert) } // PlanAndShow performs a Terraform plan, show and returns the parsed plan output. func (b *TFBlueprintTest) PlanAndShow() (string, *terraform.PlanStruct) { tDir, err := os.MkdirTemp(os.TempDir(), "btp") if err != nil { b.t.Fatalf("Temp directory %q could not created: %v", tDir, err) } defer func() { if err := os.RemoveAll(tDir); err != nil { b.t.Fatalf("Could not remove plan temp dir: %v", err) } }() planOpts := b.GetTFOptions() planOpts.PlanFilePath = filepath.Join(tDir, planFilename) rUnlockFn := b.rLockFn() defer rUnlockFn() terraform.Plan(b.t, planOpts) // Logging show output is not useful since we log plan output above // and show output is parsed and retured. planOpts.Logger = logger.Discard planJSON := terraform.Show(b.t, planOpts) ps, err := terraform.ParsePlanJSON(planJSON) assert.NoError(b.t, err) return planJSON, ps } // Plan runs the custom plan function for the blueprint. // If not custom plan function is defined, this stage is skipped. func (b *TFBlueprintTest) Plan(assert *assert.Assertions) { if b.plan == nil { b.logger.Logf(b.t, "skipping plan as no function defined") return } _, ps := b.PlanAndShow() b.plan(ps, assert) } // Apply runs the default or custom apply function for the blueprint. func (b *TFBlueprintTest) Apply(assert *assert.Assertions) { // allow only parallel reads as Terraform plugin cache isn't concurrent safe rUnlockFn := b.rLockFn() defer rUnlockFn() b.apply(assert) } // Verify runs the default or custom verify function for the blueprint. func (b *TFBlueprintTest) Verify(assert *assert.Assertions) { // allow only parallel reads as Terraform plugin cache isn't concurrent safe rUnlockFn := b.rLockFn() defer rUnlockFn() b.verify(assert) } // Teardown runs the default or custom teardown function for the blueprint. func (b *TFBlueprintTest) Teardown(assert *assert.Assertions) { // allow only parallel reads as Terraform plugin cache isn't concurrent safe rUnlockFn := b.rLockFn() defer rUnlockFn() b.teardown(assert) } const ( initStage = "init" planStage = "plan" applyStage = "apply" verifyStage = "verify" teardownStage = "teardown" ) // Test runs init, apply, verify, teardown in order for the blueprint. func (b *TFBlueprintTest) Test() { if b.ShouldSkip() { b.logger.Logf(b.t, "Skipping test due to config %s", b.BlueprintTestConfig.Path) b.t.SkipNow() return } a := assert.New(b.t) // run stages utils.RunStage(initStage, func() { b.Init(a) }) defer utils.RunStage(teardownStage, func() { b.Teardown(a) }) utils.RunStage(planStage, func() { b.Plan(a) }) utils.RunStage(applyStage, func() { b.Apply(a) }) utils.RunStage(verifyStage, func() { b.Verify(a) }) } // RedeployTest deploys the test n times in separate workspaces before teardown. func (b *TFBlueprintTest) RedeployTest(n int, nVars map[int]map[string]interface{}) { if n < 2 { b.t.Fatalf("n should be 2 or greater but got: %d", n) } if b.ShouldSkip() { b.logger.Logf(b.t, "Skipping test due to config %s", b.BlueprintTestConfig.Path) b.t.SkipNow() return } a := assert.New(b.t) // capture currently set vars as default if no override defaultVars := b.vars overrideVars := func(i int) { custom, exists := nVars[i] if exists { b.vars = custom } else { b.vars = defaultVars } } for i := 1; i <= n; i++ { ws := terraform.WorkspaceSelectOrNew(b.t, b.GetTFOptions(), fmt.Sprintf("test-%d", i)) overrideVars(i) utils.RunStage(initStage, func() { b.Init(a) }) defer func(i int) { overrideVars(i) terraform.WorkspaceSelectOrNew(b.t, b.GetTFOptions(), ws) utils.RunStage(teardownStage, func() { b.Teardown(a) }) }(i) utils.RunStage(planStage, func() { b.Plan(a) }) utils.RunStage(applyStage, func() { b.Apply(a) }) utils.RunStage(verifyStage, func() { b.Verify(a) }) } } // rLockFn sets a read mutex lock, and returns the corresponding unlock function. func (b *TFBlueprintTest) rLockFn() func() { if err := b.tftCacheMutex.RLock(); err != nil { b.t.Fatalf("Could not acquire read lock:%v", err) } return func() { if err := b.tftCacheMutex.RUnlock(); err != nil { b.t.Fatalf("Could not release read lock: %v", err) } } }