diff --git a/.changes/unreleased/ENHANCEMENTS-20250807-180902.yaml b/.changes/unreleased/ENHANCEMENTS-20250807-180902.yaml new file mode 100644 index 00000000000..bc1a105eee1 --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20250807-180902.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: 'helper/schema: Added new helper methods for converting Resource and Identity schemas to protocol representations.' +time: 2025-08-07T18:09:02.117077-04:00 +custom: + Issue: "1504" diff --git a/helper/schema/core_schema.go b/helper/schema/core_schema.go index 79f78a1dc31..d782491d6ae 100644 --- a/helper/schema/core_schema.go +++ b/helper/schema/core_schema.go @@ -4,11 +4,14 @@ package schema import ( + "context" "fmt" "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" + "github.com/hashicorp/terraform-plugin-sdk/v2/internal/plugin/convert" ) // StringKind represents the format a string is in. @@ -397,3 +400,37 @@ func (r *Resource) coreIdentitySchema() (*configschema.Block, error) { // to convert our schema return schemaMap(r.Identity.SchemaMap()).CoreConfigSchema(), nil } + +// ProtoSchema will return a function that returns the *tfprotov5.Schema +func (r *Resource) ProtoSchema(ctx context.Context) func() *tfprotov5.Schema { + return func() *tfprotov5.Schema { + return &tfprotov5.Schema{ + Version: int64(r.SchemaVersion), + Block: convert.ConfigSchemaToProto(ctx, r.CoreConfigSchema()), + } + } +} + +// ProtoIdentitySchema will return a function that returns the *tfprotov5.ResourceIdentitySchema if the resource supports identity, +// otherwise it will return nil. +func (r *Resource) ProtoIdentitySchema(ctx context.Context) func() *tfprotov5.ResourceIdentitySchema { + // Resource doesn't support identity, return nil + if r.Identity == nil { + return nil + } + + return func() *tfprotov5.ResourceIdentitySchema { + idschema, err := r.CoreIdentitySchema() + + if err != nil { + // This shouldn't be reachable unless there is an implementation error in the provider, which should raise + // a diagnostic prior to reaching this point. + panic(fmt.Sprintf("unexpected error retrieving identity schema: %s", err)) + } + + return &tfprotov5.ResourceIdentitySchema{ + Version: r.Identity.Version, + IdentityAttributes: convert.ConfigIdentitySchemaToProto(ctx, idschema), + } + } +} diff --git a/helper/schema/core_schema_proto_test.go b/helper/schema/core_schema_proto_test.go new file mode 100644 index 00000000000..6856d121883 --- /dev/null +++ b/helper/schema/core_schema_proto_test.go @@ -0,0 +1,749 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema_test + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestProtoSchema(t *testing.T) { + tests := map[string]struct { + input *schema.Resource + expected *tfprotov5.Schema + }{ + "empty": { + input: &schema.Resource{ + Schema: map[string]*schema.Schema{}, + }, + expected: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + // ID is automatically added by SDKv2 + { + Name: "id", + Type: tftypes.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + "primitives": { + input: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "int": { + Type: schema.TypeInt, + Required: true, + Description: "foo bar baz", + }, + "float": { + Type: schema.TypeFloat, + Optional: true, + }, + "bool": { + Type: schema.TypeBool, + Computed: true, + }, + "string": { + Type: schema.TypeString, + Optional: true, + Computed: true, + }, + }, + }, + expected: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Computed: true, + }, + { + Name: "float", + Type: tftypes.Number, + Optional: true, + }, + // ID is automatically added by SDKv2 + { + Name: "id", + Type: tftypes.String, + Optional: true, + Computed: true, + }, + { + Name: "int", + Type: tftypes.Number, + Description: "foo bar baz", + Required: true, + }, + { + Name: "string", + Type: tftypes.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + "simple collections": { + input: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "list": { + Type: schema.TypeList, + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeInt, + }, + }, + "set": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "map": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeBool, + }, + }, + "map_default_type": { + Type: schema.TypeMap, + Optional: true, + // Maps historically don't have elements because we + // assumed they would be strings, so this needs to work + // for pre-existing schemas. + }, + }, + }, + expected: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + // ID is automatically added by SDKv2 + { + Name: "id", + Type: tftypes.String, + Optional: true, + Computed: true, + }, + { + Name: "list", + Type: tftypes.List{ElementType: tftypes.Number}, + Required: true, + }, + { + Name: "map", + Type: tftypes.Map{ElementType: tftypes.Bool}, + Optional: true, + }, + { + Name: "map_default_type", + Type: tftypes.Map{ElementType: tftypes.String}, + Optional: true, + }, + { + Name: "set", + Type: tftypes.Set{ElementType: tftypes.String}, + Optional: true, + }, + }, + }, + }, + }, + "incorrectly-specified collections": { + // Historically we tolerated setting a type directly as the Elem + // attribute, rather than a Schema object. This is common enough + // in existing provider code that we must support it as an alias + // for a schema object with the given type. + input: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "list": { + Type: schema.TypeList, + Required: true, + Elem: schema.TypeInt, + }, + "set": { + Type: schema.TypeSet, + Optional: true, + Elem: schema.TypeString, + }, + "map": { + Type: schema.TypeMap, + Optional: true, + Elem: schema.TypeBool, + }, + }, + }, + expected: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + // ID is automatically added by SDKv2 + { + Name: "id", + Type: tftypes.String, + Optional: true, + Computed: true, + }, + { + Name: "list", + Type: tftypes.List{ElementType: tftypes.Number}, + Required: true, + }, + { + Name: "map", + Type: tftypes.Map{ElementType: tftypes.Bool}, + Optional: true, + }, + { + Name: "set", + Type: tftypes.Set{ElementType: tftypes.String}, + Optional: true, + }, + }, + }, + }, + }, + "sub-resource collections": { + input: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "list": { + Type: schema.TypeList, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{}, + }, + MinItems: 1, + MaxItems: 2, + }, + "set": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{}, + }, + }, + "map": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{}, + }, + }, + }, + }, + expected: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + // ID is automatically added by SDKv2 + { + Name: "id", + Type: tftypes.String, + Optional: true, + Computed: true, + }, + // This one becomes a string attribute because helper/schema + // doesn't actually support maps of resource. The given + // "Elem" is just ignored entirely here, which is important + // because that is also true of the helper/schema logic and + // existing providers rely on this being ignored for + // correct operation. + { + Name: "map", + Type: tftypes.Map{ElementType: tftypes.String}, + Optional: true, + }, + }, + BlockTypes: []*tfprotov5.SchemaNestedBlock{ + { + TypeName: "list", + Block: &tfprotov5.SchemaBlock{}, + Nesting: tfprotov5.SchemaNestedBlockNestingModeList, + MinItems: 1, + MaxItems: 2, + }, + { + TypeName: "set", + Block: &tfprotov5.SchemaBlock{}, + Nesting: tfprotov5.SchemaNestedBlockNestingModeSet, + MinItems: 1, // because schema is Required + }, + }, + }, + }, + }, + "sub-resource collections minitems+optional": { + // This particular case is an odd one where the provider gives + // conflicting information about whether a sub-resource is required, + // by marking it as optional but also requiring one item. + // Historically the optional-ness "won" here, and so we must + // honor that for compatibility with providers that relied on this + // undocumented interaction. + input: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "list": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{}, + }, + MinItems: 1, + MaxItems: 1, + }, + "set": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{}, + }, + MinItems: 1, + MaxItems: 1, + }, + }, + }, + expected: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + // ID is automatically added by SDKv2 + { + Name: "id", + Type: tftypes.String, + Optional: true, + Computed: true, + }, + }, + BlockTypes: []*tfprotov5.SchemaNestedBlock{ + { + TypeName: "list", + Block: &tfprotov5.SchemaBlock{}, + Nesting: tfprotov5.SchemaNestedBlockNestingModeList, + MinItems: 0, + MaxItems: 1, + }, + { + TypeName: "set", + Block: &tfprotov5.SchemaBlock{}, + Nesting: tfprotov5.SchemaNestedBlockNestingModeSet, + MinItems: 0, + MaxItems: 1, + }, + }, + }, + }, + }, + "sub-resource collections minitems+computed": { + input: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "list": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{}, + }, + MinItems: 1, + MaxItems: 1, + }, + "set": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{}, + }, + MinItems: 1, + MaxItems: 1, + }, + }, + }, + expected: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + // ID is automatically added by SDKv2 + { + Name: "id", + Type: tftypes.String, + Optional: true, + Computed: true, + }, + { + Name: "list", + Type: tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{}}}, + Computed: true, + }, + { + Name: "set", + Type: tftypes.Set{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{}}}, + Computed: true, + }, + }, + }, + }, + }, + "nested attributes and blocks": { + input: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "foo": { + Type: schema.TypeList, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "bar": { + Type: schema.TypeList, + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeList, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + "baz": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{}, + }, + }, + }, + }, + }, + }, + }, + expected: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + // ID is automatically added by SDKv2 + { + Name: "id", + Type: tftypes.String, + Optional: true, + Computed: true, + }, + }, + BlockTypes: []*tfprotov5.SchemaNestedBlock{ + { + TypeName: "foo", + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "bar", + Type: tftypes.List{ElementType: tftypes.List{ElementType: tftypes.String}}, + Required: true, + }, + }, + BlockTypes: []*tfprotov5.SchemaNestedBlock{ + { + TypeName: "baz", + Nesting: tfprotov5.SchemaNestedBlockNestingModeSet, + Block: &tfprotov5.SchemaBlock{}, + }, + }, + }, + Nesting: tfprotov5.SchemaNestedBlockNestingModeList, + MinItems: 1, // because schema is Required + }, + }, + }, + }, + }, + "sensitive": { + input: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "string": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + }, + }, + expected: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + // ID is automatically added by SDKv2 + { + Name: "id", + Type: tftypes.String, + Optional: true, + Computed: true, + }, + { + Name: "string", + Type: tftypes.String, + Optional: true, + Sensitive: true, + }, + }, + }, + }, + }, + "conditionally required on": { + input: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "string": { + Type: schema.TypeString, + Required: true, + DefaultFunc: func() (interface{}, error) { + return nil, nil + }, + }, + }, + }, + expected: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + // ID is automatically added by SDKv2 + { + Name: "id", + Type: tftypes.String, + Optional: true, + Computed: true, + }, + { + Name: "string", + Type: tftypes.String, + Required: true, + }, + }, + }, + }, + }, + "conditionally required off": { + input: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "string": { + Type: schema.TypeString, + Required: true, + DefaultFunc: func() (interface{}, error) { + // If we return a non-nil default then this overrides + // the "Required: true" for the purpose of building + // the core schema, so that core will ignore it not + // being set and let the provider handle it. + return "boop", nil + }, + }, + }, + }, + expected: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + // ID is automatically added by SDKv2 + { + Name: "id", + Type: tftypes.String, + Optional: true, + Computed: true, + }, + { + Name: "string", + Type: tftypes.String, + Optional: true, + }, + }, + }, + }, + }, + "conditionally required error": { + input: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "string": { + Type: schema.TypeString, + Required: true, + DefaultFunc: func() (interface{}, error) { + return nil, fmt.Errorf("placeholder error") + }, + }, + }, + }, + expected: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + // ID is automatically added by SDKv2 + { + Name: "id", + Type: tftypes.String, + Optional: true, + Computed: true, + }, + { + Name: "string", + Type: tftypes.String, + Optional: true, // Just so we can progress to provider-driven validation and return the error there + }, + }, + }, + }, + }, + "write-only": { + input: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "string": { + Type: schema.TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + expected: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + // ID is automatically added by SDKv2 + { + Name: "id", + Type: tftypes.String, + Optional: true, + Computed: true, + }, + { + Name: "string", + Type: tftypes.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := tc.input.ProtoSchema(context.Background())() + if diff := cmp.Diff(got, tc.expected); diff != "" { + t.Errorf("Unexpected diff (+wanted, -got): %s", diff) + return + } + }) + } +} + +func TestProtoIdentitySchema(t *testing.T) { + tests := map[string]struct { + input *schema.Resource + expected *tfprotov5.ResourceIdentitySchema + }{ + "empty": { + input: &schema.Resource{ + Schema: map[string]*schema.Schema{}, + }, + expected: nil, + }, + "no-identity": { + input: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "string": { + Type: schema.TypeString, + Optional: true, + Computed: true, + }, + }, + }, + expected: nil, + }, + "primitives": { + input: &schema.Resource{ + Identity: &schema.ResourceIdentity{ + SchemaFunc: func() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "float": { + Type: schema.TypeFloat, + OptionalForImport: true, + }, + "bool": { + Type: schema.TypeBool, + OptionalForImport: true, + }, + "string": { + Type: schema.TypeString, + OptionalForImport: true, + }, + "int": { + Type: schema.TypeInt, + RequiredForImport: true, + Description: "foo bar baz", + }, + } + }, + }, + Schema: map[string]*schema.Schema{}, + }, + expected: &tfprotov5.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov5.ResourceIdentitySchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + OptionalForImport: true, + }, + { + Name: "float", + Type: tftypes.Number, + OptionalForImport: true, + }, + { + Name: "int", + Type: tftypes.Number, + Description: "foo bar baz", + RequiredForImport: true, + }, + { + Name: "string", + Type: tftypes.String, + OptionalForImport: true, + }, + }, + }, + }, + "list": { + input: &schema.Resource{ + Identity: &schema.ResourceIdentity{ + SchemaFunc: func() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "list": { + Type: schema.TypeList, + RequiredForImport: true, + Elem: &schema.Schema{ + Type: schema.TypeInt, + }, + }, + } + }, + }, + Schema: map[string]*schema.Schema{}, + }, + expected: &tfprotov5.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov5.ResourceIdentitySchemaAttribute{ + { + Name: "list", + Type: tftypes.List{ElementType: tftypes.Number}, + RequiredForImport: true, + }, + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := tc.input.ProtoIdentitySchema(context.Background()) + // Nil identity function is valid, we can return + if got == nil && tc.expected == nil { + return + } + + if diff := cmp.Diff(got(), tc.expected); diff != "" { + t.Errorf("Unexpected diff (+wanted, -got): %s", diff) + return + } + }) + } +} diff --git a/helper/schema/core_schema_test.go b/helper/schema/core_schema_test.go index 76fcfa8b945..b9cc0e432d9 100644 --- a/helper/schema/core_schema_test.go +++ b/helper/schema/core_schema_test.go @@ -34,15 +34,6 @@ func testResource(block *configschema.Block) *configschema.Block { } func TestSchemaMapCoreConfigSchema(t *testing.T) { - // these are global so if new tests are written we should probably employ a mutex - DescriptionKind = StringMarkdown - SchemaDescriptionBuilder = func(s *Schema) string { - if s.Required && s.Description != "" { - return fmt.Sprintf("**Required** %s", s.Description) - } - return s.Description - } - tests := map[string]struct { Schema map[string]*Schema Want *configschema.Block @@ -75,10 +66,9 @@ func TestSchemaMapCoreConfigSchema(t *testing.T) { testResource(&configschema.Block{ Attributes: map[string]*configschema.Attribute{ "int": { - Type: cty.Number, - Required: true, - Description: "**Required** foo bar baz", - DescriptionKind: configschema.StringMarkdown, + Type: cty.Number, + Required: true, + Description: "foo bar baz", }, "float": { Type: cty.Number, diff --git a/internal/plugin/convert/schema.go b/internal/plugin/convert/schema.go index d6fd7d8d9ab..563e0d38cf0 100644 --- a/internal/plugin/convert/schema.go +++ b/internal/plugin/convert/schema.go @@ -175,7 +175,8 @@ func ConfigSchemaToProto(ctx context.Context, b *configschema.Block) *tfprotov5. func ConfigIdentitySchemaToProto(ctx context.Context, identitySchema *configschema.Block) []*tfprotov5.ResourceIdentitySchemaAttribute { output := make([]*tfprotov5.ResourceIdentitySchemaAttribute, 0) - for name, a := range identitySchema.Attributes { + for _, name := range sortedKeys(identitySchema.Attributes) { + a := identitySchema.Attributes[name] attr := &tfprotov5.ResourceIdentitySchemaAttribute{ Name: name,