diff --git a/.changes/unreleased/FEATURES-20230118-083204.yaml b/.changes/unreleased/FEATURES-20230118-083204.yaml new file mode 100644 index 000000000..3203f5565 --- /dev/null +++ b/.changes/unreleased/FEATURES-20230118-083204.yaml @@ -0,0 +1,8 @@ +kind: FEATURES +body: 'helper/resource: Added `TestStep` type `ExpectWarning` field, which enables + verifying that a warning has been emitted during plan, apply, refresh and destroy. + Requires that Terraform 0.15.3 or later is used as it makes use of the Terraform + [machine-readable UI](https://developer.hashicorp.com/terraform/internals/machine-readable-ui)' +time: 2023-01-18T08:32:04.688764Z +custom: + Issue: "16" diff --git a/go.mod b/go.mod index 0634047be..db9755e3e 100644 --- a/go.mod +++ b/go.mod @@ -12,8 +12,9 @@ require ( github.com/hashicorp/hc-install v0.4.0 github.com/hashicorp/hcl/v2 v2.15.0 github.com/hashicorp/logutils v1.0.0 - github.com/hashicorp/terraform-exec v0.17.3 + github.com/hashicorp/terraform-exec v0.17.4-0.20230124151948-5eada031c166 github.com/hashicorp/terraform-json v0.14.0 + github.com/hashicorp/terraform-plugin-framework v1.0.1 github.com/hashicorp/terraform-plugin-go v0.14.3 github.com/hashicorp/terraform-plugin-log v0.7.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.24.1 diff --git a/go.sum b/go.sum index e06c51538..e7a873fab 100644 --- a/go.sum +++ b/go.sum @@ -93,10 +93,12 @@ github.com/hashicorp/hcl/v2 v2.15.0 h1:CPDXO6+uORPjKflkWCCwoWc9uRp+zSIPcCQ+BrxV7 github.com/hashicorp/hcl/v2 v2.15.0/go.mod h1:JRmR89jycNkrrqnMmvPDMd56n1rQJ2Q6KocSLCMCXng= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/terraform-exec v0.17.3 h1:MX14Kvnka/oWGmIkyuyvL6POx25ZmKrjlaclkx3eErU= -github.com/hashicorp/terraform-exec v0.17.3/go.mod h1:+NELG0EqQekJzhvikkeQsOAZpsw0cv/03rbeQJqscAI= +github.com/hashicorp/terraform-exec v0.17.4-0.20230124151948-5eada031c166 h1:WCvC+VknMywOyDXTIzvHHYWknjNBKI0/dmIR9EZd0bw= +github.com/hashicorp/terraform-exec v0.17.4-0.20230124151948-5eada031c166/go.mod h1:5M9hP3RX39/2AWDpAEWf0whTJFibOUN7DfH1E1lCZN8= github.com/hashicorp/terraform-json v0.14.0 h1:sh9iZ1Y8IFJLx+xQiKHGud6/TSUCM0N8e17dKDpqV7s= github.com/hashicorp/terraform-json v0.14.0/go.mod h1:5A9HIWPkk4e5aeeXIBbkcOvaZbIYnAIkEyqP2pNSckM= +github.com/hashicorp/terraform-plugin-framework v1.0.1 h1:apX2jtaEKa15+do6H2izBJdl1dEH2w5BPVkDJ3Q3mKA= +github.com/hashicorp/terraform-plugin-framework v1.0.1/go.mod h1:FV97t2BZOARkL7NNlsc/N25c84MyeSSz72uPp7Vq1lg= github.com/hashicorp/terraform-plugin-go v0.14.3 h1:nlnJ1GXKdMwsC8g1Nh05tK2wsC3+3BL/DBBxFEki+j0= github.com/hashicorp/terraform-plugin-go v0.14.3/go.mod h1:7ees7DMZ263q8wQ6E4RdIdR6nHHJtrdt4ogX5lPkX1A= github.com/hashicorp/terraform-plugin-log v0.7.0 h1:SDxJUyT8TwN4l5b5/VkiTIaQgY6R+Y2BQ0sRZftGKQs= diff --git a/helper/resource/testing.go b/helper/resource/testing.go index 0d479d8d6..3e586a59c 100644 --- a/helper/resource/testing.go +++ b/helper/resource/testing.go @@ -510,6 +510,14 @@ type TestStep struct { // test to pass. ExpectError *regexp.Regexp + // ExpectWarning requires the use of Terraform 0.15.3 or later as it makes + // use of the Terraform [machine-readable UI](https://developer.hashicorp.com/terraform/internals/machine-readable-ui). + // + // ExpectWarning allows the construction of test cases that we expect to pass + // with a warning. The specified regexp must match against stdout for the + // test to pass. + ExpectWarning *regexp.Regexp + // PlanOnly can be set to only run `plan` with this configuration, and not // actually apply it. This is useful for ensuring config changes result in // no-op plans diff --git a/helper/resource/testing_new.go b/helper/resource/testing_new.go index d671a0a6f..3f55aa91c 100644 --- a/helper/resource/testing_new.go +++ b/helper/resource/testing_new.go @@ -5,6 +5,7 @@ package resource import ( "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -253,26 +254,61 @@ func runNewTest(ctx context.Context, t testing.T, c TestCase, helper *plugintest if step.RefreshState { logging.HelperResourceTrace(ctx, "TestStep is RefreshState mode") - err := testStepNewRefreshState(ctx, t, wd, step, providers) + testStepNewRefreshStateResponse, err := testStepNewRefreshState(ctx, t, wd, step, providers) + refreshTfJSONDiags := testStepNewRefreshStateResponse.tfJSONDiags + if step.ExpectError != nil { logging.HelperResourceDebug(ctx, "Checking TestStep ExpectError") + if err == nil { logging.HelperResourceError(ctx, "Error running refresh: expected an error but got none", ) t.Fatalf("Step %d/%d error running refresh: expected an error but got none", stepNumber, len(c.Steps)) } - if !step.ExpectError.MatchString(err.Error()) { + + errorFound := step.ExpectError.MatchString(err.Error()) + jsonErrorFound := refreshTfJSONDiags.Contains(step.ExpectError, tfjson.DiagnosticSeverityError) + + errorOutput := []string{err.Error()} + + for _, v := range refreshTfJSONDiags.Errors() { + b, err := json.Marshal(v) + if err != nil { + t.Errorf("could not marshal tfjson diagnostic: %s", err) + } + + errorOutput = append(errorOutput, string(b)) + } + + if !errorFound && !jsonErrorFound { logging.HelperResourceError(ctx, fmt.Sprintf("Error running refresh: expected an error with pattern (%s)", step.ExpectError.String()), - map[string]interface{}{logging.KeyError: err}, + map[string]interface{}{logging.KeyError: strings.Join(errorOutput, "")}, ) - t.Fatalf("Step %d/%d error running refresh, expected an error with pattern (%s), no match on: %s", stepNumber, len(c.Steps), step.ExpectError.String(), err) + t.Fatalf("Step %d/%d error running refresh, expected an error with pattern (%s), no match on: %s", stepNumber, len(c.Steps), step.ExpectError.String(), strings.Join(errorOutput, "\n")) } } else { if err != nil && c.ErrorCheck != nil { logging.HelperResourceDebug(ctx, "Calling TestCase ErrorCheck") + + // This is a "best effort" to supply ErrorCheck with the contents of the + // error diagnostics returned from running the Terraform command when + // executed with the -json flag. If ErrorCheck functions are using + // regexp to perform matches that include line breaks then these will + // likely no longer behave as expected and therefore potentially + // represent a breaking change. + var diagStrings []string + + for _, v := range refreshTfJSONDiags.Errors() { + diagStrings = append(diagStrings, "Error: "+v.Summary) + diagStrings = append(diagStrings, v.Detail) + } + + err = fmt.Errorf("%s\n"+strings.Join(diagStrings, "\n"), err) + err = c.ErrorCheck(err) + logging.HelperResourceDebug(ctx, "Called TestCase ErrorCheck") } if err != nil { @@ -280,7 +316,45 @@ func runNewTest(ctx context.Context, t testing.T, c TestCase, helper *plugintest "Error running refresh", map[string]interface{}{logging.KeyError: err}, ) - t.Fatalf("Step %d/%d error running refresh: %s", stepNumber, len(c.Steps), err) + + errorOutput := []string{err.Error()} + + for _, v := range refreshTfJSONDiags { + b, err := json.Marshal(v) + if err != nil { + t.Errorf("could not marshal tfjson diagnostic: %s", err) + } + + errorOutput = append(errorOutput, string(b)) + } + + t.Fatalf("Step %d/%d error: %s", stepNumber, len(c.Steps), strings.Join(errorOutput, "\n")) + } + } + + if step.ExpectWarning != nil { + logging.HelperResourceDebug(ctx, "Checking TestStep ExpectWarning") + + warningFound := refreshTfJSONDiags.Contains(step.ExpectWarning, tfjson.DiagnosticSeverityWarning) + + if !warningFound { + logging.HelperResourceError(ctx, + fmt.Sprintf("Expected a warning with pattern (%s)", step.ExpectWarning.String()), + map[string]interface{}{logging.KeyError: err}, + ) + + var warningDiags []string + + for _, v := range refreshTfJSONDiags.Warnings() { + b, err := json.Marshal(v) + if err != nil { + t.Errorf("could not marshal tfjson diagnostic: %s", err) + } + + warningDiags = append(warningDiags, string(b)) + } + + t.Errorf("Step %d/%d, expected a warning matching pattern: %q, no match on: %s", stepNumber, len(c.Steps), step.ExpectWarning.String(), strings.Join(warningDiags, "\n")) } } @@ -292,7 +366,9 @@ func runNewTest(ctx context.Context, t testing.T, c TestCase, helper *plugintest if step.Config != "" { logging.HelperResourceTrace(ctx, "TestStep is Config mode") - err := testStepNewConfig(ctx, t, c, wd, step, providers) + testStepNewConfigResponse, err := testStepNewConfig(ctx, t, c, wd, step, providers) + newConfigTfJSONDiags := testStepNewConfigResponse.tfJSONDiags + if step.ExpectError != nil { logging.HelperResourceDebug(ctx, "Checking TestStep ExpectError") @@ -302,17 +378,47 @@ func runNewTest(ctx context.Context, t testing.T, c TestCase, helper *plugintest ) t.Fatalf("Step %d/%d, expected an error but got none", stepNumber, len(c.Steps)) } - if !step.ExpectError.MatchString(err.Error()) { + + errorFound := step.ExpectError.MatchString(err.Error()) + jsonErrorFound := newConfigTfJSONDiags.Contains(step.ExpectError, tfjson.DiagnosticSeverityError) + + errorOutput := []string{err.Error()} + + for _, v := range newConfigTfJSONDiags.Errors() { + b, err := json.Marshal(v) + if err != nil { + t.Errorf("could not marshal tfjson Diagnostic: %s", err) + } + + errorOutput = append(errorOutput, string(b)) + } + + if !errorFound && !jsonErrorFound { logging.HelperResourceError(ctx, fmt.Sprintf("Expected an error with pattern (%s)", step.ExpectError.String()), - map[string]interface{}{logging.KeyError: err}, + map[string]interface{}{logging.KeyError: strings.Join(errorOutput, "")}, ) - t.Fatalf("Step %d/%d, expected an error with pattern, no match on: %s", stepNumber, len(c.Steps), err) + t.Fatalf("Step %d/%d, expected an error matching pattern, no match on: %s", stepNumber, len(c.Steps), strings.Join(errorOutput, "")) } } else { if err != nil && c.ErrorCheck != nil { logging.HelperResourceDebug(ctx, "Calling TestCase ErrorCheck") + // This is a "best effort" to supply ErrorCheck with the contents of the + // error diagnostics returned from running the Terraform command when + // executed with the -json flag. If ErrorCheck functions are using + // regexp to perform matches that include line breaks then these will + // likely no longer behave as expected and therefore potentially + // represent a breaking change. + var diagStrings []string + + for _, v := range newConfigTfJSONDiags.Errors() { + diagStrings = append(diagStrings, "Error: "+v.Summary) + diagStrings = append(diagStrings, v.Detail) + } + + err = fmt.Errorf("%s\n"+strings.Join(diagStrings, "\n"), err) + err = c.ErrorCheck(err) logging.HelperResourceDebug(ctx, "Called TestCase ErrorCheck") @@ -322,7 +428,45 @@ func runNewTest(ctx context.Context, t testing.T, c TestCase, helper *plugintest "Unexpected error", map[string]interface{}{logging.KeyError: err}, ) - t.Fatalf("Step %d/%d error: %s", stepNumber, len(c.Steps), err) + + errorOutput := []string{err.Error()} + + for _, v := range newConfigTfJSONDiags.Errors() { + b, err := json.Marshal(v) + if err != nil { + t.Errorf("could not marshall tfjson Diagnostic: %s", err) + } + + errorOutput = append(errorOutput, string(b)) + } + + t.Fatalf("Step %d/%d error: %s", stepNumber, len(c.Steps), strings.Join(errorOutput, "\n")) + } + } + + if step.ExpectWarning != nil { + logging.HelperResourceDebug(ctx, "Checking TestStep ExpectWarning") + + warningFound := newConfigTfJSONDiags.Contains(step.ExpectWarning, tfjson.DiagnosticSeverityWarning) + + if !warningFound { + logging.HelperResourceError(ctx, + fmt.Sprintf("Expected a warning with pattern (%s)", step.ExpectWarning.String()), + map[string]interface{}{logging.KeyError: err}, + ) + + var warningDiags []string + + for _, v := range newConfigTfJSONDiags.Warnings() { + b, err := json.Marshal(v) + if err != nil { + t.Errorf("could not marshal tfjson diagnostic: %s", err) + } + + warningDiags = append(warningDiags, string(b)) + } + + t.Errorf("Step %d/%d, expected a warning matching pattern: %q, no match on: %s", stepNumber, len(c.Steps), step.ExpectWarning.String(), strings.Join(warningDiags, "\n")) } } @@ -394,7 +538,7 @@ func testIDRefresh(ctx context.Context, t testing.T, c TestCase, wd *plugintest. // Refresh! err = runProviderCommand(ctx, t, func() error { - err = wd.Refresh(ctx) + _, err := wd.Refresh(ctx) if err != nil { t.Fatalf("Error running terraform refresh: %s", err) } diff --git a/helper/resource/testing_new_config.go b/helper/resource/testing_new_config.go index 3ad3f0916..41ac69360 100644 --- a/helper/resource/testing_new_config.go +++ b/helper/resource/testing_new_config.go @@ -17,21 +17,35 @@ import ( "github.com/hashicorp/terraform-plugin-testing/internal/plugintest" ) -func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugintest.WorkingDir, step TestStep, providers *providerFactories) error { +type testStepNewConfigResponse struct { + tfJSONDiags plugintest.TerraformJSONDiagnostics +} + +func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugintest.WorkingDir, step TestStep, providers *providerFactories) (testStepNewConfigResponse, error) { t.Helper() + var tfJSONDiags plugintest.TerraformJSONDiagnostics + err := wd.SetConfig(ctx, step.mergedConfig(ctx, c)) if err != nil { - return fmt.Errorf("Error setting config: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error setting config: %w", err) } // require a refresh before applying // failing to do this will result in data sources not being updated err = runProviderCommand(ctx, t, func() error { - return wd.Refresh(ctx) + refreshResponse, err := wd.Refresh(ctx) + + tfJSONDiags = append(tfJSONDiags, refreshResponse.Diagnostics...) + + return err }, wd, providers) if err != nil { - return fmt.Errorf("Error running pre-apply refresh: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error running pre-apply refresh: %w", err) } // If this step is a PlanOnly step, skip over this first Plan and @@ -43,12 +57,22 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint // Plan! err := runProviderCommand(ctx, t, func() error { if step.Destroy { - return wd.CreateDestroyPlan(ctx) + createDestroyPlanResponse, err := wd.CreateDestroyPlan(ctx) + + tfJSONDiags = append(tfJSONDiags, createDestroyPlanResponse.Diagnostics...) + + return err } - return wd.CreatePlan(ctx) + createPlanResponse, err := wd.CreatePlan(ctx) + + tfJSONDiags = append(tfJSONDiags, createPlanResponse.Diagnostics...) + + return err }, wd, providers) if err != nil { - return fmt.Errorf("Error running pre-apply plan: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error running pre-apply plan: %w", err) } // We need to keep a copy of the state prior to destroying such @@ -63,18 +87,29 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint return nil }, wd, providers) if err != nil { - return fmt.Errorf("Error retrieving pre-apply state: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error retrieving pre-apply state: %w", err) } // Apply the diff, creating real resources err = runProviderCommand(ctx, t, func() error { - return wd.Apply(ctx) + applyResponse, err := wd.Apply(ctx) + + tfJSONDiags = append(tfJSONDiags, applyResponse.Diagnostics...) + + return err }, wd, providers) if err != nil { if step.Destroy { - return fmt.Errorf("Error running destroy: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error running destroy: %w", err) } - return fmt.Errorf("Error running apply: %w", err) + + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error running apply: %w", err) } // Get the new state @@ -87,7 +122,9 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint return nil }, wd, providers) if err != nil { - return fmt.Errorf("Error retrieving state after apply: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error retrieving state after apply: %w", err) } // Run any configured checks @@ -97,11 +134,15 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint state.IsBinaryDrivenTest = true if step.Destroy { if err := step.Check(stateBeforeApplication); err != nil { - return fmt.Errorf("Check failed: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Check failed: %w", err) } } else { if err := step.Check(state); err != nil { - return fmt.Errorf("Check failed: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Check failed: %w", err) } } } @@ -113,12 +154,22 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint // do a plan err = runProviderCommand(ctx, t, func() error { if step.Destroy { - return wd.CreateDestroyPlan(ctx) + createDestroyPlanResponse, err := wd.CreateDestroyPlan(ctx) + + tfJSONDiags = append(tfJSONDiags, createDestroyPlanResponse.Diagnostics...) + + return err } - return wd.CreatePlan(ctx) + createPlanResponse, err := wd.CreatePlan(ctx) + + tfJSONDiags = append(tfJSONDiags, createPlanResponse.Diagnostics...) + + return err }, wd, providers) if err != nil { - return fmt.Errorf("Error running post-apply plan: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error running post-apply plan: %w", err) } var plan *tfjson.Plan @@ -128,41 +179,63 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint return err }, wd, providers) if err != nil { - return fmt.Errorf("Error retrieving post-apply plan: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error retrieving post-apply plan: %w", err) } if !planIsEmpty(plan) && !step.ExpectNonEmptyPlan { - var stdout string + var savedPlanRawStdout string err = runProviderCommand(ctx, t, func() error { var err error - stdout, err = wd.SavedPlanRawStdout(ctx) + savedPlanRawStdout, err = wd.SavedPlanRawStdout(ctx) return err }, wd, providers) if err != nil { - return fmt.Errorf("Error retrieving formatted plan output: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error retrieving formatted plan output: %w", err) } - return fmt.Errorf("After applying this test step, the plan was not empty.\nstdout:\n\n%s", stdout) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("After applying this test step, the plan was not empty.\nstdout:\n\n%s", savedPlanRawStdout) } // do a refresh if !step.Destroy || (step.Destroy && !step.PreventPostDestroyRefresh) { - err := runProviderCommand(ctx, t, func() error { - return wd.Refresh(ctx) + err = runProviderCommand(ctx, t, func() error { + refreshResponse, err := wd.Refresh(ctx) + + tfJSONDiags = append(tfJSONDiags, refreshResponse.Diagnostics...) + + return err }, wd, providers) if err != nil { - return fmt.Errorf("Error running post-apply refresh: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error running post-apply refresh: %w", err) } } // do another plan err = runProviderCommand(ctx, t, func() error { if step.Destroy { - return wd.CreateDestroyPlan(ctx) + createDestroyPlanResponse, err := wd.CreateDestroyPlan(ctx) + + tfJSONDiags = append(tfJSONDiags, createDestroyPlanResponse.Diagnostics...) + + return err } - return wd.CreatePlan(ctx) + createPlanResponse, err := wd.CreatePlan(ctx) + + tfJSONDiags = append(tfJSONDiags, createPlanResponse.Diagnostics...) + + return err }, wd, providers) if err != nil { - return fmt.Errorf("Error running second post-apply plan: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error running second post-apply plan: %w", err) } err = runProviderCommand(ctx, t, func() error { @@ -171,23 +244,31 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint return err }, wd, providers) if err != nil { - return fmt.Errorf("Error retrieving second post-apply plan: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error retrieving second post-apply plan: %w", err) } // check if plan is empty if !planIsEmpty(plan) && !step.ExpectNonEmptyPlan { - var stdout string + var savedPlanRawStdout string err = runProviderCommand(ctx, t, func() error { var err error - stdout, err = wd.SavedPlanRawStdout(ctx) + savedPlanRawStdout, err = wd.SavedPlanRawStdout(ctx) return err }, wd, providers) if err != nil { - return fmt.Errorf("Error retrieving formatted second plan output: %w", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error retrieving formatted second plan output: %w", err) } - return fmt.Errorf("After applying this test step and performing a `terraform refresh`, the plan was not empty.\nstdout\n\n%s", stdout) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("After applying this test step and performing a `terraform refresh`, the plan was not empty.\nstdout\n\n%s", savedPlanRawStdout) } else if step.ExpectNonEmptyPlan && planIsEmpty(plan) { - return errors.New("Expected a non-empty plan, but got an empty plan") + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, errors.New("Expected a non-empty plan, but got an empty plan") } // ID-ONLY REFRESH @@ -207,11 +288,15 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint }, wd, providers) if err != nil { - return err + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, err } if state.Empty() { - return nil + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, nil } var idRefreshCheck *terraform.ResourceState @@ -235,11 +320,14 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint // caught a different bug. if idRefreshCheck != nil { if err := testIDRefresh(ctx, t, c, wd, step, idRefreshCheck, providers); err != nil { - return fmt.Errorf( - "[ERROR] Test: ID-only test failed: %s", err) + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("[ERROR] Test: ID-only test failed: %s", err) } } } - return nil + return testStepNewConfigResponse{ + tfJSONDiags: tfJSONDiags, + }, nil } diff --git a/helper/resource/testing_new_refresh_state.go b/helper/resource/testing_new_refresh_state.go index fe7b28145..cc08c52e5 100644 --- a/helper/resource/testing_new_refresh_state.go +++ b/helper/resource/testing_new_refresh_state.go @@ -16,9 +16,16 @@ import ( "github.com/hashicorp/terraform-plugin-testing/internal/plugintest" ) -func testStepNewRefreshState(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, step TestStep, providers *providerFactories) error { +type testStepNewRefreshStateResponse struct { + tfJSONDiags plugintest.TerraformJSONDiagnostics + stdout string +} + +func testStepNewRefreshState(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, step TestStep, providers *providerFactories) (testStepNewRefreshStateResponse, error) { t.Helper() + var tfJSONDiags plugintest.TerraformJSONDiagnostics + var err error // Explicitly ensure prior state exists before refresh. err = runProviderCommand(ctx, t, func() error { @@ -33,10 +40,16 @@ func testStepNewRefreshState(ctx context.Context, t testing.T, wd *plugintest.Wo } err = runProviderCommand(ctx, t, func() error { - return wd.Refresh(ctx) + refreshResponse, err := wd.Refresh(ctx) + + tfJSONDiags = append(tfJSONDiags, refreshResponse.Diagnostics...) + + return err }, wd, providers) if err != nil { - return err + return testStepNewRefreshStateResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error running refresh: %w", err) } var refreshState *terraform.State @@ -64,10 +77,16 @@ func testStepNewRefreshState(ctx context.Context, t testing.T, wd *plugintest.Wo // do a plan err = runProviderCommand(ctx, t, func() error { - return wd.CreatePlan(ctx) + createPlanResponse, err := wd.CreatePlan(ctx) + + tfJSONDiags = append(tfJSONDiags, createPlanResponse.Diagnostics...) + + return err }, wd, providers) if err != nil { - return fmt.Errorf("Error running post-apply plan: %w", err) + return testStepNewRefreshStateResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error running post-apply plan: %w", err) } var plan *tfjson.Plan @@ -77,7 +96,9 @@ func testStepNewRefreshState(ctx context.Context, t testing.T, wd *plugintest.Wo return err }, wd, providers) if err != nil { - return fmt.Errorf("Error retrieving post-apply plan: %w", err) + return testStepNewRefreshStateResponse{ + tfJSONDiags: tfJSONDiags, + }, fmt.Errorf("Error retrieving post-apply plan: %w", err) } if !planIsEmpty(plan) && !step.ExpectNonEmptyPlan { @@ -88,10 +109,18 @@ func testStepNewRefreshState(ctx context.Context, t testing.T, wd *plugintest.Wo return err }, wd, providers) if err != nil { - return fmt.Errorf("Error retrieving formatted plan output: %w", err) + return testStepNewRefreshStateResponse{ + tfJSONDiags: tfJSONDiags, + stdout: stdout, + }, fmt.Errorf("Error retrieving formatted plan output: %w", err) } - return fmt.Errorf("After refreshing state during this test step, a followup plan was not empty.\nstdout:\n\n%s", stdout) + return testStepNewRefreshStateResponse{ + tfJSONDiags: tfJSONDiags, + stdout: stdout, + }, fmt.Errorf("After refreshing state during this test step, a followup plan was not empty.\nstdout:\n\n%s", stdout) } - return nil + return testStepNewRefreshStateResponse{ + tfJSONDiags: tfJSONDiags, + }, nil } diff --git a/helper/resource/teststep_providers_test.go b/helper/resource/teststep_providers_test.go index 986cf7b0c..affa876d3 100644 --- a/helper/resource/teststep_providers_test.go +++ b/helper/resource/teststep_providers_test.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strconv" "strings" "testing" @@ -15,14 +16,20 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/go-cty/cty" + fwdiag "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-framework/resource" + fwresourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tfprotov6" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-testing/internal/plugintest" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" "github.com/hashicorp/terraform-plugin-testing/terraform" ) @@ -2355,6 +2362,724 @@ func TestTest_TestStep_ProviderFactories_Import_External_With_Data_Source(t *tes }) } +func TestTest_TestStep_ProviderFactories_ExpectErrorSummary(t *testing.T) { + t.Parallel() + + Test(t, TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "random": func() (*schema.Provider, error) { //nolint:unparam // required signature + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "random_password": { + CreateContext: func(ctx context.Context, d *schema.ResourceData, i interface{}) (diags diag.Diagnostics) { + d.SetId("id") + return append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "error diagnostic - summary", + }) + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + Schema: map[string]*schema.Schema{ + "id": { + Computed: true, + Type: schema.TypeString, + }, + }, + }, + }, + }, nil + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_password" "test" { }`, + ExpectError: regexp.MustCompile(`.*error diagnostic - summary`), + }, + }, + }) +} + +func TestTest_TestStep_ProviderFactories_ExpectErrorDetail(t *testing.T) { + t.Parallel() + + Test(t, TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "random": func() (*schema.Provider, error) { //nolint:unparam // required signature + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "random_password": { + CreateContext: func(ctx context.Context, d *schema.ResourceData, i interface{}) (diags diag.Diagnostics) { + d.SetId("id") + return append(diags, diag.Diagnostic{ + Severity: diag.Error, + Detail: "error diagnostic - detail", + }) + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + Schema: map[string]*schema.Schema{ + "id": { + Computed: true, + Type: schema.TypeString, + }, + }, + }, + }, + }, nil + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_password" "test" { }`, + ExpectError: regexp.MustCompile(`.*error diagnostic - detail`), + }, + }, + }) +} + +func TestTest_TestStep_ProviderFactories_ExpectWarningSummary(t *testing.T) { + t.Parallel() + + Test(t, TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "random": func() (*schema.Provider, error) { //nolint:unparam // required signature + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "random_password": { + CreateContext: func(ctx context.Context, d *schema.ResourceData, i interface{}) (diags diag.Diagnostics) { + d.SetId("id") + return append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "warning diagnostic - summary", + }) + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + Schema: map[string]*schema.Schema{ + "id": { + Computed: true, + Type: schema.TypeString, + }, + }, + }, + }, + }, nil + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_password" "test" { }`, + ExpectWarning: regexp.MustCompile(`.*warning diagnostic - summary`), + }, + }, + }) +} + +func TestTest_TestStep_ProviderFactories_ExpectWarningDetail(t *testing.T) { + t.Parallel() + + Test(t, TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "random": func() (*schema.Provider, error) { //nolint:unparam // required signature + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "random_password": { + CreateContext: func(ctx context.Context, d *schema.ResourceData, i interface{}) (diags diag.Diagnostics) { + d.SetId("id") + return append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Detail: "warning diagnostic - detail", + }) + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + Schema: map[string]*schema.Schema{ + "id": { + Computed: true, + Type: schema.TypeString, + }, + }, + }, + }, + }, nil + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_password" "test" { }`, + ExpectWarning: regexp.MustCompile(`.*warning diagnostic - detail`), + }, + }, + }) +} + +func TestTest_TestStep_ProviderFactories_ExpectErrorRefresh(t *testing.T) { + t.Parallel() + + Test(t, TestCase{ + Steps: []TestStep{ + { + ProviderFactories: map[string]func() (*schema.Provider, error){ + "random": func() (*schema.Provider, error) { //nolint:unparam // required signature + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "random_password": { + CreateContext: func(ctx context.Context, d *schema.ResourceData, i interface{}) diag.Diagnostics { + d.SetId("id") + return nil + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) (diags diag.Diagnostics) { + return nil + }, + Schema: map[string]*schema.Schema{ + "id": { + Computed: true, + Type: schema.TypeString, + }, + }, + }, + }, + }, nil + }, + }, + Config: `resource "random_password" "test" { }`, + }, + { + ProviderFactories: map[string]func() (*schema.Provider, error){ + "random": func() (*schema.Provider, error) { //nolint:unparam // required signature + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "random_password": { + CreateContext: func(ctx context.Context, d *schema.ResourceData, i interface{}) diag.Diagnostics { + d.SetId("id") + return nil + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) (diags diag.Diagnostics) { + return append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "error diagnostic - summary", + }) + }, + Schema: map[string]*schema.Schema{ + "id": { + Computed: true, + Type: schema.TypeString, + }, + }, + }, + }, + }, nil + }, + }, + RefreshState: true, + ExpectError: regexp.MustCompile(`.*error diagnostic - summary`), + }, + }, + }) +} + +func TestTest_TestStep_ProviderFactories_ExpectWarningRefresh(t *testing.T) { + t.Parallel() + + Test(t, TestCase{ + Steps: []TestStep{ + { + ProviderFactories: map[string]func() (*schema.Provider, error){ + "random": func() (*schema.Provider, error) { //nolint:unparam // required signature + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "random_password": { + CreateContext: func(ctx context.Context, d *schema.ResourceData, i interface{}) diag.Diagnostics { + d.SetId("id") + return nil + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) (diags diag.Diagnostics) { + return nil + }, + Schema: map[string]*schema.Schema{ + "id": { + Computed: true, + Type: schema.TypeString, + }, + }, + }, + }, + }, nil + }, + }, + Config: `resource "random_password" "test" { }`, + }, + { + ProviderFactories: map[string]func() (*schema.Provider, error){ + "random": func() (*schema.Provider, error) { //nolint:unparam // required signature + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "random_password": { + CreateContext: func(ctx context.Context, d *schema.ResourceData, i interface{}) diag.Diagnostics { + d.SetId("id") + return nil + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) (diags diag.Diagnostics) { + return append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "warning diagnostic - summary", + }) + }, + Schema: map[string]*schema.Schema{ + "id": { + Computed: true, + Type: schema.TypeString, + }, + }, + }, + }, + }, nil + }, + }, + RefreshState: true, + // ExpectNonEmptyPlan is set to true otherwise following error is generated: + // # random_password.test will be destroyed + // # (because random_password.test is not in configuration) + ExpectNonEmptyPlan: true, + ExpectWarning: regexp.MustCompile(`.*warning diagnostic - summary`), + }, + }, + }) +} + +func TestTest_TestStep_ProviderFactories_ExpectErrorPlan(t *testing.T) { + t.Parallel() + + Test(t, TestCase{ + Steps: []TestStep{ + { + ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){ + "random": providerserver.NewProtocol5WithError(&testprovider.Provider{ + MetadataMethod: func(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "random" + }, + ResourcesMethod: func(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.Resource{ + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_password" + }, + SchemaMethod: func(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = fwresourceschema.Schema{ + Attributes: map[string]fwresourceschema.Attribute{ + "id": fwresourceschema.StringAttribute{ + Computed: true, + }, + }, + } + }, + CreateMethod: func(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data struct { + Id types.String `tfsdk:"id"` + } + + data.Id = types.StringValue("id") + + diags := resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) + }, + } + }, + } + }, + }), + }, + Config: `resource "random_password" "test" { }`, + }, + { + ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){ + "random": providerserver.NewProtocol5WithError(&testprovider.Provider{ + MetadataMethod: func(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "random" + }, + ResourcesMethod: func(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.ResourceWithModifyPlan{ + Resource: &testprovider.Resource{ + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_password" + }, + SchemaMethod: func(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = fwresourceschema.Schema{ + Attributes: map[string]fwresourceschema.Attribute{ + "id": fwresourceschema.StringAttribute{ + Computed: true, + }, + }, + } + }, + }, + ModifyPlanMethod: func(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if !req.Plan.Raw.IsNull() { + resp.Diagnostics.Append(fwdiag.NewErrorDiagnostic("error diagnostic - summary", "")) + } + }, + } + }, + } + }, + }), + }, + Config: `resource "random_password" "test" { }`, + PlanOnly: true, + ExpectError: regexp.MustCompile(`.*error diagnostic - summary`), + }, + }, + }) +} + +func TestTest_TestStep_ProviderFactories_ExpectWarningPlan(t *testing.T) { + t.Parallel() + + Test(t, TestCase{ + Steps: []TestStep{ + { + ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){ + "random": providerserver.NewProtocol5WithError(&testprovider.Provider{ + MetadataMethod: func(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "random" + }, + ResourcesMethod: func(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.Resource{ + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_password" + }, + SchemaMethod: func(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = fwresourceschema.Schema{ + Attributes: map[string]fwresourceschema.Attribute{ + "id": fwresourceschema.StringAttribute{ + Computed: true, + }, + }, + } + }, + CreateMethod: func(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data struct { + Id types.String `tfsdk:"id"` + } + + data.Id = types.StringValue("example-id") + + diags := resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) + }, + } + }, + } + }, + }), + }, + Config: `resource "random_password" "test" { }`, + }, + { + ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){ + "random": providerserver.NewProtocol5WithError(&testprovider.Provider{ + MetadataMethod: func(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "random" + }, + ResourcesMethod: func(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.ResourceWithModifyPlan{ + Resource: &testprovider.Resource{ + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_password" + }, + SchemaMethod: func(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = fwresourceschema.Schema{ + Attributes: map[string]fwresourceschema.Attribute{ + "id": fwresourceschema.StringAttribute{ + Computed: true, + }, + }, + } + }, + }, + ModifyPlanMethod: func(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + resp.Diagnostics.Append(fwdiag.NewWarningDiagnostic("warning diagnostic - summary", "")) + }, + } + }, + } + }, + }), + }, + Config: `resource "random_password" "test" { }`, + PlanOnly: true, + ExpectWarning: regexp.MustCompile(`.*warning diagnostic - summary`), + }, + }, + }) +} + +func TestTest_TestStep_ProviderFactories_ExpectErrorDestroy(t *testing.T) { + t.Parallel() + + deleteCount := 0 + + Test(t, TestCase{ + Steps: []TestStep{ + { + ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){ + "random": providerserver.NewProtocol5WithError(&testprovider.Provider{ + MetadataMethod: func(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "random" + }, + ResourcesMethod: func(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.Resource{ + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_password" + }, + SchemaMethod: func(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = fwresourceschema.Schema{ + Attributes: map[string]fwresourceschema.Attribute{ + "id": fwresourceschema.StringAttribute{ + Computed: true, + }, + }, + } + }, + CreateMethod: func(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data struct { + Id types.String `tfsdk:"id"` + } + + data.Id = types.StringValue("example-id") + + diags := resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) + }, + } + }, + } + }, + }), + }, + Config: `resource "random_password" "test" { }`, + }, + { + ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){ + "random": providerserver.NewProtocol5WithError(&testprovider.Provider{ + MetadataMethod: func(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "random" + }, + ResourcesMethod: func(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.Resource{ + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_password" + }, + SchemaMethod: func(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = fwresourceschema.Schema{ + Attributes: map[string]fwresourceschema.Attribute{ + "id": fwresourceschema.StringAttribute{ + Computed: true, + }, + }, + } + }, + DeleteMethod: func(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // TODO: deleteCount is used so that when Delete is called during apply, diagnostic is added but + // when Delete is called during runPostTestDestroy it does not add diagnostic. + if deleteCount < 1 { + resp.Diagnostics.Append(fwdiag.NewErrorDiagnostic("error diagnostic - summary", "")) + } + + deleteCount++ + }, + } + }, + } + }, + }), + }, + Config: `resource "random_password" "test" { }`, + Destroy: true, + ExpectError: regexp.MustCompile(`.*error diagnostic - summary`), + }, + }, + }) +} + +func TestTest_TestStep_ProviderFactories_ExpectWarningDestroy(t *testing.T) { + t.Parallel() + + Test(t, TestCase{ + Steps: []TestStep{ + { + ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){ + "random": providerserver.NewProtocol5WithError(&testprovider.Provider{ + MetadataMethod: func(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "random" + }, + ResourcesMethod: func(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.Resource{ + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_password" + }, + SchemaMethod: func(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = fwresourceschema.Schema{ + Attributes: map[string]fwresourceschema.Attribute{ + "id": fwresourceschema.StringAttribute{ + Computed: true, + }, + }, + } + }, + CreateMethod: func(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data struct { + Id types.String `tfsdk:"id"` + } + + data.Id = types.StringValue("example-id") + + diags := resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) + }, + } + }, + } + }, + }), + }, + Config: `resource "random_password" "test" { }`, + }, + { + ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){ + "random": providerserver.NewProtocol5WithError(&testprovider.Provider{ + MetadataMethod: func(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "random" + }, + ResourcesMethod: func(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.Resource{ + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_password" + }, + SchemaMethod: func(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = fwresourceschema.Schema{ + Attributes: map[string]fwresourceschema.Attribute{ + "id": fwresourceschema.StringAttribute{ + Computed: true, + }, + }, + } + }, + DeleteMethod: func(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + resp.Diagnostics.Append(fwdiag.NewWarningDiagnostic("warning diagnostic - summary", "")) + }, + } + }, + } + }, + }), + }, + Config: `resource "random_password" "test" { }`, + Destroy: true, + ExpectWarning: regexp.MustCompile(`.*warning diagnostic - summary`), + }, + }, + }) +} + +func TestTest_TestStep_ProviderFactories_ErrorCheck(t *testing.T) { + t.Parallel() + + Test(t, TestCase{ + ErrorCheck: func(err error) error { + r := regexp.MustCompile("error summary") + + if r.MatchString(err.Error()) { + return nil + } + + return err + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "example": func() (*schema.Provider, error) { //nolint:unparam // required signature + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "example_resource": { + CreateContext: func(ctx context.Context, d *schema.ResourceData, i interface{}) diag.Diagnostics { + d.SetId("id") + + return diag.Diagnostics{ + diag.Diagnostic{ + Severity: 0, + Summary: "error summary", + Detail: "error detail", + }, + } + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + Schema: map[string]*schema.Schema{ + "id": { + Computed: true, + Type: schema.TypeString, + }, + }, + }, + }, + }, nil + }, + }, + Steps: []TestStep{ + { + Config: `resource "example_resource" "test" { }`, + }, + }, + }) +} + func setTimeForTest(t time.Time) func() { return func() { getTimeForTest = func() time.Time { diff --git a/helper/resource/wait_test.go b/helper/resource/wait_test.go index 07f523ad4..fbb66cf72 100644 --- a/helper/resource/wait_test.go +++ b/helper/resource/wait_test.go @@ -30,9 +30,9 @@ func TestRetry(t *testing.T) { } // make sure a slow StateRefreshFunc is allowed to complete after timeout +// +//nolint:paralleltest // Test fails when running using t.Parallel() func TestRetry_grace(t *testing.T) { - t.Parallel() - f := func() *RetryError { time.Sleep(1 * time.Second) return nil diff --git a/internal/plugintest/terraform_json_buffer.go b/internal/plugintest/terraform_json_buffer.go new file mode 100644 index 000000000..005d949c5 --- /dev/null +++ b/internal/plugintest/terraform_json_buffer.go @@ -0,0 +1,215 @@ +package plugintest + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "regexp" + + tfjson "github.com/hashicorp/terraform-json" +) + +type TerraformJSONDiagnostics []tfjson.Diagnostic + +func (d TerraformJSONDiagnostics) Contains(r *regexp.Regexp, severity tfjson.DiagnosticSeverity) bool { + for _, v := range d { + if v.Severity != severity { + continue + } + + if !r.MatchString(v.Summary) && !r.MatchString(v.Detail) { + continue + } + + return true + } + + return false +} + +func (d TerraformJSONDiagnostics) Errors() TerraformJSONDiagnostics { + var tfJSONDiagnostics TerraformJSONDiagnostics + + for _, v := range d { + if v.Severity == tfjson.DiagnosticSeverityError { + tfJSONDiagnostics = append(tfJSONDiagnostics, v) + } + } + + return tfJSONDiagnostics +} + +func (d TerraformJSONDiagnostics) Warnings() TerraformJSONDiagnostics { + var tfJSONDiagnostics TerraformJSONDiagnostics + + for _, v := range d { + if v.Severity == tfjson.DiagnosticSeverityWarning { + tfJSONDiagnostics = append(tfJSONDiagnostics, v) + } + } + + return tfJSONDiagnostics +} + +var _ io.Writer = &TerraformJSONBuffer{} +var _ io.Reader = &TerraformJSONBuffer{} + +// TerraformJSONBuffer is used for storing and processing streaming +// JSON output generated when running terraform commands with the +// `-json` flag. +type TerraformJSONBuffer struct { + buf *bytes.Buffer + diagnostics TerraformJSONDiagnostics + jsonOutput []string + rawOutput string + parsed bool +} + +func NewTerraformJSONBuffer() *TerraformJSONBuffer { + return &TerraformJSONBuffer{ + buf: new(bytes.Buffer), + } +} + +func NewTerraformJSONBufferFromFile(path string) (*TerraformJSONBuffer, error) { + tfJSON := NewTerraformJSONBuffer() + + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("cannot read file: %w", err) + } + + defer file.Close() + + _, err = io.Copy(tfJSON, file) + if err != nil { + return nil, fmt.Errorf("cannot copy file contents to buffer: %w", err) + } + + return tfJSON, nil +} + +func (b *TerraformJSONBuffer) Write(p []byte) (n int, err error) { + if b.buf == nil { + return 0, fmt.Errorf("cannot write to uninitialized buffer, use NewTerraformJSONBuffer") + } + + return b.buf.Write(p) +} + +func (b *TerraformJSONBuffer) Read(p []byte) (n int, err error) { + if b.buf == nil { + return 0, fmt.Errorf("cannot write to uninitialized buffer, use NewTerraformJSONBuffer") + } + + return b.buf.Read(p) +} + +func (b *TerraformJSONBuffer) ReadFile(path string) error { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("cannot read file: %w", err) + } + + defer file.Close() + + _, err = io.Copy(b, file) + if err != nil { + return fmt.Errorf("cannot copy file contents to buffer: %w", err) + } + + return nil +} + +func (b *TerraformJSONBuffer) Parse() error { + if b.buf == nil { + return fmt.Errorf("cannot write to uninitialized buffer, use NewTerraformJSONBuffer") + } + + reader := bufio.NewReader(b.buf) + + for { + line, err := read(reader) + if err != nil { + if err == io.EOF { + break + } + + return fmt.Errorf("cannot read line during Parse: %s", err) + } + + txt := string(line) + + b.rawOutput += "\n" + txt + + var outer struct { + Diagnostic tfjson.Diagnostic + } + + // This will only capture buffer entries that can be unmarshalled + // as JSON. If there are entries in the buffer that are not JSON + // they will be discarded. + if json.Unmarshal([]byte(txt), &outer) == nil { + b.jsonOutput = append(b.jsonOutput, txt) + + if outer.Diagnostic.Severity != "" { + b.diagnostics = append(b.diagnostics, outer.Diagnostic) + } + } + } + + b.parsed = true + + return nil +} + +func (b *TerraformJSONBuffer) Diagnostics() (TerraformJSONDiagnostics, error) { + if !b.parsed { + err := b.Parse() + if err != nil { + return nil, err + } + } + + return b.diagnostics, nil +} + +func (b *TerraformJSONBuffer) JsonOutput() ([]string, error) { + if !b.parsed { + err := b.Parse() + if err != nil { + return nil, err + } + } + + return b.jsonOutput, nil +} + +func (b *TerraformJSONBuffer) RawOutput() (string, error) { + if !b.parsed { + err := b.Parse() + if err != nil { + return "", err + } + } + + return b.rawOutput, nil +} + +func read(r *bufio.Reader) ([]byte, error) { + var ( + isPrefix = true + err error + line, ln []byte + ) + + for isPrefix && err == nil { + line, isPrefix, err = r.ReadLine() + ln = append(ln, line...) + } + + return ln, err +} diff --git a/internal/plugintest/terraform_json_buffer_test.go b/internal/plugintest/terraform_json_buffer_test.go new file mode 100644 index 000000000..a1c8a95a0 --- /dev/null +++ b/internal/plugintest/terraform_json_buffer_test.go @@ -0,0 +1,318 @@ +package plugintest + +import ( + "bufio" + "fmt" + "os" + "regexp" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + tfjson "github.com/hashicorp/terraform-json" +) + +func TestTerraformJSONDiagnostics_Contains(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + diags []tfjson.Diagnostic + regex *regexp.Regexp + severity tfjson.DiagnosticSeverity + expected bool + }{ + "severity-not-found": { + diags: []tfjson.Diagnostic{ + { + Severity: tfjson.DiagnosticSeverityError, + Summary: "error summary", + Detail: "error detail", + }, + }, + regex: regexp.MustCompile("error summary"), + severity: tfjson.DiagnosticSeverityWarning, + expected: false, + }, + "summary-not-found": { + diags: []tfjson.Diagnostic{ + { + Severity: tfjson.DiagnosticSeverityError, + Summary: "warning summary", + Detail: "warning detail", + }, + }, + regex: regexp.MustCompile("error detail"), + severity: tfjson.DiagnosticSeverityError, + expected: false, + }, + "summary-found": { + diags: []tfjson.Diagnostic{ + { + Severity: tfjson.DiagnosticSeverityError, + Summary: "error summary", + Detail: "error detail", + }, + }, + regex: regexp.MustCompile("error summary"), + severity: tfjson.DiagnosticSeverityError, + expected: true, + }, + "detail-found": { + diags: []tfjson.Diagnostic{ + { + Severity: tfjson.DiagnosticSeverityError, + Summary: "error summary", + Detail: "error detail", + }, + }, + regex: regexp.MustCompile("error detail"), + severity: tfjson.DiagnosticSeverityError, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + var tfJSONDiagnostics TerraformJSONDiagnostics = testCase.diags + + isFound := tfJSONDiagnostics.Contains(testCase.regex, testCase.severity) + + if diff := cmp.Diff(isFound, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestTerraformJSONDiagnostics_Errors(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + diags []tfjson.Diagnostic + expected TerraformJSONDiagnostics + }{ + "errors-found": { + diags: []tfjson.Diagnostic{ + { + Severity: tfjson.DiagnosticSeverityError, + Summary: "error 1 summary", + Detail: "error 1 detail", + }, + { + Severity: tfjson.DiagnosticSeverityWarning, + Summary: "warning summary", + Detail: "warning detail", + }, + { + Severity: tfjson.DiagnosticSeverityError, + Summary: "error 2 summary", + Detail: "error 2 detail", + }, + }, + expected: []tfjson.Diagnostic{ + { + Severity: tfjson.DiagnosticSeverityError, + Summary: "error 1 summary", + Detail: "error 1 detail", + }, + { + Severity: tfjson.DiagnosticSeverityError, + Summary: "error 2 summary", + Detail: "error 2 detail", + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + var tfJSONDiagnostics TerraformJSONDiagnostics = testCase.diags + + actual := tfJSONDiagnostics.Errors() + + if diff := cmp.Diff(actual, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestTerraformJSONDiagnostics_Warnings(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + diags []tfjson.Diagnostic + expected TerraformJSONDiagnostics + }{ + "warnings-found": { + diags: []tfjson.Diagnostic{ + { + Severity: tfjson.DiagnosticSeverityError, + Summary: "error 1 summary", + Detail: "error 1 detail", + }, + { + Severity: tfjson.DiagnosticSeverityWarning, + Summary: "warning summary", + Detail: "warning detail", + }, + { + Severity: tfjson.DiagnosticSeverityError, + Summary: "error 2 summary", + Detail: "error 2 detail", + }, + }, + expected: []tfjson.Diagnostic{ + { + Severity: tfjson.DiagnosticSeverityWarning, + Summary: "warning summary", + Detail: "warning detail", + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + var tfJSONDiagnostics TerraformJSONDiagnostics = testCase.diags + + actual := tfJSONDiagnostics.Warnings() + + if diff := cmp.Diff(actual, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestTerraformJSONBuffer_Parse(t *testing.T) { + t.Parallel() + + tfJSON := NewTerraformJSONBuffer() + err := tfJSON.ReadFile("../testdata/terraform-json-output.txt") + if err != nil { + t.Errorf("ReadFile err: %s", err) + } + + entries, err := fileEntries("../testdata/terraform-json-output.txt") + if err != nil { + t.Errorf("fileEntries error: %s", err) + } + + err = tfJSON.Parse() + if err != nil { + t.Errorf("parse error: %s", err) + } + + if diff := cmp.Diff(strings.Split(strings.Trim(tfJSON.rawOutput, "\n"), "\n"), entries); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(tfJSON.jsonOutput, entries); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + var tfJSONDiagnostics TerraformJSONDiagnostics = []tfjson.Diagnostic{ + { + Severity: tfjson.DiagnosticSeverityWarning, + Summary: "Empty or non-existent state", + Detail: "There are currently no remote objects tracked in the state, so there is nothing to refresh.", + }, + { + Severity: tfjson.DiagnosticSeverityError, + Summary: "error diagnostic - summary", + }, + } + + if diff := cmp.Diff(tfJSON.diagnostics, tfJSONDiagnostics); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } +} + +func TestTerraformJSONBuffer_Diagnostics(t *testing.T) { + t.Parallel() + + tfJSON, err := NewTerraformJSONBufferFromFile("../testdata/terraform-json-output.txt") + if err != nil { + t.Errorf("NewTerraformJSONBufferFromFile err: %s", err) + } + + var tfJSONDiagnostics TerraformJSONDiagnostics = []tfjson.Diagnostic{ + { + Severity: tfjson.DiagnosticSeverityWarning, + Summary: "Empty or non-existent state", + Detail: "There are currently no remote objects tracked in the state, so there is nothing to refresh.", + }, + { + Severity: tfjson.DiagnosticSeverityError, + Summary: "error diagnostic - summary", + }, + } + + diags, err := tfJSON.Diagnostics() + if err != nil { + t.Errorf("Diagnostics error: %s", err) + } + + if diff := cmp.Diff(diags, tfJSONDiagnostics); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } +} + +func TestTerraformJSONBuffer_JsonOutput(t *testing.T) { + t.Parallel() + + tfJSON := NewTerraformJSONBuffer() + + err := tfJSON.ReadFile("../testdata/terraform-json-output.txt") + if err != nil { + t.Errorf("ReadFile err: %s", err) + } + + entries, err := fileEntries("../testdata/terraform-json-output.txt") + if err != nil { + t.Errorf("fileEntries error: %s", err) + } + + jsonOutput, err := tfJSON.JsonOutput() + if err != nil { + t.Errorf("unexpected error: %s", err) + } + + if diff := cmp.Diff(jsonOutput, entries); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } +} + +func fileEntries(filePath string) ([]string, error) { + var fileEntries []string + + file, err := os.Open(filePath) + if err != nil { + return fileEntries, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + fileEntries = append(fileEntries, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + return fileEntries, fmt.Errorf("scanner error: %s", err) + } + + return fileEntries, nil +} diff --git a/internal/plugintest/working_dir.go b/internal/plugintest/working_dir.go index 734b6c9b5..4ebd79ac8 100644 --- a/internal/plugintest/working_dir.go +++ b/internal/plugintest/working_dir.go @@ -6,7 +6,9 @@ package plugintest import ( "context" "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" @@ -169,15 +171,65 @@ func (wd *WorkingDir) planFilename() string { return filepath.Join(wd.baseDir, PlanFileName) } -// CreatePlan runs "terraform plan" to create a saved plan file, which if successful -// will then be used for the next call to Apply. -func (wd *WorkingDir) CreatePlan(ctx context.Context) error { +type CreatePlanResponse struct { + Diagnostics []tfjson.Diagnostic +} + +// CreatePlan runs CreatePlanJSON first and will fall back to running terraform plan if an error +// is returned from CreatePlanJSON indicating that the version of Terraform that is being used +// does not support the `-json` flag with "terraform plan". CreatePlan will create a saved plan +// file, which if successful will then be used for the next call to Apply. +func (wd *WorkingDir) CreatePlan(ctx context.Context) (CreatePlanResponse, error) { + tfJSON := NewTerraformJSONBuffer() + createPlanResponse := CreatePlanResponse{} + + err := wd.CreatePlanJSON(ctx, tfJSON) + target := &tfexec.ErrVersionMismatch{} + if !errors.As(err, &target) { + createPlanResponse.Diagnostics, err = tfJSON.Diagnostics() + if err != nil { + return createPlanResponse, err + } + + return createPlanResponse, err + } + logging.HelperResourceTrace(ctx, "Calling Terraform CLI plan command") hasChanges, err := wd.tf.Plan(context.Background(), tfexec.Reattach(wd.reattachInfo), tfexec.Refresh(false), tfexec.Out(PlanFileName)) logging.HelperResourceTrace(ctx, "Called Terraform CLI plan command") + if err != nil { + return createPlanResponse, err + } + + if !hasChanges { + logging.HelperResourceTrace(ctx, "Created plan with no changes") + + return createPlanResponse, nil + } + + stdout, err := wd.SavedPlanRawStdout(ctx) + + if err != nil { + return createPlanResponse, fmt.Errorf("error retrieving formatted plan output: %w", err) + } + + logging.HelperResourceTrace(ctx, "Created plan with changes", map[string]any{logging.KeyTestTerraformPlan: stdout}) + + return createPlanResponse, nil +} + +// CreatePlanJSON runs "terraform plan" with `-json` to create a saved plan file, +// which if successful will then be used for the next call to Apply. +func (wd *WorkingDir) CreatePlanJSON(ctx context.Context, w io.Writer) error { + logging.HelperResourceTrace(ctx, "Calling Terraform CLI plan -json command") + + hasChanges, err := wd.tf.PlanJSON(context.Background(), w, tfexec.Reattach(wd.reattachInfo), tfexec.Refresh(false), tfexec.Out(PlanFileName)) + + logging.HelperResourceTrace(ctx, "Called Terraform CLI plan -json command") + if err != nil { return err } @@ -199,15 +251,65 @@ func (wd *WorkingDir) CreatePlan(ctx context.Context) error { return nil } -// CreateDestroyPlan runs "terraform plan -destroy" to create a saved plan +type CreateDestroyPlanResponse struct { + Diagnostics []tfjson.Diagnostic +} + +// CreateDestroyPlan runs CreateDestroyPlanJSON first and will fall back to running terraform plan if an error +// is returned from ApplyJSON indicating that the version of Terraform that is being used +// does not support the `-json` flag with "terraform plan -destroy". CreateDestroyPlan will create a saved plan // file, which if successful will then be used for the next call to Apply. -func (wd *WorkingDir) CreateDestroyPlan(ctx context.Context) error { +func (wd *WorkingDir) CreateDestroyPlan(ctx context.Context) (CreateDestroyPlanResponse, error) { + tfJSON := NewTerraformJSONBuffer() + createDestroyPlanResponse := CreateDestroyPlanResponse{} + + err := wd.CreateDestroyPlanJSON(ctx, tfJSON) + target := &tfexec.ErrVersionMismatch{} + if !errors.As(err, &target) { + createDestroyPlanResponse.Diagnostics, err = tfJSON.Diagnostics() + if err != nil { + return createDestroyPlanResponse, err + } + + return createDestroyPlanResponse, err + } + logging.HelperResourceTrace(ctx, "Calling Terraform CLI plan -destroy command") hasChanges, err := wd.tf.Plan(context.Background(), tfexec.Reattach(wd.reattachInfo), tfexec.Refresh(false), tfexec.Out(PlanFileName), tfexec.Destroy(true)) logging.HelperResourceTrace(ctx, "Called Terraform CLI plan -destroy command") + if err != nil { + return createDestroyPlanResponse, err + } + + if !hasChanges { + logging.HelperResourceTrace(ctx, "Created destroy plan with no changes") + + return createDestroyPlanResponse, nil + } + + stdout, err := wd.SavedPlanRawStdout(ctx) + + if err != nil { + return createDestroyPlanResponse, fmt.Errorf("error retrieving formatted plan output: %w", err) + } + + logging.HelperResourceTrace(ctx, "Created destroy plan with changes", map[string]any{logging.KeyTestTerraformPlan: stdout}) + + return createDestroyPlanResponse, nil +} + +// CreateDestroyPlanJSON runs "terraform plan -destroy -json" to create a saved plan +// file, which if successful will then be used for the next call to Apply. +func (wd *WorkingDir) CreateDestroyPlanJSON(ctx context.Context, w io.Writer) error { + logging.HelperResourceTrace(ctx, "Calling Terraform CLI plan -destroy -json command") + + hasChanges, err := wd.tf.PlanJSON(context.Background(), w, tfexec.Reattach(wd.reattachInfo), tfexec.Refresh(false), tfexec.Out(PlanFileName), tfexec.Destroy(true)) + + logging.HelperResourceTrace(ctx, "Called Terraform CLI plan -destroy -json command") + if err != nil { return err } @@ -229,11 +331,31 @@ func (wd *WorkingDir) CreateDestroyPlan(ctx context.Context) error { return nil } -// Apply runs "terraform apply". If CreatePlan has previously completed +type ApplyResponse struct { + Diagnostics []tfjson.Diagnostic +} + +// Apply runs ApplyJSON first and will fall back to running terraform apply if an error +// is returned from ApplyJSON indicating that the version of Terraform that is being used +// does not support the `-json` flag with "terraform apply". If CreatePlan has previously completed // successfully and the saved plan has not been cleared in the meantime then // this will apply the saved plan. Otherwise, it will implicitly create a new // plan and apply it. -func (wd *WorkingDir) Apply(ctx context.Context) error { +func (wd *WorkingDir) Apply(ctx context.Context) (ApplyResponse, error) { + tfJSON := NewTerraformJSONBuffer() + applyResponse := ApplyResponse{} + + err := wd.ApplyJSON(ctx, tfJSON) + target := &tfexec.ErrVersionMismatch{} + if !errors.As(err, &target) { + applyResponse.Diagnostics, err = tfJSON.Diagnostics() + if err != nil { + return applyResponse, err + } + + return applyResponse, err + } + args := []tfexec.ApplyOption{tfexec.Reattach(wd.reattachInfo), tfexec.Refresh(false)} if wd.HasSavedPlan() { args = append(args, tfexec.DirOrPlan(PlanFileName)) @@ -241,10 +363,30 @@ func (wd *WorkingDir) Apply(ctx context.Context) error { logging.HelperResourceTrace(ctx, "Calling Terraform CLI apply command") - err := wd.tf.Apply(context.Background(), args...) + err = wd.tf.Apply(context.Background(), args...) logging.HelperResourceTrace(ctx, "Called Terraform CLI apply command") + return applyResponse, err +} + +// ApplyJSON runs "terraform apply" with the `-json` flag. The streaming JSON output +// generated when `terraform apply` is executed is written to `w`. +// If CreatePlan has previously completed successfully and the saved plan has not +// been cleared in the meantime then this will apply the saved plan. Otherwise, +// it will implicitly create a new plan and apply it. +func (wd *WorkingDir) ApplyJSON(ctx context.Context, w io.Writer) error { + args := []tfexec.ApplyOption{tfexec.Reattach(wd.reattachInfo), tfexec.Refresh(false)} + if wd.HasSavedPlan() { + args = append(args, tfexec.DirOrPlan(PlanFileName)) + } + + logging.HelperResourceTrace(ctx, "Calling Terraform CLI apply -json command") + + err := wd.tf.ApplyJSON(context.Background(), w, args...) + + logging.HelperResourceTrace(ctx, "Called Terraform CLI apply -json command") + return err } @@ -346,14 +488,45 @@ func (wd *WorkingDir) Taint(ctx context.Context, address string) error { return err } -// Refresh runs terraform refresh -func (wd *WorkingDir) Refresh(ctx context.Context) error { +type RefreshResponse struct { + Diagnostics []tfjson.Diagnostic +} + +// Refresh runs RefreshJSON first and will fall back to running terraform refresh if an error +// is returned from RefreshJSON indicating that the version of Terraform that is being used +// does not support the `-json` flag. +func (wd *WorkingDir) Refresh(ctx context.Context) (RefreshResponse, error) { + tfJSON := NewTerraformJSONBuffer() + refreshResponse := RefreshResponse{} + + err := wd.RefreshJSON(ctx, tfJSON) + target := &tfexec.ErrVersionMismatch{} + if !errors.As(err, &target) { + refreshResponse.Diagnostics, err = tfJSON.Diagnostics() + if err != nil { + return refreshResponse, err + } + + return refreshResponse, err + } + logging.HelperResourceTrace(ctx, "Calling Terraform CLI refresh command") - err := wd.tf.Refresh(context.Background(), tfexec.Reattach(wd.reattachInfo)) + err = wd.tf.Refresh(context.Background(), tfexec.Reattach(wd.reattachInfo)) logging.HelperResourceTrace(ctx, "Called Terraform CLI refresh command") + return refreshResponse, err +} + +// RefreshJSON runs terraform refresh with `-json` flag +func (wd *WorkingDir) RefreshJSON(ctx context.Context, w io.Writer) error { + logging.HelperResourceTrace(ctx, "Calling Terraform CLI refresh -json command") + + err := wd.tf.RefreshJSON(context.Background(), w, tfexec.Reattach(wd.reattachInfo)) + + logging.HelperResourceTrace(ctx, "Called Terraform CLI refresh -json command") + return err } diff --git a/internal/testdata/terraform-json-output.txt b/internal/testdata/terraform-json-output.txt new file mode 100644 index 000000000..b8dd96bda --- /dev/null +++ b/internal/testdata/terraform-json-output.txt @@ -0,0 +1,6 @@ +{"@level":"info","@message":"Terraform 1.3.2","@module":"terraform.ui","@timestamp":"2023-01-16T17:02:14.232751Z","terraform":"1.3.2","type":"version","ui":"1.0"} +{"@level":"warn","@message":"Warning: Empty or non-existent state","@module":"terraform.ui","@timestamp":"2023-01-16T17:02:14.250725Z","diagnostic":{"severity":"warning","summary":"Empty or non-existent state","detail":"There are currently no remote objects tracked in the state, so there is nothing to refresh."},"type":"diagnostic"} +{"@level":"info","@message":"Outputs: 0","@module":"terraform.ui","@timestamp":"2023-01-16T17:02:14.251120Z","outputs":{},"type":"outputs"} +{"@level":"info","@message":"random_password.test: Plan to create","@module":"terraform.ui","@timestamp":"2023-01-16T17:02:14.282021Z","change":{"resource":{"addr":"random_password.test","module":"","resource":"random_password.test","implied_provider":"random","resource_type":"random_password","resource_name":"test","resource_key":null},"action":"create"},"type":"planned_change"} +{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","@timestamp":"2023-01-16T17:02:14.282054Z","changes":{"add":1,"change":0,"remove":0,"operation":"plan"},"type":"change_summary"} +{"@level":"error","@message":"Error: error diagnostic - summary","@module":"terraform.ui","@timestamp":"2023-01-16T17:02:14.626572Z","diagnostic":{"severity":"error","summary":"error diagnostic - summary","detail":""},"type":"diagnostic"} \ No newline at end of file diff --git a/internal/testing/testplanmodifier/string.go b/internal/testing/testplanmodifier/string.go new file mode 100644 index 000000000..cf26340ce --- /dev/null +++ b/internal/testing/testplanmodifier/string.go @@ -0,0 +1,44 @@ +package testplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +var _ planmodifier.String = &String{} + +// String is declarative planmodifier.String for unit testing. +type String struct { + // String interface methods + DescriptionMethod func(context.Context) string + MarkdownDescriptionMethod func(context.Context) string + PlanModifyStringMethod func(context.Context, planmodifier.StringRequest, *planmodifier.StringResponse) +} + +// Description satisfies the planmodifier.String interface. +func (v String) Description(ctx context.Context) string { + if v.DescriptionMethod == nil { + return "" + } + + return v.DescriptionMethod(ctx) +} + +// MarkdownDescription satisfies the planmodifier.String interface. +func (v String) MarkdownDescription(ctx context.Context) string { + if v.MarkdownDescriptionMethod == nil { + return "" + } + + return v.MarkdownDescriptionMethod(ctx) +} + +// PlanModifyString satisfies the planmodifier.String interface. +func (v String) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + if v.PlanModifyStringMethod == nil { + return + } + + v.PlanModifyStringMethod(ctx, req, resp) +} diff --git a/internal/testing/testprovider/provider.go b/internal/testing/testprovider/provider.go new file mode 100644 index 000000000..f63fe3dbb --- /dev/null +++ b/internal/testing/testprovider/provider.go @@ -0,0 +1,66 @@ +package testprovider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +var _ provider.Provider = &Provider{} + +// Provider is declarative provider.Provider for unit testing. +type Provider struct { + // Provider interface methods + MetadataMethod func(context.Context, provider.MetadataRequest, *provider.MetadataResponse) + ConfigureMethod func(context.Context, provider.ConfigureRequest, *provider.ConfigureResponse) + SchemaMethod func(context.Context, provider.SchemaRequest, *provider.SchemaResponse) + DataSourcesMethod func(context.Context) []func() datasource.DataSource + ResourcesMethod func(context.Context) []func() resource.Resource +} + +// Configure satisfies the provider.Provider interface. +func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + if p == nil || p.ConfigureMethod == nil { + return + } + + p.ConfigureMethod(ctx, req, resp) +} + +// DataSources satisfies the provider.Provider interface. +func (p *Provider) DataSources(ctx context.Context) []func() datasource.DataSource { + if p == nil || p.DataSourcesMethod == nil { + return nil + } + + return p.DataSourcesMethod(ctx) +} + +// Metadata satisfies the provider.Provider interface. +func (p *Provider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + if p == nil || p.MetadataMethod == nil { + return + } + + p.MetadataMethod(ctx, req, resp) +} + +// Schema satisfies the provider.Provider interface. +func (p *Provider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { + if p == nil || p.SchemaMethod == nil { + return + } + + p.SchemaMethod(ctx, req, resp) +} + +// Resources satisfies the provider.Provider interface. +func (p *Provider) Resources(ctx context.Context) []func() resource.Resource { + if p == nil || p.ResourcesMethod == nil { + return nil + } + + return p.ResourcesMethod(ctx) +} diff --git a/internal/testing/testprovider/resource.go b/internal/testing/testprovider/resource.go new file mode 100644 index 000000000..b5bd496d9 --- /dev/null +++ b/internal/testing/testprovider/resource.go @@ -0,0 +1,74 @@ +package testprovider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +var _ resource.Resource = &Resource{} + +// Resource is declarative resource.Resource for unit testing. +type Resource struct { + // Resource interface methods + MetadataMethod func(context.Context, resource.MetadataRequest, *resource.MetadataResponse) + SchemaMethod func(context.Context, resource.SchemaRequest, *resource.SchemaResponse) + CreateMethod func(context.Context, resource.CreateRequest, *resource.CreateResponse) + DeleteMethod func(context.Context, resource.DeleteRequest, *resource.DeleteResponse) + ReadMethod func(context.Context, resource.ReadRequest, *resource.ReadResponse) + UpdateMethod func(context.Context, resource.UpdateRequest, *resource.UpdateResponse) +} + +// Metadata satisfies the resource.Resource interface. +func (r *Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + if r.MetadataMethod == nil { + return + } + + r.MetadataMethod(ctx, req, resp) +} + +// Schema satisfies the resource.Resource interface. +func (r *Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + if r.SchemaMethod == nil { + return + } + + r.SchemaMethod(ctx, req, resp) +} + +// Create satisfies the resource.Resource interface. +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + if r.CreateMethod == nil { + return + } + + r.CreateMethod(ctx, req, resp) +} + +// Delete satisfies the resource.Resource interface. +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + if r.DeleteMethod == nil { + return + } + + r.DeleteMethod(ctx, req, resp) +} + +// Read satisfies the resource.Resource interface. +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + if r.ReadMethod == nil { + return + } + + r.ReadMethod(ctx, req, resp) +} + +// Update satisfies the resource.Resource interface. +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + if r.UpdateMethod == nil { + return + } + + r.UpdateMethod(ctx, req, resp) +} diff --git a/internal/testing/testprovider/resourcewithmodifyplan.go b/internal/testing/testprovider/resourcewithmodifyplan.go new file mode 100644 index 000000000..0162a5694 --- /dev/null +++ b/internal/testing/testprovider/resourcewithmodifyplan.go @@ -0,0 +1,27 @@ +package testprovider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +var _ resource.Resource = &ResourceWithModifyPlan{} +var _ resource.ResourceWithModifyPlan = &ResourceWithModifyPlan{} + +// ResourceWithModifyPlan is a declarative resource.ResourceWithModifyPlan for unit testing. +type ResourceWithModifyPlan struct { + *Resource + + // ResourceWithModifyPlan interface methods + ModifyPlanMethod func(context.Context, resource.ModifyPlanRequest, *resource.ModifyPlanResponse) +} + +// ModifyPlan satisfies the resource.ResourceWithModifyPlan interface. +func (p *ResourceWithModifyPlan) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if p.ModifyPlanMethod == nil { + return + } + + p.ModifyPlanMethod(ctx, req, resp) +}