From d07f61aa138c1f5a0206bfb7428e654237d38a1a Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Thu, 12 Jun 2025 23:25:24 -0400 Subject: [PATCH 01/12] list: tidy the doc comments and tests --- list/list_resource.go | 42 ++--- list/schema/schema.go | 14 +- list/schema/schema_test.go | 41 ----- list/schema/string_attribute_test.go | 246 +++++---------------------- 4 files changed, 67 insertions(+), 276 deletions(-) diff --git a/list/list_resource.go b/list/list_resource.go index def1abfd3..a5d36a533 100644 --- a/list/list_resource.go +++ b/list/list_resource.go @@ -85,35 +85,39 @@ type ListResourceWithValidateConfig interface { ValidateListResourceConfig(context.Context, ValidateConfigRequest, *ValidateConfigResponse) } -// ListRequest represents a request for the provider to list instances -// of a managed resource type that satisfy a user-defined request. An instance -// of this reqeuest struct is passed as an argument to the provider's -// ListResource function implementation. +// ListRequest represents a request for the provider to list instances of a +// managed resource type that satisfy a user-defined request. An instance of +// this reqeuest struct is passed as an argument to the provider's List +// function implementation. type ListRequest struct { // Config is the configuration the user supplied for listing resource // instances. Config tfsdk.Config // IncludeResource indicates whether the provider should populate the - // Resource field in the ListResult struct. + // [ListResult.Resource] field. IncludeResource bool } -// ListResultsStream represents a streaming response to a ListRequest. -// An instance of this struct is supplied as an argument to the provider's -// ListResource function implementation function. The provider should set a Results -// iterator function that yields zero or more results of type ListResult. +// ListResultsStream represents a streaming response to a [ListRequest]. An +// instance of this struct is supplied as an argument to the provider's +// [ListResource.List] function. The provider should set a Results iterator +// function that pushes zero or more results of type [ListResult]. // // For convenience, a provider implementation may choose to convert a slice of // results into an iterator using [slices.Values]. -// -// [slices.Values]: https://pkg.go.dev/slices#Values type ListResultsStream struct { - // Results is a function that emits ListResult values via its yield + // Results is a function that emits [ListResult] values via its push // function argument. + // + // To indicate a fatal processing error, push a [ListResult] that contains + // a [diag.ErrorDiagnostic]. Results iter.Seq[ListResult] } +// NoListResults is an iterator that pushes zero results. +var NoListResults = func(func(ListResult) bool) {} + // ListResult represents a listed managed resource instance. type ListResult struct { // Identity is the identity of the managed resource instance. @@ -124,7 +128,7 @@ type ListResult struct { // Resource is the provider's representation of the attributes of the // listed managed resource instance. // - // If ListRequest.IncludeResource is true, a nil value will raise + // If [ListRequest.IncludeResource] is true, a nil value will raise // a warning diagnostic. Resource *tfsdk.Resource @@ -140,8 +144,8 @@ type ListResult struct { // ValidateConfigRequest represents a request to validate the configuration of // a list resource. An instance of this request struct is supplied as an -// argument to the ValidateListResourceConfig receiver method or automatically -// passed through to each ListResourceConfigValidator. +// argument to the [ListResourceWithValidateConfig.ValidateListResourceConfig] +// receiver method or automatically passed through to each [ConfigValidator]. type ValidateConfigRequest struct { // Config is the configuration the user supplied for the resource. // @@ -151,10 +155,10 @@ type ValidateConfigRequest struct { Config tfsdk.Config } -// ValidateConfigResponse represents a response to a ValidateConfigRequest. An -// instance of this response struct is supplied as an argument to the -// list.ValidateListResourceConfig receiver method or automatically passed -// through to each ConfigValidator. +// ValidateConfigResponse represents a response to a [ValidateConfigRequest]. +// An instance of this response struct is supplied as an argument to the +// [list.ValidateListResourceConfig] receiver method or automatically passed +// through to each [ConfigValidator]. type ValidateConfigResponse struct { // Diagnostics report errors or warnings related to validating the list // configuration. An empty slice indicates success, with no warnings diff --git a/list/schema/schema.go b/list/schema/schema.go index cdf07d662..fad93273c 100644 --- a/list/schema/schema.go +++ b/list/schema/schema.go @@ -17,9 +17,8 @@ import ( // Schema must satify the fwschema.Schema interface. var _ fwschema.Schema = Schema{} -// Schema defines the structure and value types of resource data. This type -// is used as the resource.SchemaResponse type Schema field, which is -// implemented by the resource.DataSource type Schema method. +// Schema defines the structure and value types of a list block. This is +// returned as a ListResourceSchemas map value by the GetProviderSchemas RPC. type Schema struct { // Attributes is the mapping of underlying attribute names to attribute // definitions. @@ -106,7 +105,7 @@ func (s Schema) GetMarkdownDescription() string { return s.MarkdownDescription } -// GetVersion returns zero because list resource schemas do not have a version. +// GetVersion always returns 0 because list resource schemas cannot be versioned. func (s Schema) GetVersion() int64 { return 0 } @@ -126,13 +125,6 @@ func (s Schema) TypeAtTerraformPath(ctx context.Context, p *tftypes.AttributePat return fwschema.SchemaTypeAtTerraformPath(ctx, s, p) } -// Validate verifies that the schema is not using a reserved field name for a top-level attribute. -// -// Deprecated: Use the ValidateImplementation method instead. -func (s Schema) Validate() diag.Diagnostics { - return s.ValidateImplementation(context.Background()) -} - // ValidateImplementation contains logic for validating the provider-defined // implementation of the schema and underlying attributes and blocks to prevent // unexpected errors or panics. This logic runs during the diff --git a/list/schema/schema_test.go b/list/schema/schema_test.go index 14fb2fff0..b556ac7df 100644 --- a/list/schema/schema_test.go +++ b/list/schema/schema_test.go @@ -797,47 +797,6 @@ func TestSchemaTypeAtTerraformPath(t *testing.T) { } } -func TestSchemaValidate(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - schema schema.Schema - expectedDiags diag.Diagnostics - }{ - "empty-schema": { - schema: schema.Schema{}, - }, - "validate-implementation-error": { - schema: schema.Schema{ - Attributes: map[string]schema.Attribute{ - "depends_on": schema.StringAttribute{}, - }, - }, - expectedDiags: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Reserved Root Attribute/Block Name", - "When validating the resource or data source schema, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "\"depends_on\" is a reserved root attribute/block name. "+ - "This is to prevent practitioners from needing special Terraform configuration syntax.", - ), - }, - }, - } - - for name, testCase := range testCases { - t.Run(name, func(t *testing.T) { - t.Parallel() - - diags := testCase.schema.Validate() - - if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" { - t.Errorf("Unexpected diagnostics (+wanted, -got): %s", diff) - } - }) - } -} - func TestSchemaValidateImplementation(t *testing.T) { t.Parallel() diff --git a/list/schema/string_attribute_test.go b/list/schema/string_attribute_test.go index c1dffa2f2..b2d2ff38b 100644 --- a/list/schema/string_attribute_test.go +++ b/list/schema/string_attribute_test.go @@ -4,7 +4,6 @@ package schema_test import ( - "context" "fmt" "strings" "testing" @@ -13,15 +12,10 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + listschema "github.com/hashicorp/terraform-plugin-framework/list/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -30,31 +24,31 @@ func TestStringAttributeApplyTerraform5AttributePathStep(t *testing.T) { t.Parallel() testCases := map[string]struct { - attribute schema.StringAttribute + attribute listschema.StringAttribute step tftypes.AttributePathStep expected any expectedError error }{ "AttributeName": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, step: tftypes.AttributeName("test"), expected: nil, expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.AttributeName to basetypes.StringType"), }, "ElementKeyInt": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, step: tftypes.ElementKeyInt(1), expected: nil, expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.ElementKeyInt to basetypes.StringType"), }, "ElementKeyString": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, step: tftypes.ElementKeyString("test"), expected: nil, expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.ElementKeyString to basetypes.StringType"), }, "ElementKeyValue": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, step: tftypes.ElementKeyValue(tftypes.NewValue(tftypes.String, "test")), expected: nil, expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.ElementKeyValue to basetypes.StringType"), @@ -92,15 +86,15 @@ func TestStringAttributeGetDeprecationMessage(t *testing.T) { t.Parallel() testCases := map[string]struct { - attribute schema.StringAttribute + attribute listschema.StringAttribute expected string }{ "no-deprecation-message": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, expected: "", }, "deprecation-message": { - attribute: schema.StringAttribute{ + attribute: listschema.StringAttribute{ DeprecationMessage: "test deprecation message", }, expected: "test deprecation message", @@ -124,18 +118,18 @@ func TestStringAttributeEqual(t *testing.T) { t.Parallel() testCases := map[string]struct { - attribute schema.StringAttribute + attribute listschema.StringAttribute other fwschema.Attribute expected bool }{ "different-type": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, other: testschema.AttributeWithStringValidators{}, expected: false, }, "equal": { - attribute: schema.StringAttribute{}, - other: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, + other: listschema.StringAttribute{}, expected: true, }, } @@ -157,15 +151,15 @@ func TestStringAttributeGetDescription(t *testing.T) { t.Parallel() testCases := map[string]struct { - attribute schema.StringAttribute + attribute listschema.StringAttribute expected string }{ "no-description": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, expected: "", }, "description": { - attribute: schema.StringAttribute{ + attribute: listschema.StringAttribute{ Description: "test description", }, expected: "test description", @@ -189,15 +183,15 @@ func TestStringAttributeGetMarkdownDescription(t *testing.T) { t.Parallel() testCases := map[string]struct { - attribute schema.StringAttribute + attribute listschema.StringAttribute expected string }{ "no-markdown-description": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, expected: "", }, "markdown-description": { - attribute: schema.StringAttribute{ + attribute: listschema.StringAttribute{ MarkdownDescription: "test description", }, expected: "test description", @@ -221,15 +215,15 @@ func TestStringAttributeGetType(t *testing.T) { t.Parallel() testCases := map[string]struct { - attribute schema.StringAttribute + attribute listschema.StringAttribute expected attr.Type }{ "base": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, expected: types.StringType, }, "custom-type": { - attribute: schema.StringAttribute{ + attribute: listschema.StringAttribute{ CustomType: testtypes.StringType{}, }, expected: testtypes.StringType{}, @@ -253,19 +247,13 @@ func TestStringAttributeIsComputed(t *testing.T) { t.Parallel() testCases := map[string]struct { - attribute schema.StringAttribute + attribute listschema.StringAttribute expected bool }{ "not-computed": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, expected: false, }, - "computed": { - attribute: schema.StringAttribute{ - Computed: true, - }, - expected: true, - }, } for name, testCase := range testCases { @@ -285,15 +273,15 @@ func TestStringAttributeIsOptional(t *testing.T) { t.Parallel() testCases := map[string]struct { - attribute schema.StringAttribute + attribute listschema.StringAttribute expected bool }{ "not-optional": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, expected: false, }, "optional": { - attribute: schema.StringAttribute{ + attribute: listschema.StringAttribute{ Optional: true, }, expected: true, @@ -317,15 +305,15 @@ func TestStringAttributeIsRequired(t *testing.T) { t.Parallel() testCases := map[string]struct { - attribute schema.StringAttribute + attribute listschema.StringAttribute expected bool }{ "not-required": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, expected: false, }, "required": { - attribute: schema.StringAttribute{ + attribute: listschema.StringAttribute{ Required: true, }, expected: true, @@ -349,19 +337,13 @@ func TestStringAttributeIsSensitive(t *testing.T) { t.Parallel() testCases := map[string]struct { - attribute schema.StringAttribute + attribute listschema.StringAttribute expected bool }{ "not-sensitive": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, expected: false, }, - "sensitive": { - attribute: schema.StringAttribute{ - Sensitive: true, - }, - expected: true, - }, } for name, testCase := range testCases { @@ -381,19 +363,13 @@ func TestStringAttributeIsWriteOnly(t *testing.T) { t.Parallel() testCases := map[string]struct { - attribute schema.StringAttribute + attribute listschema.StringAttribute expected bool }{ "not-writeOnly": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, expected: false, }, - "writeOnly": { - attribute: schema.StringAttribute{ - WriteOnly: true, - }, - expected: true, - }, } for name, testCase := range testCases { @@ -409,96 +385,19 @@ func TestStringAttributeIsWriteOnly(t *testing.T) { } } -func TestStringAttributeStringDefaultValue(t *testing.T) { - t.Parallel() - - opt := cmp.Comparer(func(x, y defaults.String) bool { - ctx := context.Background() - req := defaults.StringRequest{} - - xResp := defaults.StringResponse{} - x.DefaultString(ctx, req, &xResp) - - yResp := defaults.StringResponse{} - y.DefaultString(ctx, req, &yResp) - - return xResp.PlanValue.Equal(yResp.PlanValue) - }) - - testCases := map[string]struct { - attribute schema.StringAttribute - expected defaults.String - }{ - "no-default": { - attribute: schema.StringAttribute{}, - expected: nil, - }, - "default": { - attribute: schema.StringAttribute{ - Default: stringdefault.StaticString("test-value"), - }, - expected: stringdefault.StaticString("test-value"), - }, - } - - for name, testCase := range testCases { - t.Run(name, func(t *testing.T) { - t.Parallel() - - got := testCase.attribute.StringDefaultValue() - - if diff := cmp.Diff(got, testCase.expected, opt); diff != "" { - t.Errorf("unexpected difference: %s", diff) - } - }) - } -} - -func TestStringAttributeStringPlanModifiers(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - attribute schema.StringAttribute - expected []planmodifier.String - }{ - "no-planmodifiers": { - attribute: schema.StringAttribute{}, - expected: nil, - }, - "planmodifiers": { - attribute: schema.StringAttribute{ - PlanModifiers: []planmodifier.String{}, - }, - expected: []planmodifier.String{}, - }, - } - - for name, testCase := range testCases { - t.Run(name, func(t *testing.T) { - t.Parallel() - - got := testCase.attribute.StringPlanModifiers() - - if diff := cmp.Diff(got, testCase.expected); diff != "" { - t.Errorf("unexpected difference: %s", diff) - } - }) - } -} - func TestStringAttributeStringValidators(t *testing.T) { t.Parallel() testCases := map[string]struct { - attribute schema.StringAttribute + attribute listschema.StringAttribute expected []validator.String }{ "no-validators": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, expected: nil, }, "validators": { - attribute: schema.StringAttribute{ + attribute: listschema.StringAttribute{ Validators: []validator.String{}, }, expected: []validator.String{}, @@ -518,78 +417,15 @@ func TestStringAttributeStringValidators(t *testing.T) { } } -func TestStringAttributeValidateImplementation(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - attribute schema.StringAttribute - request fwschema.ValidateImplementationRequest - expected *fwschema.ValidateImplementationResponse - }{ - "computed": { - attribute: schema.StringAttribute{ - Computed: true, - }, - request: fwschema.ValidateImplementationRequest{ - Name: "test", - Path: path.Root("test"), - }, - expected: &fwschema.ValidateImplementationResponse{}, - }, - "default-without-computed": { - attribute: schema.StringAttribute{ - Default: stringdefault.StaticString("test"), - }, - request: fwschema.ValidateImplementationRequest{ - Name: "test", - Path: path.Root("test"), - }, - expected: &fwschema.ValidateImplementationResponse{ - Diagnostics: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Schema Using Attribute Default For Non-Computed Attribute", - "Attribute \"test\" must be computed when using default. "+ - "This is an issue with the provider and should be reported to the provider developers.", - ), - }, - }, - }, - "default-with-computed": { - attribute: schema.StringAttribute{ - Computed: true, - Default: stringdefault.StaticString("test"), - }, - request: fwschema.ValidateImplementationRequest{ - Name: "test", - Path: path.Root("test"), - }, - expected: &fwschema.ValidateImplementationResponse{}, - }, - } - - for name, testCase := range testCases { - t.Run(name, func(t *testing.T) { - t.Parallel() - - got := &fwschema.ValidateImplementationResponse{} - testCase.attribute.ValidateImplementation(context.Background(), testCase.request, got) - - if diff := cmp.Diff(got, testCase.expected); diff != "" { - t.Errorf("unexpected difference: %s", diff) - } - }) - } -} - func TestStringAttributeIsRequiredForImport(t *testing.T) { t.Parallel() testCases := map[string]struct { - attribute schema.StringAttribute + attribute listschema.StringAttribute expected bool }{ "not-requiredForImport": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, expected: false, }, } @@ -611,11 +447,11 @@ func TestStringAttributeIsOptionalForImport(t *testing.T) { t.Parallel() testCases := map[string]struct { - attribute schema.StringAttribute + attribute listschema.StringAttribute expected bool }{ "not-optionalForImport": { - attribute: schema.StringAttribute{}, + attribute: listschema.StringAttribute{}, expected: false, }, } From ce0467fb9f6210c80921cf351e9013ca6c798d18 Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Fri, 13 Jun 2025 14:56:09 -0400 Subject: [PATCH 02/12] Add ListResource RPC --- go.mod | 14 +- go.sum | 48 +-- internal/fwschemadata/data_description.go | 4 + internal/fwserver/server_listresource.go | 145 ++++++++ internal/fwserver/server_listresource_test.go | 257 +++++++++++++++ internal/fwserver/server_listresources.go | 61 +++- internal/proto5server/serve.go | 1 + internal/proto5server/server_listresource.go | 91 ++++++ .../proto5server/server_listresource_test.go | 309 ++++++++++++++++++ .../server_validatelistresourceconfig.go | 14 + internal/proto6server/serve.go | 1 + internal/proto6server/server_listresource.go | 91 ++++++ .../proto6server/server_listresource_test.go | 309 ++++++++++++++++++ .../server_validatelistresourceconfig.go | 14 + internal/toproto5/list_resource_result.go | 51 +++ internal/toproto5/listresourcemetadata.go | 19 ++ internal/toproto5/resource.go | 28 ++ internal/toproto6/list_resource_result.go | 51 +++ internal/toproto6/listresourcemetadata.go | 19 ++ internal/toproto6/resource.go | 28 ++ list/list_resource.go | 7 + list/tosdk.go | 50 +++ tfsdk/resource.go | 36 +- tfsdk/resource_identity.go | 1 + 24 files changed, 1602 insertions(+), 47 deletions(-) create mode 100644 internal/fwserver/server_listresource.go create mode 100644 internal/fwserver/server_listresource_test.go create mode 100644 internal/proto5server/server_listresource.go create mode 100644 internal/proto5server/server_listresource_test.go create mode 100644 internal/proto5server/server_validatelistresourceconfig.go create mode 100644 internal/proto6server/server_listresource.go create mode 100644 internal/proto6server/server_listresource_test.go create mode 100644 internal/proto6server/server_validatelistresourceconfig.go create mode 100644 internal/toproto5/list_resource_result.go create mode 100644 internal/toproto5/listresourcemetadata.go create mode 100644 internal/toproto5/resource.go create mode 100644 internal/toproto6/list_resource_result.go create mode 100644 internal/toproto6/listresourcemetadata.go create mode 100644 internal/toproto6/resource.go create mode 100644 list/tosdk.go diff --git a/go.mod b/go.mod index e665cd8d7..6b40abeec 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.23.7 require ( github.com/google/go-cmp v0.7.0 - github.com/hashicorp/terraform-plugin-go v0.28.0 + github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250612194609-92d41e5e5c0a github.com/hashicorp/terraform-plugin-log v0.9.0 ) @@ -16,7 +16,7 @@ require ( github.com/hashicorp/go-hclog v1.5.0 // indirect github.com/hashicorp/go-plugin v1.6.3 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect - github.com/hashicorp/terraform-registry-address v0.2.5 // indirect + github.com/hashicorp/terraform-registry-address v0.3.0 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/mattn/go-colorable v0.1.12 // indirect @@ -25,10 +25,10 @@ require ( github.com/oklog/run v1.0.0 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect - google.golang.org/grpc v1.72.1 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect + google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect ) diff --git a/go.sum b/go.sum index 1020ed5b4..6160824b5 100644 --- a/go.sum +++ b/go.sum @@ -21,12 +21,12 @@ github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0U github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/terraform-plugin-go v0.28.0 h1:zJmu2UDwhVN0J+J20RE5huiF3XXlTYVIleaevHZgKPA= -github.com/hashicorp/terraform-plugin-go v0.28.0/go.mod h1:FDa2Bb3uumkTGSkTFpWSOwWJDwA7bf3vdP3ltLDTH6o= +github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250612194609-92d41e5e5c0a h1:nVzFWFdKJ1DB0j9Q8DXd9uyrsTYq2ehZGAYuYWyqi2w= +github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250612194609-92d41e5e5c0a/go.mod h1:hL//wLEfYo0YVt0TC/VLzia/ADQQto3HEm4/jX2gkdY= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= -github.com/hashicorp/terraform-registry-address v0.2.5 h1:2GTftHqmUhVOeuu9CW3kwDkRe4pcBDq0uuK5VJngU1M= -github.com/hashicorp/terraform-registry-address v0.2.5/go.mod h1:PpzXWINwB5kuVS5CA7m1+eO2f1jKb5ZDIxrOPfpnGkg= +github.com/hashicorp/terraform-registry-address v0.3.0 h1:HMpK3nqaGFPS9VmgRXrJL/dzHNdheGVKk5k7VlFxzCo= +github.com/hashicorp/terraform-registry-address v0.3.0/go.mod h1:jRGCMiLaY9zii3GLC7hqpSnwhfnCN5yzvY0hh4iCGbM= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= @@ -56,32 +56,32 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= -google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= -google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/fwschemadata/data_description.go b/internal/fwschemadata/data_description.go index 282a53321..fe3d71cf8 100644 --- a/internal/fwschemadata/data_description.go +++ b/internal/fwschemadata/data_description.go @@ -12,6 +12,10 @@ const ( // a plan-based value. DataDescriptionPlan DataDescription = "plan" + // DataDescriptionResource is used for Data that represents + // a resource value. + DataDescriptionResource DataDescription = "resource" + // DataDescriptionState is used for Data that represents // a state-based value. DataDescriptionState DataDescription = "state" diff --git a/internal/fwserver/server_listresource.go b/internal/fwserver/server_listresource.go new file mode 100644 index 000000000..2053d3dab --- /dev/null +++ b/internal/fwserver/server_listresource.go @@ -0,0 +1,145 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fwserver + +import ( + "context" + "errors" + "iter" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/logging" + "github.com/hashicorp/terraform-plugin-framework/list" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +// ListRequest is the framework server request for the ListResource RPC. +type ListRequest struct { + // ListResource is an instance of the provider's list resource + // implementation for a specific managed resource type. + ListResource list.ListResource + + // Config is the configuration the user supplied for listing resource + // instances. + Config *tfsdk.Config + + // IncludeResource indicates whether the provider should populate the + // Resource field in the ListResult struct. + IncludeResource bool + + ResourceSchema fwschema.Schema + ResourceIdentitySchema fwschema.Schema +} + +// ListResultsStream represents a streaming response to a [ListRequest]. An +// instance of this struct is supplied as an argument to the provider's List +// function. The provider should set a Results iterator function that pushes +// zero or more results of type [ListResult]. +// +// For convenience, a provider implementation may choose to convert a slice of +// results into an iterator using [slices.Values]. +type ListResultsStream struct { + // Results is a function that emits [ListResult] values via its push + // function argument. + Results iter.Seq[ListResult] +} + +func ListResultError(summary string, detail string) ListResult { + return ListResult{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic(summary, detail), + }, + } +} + +// ListResult represents a listed managed resource instance. +type ListResult struct { + // Identity is the identity of the managed resource instance. A nil value + // will raise will raise a diagnostic. + Identity *tfsdk.ResourceIdentity + + // Resource is the provider's representation of the attributes of the + // listed managed resource instance. + // + // If [ListRequest.IncludeResource] is true, a nil value will raise + // a warning diagnostic. + Resource *tfsdk.Resource + + // DisplayName is a provider-defined human-readable description of the + // listed managed resource instance, intended for CLI and browser UIs. + DisplayName string + + // Diagnostics report errors or warnings related to the listed managed + // resource instance. An empty slice indicates a successful operation with + // no warnings or errors generated. + Diagnostics diag.Diagnostics +} + +// ListResource implements the framework server ListResource RPC. +func (s *Server) ListResource(ctx context.Context, fwReq *ListRequest, fwStream *ListResultsStream) error { + listResource := fwReq.ListResource + + if fwReq.Config == nil { + return errors.New("Invalid ListResource request: Config cannot be nil") + } + + req := list.ListRequest{ + Config: *fwReq.Config, + IncludeResource: fwReq.IncludeResource, + ResourceSchema: fwReq.ResourceSchema, // TODO: revisit + ResourceIdentitySchema: fwReq.ResourceIdentitySchema, // TODO: revisit + } + + stream := &list.ListResultsStream{} + + logging.FrameworkTrace(ctx, "Calling provider defined ListResource") + listResource.List(ctx, req, stream) + logging.FrameworkTrace(ctx, "Called provider defined ListResource") + + // If the provider returned a nil results stream, we return an empty stream. + if stream.Results == nil { + stream.Results = list.NoListResults + } + + fwStream.Results = processListResults(req, stream.Results) + return nil +} + +func processListResults(req list.ListRequest, stream iter.Seq[list.ListResult]) iter.Seq[ListResult] { + return func(push func(ListResult) 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) ListResult { + if result.Diagnostics.HasError() { + return ListResult(result) + } + + if result.Identity == nil { // TODO: is result.Identity.Raw.IsNull() a practical concern? + return ListResultError( + "Incomplete List Result", + "The provider did not populate the Identity field in the ListResourceResult. This may be due to an error in the provider's implementation.", + ) + } + + if req.IncludeResource { + if result.Resource == nil { // TODO: is result.Resource.Raw.IsNull() a practical concern? + result.Resource = nil + result.Diagnostics.AddWarning( + "Incomplete List Result", + "The provider did not populate the Resource field in the ListResourceResult. This may be due to the provider not supporting this functionality or an error in the provider's implementation.", + ) + } + } + + return ListResult(result) // TODO: do we need to .Copy() the raw Identity and Resource values? +} diff --git a/internal/fwserver/server_listresource_test.go b/internal/fwserver/server_listresource_test.go new file mode 100644 index 000000000..6dbd0f66a --- /dev/null +++ b/internal/fwserver/server_listresource_test.go @@ -0,0 +1,257 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fwserver_test + +import ( + "context" + "slices" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/list" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerListResource(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test_computed": schema.StringAttribute{ + Computed: true, + }, + "test_required": schema.StringAttribute{ + Required: true, + }, + }, + } + + testType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute": tftypes.String, + }, + } + + testResourceValue1 := tftypes.NewValue(testType, map[string]tftypes.Value{ + "test_attribute": tftypes.NewValue(tftypes.String, "test-value-1"), + }) + + testResourceValue2 := tftypes.NewValue(testType, map[string]tftypes.Value{ + "test_attribute": tftypes.NewValue(tftypes.String, "test-value-2"), + }) + + testIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + + testIdentityType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testIdentityValue1 := tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }) + + testIdentityValue2 := tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-456"), + }) + + testCases := map[string]struct { + server *fwserver.Server + request *fwserver.ListRequest + expectedStreamEvents []fwserver.ListResult + }{ + "success-with-zero-results": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + Config: &tfsdk.Config{}, + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + resp.Results = list.NoListResults + }, + }, + }, + expectedStreamEvents: []fwserver.ListResult{}, + }, + "success-with-nil-results": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + Config: &tfsdk.Config{}, + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + // Do nothing, so that resp.Results is nil + }, + }, + }, + expectedStreamEvents: []fwserver.ListResult{}, + }, + "success-with-multiple-results": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + Config: &tfsdk.Config{}, + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { // TODO + resp.Results = slices.Values([]list.ListResult{ + { + Identity: &tfsdk.ResourceIdentity{ + Schema: testIdentitySchema, + Raw: testIdentityValue1, + }, + Resource: &tfsdk.Resource{ + Schema: testSchema, + Raw: testResourceValue1, + }, + DisplayName: "Test Resource 1", + Diagnostics: diag.Diagnostics{}, + }, + { + Identity: &tfsdk.ResourceIdentity{ + Schema: testIdentitySchema, + Raw: testIdentityValue2, + }, + Resource: &tfsdk.Resource{ + Schema: testSchema, + Raw: testResourceValue2, + }, + DisplayName: "Test Resource 2", + Diagnostics: diag.Diagnostics{}, + }, + }) + }, + }, + }, + expectedStreamEvents: []fwserver.ListResult{ + { + Identity: &tfsdk.ResourceIdentity{ + Schema: testIdentitySchema, + Raw: testIdentityValue1, + }, + Resource: &tfsdk.Resource{ + Schema: testSchema, + Raw: testResourceValue1, + }, + DisplayName: "Test Resource 1", + Diagnostics: diag.Diagnostics{}, + }, + { + Identity: &tfsdk.ResourceIdentity{ + Schema: testIdentitySchema, + Raw: testIdentityValue2, + }, + Resource: &tfsdk.Resource{ + Schema: testSchema, + Raw: testResourceValue2, + }, + DisplayName: "Test Resource 2", + Diagnostics: diag.Diagnostics{}, + }, + }, + }, + "error-on-nil-resource-identity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + Config: &tfsdk.Config{}, + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + resp.Results = slices.Values([]list.ListResult{ + { + Identity: nil, + Resource: &tfsdk.Resource{ + Schema: testSchema, + Raw: testResourceValue1, + }, + DisplayName: "Test Resource 1", + }, + }) + }, + }, + }, + expectedStreamEvents: []fwserver.ListResult{ + { + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Incomplete List Result", + "The provider did not populate the Identity field in the ListResourceResult. This may be due to an error in the provider's implementation.", + ), + }, + }, + }, + }, + "warning-on-missing-resource": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + Config: &tfsdk.Config{}, + IncludeResource: true, + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + resp.Results = slices.Values([]list.ListResult{ + { + Identity: &tfsdk.ResourceIdentity{ + Schema: testIdentitySchema, + Raw: testIdentityValue1, + }, + Resource: nil, + DisplayName: "Test Resource 1", + }, + }) + }, + }, + }, + expectedStreamEvents: []fwserver.ListResult{ + { + Identity: &tfsdk.ResourceIdentity{ + Schema: testIdentitySchema, + Raw: testIdentityValue1, + }, + DisplayName: "Test Resource 1", + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic( + "Incomplete List Result", + "The provider did not populate the Resource field in the ListResourceResult. This may be due to the provider not supporting this functionality or an error in the provider's implementation.", + ), + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + response := &fwserver.ListResultsStream{} + err := testCase.server.ListResource(context.Background(), testCase.request, response) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + events := slices.AppendSeq([]fwserver.ListResult{}, response.Results) + if diff := cmp.Diff(events, testCase.expectedStreamEvents); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/fwserver/server_listresources.go b/internal/fwserver/server_listresources.go index 569e43bfc..c1a314d2e 100644 --- a/internal/fwserver/server_listresources.go +++ b/internal/fwserver/server_listresources.go @@ -15,6 +15,39 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" ) +type ListResourceTypeNotFoundError struct { + TypeName string +} + +func (e *ListResourceTypeNotFoundError) Error() string { + return "listResource Type Not Found: no listResource type named " + e.TypeName + " was found in the provider." +} + +func (e *ListResourceTypeNotFoundError) Is(err error) bool { + compatibleErr, ok := err.(*ListResourceTypeNotFoundError) + if !ok { + return false + } + + return e.TypeName == compatibleErr.TypeName +} + +func (s *Server) ListResourceType(ctx context.Context, typeName string) (list.ListResource, diag.Diagnostics) { + listResourceFuncs, diags := s.ListResourceFuncs(ctx) + listResourceFunc, ok := listResourceFuncs[typeName] + + if !ok { + diags.AddError( + "List Resource Type Not Found", + fmt.Sprintf("No list resource type named %q was found in the provider.", typeName), + ) + + return nil, diags + } + + return listResourceFunc(), nil +} + // ListResourceFuncs returns a map of ListResource functions. The results are // cached on first use. func (s *Server) ListResourceFuncs(ctx context.Context) (map[string]func() list.ListResource, diag.Diagnostics) { @@ -69,7 +102,8 @@ func (s *Server) ListResourceFuncs(ctx context.Context) (map[string]func() list. continue } - if _, ok := s.resourceFuncs[typeName]; !ok { + resourceFuncs, _ := s.ResourceFuncs(ctx) + if _, ok := resourceFuncs[typeName]; !ok { s.listResourceFuncsDiags.AddError( "ListResource Type Defined without a Matching Managed Resource Type", fmt.Sprintf("The %s ListResource type name was returned, but no matching managed Resource type was defined. ", typeName)+ @@ -87,17 +121,32 @@ func (s *Server) ListResourceFuncs(ctx context.Context) (map[string]func() list. // ListResourceMetadatas returns a slice of ListResourceMetadata for the GetMetadata // RPC. func (s *Server) ListResourceMetadatas(ctx context.Context) ([]ListResourceMetadata, diag.Diagnostics) { - resourceFuncs, diags := s.ListResourceFuncs(ctx) + listResourceFuncs, diags := s.ListResourceFuncs(ctx) - resourceMetadatas := make([]ListResourceMetadata, 0, len(resourceFuncs)) + listResourceMetadatas := make([]ListResourceMetadata, 0, len(listResourceFuncs)) - for typeName := range resourceFuncs { - resourceMetadatas = append(resourceMetadatas, ListResourceMetadata{ + for typeName := range listResourceFuncs { + listResourceMetadatas = append(listResourceMetadatas, ListResourceMetadata{ TypeName: typeName, }) } - return resourceMetadatas, diags + return listResourceMetadatas, diags +} + +func (s *Server) ListResourceSchema(ctx context.Context, typeName string) (fwschema.Schema, diag.Diagnostics) { + schemas, _ := s.ListResourceSchemas(ctx) + schema, ok := schemas[typeName] + if !ok { + return nil, diag.Diagnostics{ + diag.NewErrorDiagnostic( + "ListResource Schema Not Found", + fmt.Sprintf("No ListResource schema was found for type %q.", typeName), + ), + } + } + + return schema, nil } // ListResourceSchemas returns a map of ListResource Schemas for the diff --git a/internal/proto5server/serve.go b/internal/proto5server/serve.go index c0f44d92d..5ce3a7919 100644 --- a/internal/proto5server/serve.go +++ b/internal/proto5server/serve.go @@ -12,6 +12,7 @@ import ( ) var _ tfprotov5.ProviderServer = &Server{} +var _ tfprotov5.ProviderServerWithListResource = &Server{} //nolint:staticcheck // Provider server implementation. type Server struct { diff --git a/internal/proto5server/server_listresource.go b/internal/proto5server/server_listresource.go new file mode 100644 index 000000000..af85693c4 --- /dev/null +++ b/internal/proto5server/server_listresource.go @@ -0,0 +1,91 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto5server + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto5" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +// ListRequestErrorDiagnostics returns a value suitable for +// [ListResourceServerStream.Results]. It yields a single result that contains +// the given error diagnostics. +func ListRequestErrorDiagnostics(ctx context.Context, diags ...diag.Diagnostic) (*tfprotov5.ListResourceServerStream, error) { + protoDiags := toproto5.Diagnostics(ctx, diags) + return &tfprotov5.ListResourceServerStream{ + Results: func(push func(tfprotov5.ListResourceResult) bool) { + push(tfprotov5.ListResourceResult{Diagnostics: protoDiags}) + }, + }, nil +} + +func (s *Server) ListResource(ctx context.Context, protoReq *tfprotov5.ListResourceRequest) (*tfprotov5.ListResourceServerStream, error) { + protoStream := &tfprotov5.ListResourceServerStream{Results: tfprotov5.NoListResults} + allDiags := diag.Diagnostics{} + + listResource, diags := s.FrameworkServer.ListResourceType(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + listResourceSchema, diags := s.FrameworkServer.ListResourceSchema(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + config, diags := fromproto5.Config(ctx, protoReq.Config, listResourceSchema) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + resourceSchema, diags := s.FrameworkServer.ResourceSchema(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + identitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + req := &fwserver.ListRequest{ + Config: config, + ListResource: listResource, + ResourceSchema: resourceSchema, + ResourceIdentitySchema: identitySchema, + IncludeResource: protoReq.IncludeResource, + } + stream := &fwserver.ListResultsStream{} + + err := s.FrameworkServer.ListResource(ctx, req, stream) + if err != nil { + return protoStream, err + } + + protoStream.Results = func(push func(tfprotov5.ListResourceResult) bool) { + for result := range stream.Results { + var protoResult tfprotov5.ListResourceResult + if req.IncludeResource { + protoResult = toproto5.ListResourceResultWithResource(ctx, &result) + } else { + protoResult = toproto5.ListResourceResult(ctx, &result) + } + + if !push(protoResult) { + return + } + } + } + return protoStream, nil +} diff --git a/internal/proto5server/server_listresource_test.go b/internal/proto5server/server_listresource_test.go new file mode 100644 index 000000000..69cc206c9 --- /dev/null +++ b/internal/proto5server/server_listresource_test.go @@ -0,0 +1,309 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto5server + +import ( + "context" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/list" + listschema "github.com/hashicorp/terraform-plugin-framework/list/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerListResource(t *testing.T) { + t.Parallel() + + type ThingResourceIdentity struct { + Id string `tfsdk:"id"` + } + + type ThingResource struct { + // TODO: how do we feel about this? + ThingResourceIdentity + Name string `tfsdk:"name"` + } + + resources := map[string]ThingResource{} + expectedResources := map[string]*tfprotov5.DynamicValue{} + expectedResourceIdentities := map[string]*tfprotov5.ResourceIdentityData{} + + examples := []string{"bookbag", "bookshelf", "bookworm", "plateau", "platinum", "platypus"} + for _, example := range examples { + id := "id-" + example + resources[example] = ThingResource{Name: example, ThingResourceIdentity: ThingResourceIdentity{Id: id}} + + expectedResources[example] = testNewDynamicValue(t, tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "name": tftypes.String, + }, + }, map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, id), + "name": tftypes.NewValue(tftypes.String, example), + }) + + expectedResourceIdentities[example] = &tfprotov5.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + }, + }, map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, id), + }), + } + } + + listResourceType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "filter": tftypes.String, + }, + } + + type listConfig struct { + Filter string `tfsdk:"filter"` + } + + plat := testNewDynamicValue(t, listResourceType, map[string]tftypes.Value{ + "filter": tftypes.NewValue(tftypes.String, "plat"), + }) + + plateau := testNewDynamicValue(t, listResourceType, map[string]tftypes.Value{ + "filter": tftypes.NewValue(tftypes.String, "plateau"), + }) + + listResource := func() list.ListResource { + return &testprovider.ListResource{ + ListResourceConfigSchemaMethod: func(ctx context.Context, req list.ListResourceSchemaRequest, resp *list.ListResourceSchemaResponse) { + resp.Schema = listschema.Schema{ + Attributes: map[string]listschema.Attribute{ + "filter": listschema.StringAttribute{}, + }, + } + }, + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + var config listConfig + diags := req.Config.Get(ctx, &config) + if len(diags) > 0 { + t.Fatalf("unexpected diagnostics: %s", diags) + } + + results := []list.ListResult{} + for name := range resources { + if !strings.HasPrefix(name, config.Filter) { + continue + } + + result := req.ToListResult(ctx, resources[name].ThingResourceIdentity, resources[name], name) + results = append(results, result) + } + resp.Results = slices.Values(results) + }, + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + } + } + + listResourceThatDoesNotPopulateResource := func() list.ListResource { + r, ok := listResource().(*testprovider.ListResource) + if !ok { + t.Fatal("listResourceThatDoesNotPopulateResource must be a testprovider.ListResource") + } + + r.ListMethod = func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + result := req.ToListResult(ctx, resources["plateau"].ThingResourceIdentity, nil, "plateau") + + resp.Results = slices.Values([]list.ListResult{result}) + } + + return r + } + + managedResource := func() resource.Resource { + return &testprovider.ResourceWithIdentity{ + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "id": identityschema.StringAttribute{}, + }, + } + }, + Resource: &testprovider.Resource{ + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + SchemaMethod: func(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "id": resourceschema.StringAttribute{}, + "name": resourceschema.StringAttribute{}, + }, + } + }, + }, + } + } + + server := func(listResource func() list.ListResource, managedResource func() resource.Resource) *Server { + return &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ListResourcesMethod: func(ctx context.Context) []func() list.ListResource { + return []func() list.ListResource{ + listResource, + } + }, + ResourcesMethod: func(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + managedResource, + } + }, + }, + }, + } + } + + happyServer := server(listResource, managedResource) + + testCases := map[string]struct { + server *Server + request *tfprotov5.ListResourceRequest + expectedError error + expectedDiagnostics diag.Diagnostics + expectedResults []tfprotov5.ListResourceResult + }{ + "error-on-unknown-list-resource-type": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ListResourcesMethod: func(ctx context.Context) []func() list.ListResource { + return []func() list.ListResource{} + }, + }, + }, + }, + request: &tfprotov5.ListResourceRequest{ + TypeName: "test_resource", + Config: plat, + }, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov5.ListResourceResult{ + { + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "List Resource Type Not Found", + }, + }, + }, + }, + }, + "result": { + server: happyServer, + request: &tfprotov5.ListResourceRequest{ + TypeName: "test_resource", + Config: plat, + }, + expectedError: nil, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov5.ListResourceResult{ + { + DisplayName: "plateau", + Identity: expectedResourceIdentities["plateau"], + }, + { + DisplayName: "platinum", + Identity: expectedResourceIdentities["platinum"], + }, + { + DisplayName: "platypus", + Identity: expectedResourceIdentities["platypus"], + }, + }, + }, + "result-with-include-resource": { + server: happyServer, + request: &tfprotov5.ListResourceRequest{ + TypeName: "test_resource", + Config: plateau, + IncludeResource: true, + }, + expectedError: nil, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov5.ListResourceResult{ + { + DisplayName: "plateau", + Identity: expectedResourceIdentities["plateau"], + Resource: expectedResources["plateau"], + }, + }, + }, + "result-with-include-resource-warning": { + server: server(listResourceThatDoesNotPopulateResource, managedResource), + request: &tfprotov5.ListResourceRequest{ + TypeName: "test_resource", + Config: plateau, + IncludeResource: true, + }, + expectedError: nil, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov5.ListResourceResult{ + { + DisplayName: "plateau", + Identity: expectedResourceIdentities["plateau"], + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityWarning, + Summary: "Incomplete List Result", + Detail: "The provider did not populate the Resource field in the ListResourceResult. This may be due to the provider not supporting this functionality or an error in the provider's implementation.", + }, + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + metadataResponse := &fwserver.GetMetadataResponse{} + testCase.server.FrameworkServer.GetMetadata(context.Background(), &fwserver.GetMetadataRequest{}, metadataResponse) + + if diff := cmp.Diff(metadataResponse.Diagnostics, diag.Diagnostics{}); diff != "" { + t.Fatalf("unexpected metadata diagnostics difference: got %s\nwanted %s", metadataResponse.Diagnostics, diag.Diagnostics{}) + } + + got, err := testCase.server.ListResource(context.Background(), testCase.request) + + if diff := cmp.Diff(testCase.expectedError, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("unexpected error difference: %s", diff) + } + + sortResults := cmpopts.SortSlices(func(a, b tfprotov5.ListResourceResult) bool { + return a.DisplayName < b.DisplayName + }) + opts := []cmp.Option{ + sortResults, + cmpopts.EquateEmpty(), + cmpopts.IgnoreFields(tfprotov5.Diagnostic{}, "Detail"), + } + if diff := cmp.Diff(testCase.expectedResults, slices.Collect(got.Results), opts...); diff != "" { + t.Errorf("unexpected results difference: %s", diff) + } + }) + } +} diff --git a/internal/proto5server/server_validatelistresourceconfig.go b/internal/proto5server/server_validatelistresourceconfig.go new file mode 100644 index 000000000..40c47ae15 --- /dev/null +++ b/internal/proto5server/server_validatelistresourceconfig.go @@ -0,0 +1,14 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto5server + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +func (s *Server) ValidateListResourceConfig(ctx context.Context, request *tfprotov5.ValidateListResourceConfigRequest) (*tfprotov5.ValidateListResourceConfigResponse, error) { + return &tfprotov5.ValidateListResourceConfigResponse{}, nil +} diff --git a/internal/proto6server/serve.go b/internal/proto6server/serve.go index 26cf0c4e1..d3d438253 100644 --- a/internal/proto6server/serve.go +++ b/internal/proto6server/serve.go @@ -12,6 +12,7 @@ import ( ) var _ tfprotov6.ProviderServer = &Server{} +var _ tfprotov6.ProviderServerWithListResource = &Server{} //nolint:staticcheck // Provider server implementation. type Server struct { diff --git a/internal/proto6server/server_listresource.go b/internal/proto6server/server_listresource.go new file mode 100644 index 000000000..5a5bf0b4c --- /dev/null +++ b/internal/proto6server/server_listresource.go @@ -0,0 +1,91 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto6server + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto6" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/toproto6" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// ListRequestErrorDiagnostics returns a value suitable for +// [ListResourceServerStream.Results]. It yields a single result that contains +// the given error diagnostics. +func ListRequestErrorDiagnostics(ctx context.Context, diags ...diag.Diagnostic) (*tfprotov6.ListResourceServerStream, error) { + protoDiags := toproto6.Diagnostics(ctx, diags) + return &tfprotov6.ListResourceServerStream{ + Results: func(push func(tfprotov6.ListResourceResult) bool) { + push(tfprotov6.ListResourceResult{Diagnostics: protoDiags}) + }, + }, nil +} + +func (s *Server) ListResource(ctx context.Context, protoReq *tfprotov6.ListResourceRequest) (*tfprotov6.ListResourceServerStream, error) { + protoStream := &tfprotov6.ListResourceServerStream{Results: tfprotov6.NoListResults} + allDiags := diag.Diagnostics{} + + listResource, diags := s.FrameworkServer.ListResourceType(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + listResourceSchema, diags := s.FrameworkServer.ListResourceSchema(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + config, diags := fromproto6.Config(ctx, protoReq.Config, listResourceSchema) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + resourceSchema, diags := s.FrameworkServer.ResourceSchema(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + identitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + req := &fwserver.ListRequest{ + Config: config, + ListResource: listResource, + ResourceSchema: resourceSchema, + ResourceIdentitySchema: identitySchema, + IncludeResource: protoReq.IncludeResource, + } + stream := &fwserver.ListResultsStream{} + + err := s.FrameworkServer.ListResource(ctx, req, stream) + if err != nil { + return protoStream, err + } + + protoStream.Results = func(push func(tfprotov6.ListResourceResult) bool) { + for result := range stream.Results { + var protoResult tfprotov6.ListResourceResult + if req.IncludeResource { + protoResult = toproto6.ListResourceResultWithResource(ctx, &result) + } else { + protoResult = toproto6.ListResourceResult(ctx, &result) + } + + if !push(protoResult) { + return + } + } + } + return protoStream, nil +} diff --git a/internal/proto6server/server_listresource_test.go b/internal/proto6server/server_listresource_test.go new file mode 100644 index 000000000..162666484 --- /dev/null +++ b/internal/proto6server/server_listresource_test.go @@ -0,0 +1,309 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto6server + +import ( + "context" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/list" + listschema "github.com/hashicorp/terraform-plugin-framework/list/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerListResource(t *testing.T) { + t.Parallel() + + type ThingResourceIdentity struct { + Id string `tfsdk:"id"` + } + + type ThingResource struct { + // TODO: how do we feel about this? + ThingResourceIdentity + Name string `tfsdk:"name"` + } + + resources := map[string]ThingResource{} + expectedResources := map[string]*tfprotov6.DynamicValue{} + expectedResourceIdentities := map[string]*tfprotov6.ResourceIdentityData{} + + examples := []string{"bookbag", "bookshelf", "bookworm", "plateau", "platinum", "platypus"} + for _, example := range examples { + id := "id-" + example + resources[example] = ThingResource{Name: example, ThingResourceIdentity: ThingResourceIdentity{Id: id}} + + expectedResources[example] = testNewDynamicValue(t, tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "name": tftypes.String, + }, + }, map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, id), + "name": tftypes.NewValue(tftypes.String, example), + }) + + expectedResourceIdentities[example] = &tfprotov6.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + }, + }, map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, id), + }), + } + } + + listResourceType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "filter": tftypes.String, + }, + } + + type listConfig struct { + Filter string `tfsdk:"filter"` + } + + plat := testNewDynamicValue(t, listResourceType, map[string]tftypes.Value{ + "filter": tftypes.NewValue(tftypes.String, "plat"), + }) + + plateau := testNewDynamicValue(t, listResourceType, map[string]tftypes.Value{ + "filter": tftypes.NewValue(tftypes.String, "plateau"), + }) + + listResource := func() list.ListResource { + return &testprovider.ListResource{ + ListResourceConfigSchemaMethod: func(ctx context.Context, req list.ListResourceSchemaRequest, resp *list.ListResourceSchemaResponse) { + resp.Schema = listschema.Schema{ + Attributes: map[string]listschema.Attribute{ + "filter": listschema.StringAttribute{}, + }, + } + }, + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + var config listConfig + diags := req.Config.Get(ctx, &config) + if len(diags) > 0 { + t.Fatalf("unexpected diagnostics: %s", diags) + } + + results := []list.ListResult{} + for name := range resources { + if !strings.HasPrefix(name, config.Filter) { + continue + } + + result := req.ToListResult(ctx, resources[name].ThingResourceIdentity, resources[name], name) + results = append(results, result) + } + resp.Results = slices.Values(results) + }, + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + } + } + + listResourceThatDoesNotPopulateResource := func() list.ListResource { + r, ok := listResource().(*testprovider.ListResource) + if !ok { + t.Fatal("listResourceThatDoesNotPopulateResource must be a testprovider.ListResource") + } + + r.ListMethod = func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + result := req.ToListResult(ctx, resources["plateau"].ThingResourceIdentity, nil, "plateau") + + resp.Results = slices.Values([]list.ListResult{result}) + } + + return r + } + + managedResource := func() resource.Resource { + return &testprovider.ResourceWithIdentity{ + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "id": identityschema.StringAttribute{}, + }, + } + }, + Resource: &testprovider.Resource{ + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + SchemaMethod: func(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "id": resourceschema.StringAttribute{}, + "name": resourceschema.StringAttribute{}, + }, + } + }, + }, + } + } + + server := func(listResource func() list.ListResource, managedResource func() resource.Resource) *Server { + return &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ListResourcesMethod: func(ctx context.Context) []func() list.ListResource { + return []func() list.ListResource{ + listResource, + } + }, + ResourcesMethod: func(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + managedResource, + } + }, + }, + }, + } + } + + happyServer := server(listResource, managedResource) + + testCases := map[string]struct { + server *Server + request *tfprotov6.ListResourceRequest + expectedError error + expectedDiagnostics diag.Diagnostics + expectedResults []tfprotov6.ListResourceResult + }{ + "error-on-unknown-list-resource-type": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ListResourcesMethod: func(ctx context.Context) []func() list.ListResource { + return []func() list.ListResource{} + }, + }, + }, + }, + request: &tfprotov6.ListResourceRequest{ + TypeName: "test_resource", + Config: plat, + }, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov6.ListResourceResult{ + { + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "List Resource Type Not Found", + }, + }, + }, + }, + }, + "result": { + server: happyServer, + request: &tfprotov6.ListResourceRequest{ + TypeName: "test_resource", + Config: plat, + }, + expectedError: nil, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov6.ListResourceResult{ + { + DisplayName: "plateau", + Identity: expectedResourceIdentities["plateau"], + }, + { + DisplayName: "platinum", + Identity: expectedResourceIdentities["platinum"], + }, + { + DisplayName: "platypus", + Identity: expectedResourceIdentities["platypus"], + }, + }, + }, + "result-with-include-resource": { + server: happyServer, + request: &tfprotov6.ListResourceRequest{ + TypeName: "test_resource", + Config: plateau, + IncludeResource: true, + }, + expectedError: nil, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov6.ListResourceResult{ + { + DisplayName: "plateau", + Identity: expectedResourceIdentities["plateau"], + Resource: expectedResources["plateau"], + }, + }, + }, + "result-with-include-resource-warning": { + server: server(listResourceThatDoesNotPopulateResource, managedResource), + request: &tfprotov6.ListResourceRequest{ + TypeName: "test_resource", + Config: plateau, + IncludeResource: true, + }, + expectedError: nil, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov6.ListResourceResult{ + { + DisplayName: "plateau", + Identity: expectedResourceIdentities["plateau"], + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "Incomplete List Result", + Detail: "The provider did not populate the Resource field in the ListResourceResult. This may be due to the provider not supporting this functionality or an error in the provider's implementation.", + }, + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + metadataResponse := &fwserver.GetMetadataResponse{} + testCase.server.FrameworkServer.GetMetadata(context.Background(), &fwserver.GetMetadataRequest{}, metadataResponse) + + if diff := cmp.Diff(metadataResponse.Diagnostics, diag.Diagnostics{}); diff != "" { + t.Fatalf("unexpected metadata diagnostics difference: got %s\nwanted %s", metadataResponse.Diagnostics, diag.Diagnostics{}) + } + + got, err := testCase.server.ListResource(context.Background(), testCase.request) + + if diff := cmp.Diff(testCase.expectedError, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("unexpected error difference: %s", diff) + } + + sortResults := cmpopts.SortSlices(func(a, b tfprotov6.ListResourceResult) bool { + return a.DisplayName < b.DisplayName + }) + opts := []cmp.Option{ + sortResults, + cmpopts.EquateEmpty(), + cmpopts.IgnoreFields(tfprotov6.Diagnostic{}, "Detail"), + } + if diff := cmp.Diff(testCase.expectedResults, slices.Collect(got.Results), opts...); diff != "" { + t.Errorf("unexpected results difference: %s", diff) + } + }) + } +} diff --git a/internal/proto6server/server_validatelistresourceconfig.go b/internal/proto6server/server_validatelistresourceconfig.go new file mode 100644 index 000000000..f3b282018 --- /dev/null +++ b/internal/proto6server/server_validatelistresourceconfig.go @@ -0,0 +1,14 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto6server + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +func (s *Server) ValidateListResourceConfig(ctx context.Context, request *tfprotov6.ValidateListResourceConfigRequest) (*tfprotov6.ValidateListResourceConfigResponse, error) { + return &tfprotov6.ValidateListResourceConfigResponse{}, nil +} diff --git a/internal/toproto5/list_resource_result.go b/internal/toproto5/list_resource_result.go new file mode 100644 index 000000000..262fa0559 --- /dev/null +++ b/internal/toproto5/list_resource_result.go @@ -0,0 +1,51 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto5 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +func ListResourceResult(ctx context.Context, result *fwserver.ListResult) tfprotov5.ListResourceResult { + diags := result.Diagnostics + if diags.HasError() { + return tfprotov5.ListResourceResult{ + Diagnostics: Diagnostics(ctx, diags), + } + } + + resourceIdentity, d := ResourceIdentity(ctx, result.Identity) + diags.Append(d...) + + return tfprotov5.ListResourceResult{ + DisplayName: result.DisplayName, + Identity: resourceIdentity, + Diagnostics: Diagnostics(ctx, result.Diagnostics), + } +} + +func ListResourceResultWithResource(ctx context.Context, result *fwserver.ListResult) tfprotov5.ListResourceResult { + diags := result.Diagnostics + if diags.HasError() { + return tfprotov5.ListResourceResult{ + Diagnostics: Diagnostics(ctx, diags), + } + } + + resourceIdentity, d := ResourceIdentity(ctx, result.Identity) + diags.Append(d...) + + resource, d := Resource(ctx, result.Resource) + diags.Append(d...) + + return tfprotov5.ListResourceResult{ + DisplayName: result.DisplayName, + Identity: resourceIdentity, + Resource: resource, + Diagnostics: Diagnostics(ctx, result.Diagnostics), + } +} diff --git a/internal/toproto5/listresourcemetadata.go b/internal/toproto5/listresourcemetadata.go new file mode 100644 index 000000000..f27ab46f3 --- /dev/null +++ b/internal/toproto5/listresourcemetadata.go @@ -0,0 +1,19 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto5 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +// ListResourceMetadata returns the tfprotov5.ListResourceMetadata for a +// fwserver.ListResourceMetadata. +func ListResourceMetadata(ctx context.Context, fw fwserver.ListResourceMetadata) tfprotov5.ListResourceMetadata { + return tfprotov5.ListResourceMetadata{ + TypeName: fw.TypeName, + } +} diff --git a/internal/toproto5/resource.go b/internal/toproto5/resource.go new file mode 100644 index 000000000..76f7b8fb4 --- /dev/null +++ b/internal/toproto5/resource.go @@ -0,0 +1,28 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto5 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschemadata" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +// Resource returns the *tfprotov5.DynamicValue for a *tfsdk.Resource. +func Resource(ctx context.Context, fw *tfsdk.Resource) (*tfprotov5.DynamicValue, diag.Diagnostics) { + if fw == nil { + return nil, nil + } + + data := &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionResource, + Schema: fw.Schema, + TerraformValue: fw.Raw, + } + + return DynamicValue(ctx, data) +} diff --git a/internal/toproto6/list_resource_result.go b/internal/toproto6/list_resource_result.go new file mode 100644 index 000000000..1ac945e51 --- /dev/null +++ b/internal/toproto6/list_resource_result.go @@ -0,0 +1,51 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto6 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +func ListResourceResult(ctx context.Context, result *fwserver.ListResult) tfprotov6.ListResourceResult { + diags := result.Diagnostics + if diags.HasError() { + return tfprotov6.ListResourceResult{ + Diagnostics: Diagnostics(ctx, diags), + } + } + + resourceIdentity, d := ResourceIdentity(ctx, result.Identity) + diags.Append(d...) + + return tfprotov6.ListResourceResult{ + DisplayName: result.DisplayName, + Identity: resourceIdentity, + Diagnostics: Diagnostics(ctx, result.Diagnostics), + } +} + +func ListResourceResultWithResource(ctx context.Context, result *fwserver.ListResult) tfprotov6.ListResourceResult { + diags := result.Diagnostics + if diags.HasError() { + return tfprotov6.ListResourceResult{ + Diagnostics: Diagnostics(ctx, diags), + } + } + + resourceIdentity, d := ResourceIdentity(ctx, result.Identity) + diags.Append(d...) + + resource, d := Resource(ctx, result.Resource) + diags.Append(d...) + + return tfprotov6.ListResourceResult{ + DisplayName: result.DisplayName, + Identity: resourceIdentity, + Resource: resource, + Diagnostics: Diagnostics(ctx, result.Diagnostics), + } +} diff --git a/internal/toproto6/listresourcemetadata.go b/internal/toproto6/listresourcemetadata.go new file mode 100644 index 000000000..6a6d8ab2d --- /dev/null +++ b/internal/toproto6/listresourcemetadata.go @@ -0,0 +1,19 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto6 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// ListResourceMetadata returns the tfprotov6.ListResourceMetadata for a +// fwserver.ListResourceMetadata. +func ListResourceMetadata(ctx context.Context, fw fwserver.ListResourceMetadata) tfprotov6.ListResourceMetadata { + return tfprotov6.ListResourceMetadata{ + TypeName: fw.TypeName, + } +} diff --git a/internal/toproto6/resource.go b/internal/toproto6/resource.go new file mode 100644 index 000000000..2aa6e8dd5 --- /dev/null +++ b/internal/toproto6/resource.go @@ -0,0 +1,28 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto6 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschemadata" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// Resource returns the *tfprotov6.DynamicValue for a *tfsdk.Resource. +func Resource(ctx context.Context, fw *tfsdk.Resource) (*tfprotov6.DynamicValue, diag.Diagnostics) { + if fw == nil { + return nil, nil + } + + data := &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionResource, + Schema: fw.Schema, + TerraformValue: fw.Raw, + } + + return DynamicValue(ctx, data) +} diff --git a/list/list_resource.go b/list/list_resource.go index a5d36a533..75144f099 100644 --- a/list/list_resource.go +++ b/list/list_resource.go @@ -8,6 +8,7 @@ import ( "iter" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) @@ -97,6 +98,9 @@ type ListRequest struct { // IncludeResource indicates whether the provider should populate the // [ListResult.Resource] field. IncludeResource bool + + ResourceSchema fwschema.Schema + ResourceIdentitySchema fwschema.Schema } // ListResultsStream represents a streaming response to a [ListRequest]. An @@ -113,6 +117,9 @@ type ListResultsStream struct { // To indicate a fatal processing error, push a [ListResult] that contains // a [diag.ErrorDiagnostic]. Results iter.Seq[ListResult] + + // Diagnostics report errors or warnings related to the list operation. + Diagnostics diag.Diagnostics } // NoListResults is an iterator that pushes zero results. diff --git a/list/tosdk.go b/list/tosdk.go new file mode 100644 index 000000000..cabf10bf9 --- /dev/null +++ b/list/tosdk.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package list + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +func (r ListRequest) ToResource(ctx context.Context, val any) (*tfsdk.Resource, diag.Diagnostics) { + resource := &tfsdk.Resource{Schema: r.ResourceSchema} + diags := resource.Set(ctx, val) + return resource, diags +} + +func (r ListRequest) ToIdentity(ctx context.Context, val any) (*tfsdk.ResourceIdentity, diag.Diagnostics) { + identity := &tfsdk.ResourceIdentity{Schema: r.ResourceIdentitySchema} + diags := identity.Set(ctx, val) + + return identity, diags +} + +func (r ListRequest) ToListResult(ctx context.Context, identityVal any, resourceVal any, displayName string) ListResult { + allDiags := diag.Diagnostics{} + + identity, diags := r.ToIdentity(ctx, identityVal) + allDiags.Append(diags...) + if diags.HasError() { + return ListResult{Diagnostics: allDiags} + } + + var resource *tfsdk.Resource + if r.IncludeResource && resourceVal != nil { + resource, diags = r.ToResource(ctx, resourceVal) + allDiags.Append(diags...) + if diags.HasError() { + return ListResult{Diagnostics: allDiags} + } + } + + return ListResult{ + DisplayName: displayName, + Resource: resource, + Identity: identity, + Diagnostics: allDiags, + } +} diff --git a/tfsdk/resource.go b/tfsdk/resource.go index 49bc09b8e..38aac19ad 100644 --- a/tfsdk/resource.go +++ b/tfsdk/resource.go @@ -20,8 +20,8 @@ type Resource struct { } // Get populates the struct passed as `target` with the resource. -func (c Resource) Get(ctx context.Context, target interface{}) diag.Diagnostics { - return c.data().Get(ctx, target) +func (r Resource) Get(ctx context.Context, target interface{}) diag.Diagnostics { + return r.data().Get(ctx, target) } // GetAttribute retrieves the attribute or block found at `path` and populates @@ -31,8 +31,8 @@ func (c Resource) Get(ctx context.Context, target interface{}) diag.Diagnostics // // Attributes or elements under null or unknown collections return null // values, however this behavior is not protected by compatibility promises. -func (c Resource) GetAttribute(ctx context.Context, path path.Path, target interface{}) diag.Diagnostics { - return c.data().GetAtPath(ctx, path, target) +func (r Resource) GetAttribute(ctx context.Context, path path.Path, target interface{}) diag.Diagnostics { + return r.data().GetAtPath(ctx, path, target) } // PathMatches returns all matching path.Paths from the given path.Expression. @@ -40,14 +40,30 @@ func (c Resource) GetAttribute(ctx context.Context, path path.Path, target inter // If a parent path is null or unknown, which would prevent a full expression // from matching, the parent path is returned rather than no match to prevent // false positives. -func (c Resource) PathMatches(ctx context.Context, pathExpr path.Expression) (path.Paths, diag.Diagnostics) { - return c.data().PathMatches(ctx, pathExpr) +func (r Resource) PathMatches(ctx context.Context, pathExpr path.Expression) (path.Paths, diag.Diagnostics) { + return r.data().PathMatches(ctx, pathExpr) } -func (c Resource) data() fwschemadata.Data { +// Set populates the entire identity using the supplied Go value. The value `val` +// should be a struct whose values have one of the attr.Value types. Each field +// must be tagged with the corresponding schema field. +func (r *Resource) Set(ctx context.Context, val interface{}) diag.Diagnostics { + data := r.data() + diags := data.Set(ctx, val) + + if diags.HasError() { + return diags + } + + r.Raw = data.TerraformValue + + return diags +} + +func (r Resource) data() fwschemadata.Data { return fwschemadata.Data{ - Description: fwschemadata.DataDescriptionConfiguration, - Schema: c.Schema, - TerraformValue: c.Raw, + Description: fwschemadata.DataDescriptionResource, + Schema: r.Schema, + TerraformValue: r.Raw, } } diff --git a/tfsdk/resource_identity.go b/tfsdk/resource_identity.go index fd9f30579..98737d6a3 100644 --- a/tfsdk/resource_identity.go +++ b/tfsdk/resource_identity.go @@ -5,6 +5,7 @@ package tfsdk import ( "context" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschemadata" From fe9a7fa455c8644e48dcaedd854af82819f17f60 Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Fri, 13 Jun 2025 22:47:44 -0400 Subject: [PATCH 03/12] Remove unused error type --- internal/fwserver/server_listresources.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/internal/fwserver/server_listresources.go b/internal/fwserver/server_listresources.go index c1a314d2e..ffb3f2231 100644 --- a/internal/fwserver/server_listresources.go +++ b/internal/fwserver/server_listresources.go @@ -15,23 +15,6 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" ) -type ListResourceTypeNotFoundError struct { - TypeName string -} - -func (e *ListResourceTypeNotFoundError) Error() string { - return "listResource Type Not Found: no listResource type named " + e.TypeName + " was found in the provider." -} - -func (e *ListResourceTypeNotFoundError) Is(err error) bool { - compatibleErr, ok := err.(*ListResourceTypeNotFoundError) - if !ok { - return false - } - - return e.TypeName == compatibleErr.TypeName -} - func (s *Server) ListResourceType(ctx context.Context, typeName string) (list.ListResource, diag.Diagnostics) { listResourceFuncs, diags := s.ListResourceFuncs(ctx) listResourceFunc, ok := listResourceFuncs[typeName] From a193b8dd3663b75aea6d09b56d01facd29fe4233 Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Fri, 13 Jun 2025 22:50:14 -0400 Subject: [PATCH 04/12] Tidy doc comments --- internal/fwserver/server_listresource.go | 4 ++-- list/list_resource.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/fwserver/server_listresource.go b/internal/fwserver/server_listresource.go index 2053d3dab..bda8ba870 100644 --- a/internal/fwserver/server_listresource.go +++ b/internal/fwserver/server_listresource.go @@ -56,8 +56,8 @@ func ListResultError(summary string, detail string) ListResult { // ListResult represents a listed managed resource instance. type ListResult struct { - // Identity is the identity of the managed resource instance. A nil value - // will raise will raise a diagnostic. + // Identity is the identity of the managed resource instance. A nil value + // will raise an error diagnostic. Identity *tfsdk.ResourceIdentity // Resource is the provider's representation of the attributes of the diff --git a/list/list_resource.go b/list/list_resource.go index 75144f099..311d7a033 100644 --- a/list/list_resource.go +++ b/list/list_resource.go @@ -129,7 +129,7 @@ var NoListResults = func(func(ListResult) bool) {} type ListResult struct { // Identity is the identity of the managed resource instance. // - // A nil value will raise will raise a diagnostic. + // A nil value will raise an error diagnostic. Identity *tfsdk.ResourceIdentity // Resource is the provider's representation of the attributes of the From a5a3d3f84e6833655048e0e8cdf9623ae1aa6f17 Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Fri, 13 Jun 2025 22:51:42 -0400 Subject: [PATCH 05/12] More correct processListResult() --- internal/fwserver/server_listresource.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/fwserver/server_listresource.go b/internal/fwserver/server_listresource.go index bda8ba870..77c1a6bbc 100644 --- a/internal/fwserver/server_listresource.go +++ b/internal/fwserver/server_listresource.go @@ -133,7 +133,7 @@ func processListResult(req list.ListRequest, result list.ListResult) ListResult if req.IncludeResource { if result.Resource == nil { // TODO: is result.Resource.Raw.IsNull() a practical concern? - result.Resource = nil + result.Identity = nil result.Diagnostics.AddWarning( "Incomplete List Result", "The provider did not populate the Resource field in the ListResourceResult. This may be due to the provider not supporting this functionality or an error in the provider's implementation.", From 3ff5bfa66400a6e5988283c0747d5e477144851f Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Fri, 13 Jun 2025 22:55:13 -0400 Subject: [PATCH 06/12] Tidy diagnostics handling in list_resource_result.go --- internal/toproto5/list_resource_result.go | 28 +++++++++++------------ internal/toproto6/list_resource_result.go | 28 +++++++++++------------ 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/internal/toproto5/list_resource_result.go b/internal/toproto5/list_resource_result.go index 262fa0559..6ed77d12a 100644 --- a/internal/toproto5/list_resource_result.go +++ b/internal/toproto5/list_resource_result.go @@ -11,41 +11,41 @@ import ( ) func ListResourceResult(ctx context.Context, result *fwserver.ListResult) tfprotov5.ListResourceResult { - diags := result.Diagnostics - if diags.HasError() { + allDiags := result.Diagnostics + if allDiags.HasError() { return tfprotov5.ListResourceResult{ - Diagnostics: Diagnostics(ctx, diags), + Diagnostics: Diagnostics(ctx, allDiags), } } - resourceIdentity, d := ResourceIdentity(ctx, result.Identity) - diags.Append(d...) + resourceIdentity, diags := ResourceIdentity(ctx, result.Identity) + allDiags.Append(diags...) return tfprotov5.ListResourceResult{ DisplayName: result.DisplayName, Identity: resourceIdentity, - Diagnostics: Diagnostics(ctx, result.Diagnostics), + Diagnostics: Diagnostics(ctx, allDiags), } } func ListResourceResultWithResource(ctx context.Context, result *fwserver.ListResult) tfprotov5.ListResourceResult { - diags := result.Diagnostics - if diags.HasError() { + allDiags := result.Diagnostics + if allDiags.HasError() { return tfprotov5.ListResourceResult{ - Diagnostics: Diagnostics(ctx, diags), + Diagnostics: Diagnostics(ctx, allDiags), } } - resourceIdentity, d := ResourceIdentity(ctx, result.Identity) - diags.Append(d...) + resourceIdentity, diags := ResourceIdentity(ctx, result.Identity) + allDiags.Append(diags...) - resource, d := Resource(ctx, result.Resource) - diags.Append(d...) + resource, diags := Resource(ctx, result.Resource) + allDiags.Append(diags...) return tfprotov5.ListResourceResult{ DisplayName: result.DisplayName, Identity: resourceIdentity, Resource: resource, - Diagnostics: Diagnostics(ctx, result.Diagnostics), + Diagnostics: Diagnostics(ctx, allDiags), } } diff --git a/internal/toproto6/list_resource_result.go b/internal/toproto6/list_resource_result.go index 1ac945e51..a2945b897 100644 --- a/internal/toproto6/list_resource_result.go +++ b/internal/toproto6/list_resource_result.go @@ -11,41 +11,41 @@ import ( ) func ListResourceResult(ctx context.Context, result *fwserver.ListResult) tfprotov6.ListResourceResult { - diags := result.Diagnostics - if diags.HasError() { + allDiags := result.Diagnostics + if allDiags.HasError() { return tfprotov6.ListResourceResult{ - Diagnostics: Diagnostics(ctx, diags), + Diagnostics: Diagnostics(ctx, allDiags), } } - resourceIdentity, d := ResourceIdentity(ctx, result.Identity) - diags.Append(d...) + resourceIdentity, diags := ResourceIdentity(ctx, result.Identity) + allDiags.Append(diags...) return tfprotov6.ListResourceResult{ DisplayName: result.DisplayName, Identity: resourceIdentity, - Diagnostics: Diagnostics(ctx, result.Diagnostics), + Diagnostics: Diagnostics(ctx, allDiags), } } func ListResourceResultWithResource(ctx context.Context, result *fwserver.ListResult) tfprotov6.ListResourceResult { - diags := result.Diagnostics - if diags.HasError() { + allDiags := result.Diagnostics + if allDiags.HasError() { return tfprotov6.ListResourceResult{ - Diagnostics: Diagnostics(ctx, diags), + Diagnostics: Diagnostics(ctx, allDiags), } } - resourceIdentity, d := ResourceIdentity(ctx, result.Identity) - diags.Append(d...) + resourceIdentity, diags := ResourceIdentity(ctx, result.Identity) + allDiags.Append(diags...) - resource, d := Resource(ctx, result.Resource) - diags.Append(d...) + resource, diags := Resource(ctx, result.Resource) + allDiags.Append(diags...) return tfprotov6.ListResourceResult{ DisplayName: result.DisplayName, Identity: resourceIdentity, Resource: resource, - Diagnostics: Diagnostics(ctx, result.Diagnostics), + Diagnostics: Diagnostics(ctx, allDiags), } } From eecfd7640506f34233e67962e845d89337748dbb Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Fri, 13 Jun 2025 22:59:06 -0400 Subject: [PATCH 07/12] fixup! More correct processListResult() --- internal/fwserver/server_listresource.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/fwserver/server_listresource.go b/internal/fwserver/server_listresource.go index 77c1a6bbc..075658792 100644 --- a/internal/fwserver/server_listresource.go +++ b/internal/fwserver/server_listresource.go @@ -133,7 +133,6 @@ func processListResult(req list.ListRequest, result list.ListResult) ListResult if req.IncludeResource { if result.Resource == nil { // TODO: is result.Resource.Raw.IsNull() a practical concern? - result.Identity = nil result.Diagnostics.AddWarning( "Incomplete List Result", "The provider did not populate the Resource field in the ListResourceResult. This may be due to the provider not supporting this functionality or an error in the provider's implementation.", From 7f3191de86a6eea44650aa1799c41156c3693d08 Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Fri, 13 Jun 2025 23:21:19 -0400 Subject: [PATCH 08/12] fwserver.ListResourceSchema++ --- internal/fwserver/server.go | 10 +++++ internal/fwserver/server_listresources.go | 49 ++++++++++++++++++----- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/internal/fwserver/server.go b/internal/fwserver/server.go index e3cb1aefb..f178b21aa 100644 --- a/internal/fwserver/server.go +++ b/internal/fwserver/server.go @@ -125,6 +125,16 @@ type Server struct { // access from race conditions. listResourceFuncsMutex sync.Mutex + // listResourceSchemas is the cached ListResource Schemas for RPCs that + // need to convert configuration data from the protocol. If not found, it + // will be fetched from the [list.ListResource.ListResourceConfigSchema] + // method. + listResourceSchemas map[string]fwschema.Schema + + // listResourceSchemasMutex is a mutex to protect concurrent + // listResourceSchemas access from race conditions. + listResourceSchemasMutex sync.RWMutex + // providerSchema is the cached Provider Schema for RPCs that need to // convert configuration data from the protocol. If not found, it will be // fetched from the Provider.GetSchema() method. diff --git a/internal/fwserver/server_listresources.go b/internal/fwserver/server_listresources.go index ffb3f2231..94760afb3 100644 --- a/internal/fwserver/server_listresources.go +++ b/internal/fwserver/server_listresources.go @@ -117,19 +117,48 @@ func (s *Server) ListResourceMetadatas(ctx context.Context) ([]ListResourceMetad return listResourceMetadatas, diags } +// ListResourceSchema returns the ListResource Schema for the given type name and +// caches the result for later ListResource operations. func (s *Server) ListResourceSchema(ctx context.Context, typeName string) (fwschema.Schema, diag.Diagnostics) { - schemas, _ := s.ListResourceSchemas(ctx) - schema, ok := schemas[typeName] - if !ok { - return nil, diag.Diagnostics{ - diag.NewErrorDiagnostic( - "ListResource Schema Not Found", - fmt.Sprintf("No ListResource schema was found for type %q.", typeName), - ), - } + s.listResourceSchemasMutex.RLock() + listResourceSchema, ok := s.listResourceSchemas[typeName] + s.listResourceSchemasMutex.RUnlock() + + if ok { + return listResourceSchema, nil + } + + var diags diag.Diagnostics + + listResource, listResourceDiags := s.ListResourceType(ctx, typeName) + diags.Append(listResourceDiags...) + if diags.HasError() { + return nil, diags + } + + schemaReq := list.ListResourceSchemaRequest{} + schemaResp := list.ListResourceSchemaResponse{} + + logging.FrameworkTrace(ctx, "Calling provider defined ListResourceConfigSchema method", map[string]interface{}{logging.KeyListResourceType: typeName}) + listResource.ListResourceConfigSchema(ctx, schemaReq, &schemaResp) + logging.FrameworkTrace(ctx, "Called provider defined ListResourceConfigSchema method", map[string]interface{}{logging.KeyListResourceType: typeName}) + + diags.Append(schemaResp.Diagnostics...) + if diags.HasError() { + return schemaResp.Schema, diags + } + + s.listResourceSchemasMutex.Lock() + + if s.listResourceSchemas == nil { + s.listResourceSchemas = make(map[string]fwschema.Schema) } - return schema, nil + s.listResourceSchemas[typeName] = schemaResp.Schema + + s.listResourceSchemasMutex.Unlock() + + return schemaResp.Schema, diags } // ListResourceSchemas returns a map of ListResource Schemas for the From f6b4307406f15d91938aff80b1cd7d54706dba8c Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Fri, 13 Jun 2025 23:23:09 -0400 Subject: [PATCH 09/12] Tidy comments --- internal/fwserver/server_listresource.go | 6 +++--- internal/fwserver/server_listresource_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/fwserver/server_listresource.go b/internal/fwserver/server_listresource.go index 075658792..03ebdbd05 100644 --- a/internal/fwserver/server_listresource.go +++ b/internal/fwserver/server_listresource.go @@ -88,8 +88,8 @@ func (s *Server) ListResource(ctx context.Context, fwReq *ListRequest, fwStream req := list.ListRequest{ Config: *fwReq.Config, IncludeResource: fwReq.IncludeResource, - ResourceSchema: fwReq.ResourceSchema, // TODO: revisit - ResourceIdentitySchema: fwReq.ResourceIdentitySchema, // TODO: revisit + ResourceSchema: fwReq.ResourceSchema, + ResourceIdentitySchema: fwReq.ResourceIdentitySchema, } stream := &list.ListResultsStream{} @@ -140,5 +140,5 @@ func processListResult(req list.ListRequest, result list.ListResult) ListResult } } - return ListResult(result) // TODO: do we need to .Copy() the raw Identity and Resource values? + return ListResult(result) // TODO: do we want to .Copy() the raw Identity and Resource values? } diff --git a/internal/fwserver/server_listresource_test.go b/internal/fwserver/server_listresource_test.go index 6dbd0f66a..87eb058bf 100644 --- a/internal/fwserver/server_listresource_test.go +++ b/internal/fwserver/server_listresource_test.go @@ -109,7 +109,7 @@ func TestServerListResource(t *testing.T) { request: &fwserver.ListRequest{ Config: &tfsdk.Config{}, ListResource: &testprovider.ListResource{ - ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { // TODO + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { resp.Results = slices.Values([]list.ListResult{ { Identity: &tfsdk.ResourceIdentity{ From d18edd00fa359426e958457fa2f1ab6a952317be Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Wed, 18 Jun 2025 10:30:32 -0400 Subject: [PATCH 10/12] Tidy --- internal/fwserver/server_listresource.go | 2 +- internal/proto5server/serve.go | 1 - internal/proto6server/serve.go | 1 - list/list_resource.go | 3 --- tfsdk/resource.go | 2 +- 5 files changed, 2 insertions(+), 7 deletions(-) diff --git a/internal/fwserver/server_listresource.go b/internal/fwserver/server_listresource.go index 03ebdbd05..e69e2f9ec 100644 --- a/internal/fwserver/server_listresource.go +++ b/internal/fwserver/server_listresource.go @@ -140,5 +140,5 @@ func processListResult(req list.ListRequest, result list.ListResult) ListResult } } - return ListResult(result) // TODO: do we want to .Copy() the raw Identity and Resource values? + return ListResult(result) } diff --git a/internal/proto5server/serve.go b/internal/proto5server/serve.go index 5ce3a7919..c0f44d92d 100644 --- a/internal/proto5server/serve.go +++ b/internal/proto5server/serve.go @@ -12,7 +12,6 @@ import ( ) var _ tfprotov5.ProviderServer = &Server{} -var _ tfprotov5.ProviderServerWithListResource = &Server{} //nolint:staticcheck // Provider server implementation. type Server struct { diff --git a/internal/proto6server/serve.go b/internal/proto6server/serve.go index d3d438253..26cf0c4e1 100644 --- a/internal/proto6server/serve.go +++ b/internal/proto6server/serve.go @@ -12,7 +12,6 @@ import ( ) var _ tfprotov6.ProviderServer = &Server{} -var _ tfprotov6.ProviderServerWithListResource = &Server{} //nolint:staticcheck // Provider server implementation. type Server struct { diff --git a/list/list_resource.go b/list/list_resource.go index 311d7a033..8e793dc70 100644 --- a/list/list_resource.go +++ b/list/list_resource.go @@ -117,9 +117,6 @@ type ListResultsStream struct { // To indicate a fatal processing error, push a [ListResult] that contains // a [diag.ErrorDiagnostic]. Results iter.Seq[ListResult] - - // Diagnostics report errors or warnings related to the list operation. - Diagnostics diag.Diagnostics } // NoListResults is an iterator that pushes zero results. diff --git a/tfsdk/resource.go b/tfsdk/resource.go index 38aac19ad..fc6f485f6 100644 --- a/tfsdk/resource.go +++ b/tfsdk/resource.go @@ -44,7 +44,7 @@ func (r Resource) PathMatches(ctx context.Context, pathExpr path.Expression) (pa return r.data().PathMatches(ctx, pathExpr) } -// Set populates the entire identity using the supplied Go value. The value `val` +// Set populates the entire resource using the supplied Go value. The value `val` // should be a struct whose values have one of the attr.Value types. Each field // must be tagged with the corresponding schema field. func (r *Resource) Set(ctx context.Context, val interface{}) diag.Diagnostics { From 7e41955d4fcc6b7357b8558b2cf2f9877d75524a Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Wed, 18 Jun 2025 10:32:51 -0400 Subject: [PATCH 11/12] fwserver: add a test --- internal/fwserver/server_listresource.go | 3 +++ internal/fwserver/server_listresource_test.go | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/internal/fwserver/server_listresource.go b/internal/fwserver/server_listresource.go index e69e2f9ec..37adb9ff3 100644 --- a/internal/fwserver/server_listresource.go +++ b/internal/fwserver/server_listresource.go @@ -77,11 +77,14 @@ type ListResult struct { Diagnostics diag.Diagnostics } +var NoListResults = func(func(ListResult) bool) {} + // ListResource implements the framework server ListResource RPC. func (s *Server) ListResource(ctx context.Context, fwReq *ListRequest, fwStream *ListResultsStream) error { listResource := fwReq.ListResource if fwReq.Config == nil { + fwStream.Results = NoListResults return errors.New("Invalid ListResource request: Config cannot be nil") } diff --git a/internal/fwserver/server_listresource_test.go b/internal/fwserver/server_listresource_test.go index 87eb058bf..f404d29fa 100644 --- a/internal/fwserver/server_listresource_test.go +++ b/internal/fwserver/server_listresource_test.go @@ -73,6 +73,7 @@ func TestServerListResource(t *testing.T) { server *fwserver.Server request *fwserver.ListRequest expectedStreamEvents []fwserver.ListResult + expectedError string }{ "success-with-zero-results": { server: &fwserver.Server{ @@ -166,6 +167,21 @@ func TestServerListResource(t *testing.T) { }, }, }, + "error-on-nil-config": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + Config: nil, + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + resp.Results = list.NoListResults + }, + }, + }, + expectedError: "Invalid ListResource request: Config cannot be nil", + expectedStreamEvents: []fwserver.ListResult{}, + }, "error-on-nil-resource-identity": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, @@ -244,7 +260,7 @@ func TestServerListResource(t *testing.T) { response := &fwserver.ListResultsStream{} err := testCase.server.ListResource(context.Background(), testCase.request, response) - if err != nil { + if err != nil && err.Error() != testCase.expectedError { t.Fatalf("unexpected error: %s", err) } From 9590b11bb23a04f2e90523015a8f3d4fee653c34 Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Wed, 18 Jun 2025 10:37:56 -0400 Subject: [PATCH 12/12] go get github.com/hashicorp/terraform-plugin-go@a19df43120ead10fa3e9daaa40dc65a33a10f6af --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 6b40abeec..9ad4347fc 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.23.7 require ( github.com/google/go-cmp v0.7.0 - github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250612194609-92d41e5e5c0a + github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250616135123-a19df43120ea github.com/hashicorp/terraform-plugin-log v0.9.0 ) diff --git a/go.sum b/go.sum index 6160824b5..33da5983d 100644 --- a/go.sum +++ b/go.sum @@ -21,8 +21,8 @@ github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0U github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250612194609-92d41e5e5c0a h1:nVzFWFdKJ1DB0j9Q8DXd9uyrsTYq2ehZGAYuYWyqi2w= -github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250612194609-92d41e5e5c0a/go.mod h1:hL//wLEfYo0YVt0TC/VLzia/ADQQto3HEm4/jX2gkdY= +github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250616135123-a19df43120ea h1:U9EAAeQtszGlR7mDS7rY77B/a4/XiMDB8HfAtqLAuAQ= +github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250616135123-a19df43120ea/go.mod h1:hL//wLEfYo0YVt0TC/VLzia/ADQQto3HEm4/jX2gkdY= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-registry-address v0.3.0 h1:HMpK3nqaGFPS9VmgRXrJL/dzHNdheGVKk5k7VlFxzCo=