diff --git a/modules/terraform/apply_test.go b/modules/terraform/apply_test.go index 86e446c4e..30cd0158a 100644 --- a/modules/terraform/apply_test.go +++ b/modules/terraform/apply_test.go @@ -2,6 +2,7 @@ package terraform import ( "path/filepath" + "strings" "testing" "time" @@ -63,6 +64,73 @@ func TestApplyWithErrorWithRetry(t *testing.T) { require.Contains(t, out, "This is the first run, exiting with an error") } + +func TestApplyWithWarning(t *testing.T) { + scenarios := []struct { + name string + folder string + isError bool + warnings map[string]string + }{ + { + name: "Warning", + folder: "../../test/fixtures/terraform-with-warning", + isError: true, + warnings: map[string]string{ + "lorem ipsum": "lorem ipsum warning", + }, + }, + { + name: "WarningNotMatch", + folder: "../../test/fixtures/terraform-with-warning", + isError: false, + warnings: map[string]string{ + "lorem ipsum dolor sit amet": "some warning", + }, + }, + { + name: "Error", + folder: "../../test/fixtures/terraform-with-error", + isError: true, + warnings: map[string]string{ + "lorem ipsum": "lorem ipsum warning", + }, + }, + { + name: "NoError", + folder: "../../test/fixtures/terraform-no-error", + isError: false, + warnings: map[string]string{ + "lorem ipsum": "lorem ipsum warning", + }, + }, + } + + for _, scenario := range scenarios { + scenario := scenario + t.Run(scenario.name, func(t *testing.T) { + t.Parallel() + + testFolder, err := files.CopyTerraformFolderToTemp(scenario.folder, strings.Replace(t.Name(), "/", "-", -1)) + require.NoError(t, err) + + options := WithDefaultRetryableErrors(t, &Options{ + TerraformDir: testFolder, + NoColor: true, + WarningsAsErrors: scenario.warnings, + }) + + out, err := InitAndApplyE(t, options) + if scenario.isError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.NotEmpty(t, out) + }) + } +} + func TestTgApplyAllTgError(t *testing.T) { t.Parallel() diff --git a/modules/terraform/cmd.go b/modules/terraform/cmd.go index ff7f425c1..48e1324f7 100644 --- a/modules/terraform/cmd.go +++ b/modules/terraform/cmd.go @@ -3,6 +3,8 @@ package terraform import ( "fmt" "os/exec" + "regexp" + "strings" "github.com/gruntwork-io/terratest/modules/collections" "github.com/gruntwork-io/terratest/modules/retry" @@ -81,9 +83,18 @@ func RunTerraformCommandE(t testing.TestingT, additionalOptions *Options, additi cmd := generateCommand(options, args...) description := fmt.Sprintf("%s %v", options.TerraformBinary, args) + return retry.DoWithRetryableErrorsE(t, description, options.RetryableTerraformErrors, options.MaxRetries, options.TimeBetweenRetries, func() (string, error) { - return shell.RunCommandAndGetOutputE(t, cmd) + s, err := shell.RunCommandAndGetOutputE(t, cmd) + if err != nil { + return s, err + } + if err := hasWarning(additionalOptions, s); err != nil { + return s, err + } + return s, err }) + } // RunTerraformCommandAndGetStdoutE runs terraform with the given arguments and options and returns solely its stdout @@ -94,7 +105,14 @@ func RunTerraformCommandAndGetStdoutE(t testing.TestingT, additionalOptions *Opt cmd := generateCommand(options, args...) description := fmt.Sprintf("%s %v", options.TerraformBinary, args) return retry.DoWithRetryableErrorsE(t, description, options.RetryableTerraformErrors, options.MaxRetries, options.TimeBetweenRetries, func() (string, error) { - return shell.RunCommandAndGetStdOutE(t, cmd) + s, err := shell.RunCommandAndGetOutputE(t, cmd) + if err != nil { + return s, err + } + if err := hasWarning(additionalOptions, s); err != nil { + return s, err + } + return s, err }) } @@ -137,3 +155,19 @@ func defaultTerraformExecutable() string { // fallback to Tofu if terraform is not available return TofuDefaultPath } + +func hasWarning(opts *Options, out string) error { + for k, v := range opts.WarningsAsErrors { + str := fmt.Sprintf("\nWarning: %s[^\n]*\n", k) + re, err := regexp.Compile(str) + if err != nil { + return fmt.Errorf("cannot compile regex for warning detection: %w", err) + } + m := re.FindAllString(out, -1) + if len(m) == 0 { + continue + } + return fmt.Errorf("warning(s) were found: %s:\n%s", v, strings.Join(m, "")) + } + return nil +} diff --git a/modules/terraform/options.go b/modules/terraform/options.go index 9495a6c09..4667ff06e 100644 --- a/modules/terraform/options.go +++ b/modules/terraform/options.go @@ -71,6 +71,7 @@ type Options struct { PlanFilePath string // The path to output a plan file to (for the plan command) or read one from (for the apply command) PluginDir string // The path of downloaded plugins to pass to the terraform init command (-plugin-dir) SetVarsAfterVarFiles bool // Pass -var options after -var-file options to Terraform commands + WarningsAsErrors map[string]string // Terraform warning messages that should be treated as errors. The keys are a regexp to match against the warning and the value is what to display to a user if that warning is matched. } // Clone makes a deep copy of most fields on the Options object and returns it. @@ -99,6 +100,10 @@ func (options *Options) Clone() (*Options, error) { for key, val := range options.RetryableTerraformErrors { newOptions.RetryableTerraformErrors[key] = val } + newOptions.WarningsAsErrors = make(map[string]string) + for key, val := range options.WarningsAsErrors { + newOptions.WarningsAsErrors[key] = val + } return newOptions, nil } diff --git a/test/fixtures/terraform-with-warning/main.tf b/test/fixtures/terraform-with-warning/main.tf new file mode 100644 index 000000000..69c45fb93 --- /dev/null +++ b/test/fixtures/terraform-with-warning/main.tf @@ -0,0 +1,22 @@ +terraform { + required_providers { + validation = { + source = "tlkamp/validation" + version = "1.1.1" + } + null = { + source = "hashicorp/null" + version = "3.2.2" + } + } +} + +# this data source will produce warning when `condition` is evaluated to `true` +data "validation_warning" "warn" { + for_each = toset([for i in range(10) : format("%02d", i)]) + condition = true + summary = "lorem ipsum ${each.value}" + details = "lorem ipsum dolor sit amet" +} + +resource "null_resource" "empty" {}