diff --git a/go.mod b/go.mod index 43f7be2e..f82f0a06 100644 --- a/go.mod +++ b/go.mod @@ -13,8 +13,8 @@ require ( github.com/hashicorp/hc-install v0.9.2 github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/logutils v1.0.0 - github.com/hashicorp/terraform-exec v0.23.1-0.20250717072919-061a850a52d2 - github.com/hashicorp/terraform-json v0.26.0 + github.com/hashicorp/terraform-exec v0.24.0 + github.com/hashicorp/terraform-json v0.27.2 github.com/hashicorp/terraform-plugin-go v0.29.0-beta.1 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 diff --git a/go.sum b/go.sum index e6c3bda1..9a1d32d1 100644 --- a/go.sum +++ b/go.sum @@ -76,10 +76,10 @@ github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQx github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= 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.23.1-0.20250717072919-061a850a52d2 h1:90fcAqw0Qmv4vY7zL4jEKgKarHmOnNN6SjTY68eLKGA= -github.com/hashicorp/terraform-exec v0.23.1-0.20250717072919-061a850a52d2/go.mod h1:8D3RLLpzAZdhT9jvALYz1KHyGU4OvI73I1o0+01QJxA= -github.com/hashicorp/terraform-json v0.26.0 h1:+BnJavhRH+oyNWPnfzrfQwVWCZBFMvjdiH2Vi38Udz4= -github.com/hashicorp/terraform-json v0.26.0/go.mod h1:eyWCeC3nrZamyrKLFnrvwpc3LQPIJsx8hWHQ/nu2/v4= +github.com/hashicorp/terraform-exec v0.24.0 h1:mL0xlk9H5g2bn0pPF6JQZk5YlByqSqrO5VoaNtAf8OE= +github.com/hashicorp/terraform-exec v0.24.0/go.mod h1:lluc/rDYfAhYdslLJQg3J0oDqo88oGQAdHR+wDqFvo4= +github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU= +github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE= github.com/hashicorp/terraform-plugin-go v0.29.0-beta.1 h1:xeHlRQYev3iMXwX2W7+D1bSfLRBs9jojZXqE6hmNxMI= github.com/hashicorp/terraform-plugin-go v0.29.0-beta.1/go.mod h1:5pww/UULn9C2tItq6o5sbScEkJxBUt9X9kI4DkeRsIw= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= diff --git a/helper/resource/query/examplecloud_list_test.go b/helper/resource/query/examplecloud_list_test.go new file mode 100644 index 00000000..7b18943e --- /dev/null +++ b/helper/resource/query/examplecloud_list_test.go @@ -0,0 +1,91 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package query_test + +import ( + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/list" + "github.com/hashicorp/terraform-plugin-testing/internal/teststep" +) + +func examplecloudListResource() testprovider.ListResource { + return testprovider.ListResource{ + IncludeResource: true, + SchemaResponse: &list.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "id", + Type: tftypes.String, + Computed: true, + }, + }, + }, + }, + }, + ListResultsStream: &list.ListResultsStream{ + Results: func(push func(list.ListResult) bool) { + push(list.ListResult{ + Resource: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "location": tftypes.String, + "name": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "westeurope/somevalue"), + "location": tftypes.NewValue(tftypes.String, "westeurope"), + "name": tftypes.NewValue(tftypes.String, "somevalue"), + }, + )), + Identity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "location": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "westeurope/somevalue1"), + "location": tftypes.NewValue(tftypes.String, "westeurope"), + }, + )), + }) + push(list.ListResult{ + Identity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "location": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "westeurope/somevalue2"), + "location": tftypes.NewValue(tftypes.String, "westeurope2"), + }, + )), + }) + push(list.ListResult{ + Identity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "location": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "westeurope/somevalue3"), + "location": tftypes.NewValue(tftypes.String, "westeurope3"), + }, + )), + }) + }, + }, + } +} diff --git a/helper/resource/query/examplecloud_test.go b/helper/resource/query/examplecloud_test.go index 1cae8e95..3ad86c16 100644 --- a/helper/resource/query/examplecloud_test.go +++ b/helper/resource/query/examplecloud_test.go @@ -31,11 +31,13 @@ func examplecloudResource() testprovider.Resource { NewIdentity: teststep.Pointer(tftypes.NewValue( tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ - "id": tftypes.String, + "id": tftypes.String, + "location": tftypes.String, }, }, map[string]tftypes.Value{ - "id": tftypes.NewValue(tftypes.String, "westeurope/somevalue"), + "id": tftypes.NewValue(tftypes.String, "westeurope/somevalue"), + "location": tftypes.NewValue(tftypes.String, "somelocation"), }, )), }, @@ -57,11 +59,13 @@ func examplecloudResource() testprovider.Resource { NewIdentity: teststep.Pointer(tftypes.NewValue( tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ - "id": tftypes.String, + "id": tftypes.String, + "location": tftypes.String, }, }, map[string]tftypes.Value{ - "id": tftypes.NewValue(tftypes.String, "westeurope/somevalue"), + "id": tftypes.NewValue(tftypes.String, "westeurope/somevalue"), + "location": tftypes.NewValue(tftypes.String, "westeurope"), }, )), }, @@ -83,11 +87,13 @@ func examplecloudResource() testprovider.Resource { Identity: teststep.Pointer(tftypes.NewValue( tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ - "id": tftypes.String, + "id": tftypes.String, + "location": tftypes.String, }, }, map[string]tftypes.Value{ - "id": tftypes.NewValue(tftypes.String, "westeurope/somevalue"), + "id": tftypes.NewValue(tftypes.String, "westeurope/somevalue"), + "location": tftypes.NewValue(tftypes.String, "westeurope/somevalue"), }, )), }, @@ -111,6 +117,11 @@ func examplecloudResource() testprovider.Resource { Type: tftypes.String, RequiredForImport: true, }, + { + Name: "location", + Type: tftypes.String, + RequiredForImport: true, + }, }, }, }, diff --git a/helper/resource/query/query_checks.go b/helper/resource/query/query_checks.go new file mode 100644 index 00000000..8ef9a3e0 --- /dev/null +++ b/helper/resource/query/query_checks.go @@ -0,0 +1,52 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package query + +import ( + "context" + "errors" + "fmt" + + tfjson "github.com/hashicorp/terraform-json" + "github.com/mitchellh/go-testing-interface" + + "github.com/hashicorp/terraform-plugin-testing/querycheck" +) + +func RunQueryChecks(ctx context.Context, t testing.T, query []tfjson.LogMsg, queryChecks []querycheck.QueryResultCheck) error { + t.Helper() + + var result []error + + if query == nil { + result = append(result, fmt.Errorf("no query results found")) + } + + found := make([]tfjson.ListResourceFoundData, 0) + summary := tfjson.ListCompleteData{} + + for _, msg := range query { + switch v := msg.(type) { + case tfjson.ListResourceFoundMessage: + found = append(found, v.ListResourceFound) + case tfjson.ListCompleteMessage: + summary = v.ListComplete + // TODO diagnostics and errors? + default: + continue + } + } + + for _, queryCheck := range queryChecks { + resp := querycheck.CheckQueryResponse{} + queryCheck.CheckQuery(ctx, querycheck.CheckQueryRequest{ + Query: found, + QuerySummary: &summary, + }, &resp) + + result = append(result, resp.Error) + } + + return errors.Join(result...) +} diff --git a/helper/resource/query/query_checks_test.go b/helper/resource/query/query_checks_test.go new file mode 100644 index 00000000..2c17be8d --- /dev/null +++ b/helper/resource/query/query_checks_test.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package query + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-testing/querycheck" +) + +var _ querycheck.QueryResultCheck = &queryCheckSpy{} + +type queryCheckSpy struct { + err error + called bool +} + +func (s *queryCheckSpy) CheckQuery(ctx context.Context, req querycheck.CheckQueryRequest, resp *querycheck.CheckQueryResponse) { + s.called = true + resp.Error = s.err +} diff --git a/helper/resource/query/query_test.go b/helper/resource/query/query_test.go index 65011c50..1fa76381 100644 --- a/helper/resource/query/query_test.go +++ b/helper/resource/query/query_test.go @@ -9,28 +9,15 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov6" r "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" - "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/list" "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/providerserver" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/querycheck" "github.com/hashicorp/terraform-plugin-testing/tfversion" ) func TestQuery(t *testing.T) { t.Parallel() - // --- FAIL: TestQuery (0.16s) - // query_test.go:20: Step 1/1 error running query: error running terraform query command: exit status 1 - - // Error: Inconsistent dependency lock file - - // The following dependency selections recorded in the lock file are - // inconsistent with the current configuration: - // - provider registry.terraform.io/hashicorp/examplecloud: required by this configuration but no version is selected - - // To make the initial dependency selections that will initialize the dependency - // lock file, run: - // terraform init - t.Skip() - r.UnitTest(t, r.TestCase{ TerraformVersionChecks: []tfversion.TerraformVersionCheck{ tfversion.SkipBelow(tfversion.Version1_14_0), @@ -38,21 +25,7 @@ func TestQuery(t *testing.T) { ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ ListResources: map[string]testprovider.ListResource{ - "examplecloud_containerette": { - SchemaResponse: &list.SchemaResponse{ - Schema: &tfprotov6.Schema{ - Block: &tfprotov6.SchemaBlock{ - Attributes: []*tfprotov6.SchemaAttribute{ - ComputedStringAttribute("id"), - }, - }, - }, - }, - ListResultsStream: &list.ListResultsStream{ - Results: func(push func(list.ListResult) bool) { - }, - }, - }, + "examplecloud_containerette": examplecloudListResource(), }, Resources: map[string]testprovider.Resource{ "examplecloud_containerette": examplecloudResource(), @@ -60,17 +33,111 @@ func TestQuery(t *testing.T) { }), }, Steps: []r.TestStep{ + { // config mode step 1 needs tf file with terraform providers block + Config: ` + resource "examplecloud_containerette" "primary" { + id = "westeurope/somevalue" + location = "westeurope" + name = "somevalue" + }`, + }, + { // Query mode step 2, operates on .tfquery.hcl files (needs tf file with terraform providers block) + // ```provider "examplecloud" {}``` has a slightly different syntax for a .tfquery.hcl file + // provider bock simulates a real providers workflow + // "config" in this case means configuration of the list resource/filters + + Query: true, + Config: ` + provider "examplecloud" {} + list "examplecloud_containerette" "test" { + provider = examplecloud + + config { + id = "westeurope/somevalue" + } + } + list "examplecloud_containerette" "test2" { + provider = examplecloud + + config { + id = "foo" + } + } + `, + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectIdentity("examplecloud_containerette.test", map[string]knownvalue.Check{ + "id": knownvalue.StringExact("westeurope/somevalue1"), + "location": knownvalue.StringExact("westeurope"), + }), + querycheck.ExpectIdentity("examplecloud_containerette.test", map[string]knownvalue.Check{ + "id": knownvalue.StringExact("westeurope/somevalue2"), + "location": knownvalue.StringExact("westeurope2"), + }), + querycheck.ExpectIdentity("examplecloud_containerette.test", map[string]knownvalue.Check{ + "id": knownvalue.StringExact("westeurope/somevalue3"), + "location": knownvalue.StringExact("westeurope3"), + }), + querycheck.ExpectIdentity("examplecloud_containerette.test2", map[string]knownvalue.Check{ + "id": knownvalue.StringExact("westeurope/somevalue1"), + "location": knownvalue.StringExact("westeurope"), + }), + querycheck.ExpectIdentity("examplecloud_containerette.test2", map[string]knownvalue.Check{ + "id": knownvalue.StringExact("westeurope/somevalue2"), + "location": knownvalue.StringExact("westeurope2"), + }), + querycheck.ExpectIdentity("examplecloud_containerette.test2", map[string]knownvalue.Check{ + "id": knownvalue.StringExact("westeurope/somevalue3"), + "location": knownvalue.StringExact("westeurope3"), + }), + }, + }, { Query: true, Config: ` - provider "examplecloud" {} + provider "examplecloud" {} list "examplecloud_containerette" "test" { provider = examplecloud config { - id = "bat" + id = "westeurope/somevalue" + } + } + list "examplecloud_containerette" "test2" { + provider = examplecloud + + config { + id = "foo" } - }`, + } + `, + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectLength("examplecloud_containerette.test", 3), + querycheck.ExpectLength("examplecloud_containerette.test2", 3), + }, + }, + { + Query: true, + Config: ` + provider "examplecloud" {} + list "examplecloud_containerette" "test" { + provider = examplecloud + + config { + id = "westeurope/somevalue" + } + } + list "examplecloud_containerette" "test2" { + provider = examplecloud + + config { + id = "foo" + } + } + `, + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectLengthAtLeast("examplecloud_containerette.test", 2), + querycheck.ExpectLengthAtLeast("examplecloud_containerette.test2", 1), + }, }, }, }) diff --git a/helper/resource/testing.go b/helper/resource/testing.go index fda33bf7..61c03ff7 100644 --- a/helper/resource/testing.go +++ b/helper/resource/testing.go @@ -15,6 +15,8 @@ import ( "strings" "time" + "github.com/hashicorp/terraform-plugin-testing/querycheck" + "github.com/mitchellh/go-testing-interface" "github.com/hashicorp/terraform-plugin-go/tfprotov5" @@ -640,6 +642,10 @@ type TestStep struct { // Custom state checks can be created by implementing the [statecheck.StateCheck] interface, or by using a StateCheck implementation from the provided [statecheck] package. ConfigStateChecks []statecheck.StateCheck + // QueryResultChecks allow assertions to be made against a collection of found resources that were returned by a query using a query check. + // Custom query checks can be created by implementing the [querycheck.QueryResultCheck] interface, or by using a QueryResultCheck implementation from the provided [querycheck] package. + QueryResultChecks []querycheck.QueryResultCheck + // 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 63c03ca6..c94a97c0 100644 --- a/helper/resource/testing_new.go +++ b/helper/resource/testing_new.go @@ -15,6 +15,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/go-version" tfjson "github.com/hashicorp/terraform-json" + "github.com/hashicorp/terraform-plugin-testing/helper/resource/query" "github.com/mitchellh/go-testing-interface" "github.com/hashicorp/terraform-plugin-testing/config" @@ -376,7 +377,7 @@ func runNewTest(ctx context.Context, t testing.T, c TestCase, helper *plugintest t.Fatalf("Step %d/%d error running init: %s", stepNumber, len(c.Steps), err) } - var queryOut []string + var queryOut []tfjson.LogMsg err = runProviderCommand(ctx, t, wd, providers, func() error { var err error queryOut, err = wd.Query(ctx) @@ -387,6 +388,11 @@ func runNewTest(ctx context.Context, t testing.T, c TestCase, helper *plugintest t.Fatalf("Step %d/%d error running query: %s", stepNumber, len(c.Steps), err) } + err = query.RunQueryChecks(ctx, t, queryOut, step.QueryResultChecks) + if err != nil { + t.Fatalf("Step %d/%d error running query checks: %s", stepNumber, len(c.Steps), err) + } + fmt.Printf("Step %d/%d Query Output:\n%s\n", stepNumber, len(c.Steps), queryOut) continue } @@ -681,7 +687,7 @@ func copyWorkingDir(ctx context.Context, t testing.T, stepNumber int, wd *plugin dest := filepath.Join(workingDir, fmt.Sprintf("%s%s", "step_", strconv.Itoa(stepNumber))) baseDir := wd.BaseDir() - rootBaseDir := strings.TrimLeft(baseDir, workingDir) + rootBaseDir := strings.TrimPrefix(baseDir, workingDir) err := plugintest.CopyDir(workingDir, dest, rootBaseDir) if err != nil { diff --git a/internal/plugintest/working_dir.go b/internal/plugintest/working_dir.go index f7020d43..6494125f 100644 --- a/internal/plugintest/working_dir.go +++ b/internal/plugintest/working_dir.go @@ -4,16 +4,14 @@ package plugintest import ( - "bytes" "context" "fmt" + "github.com/hashicorp/terraform-exec/tfexec" + tfjson "github.com/hashicorp/terraform-json" "io" "os" "path/filepath" - "github.com/hashicorp/terraform-exec/tfexec" - tfjson "github.com/hashicorp/terraform-json" - "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/internal/logging" "github.com/hashicorp/terraform-plugin-testing/internal/teststep" @@ -525,20 +523,42 @@ func (wd *WorkingDir) Schemas(ctx context.Context) (*tfjson.ProviderSchemas, err return providerSchemas, err } -func (wd *WorkingDir) Query(ctx context.Context) ([]string, error) { +func (wd *WorkingDir) Query(ctx context.Context) ([]tfjson.LogMsg, error) { + var messages []tfjson.LogMsg + var diags []tfjson.LogMsg + logging.HelperResourceTrace(ctx, "Calling Terraform CLI providers query command") - // Query the provider using the Terraform CLI function - var buffer bytes.Buffer - err := wd.tf.QueryJSON(context.Background(), &buffer) + args := []tfexec.QueryOption{tfexec.Reattach(wd.reattachInfo)} + + logs, err := wd.tf.QueryJSON(context.Background(), args...) if err != nil { - return nil, fmt.Errorf("error running terraform query command: %w", err) + return nil, fmt.Errorf("running terraform query command: %w", err) } - logging.HelperResourceTrace(ctx, "Called Terraform CLI providers query command") + for msg := range logs { + if msg.Msg == nil { + continue + } - output := buffer.String() + if msg.Err != nil { + return nil, fmt.Errorf("retrieving message: %w", msg.Err) + } + + if msg.Msg.Level() == tfjson.Error { + // TODO reimplement missing .tf config error + diags = append(diags, msg.Msg) + continue + } + messages = append(messages, msg.Msg) + } + + if len(diags) > 0 { + return nil, fmt.Errorf("running terraform query command returned diagnostics: %+v", diags) + } + + logging.HelperResourceTrace(ctx, "Called Terraform CLI providers query command") - return []string{output}, nil + return messages, nil } diff --git a/internal/testing/testprovider/list_resource.go b/internal/testing/testprovider/list_resource.go index f57c95a4..630723b8 100644 --- a/internal/testing/testprovider/list_resource.go +++ b/internal/testing/testprovider/list_resource.go @@ -12,6 +12,7 @@ import ( var _ list.ListResource = ListResource{} type ListResource struct { + IncludeResource bool SchemaResponse *list.SchemaResponse ListResultsStream *list.ListResultsStream ValidateListConfigResponse *list.ValidateListConfigResponse @@ -24,5 +25,6 @@ func (r ListResource) Schema(ctx context.Context, req list.SchemaRequest, resp * } } func (r ListResource) List(ctx context.Context, req list.ListRequest, stream *list.ListResultsStream) { + req.IncludeResource = r.IncludeResource stream.Results = r.ListResultsStream.Results } diff --git a/internal/testing/testsdk/list/list_resource.go b/internal/testing/testsdk/list/list_resource.go index fb5d7baf..1660d342 100644 --- a/internal/testing/testsdk/list/list_resource.go +++ b/internal/testing/testsdk/list/list_resource.go @@ -8,6 +8,7 @@ import ( "iter" "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" ) type ListResource interface { @@ -16,6 +17,21 @@ type ListResource interface { } type ListRequest struct { + TypeName string + // Config is the configuration the user supplied for listing resource + // instances. + Config tftypes.Value + + // IncludeResource indicates whether the provider should populate the + // [ListResult.Resource] field. + IncludeResource bool + + // Limit specifies the maximum number of results that Terraform is + // expecting. + Limit int64 + + ResourceSchema *tfprotov6.Schema + ResourceIdentitySchema *tfprotov6.ResourceIdentitySchema } type ListResultsStream struct { @@ -23,6 +39,10 @@ type ListResultsStream struct { } type ListResult struct { + DisplayName string + Identity *tftypes.Value + Resource *tftypes.Value + Diagnostics []*tfprotov6.Diagnostic } type ValidateListConfigResponse struct { diff --git a/internal/testing/testsdk/providerserver/providerserver.go b/internal/testing/testsdk/providerserver/providerserver.go index a55b5c5c..0f14d0da 100644 --- a/internal/testing/testsdk/providerserver/providerserver.go +++ b/internal/testing/testsdk/providerserver/providerserver.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "iter" "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -1040,45 +1041,138 @@ func (s ProviderServer) ValidateEphemeralResourceConfig(ctx context.Context, req } func (s ProviderServer) ListResource(ctx context.Context, req *tfprotov6.ListResourceRequest) (*tfprotov6.ListResourceServerStream, error) { - resp := &tfprotov6.ListResourceServerStream{} + resultStream := &tfprotov6.ListResourceServerStream{} + respStream := &list.ListResultsStream{} // Copy over identity if it's supported identitySchemaReq := resource.IdentitySchemaRequest{} identitySchemaResp := &resource.IdentitySchemaResponse{} - r, _ := ProviderResource(s.Provider, req.TypeName) - // TODO: diag + r, err := ProviderResource(s.Provider, req.TypeName) + if err != nil { + return nil, fmt.Errorf("failed to retrieve resource: %v", err) + } r.IdentitySchema(ctx, identitySchemaReq, identitySchemaResp) + if len(identitySchemaResp.Diagnostics) > 0 { + return nil, fmt.Errorf("failed to retrieve resource schema: %v", identitySchemaResp.Diagnostics) + } - results := func(push func(tfprotov6.ListResourceResult) bool) { - _, diag := ProviderListResource(s.Provider, req.TypeName) - if diag != nil { - push(tfprotov6.ListResourceResult{Diagnostics: []*tfprotov6.Diagnostic{diag}}) - return - } + listresource, diag := ProviderListResource(s.Provider, req.TypeName) + if diag != nil { + return nil, fmt.Errorf("failed to retrieve resource identity schema: %v", err) + } - identityData := tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "id": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "id": tftypes.NewValue(tftypes.String, "westeurope/somevalue"), - }, - ) - identity, _ := IdentityValuetoDynamicValue(identitySchemaResp.Schema, identityData) // TODO: diag - push(tfprotov6.ListResourceResult{ - Identity: &tfprotov6.ResourceIdentityData{ - IdentityData: identity, - }, - }) + schemaReq := list.SchemaRequest{} + schemaResp := &list.SchemaResponse{} + + listresource.Schema(ctx, schemaReq, schemaResp) + if len(schemaResp.Diagnostics) > 0 { + return nil, fmt.Errorf("failed to retrieve resource schema: %v", schemaResp.Diagnostics) } - resp.Results = results - return resp, nil + listReq := list.ListRequest{ + TypeName: req.TypeName, + IncludeResource: req.IncludeResource, + Limit: req.Limit, + ResourceSchema: schemaResp.Schema, + } + + listReq.Config, diag = DynamicValueToValue(schemaResp.Schema, req.Config) + if diag != nil { + return nil, fmt.Errorf("failed to convert config to value: %v", err) + } + + if identitySchemaResp.Schema != nil { + listReq.ResourceIdentitySchema = identitySchemaResp.Schema + } + + listresource.List(ctx, listReq, respStream) + + // If the provider returned a nil results stream, we return an empty stream. + if respStream.Results == nil { + resultStream.Results = func(push func(result tfprotov6.ListResourceResult) bool) {} + } + + resultStream.Results = processListResults(listReq, respStream.Results) + return resultStream, nil } func (s ProviderServer) ValidateListResourceConfig(ctx context.Context, req *tfprotov6.ValidateListResourceConfigRequest) (*tfprotov6.ValidateListResourceConfigResponse, error) { return &tfprotov6.ValidateListResourceConfigResponse{}, nil } + +func processListResults(req list.ListRequest, stream iter.Seq[list.ListResult]) iter.Seq[tfprotov6.ListResourceResult] { + return func(push func(tfprotov6.ListResourceResult) bool) { + for result := range stream { + if !push(processListResult(req, result)) { + return + } + } + } +} + +// processListResult validates the content of a list.ListResult and returns a +// ListResult +func processListResult(req list.ListRequest, result list.ListResult) tfprotov6.ListResourceResult { + var listResourceResult tfprotov6.ListResourceResult + listResourceResult.Diagnostics = []*tfprotov6.Diagnostic{} + var diag *tfprotov6.Diagnostic + + // Allow any non-error diags to pass through + if len(result.Diagnostics) > 0 && result.DisplayName == "" && result.Identity == nil && result.Resource == nil { + return tfprotov6.ListResourceResult{ + Diagnostics: result.Diagnostics, + } + } + + if result.Diagnostics != nil { + return tfprotov6.ListResourceResult{ + Diagnostics: result.Diagnostics, + } + } + + if result.Identity == nil { + return tfprotov6.ListResourceResult{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Incomplete List Result", + Detail: "When listing resources, an implementation issue was found. " + + "This is always a problem with the provider. Please report this to the provider developers.\n\n" + + "The \"Identity\" field is nil.\n\n", + }, + }, + } + } + + if req.IncludeResource { + if result.Resource == nil { + listResourceResult.Diagnostics = append(listResourceResult.Diagnostics, &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "Incomplete List Result", + Detail: "When listing resources, an implementation issue was found. " + + "This is always a problem with the provider. Please report this to the provider developers.\n\n" + + "The \"IncludeResource\" field in the ListRequest is true, but the \"Resource\" field in the ListResult is nil.\n\n", + }) + } + + listResourceResult.Resource, diag = ValuetoDynamicValue(req.ResourceSchema, *result.Resource) + listResourceResult.Diagnostics = append(listResourceResult.Diagnostics, diag) + return listResourceResult + + } + listResourceResult.Identity = &tfprotov6.ResourceIdentityData{} + + if result.Identity != nil { + listResourceResult.Identity.IdentityData, diag = IdentityValuetoDynamicValue(req.ResourceIdentitySchema, *result.Identity) + if diag != nil { + listResourceResult.Diagnostics = append(listResourceResult.Diagnostics, diag) + return listResourceResult + } + } + + listResourceResult.DisplayName = result.DisplayName + + return listResourceResult + +} diff --git a/internal/testing/testsdk/resource/resource.go b/internal/testing/testsdk/resource/resource.go index dc6412d5..2a64e323 100644 --- a/internal/testing/testsdk/resource/resource.go +++ b/internal/testing/testsdk/resource/resource.go @@ -31,6 +31,7 @@ type CreateResponse struct { Diagnostics []*tfprotov6.Diagnostic NewState tftypes.Value NewIdentity *tftypes.Value + NewQuery tftypes.Value } type DeleteRequest struct { @@ -83,6 +84,7 @@ type ReadResponse struct { Diagnostics []*tfprotov6.Diagnostic NewState tftypes.Value NewIdentity *tftypes.Value + NewQuery tftypes.Value } type SchemaRequest struct{} diff --git a/querycheck/contains.go b/querycheck/contains.go new file mode 100644 index 00000000..6cef5701 --- /dev/null +++ b/querycheck/contains.go @@ -0,0 +1,38 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package querycheck + +import ( + "context" + "fmt" + "strings" +) + +var _ QueryResultCheck = contains{} + +type contains struct { + resourceAddress string + check string +} + +func (c contains) CheckQuery(_ context.Context, req CheckQueryRequest, resp *CheckQueryResponse) { + for _, res := range req.Query { + if strings.EqualFold(c.check, res.DisplayName) { + return + } + } + + resp.Error = fmt.Errorf("expected to find resource with display name %q in results but resource was not found", c.check) + +} + +// ContainsResourceWithName returns a query check that asserts that a resource with a given display name exists within the returned results of the query. +// +// This query check can only be used with managed resources that support query. Query is only supported in Terraform v1.14+ +func ContainsResourceWithName(resourceAddress string, displayName string) QueryResultCheck { + return contains{ + resourceAddress: resourceAddress, + check: displayName, + } +} diff --git a/querycheck/doc.go b/querycheck/doc.go new file mode 100644 index 00000000..67aa0ced --- /dev/null +++ b/querycheck/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package querycheck contains the query check interface, request/response structs, and common query check implementations. +package querycheck diff --git a/querycheck/expect_identity.go b/querycheck/expect_identity.go new file mode 100644 index 00000000..2d8777f7 --- /dev/null +++ b/querycheck/expect_identity.go @@ -0,0 +1,93 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package querycheck + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" +) + +var _ QueryResultCheck = expectIdentity{} + +type expectIdentity struct { + listResourceAddress string + check map[string]knownvalue.Check +} + +// CheckQuery implements the query check logic. +func (e expectIdentity) CheckQuery(_ context.Context, req CheckQueryRequest, resp *CheckQueryResponse) { + for _, res := range req.Query { + var errCollection []error + + if e.listResourceAddress != strings.TrimPrefix(res.Address, "list.") { + continue + } + + if len(res.Identity) != len(e.check) { + deltaMsg := "" + if len(res.Identity) > len(e.check) { + deltaMsg = statecheck.CreateDeltaString(res.Identity, e.check, "actual identity has extra attribute(s): ") + } else { + deltaMsg = statecheck.CreateDeltaString(e.check, res.Identity, "actual identity is missing attribute(s): ") + } + + resp.Error = fmt.Errorf("%s - Expected %d attribute(s) in the actual identity object, got %d attribute(s): %s", e.listResourceAddress, len(e.check), len(res.Identity), deltaMsg) + return + } + + var keys []string + + for k := range e.check { + keys = append(keys, k) + } + + sort.SliceStable(keys, func(i, j int) bool { + return keys[i] < keys[j] + }) + + for _, k := range keys { + actualIdentityVal, ok := res.Identity[k] + + if !ok { + resp.Error = fmt.Errorf("%s - missing attribute %q in actual identity object", e.listResourceAddress, k) + return + } + + if err := e.check[k].CheckValue(actualIdentityVal); err != nil { + errCollection = append(errCollection, fmt.Errorf("%s - %q identity attribute: %s", e.listResourceAddress, k, err)) + } + } + + if errCollection == nil { + return + } + } + + var errCollection []error + errCollection = append(errCollection, fmt.Errorf("an identity with the following attributes was not found")) + + // wrap errors for each check + for attr, check := range e.check { + errCollection = append(errCollection, fmt.Errorf("attribute %q: %s", attr, check)) + } + errCollection = append(errCollection, fmt.Errorf("address: %s\n", e.listResourceAddress)) + resp.Error = errors.Join(errCollection...) +} + +// ExpectIdentity returns a query check that asserts that the identity at the given resource matches a known object, where each +// map key represents an identity attribute name. The identity in query must exactly match the given object. +// +// This query check can only be used with managed resources that support resource identity and query. Query is only supported in Terraform v1.14+ +func ExpectIdentity(resourceAddress string, identity map[string]knownvalue.Check) QueryResultCheck { + return expectIdentity{ + listResourceAddress: resourceAddress, + check: identity, + } +} diff --git a/querycheck/expect_known_value.go b/querycheck/expect_known_value.go new file mode 100644 index 00000000..faa046eb --- /dev/null +++ b/querycheck/expect_known_value.go @@ -0,0 +1,74 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package querycheck + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +var _ QueryResultCheck = expectKnownValue{} + +type expectKnownValue struct { + listResourceAddress string + resourceName string + attributePath tfjsonpath.Path + knownValue knownvalue.Check +} + +func (e expectKnownValue) CheckQuery(_ context.Context, req CheckQueryRequest, resp *CheckQueryResponse) { + for _, res := range req.Query { + var diags []error + + if e.listResourceAddress == strings.TrimPrefix(res.Address, "list.") && e.resourceName == res.DisplayName { + if res.ResourceObject == nil { + resp.Error = fmt.Errorf("%s - no resource object was returned, ensure `include_resource` has been set to `true` in the list resource config`", e.listResourceAddress) + return + } + + resource, err := tfjsonpath.Traverse(res.ResourceObject, e.attributePath) + if err != nil { + resp.Error = err + return + } + + if err := e.knownValue.CheckValue(resource); err != nil { + diags = append(diags, fmt.Errorf("error checking value for attribute at path: %s for resource %s, err: %s", e.attributePath.String(), e.resourceName, err)) + } + + if diags == nil { + return + } + } + + if diags != nil { + var diagsStr string + for _, diag := range diags { + diagsStr += diag.Error() + "; " + } + resp.Error = fmt.Errorf("the following errors were found while checking values: %s", diagsStr) + return + } + } + + resp.Error = fmt.Errorf("%s - the resource %s was not found", e.listResourceAddress, e.resourceName) +} + +// ExpectKnownValue returns a query check that asserts the specified attribute values are present for a given resource object +// returned by a list query. The resource object can only be identified by providing the list resource address as well as the +// resource name (display name). +// +// This query check can only be used with managed resources that support resource identity and query. Query is only supported in Terraform v1.14+ +func ExpectKnownValue(listResourceAddress string, resourceName string, attributePath tfjsonpath.Path, knownValue knownvalue.Check) QueryResultCheck { + return expectKnownValue{ + listResourceAddress: listResourceAddress, + resourceName: resourceName, + attributePath: attributePath, + knownValue: knownValue, + } +} diff --git a/querycheck/expect_result_length_atleast.go b/querycheck/expect_result_length_atleast.go new file mode 100644 index 00000000..6b3dbde8 --- /dev/null +++ b/querycheck/expect_result_length_atleast.go @@ -0,0 +1,39 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package querycheck + +import ( + "context" + "fmt" +) + +var _ QueryResultCheck = expectLengthAtLeast{} + +type expectLengthAtLeast struct { + resourceAddress string + check int +} + +// CheckQuery implements the query check logic. +func (e expectLengthAtLeast) CheckQuery(_ context.Context, req CheckQueryRequest, resp *CheckQueryResponse) { + if req.QuerySummary == nil { + resp.Error = fmt.Errorf("no completed query information available") + return + } + + if req.QuerySummary.Total < e.check { + resp.Error = fmt.Errorf("Query result of at least length %v - expected but got %v.", e.check, req.QuerySummary.Total) + return + } +} + +// ExpectLengthAtLeast returns a query check that asserts that the length of the query result is at least the given value. +// +// This query check can only be used with managed resources that support query. Query is only supported in Terraform v1.14+ +func ExpectLengthAtLeast(resourceAddress string, length int) QueryResultCheck { + return expectLengthAtLeast{ + resourceAddress: resourceAddress, + check: length, + } +} diff --git a/querycheck/expect_result_length_exact.go b/querycheck/expect_result_length_exact.go new file mode 100644 index 00000000..f10546ac --- /dev/null +++ b/querycheck/expect_result_length_exact.go @@ -0,0 +1,39 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package querycheck + +import ( + "context" + "fmt" +) + +var _ QueryResultCheck = expectLength{} + +type expectLength struct { + resourceAddress string + check int +} + +// CheckQuery implements the query check logic. +func (e expectLength) CheckQuery(_ context.Context, req CheckQueryRequest, resp *CheckQueryResponse) { + if req.QuerySummary == nil { + resp.Error = fmt.Errorf("no query summary information available") + return + } + + if e.check != req.QuerySummary.Total { + resp.Error = fmt.Errorf("number of found resources %v - expected but got %v.", e.check, req.QuerySummary.Total) + return + } +} + +// ExpectLength returns a query check that asserts that the length of the query result is exactly the given value. +// +// This query check can only be used with managed resources that support query. Query is only supported in Terraform v1.14+ +func ExpectLength(resourceAddress string, length int) QueryResultCheck { + return expectLength{ + resourceAddress: resourceAddress, + check: length, + } +} diff --git a/querycheck/query_check.go b/querycheck/query_check.go new file mode 100644 index 00000000..66d32628 --- /dev/null +++ b/querycheck/query_check.go @@ -0,0 +1,33 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package querycheck + +import ( + "context" + + tfjson "github.com/hashicorp/terraform-json" +) + +// QueryResultCheck defines an interface for implementing test logic to apply an assertion against a collection of found +// resources that were returned by a query. It returns an error if the query results do not match what is expected. +type QueryResultCheck interface { + // CheckQuery should perform the query check. + CheckQuery(context.Context, CheckQueryRequest, *CheckQueryResponse) +} + +// CheckQueryRequest is a request for an invoke of the CheckQuery function. +type CheckQueryRequest struct { + // Query represents the parsed log messages relating to found resources returned by the `terraform query -json` command. + Query []tfjson.ListResourceFoundData + + // QuerySummary contains a summary of the completed query operation + QuerySummary *tfjson.ListCompleteData +} + +// CheckQueryResponse is a response to an invoke of the CheckQuery function. +type CheckQueryResponse struct { + // Error is used to report the failure of a query check assertion and is combined with other QueryResultCheck errors + // to be reported as a test failure. + Error error +} diff --git a/statecheck/expect_identity.go b/statecheck/expect_identity.go index df5147b2..a89a06e6 100644 --- a/statecheck/expect_identity.go +++ b/statecheck/expect_identity.go @@ -67,9 +67,9 @@ func (e expectIdentity) CheckState(ctx context.Context, req CheckStateRequest, r if len(resource.IdentityValues) != len(e.identity) { deltaMsg := "" if len(resource.IdentityValues) > len(e.identity) { - deltaMsg = createDeltaString(resource.IdentityValues, e.identity, "actual identity has extra attribute(s): ") + deltaMsg = CreateDeltaString(resource.IdentityValues, e.identity, "actual identity has extra attribute(s): ") } else { - deltaMsg = createDeltaString(e.identity, resource.IdentityValues, "actual identity is missing attribute(s): ") + deltaMsg = CreateDeltaString(e.identity, resource.IdentityValues, "actual identity is missing attribute(s): ") } resp.Error = fmt.Errorf("%s - Expected %d attribute(s) in the actual identity object, got %d attribute(s): %s", e.resourceAddress, len(e.identity), len(resource.IdentityValues), deltaMsg) @@ -113,8 +113,8 @@ func ExpectIdentity(resourceAddress string, identity map[string]knownvalue.Check } } -// createDeltaString prints the map keys that are present in mapA and not present in mapB -func createDeltaString[T any, V any](mapA map[string]T, mapB map[string]V, msgPrefix string) string { +// CreateDeltaString prints the map keys that are present in mapA and not present in mapB +func CreateDeltaString[T any, V any](mapA map[string]T, mapB map[string]V, msgPrefix string) string { deltaMsg := "" deltaMap := make(map[string]T, len(mapA))