diff --git a/internal/acctest/template/builder.go b/internal/acctest/template/builder.go index 5354edeee..9840ee991 100644 --- a/internal/acctest/template/builder.go +++ b/internal/acctest/template/builder.go @@ -59,7 +59,7 @@ func (b *CompositionBuilder) Remove(resourcePath string) *CompositionBuilder { resourceType := parts[0] // Filter out the composition entry that matches both resource type and name - var filtered = make([]compositionEntry, 0, len(b.compositions)) + filtered := make([]compositionEntry, 0, len(b.compositions)) for _, comp := range b.compositions { // Skip if this is the entry we want to remove if comp.ResourceType == resourceType { diff --git a/internal/acctest/template/error.go b/internal/acctest/template/error.go deleted file mode 100644 index 38cdfe449..000000000 --- a/internal/acctest/template/error.go +++ /dev/null @@ -1 +0,0 @@ -package template diff --git a/internal/acctest/template/field_extractor.go b/internal/acctest/template/field_extractor.go new file mode 100644 index 000000000..db43d6116 --- /dev/null +++ b/internal/acctest/template/field_extractor.go @@ -0,0 +1,273 @@ +package template + +import ( + "fmt" + "reflect" + "sort" + + datasourceschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + sdkschema "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// FieldType represents the type of a field in a schema +type FieldType int + +const ( + // https://developer.hashicorp.com/terraform/language/expressions/types + FieldTypeUnknown FieldType = iota + FieldTypeString + FieldTypeNumber + FieldTypeBool + FieldTypeMap + FieldTypeCollection + FieldTypeObject +) + +// TemplateField represents a field in a schema with its properties +type TemplateField struct { + Name string + Required bool + Optional bool + Computed bool + FieldType FieldType + NestedFields []TemplateField + IsObject bool + IsCollection bool + IsMap bool +} + +// FrameworkFieldExtractor extracts fields from Framework schema +type FrameworkFieldExtractor struct{} + +// NewFrameworkFieldExtractor creates a new extractor for Framework schemas +func NewFrameworkFieldExtractor() *FrameworkFieldExtractor { + return &FrameworkFieldExtractor{} +} + +var _ SchemaFieldExtractor = &FrameworkFieldExtractor{} + +// ExtractFields implements SchemaFieldExtractor for Framework schemas +func (e *FrameworkFieldExtractor) ExtractFields(schema interface{}) ([]TemplateField, error) { + var fields []TemplateField + + switch s := schema.(type) { + case resourceschema.Schema: + fields = e.extractFields(s.Attributes) + case datasourceschema.Schema: + fields = e.extractFields(s.Attributes) + default: + return nil, fmt.Errorf("unsupported schema type: %T", schema) + } + + // Check if we extracted zero fields, which indicates an invalid or empty schema + if len(fields) == 0 { + return nil, fmt.Errorf("no fields could be extracted from framework schema, schema may be empty or nil") + } + + // Sort fields to ensure consistent order + fields = sortTemplateFields(fields) + + return fields, nil +} + +// extractFields extracts fields from either resource or datasource attributes +func (e *FrameworkFieldExtractor) extractFields(attributes interface{}) []TemplateField { + fields := make([]TemplateField, 0) + + // Handle the different attribute map types with a type switch + switch attrs := attributes.(type) { + case map[string]resourceschema.Attribute: + for name, attr := range attrs { + e.processField(name, attr, &fields) + } + case map[string]datasourceschema.Attribute: + for name, attr := range attrs { + e.processField(name, attr, &fields) + } + } + + return fields +} + +// processField processes a single field and adds it to the fields list if not skipped +func (e *FrameworkFieldExtractor) processField(name string, attr interface{}, fields *[]TemplateField) { + // Create field with common properties + field := TemplateField{ + Name: name, + Required: e.isRequired(attr), + Optional: e.isOptional(attr), + Computed: e.isComputed(attr), + FieldType: FieldTypeUnknown, // Default to unknown + } + + switch a := attr.(type) { + case resourceschema.BoolAttribute, datasourceschema.BoolAttribute: + field.FieldType = FieldTypeBool + case resourceschema.Int64Attribute, resourceschema.Float64Attribute, resourceschema.NumberAttribute, + datasourceschema.Int64Attribute, datasourceschema.Float64Attribute, datasourceschema.NumberAttribute: + field.FieldType = FieldTypeNumber + case resourceschema.MapAttribute, datasourceschema.MapAttribute: + field.IsMap = true + field.FieldType = FieldTypeMap + case resourceschema.ListAttribute, resourceschema.SetAttribute, + datasourceschema.ListAttribute, datasourceschema.SetAttribute: + field.IsCollection = true + field.FieldType = FieldTypeCollection + case resourceschema.ListNestedAttribute, resourceschema.SetNestedAttribute: + field.IsCollection = true + field.IsObject = true + field.FieldType = FieldTypeObject + // Type assertion is needed to access the NestedObject.Attributes + switch nested := a.(type) { + case resourceschema.ListNestedAttribute: + field.NestedFields = e.extractFields(nested.NestedObject.Attributes) + case resourceschema.SetNestedAttribute: + field.NestedFields = e.extractFields(nested.NestedObject.Attributes) + } + case datasourceschema.ListNestedAttribute, datasourceschema.SetNestedAttribute: + field.IsCollection = true + field.IsObject = true + field.FieldType = FieldTypeObject + // Type assertion is needed to access the NestedObject.Attributes + switch nested := a.(type) { + case datasourceschema.ListNestedAttribute: + field.NestedFields = e.extractFields(nested.NestedObject.Attributes) + case datasourceschema.SetNestedAttribute: + field.NestedFields = e.extractFields(nested.NestedObject.Attributes) + } + case resourceschema.SingleNestedAttribute: + field.IsObject = true + field.FieldType = FieldTypeObject + field.NestedFields = e.extractFields(a.Attributes) + case datasourceschema.SingleNestedAttribute: + field.IsObject = true + field.FieldType = FieldTypeObject + field.NestedFields = e.extractFields(a.Attributes) + } + + *fields = append(*fields, field) +} + +// getFieldBool checks if a boolean field is set on an attribute +func (e *FrameworkFieldExtractor) getFieldBool(attr interface{}, fieldName string) bool { + v := reflect.ValueOf(attr) + if v.Kind() == reflect.Struct { + if f := v.FieldByName(fieldName); f.IsValid() && f.Kind() == reflect.Bool { // nosemgrep + return f.Bool() + } + } + return false +} + +// isRequired checks if an attribute is required +func (e *FrameworkFieldExtractor) isRequired(attr interface{}) bool { + return e.getFieldBool(attr, "Required") +} + +// isOptional checks if an attribute is optional +func (e *FrameworkFieldExtractor) isOptional(attr interface{}) bool { + return e.getFieldBool(attr, "Optional") +} + +// isComputed checks if an attribute is computed +func (e *FrameworkFieldExtractor) isComputed(attr interface{}) bool { + return e.getFieldBool(attr, "Computed") +} + +// SDKFieldExtractor extracts fields from SDK schema +type SDKFieldExtractor struct{} + +// NewSDKFieldExtractor creates a new extractor for SDK schemas +func NewSDKFieldExtractor() *SDKFieldExtractor { + return &SDKFieldExtractor{} +} + +var _ SchemaFieldExtractor = &SDKFieldExtractor{} + +// ExtractFields implements SchemaFieldExtractor for SDK schemas +func (e *SDKFieldExtractor) ExtractFields(schema interface{}) ([]TemplateField, error) { + resource, ok := schema.(*sdkschema.Resource) + if !ok { + return nil, fmt.Errorf("schema is not a *schema.Resource: %T", schema) + } + + fields := e.processSchema(resource.Schema) + + // Check if we extracted zero fields, which indicates an invalid or empty schema + if len(fields) == 0 { + return nil, fmt.Errorf("no fields could be extracted from SDK schema, schema may be empty or nil") + } + + // Sort fields to ensure consistent order + fields = sortTemplateFields(fields) + + return fields, nil +} + +// processSchema extracts fields from an SDK schema +func (e *SDKFieldExtractor) processSchema(schema map[string]*sdkschema.Schema) []TemplateField { + fields := make([]TemplateField, 0, len(schema)) + + for name, sch := range schema { + // Skip computed-only fields (that aren't required or optional) + if sch.Computed && !sch.Optional && !sch.Required { + continue + } + + field := TemplateField{ + Name: name, + Required: sch.Required, + Optional: sch.Optional, + Computed: sch.Computed, + FieldType: FieldTypeUnknown, // Default to unknown + } + + // Handle different schema types + switch sch.Type { + case sdkschema.TypeBool: + field.FieldType = FieldTypeBool + case sdkschema.TypeInt, sdkschema.TypeFloat: + field.FieldType = FieldTypeNumber + case sdkschema.TypeMap: + field.IsMap = true + field.FieldType = FieldTypeMap + case sdkschema.TypeList, sdkschema.TypeSet: + field.IsCollection = true + field.FieldType = FieldTypeCollection + + // Check if Elem is a resource (nested block) + if res, ok := sch.Elem.(*sdkschema.Resource); ok { + field.IsObject = true + field.FieldType = FieldTypeObject + // Recursively process nested schema + field.NestedFields = e.processSchema(res.Schema) + } + } + + fields = append(fields, field) + } + + return fields +} + +// sortTemplateFields returns a sorted copy of the fields slice to ensure deterministic rendering +func sortTemplateFields(fields []TemplateField) []TemplateField { + // Make a copy to avoid modifying the original + result := make([]TemplateField, len(fields)) + copy(result, fields) + + // Sort fields by name + sort.Slice(result, func(i, j int) bool { + return result[i].Name < result[j].Name + }) + + // Sort nested fields recursively + for i := range result { + if len(result[i].NestedFields) > 0 { + result[i].NestedFields = sortTemplateFields(result[i].NestedFields) + } + } + + return result +} diff --git a/internal/acctest/template/field_extractor_test.go b/internal/acctest/template/field_extractor_test.go new file mode 100644 index 000000000..73afaacca --- /dev/null +++ b/internal/acctest/template/field_extractor_test.go @@ -0,0 +1,145 @@ +package template + +import ( + "testing" + + datasourceschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + sdkschema "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/stretchr/testify/assert" +) + +func TestSDKExtractFieldsWithErrors(t *testing.T) { + t.Parallel() + + sdkExtractor := NewSDKFieldExtractor() + + // Test with invalid schema type + _, err := sdkExtractor.ExtractFields("not a schema") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not a *schema.Resource") + + // Test with nil Schema + nilSchemaResource := &sdkschema.Resource{ + Schema: nil, + } + _, err = sdkExtractor.ExtractFields(nilSchemaResource) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no fields could be extracted from SDK schema") + + // Test with empty Schema + emptySchemaResource := &sdkschema.Resource{ + Schema: map[string]*sdkschema.Schema{}, + } + _, err = sdkExtractor.ExtractFields(emptySchemaResource) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no fields could be extracted from SDK schema") +} + +func TestFrameworkExtractFieldsWithErrors(t *testing.T) { + t.Parallel() + + frameworkExtractor := NewFrameworkFieldExtractor() + + // Test with invalid schema type + _, err := frameworkExtractor.ExtractFields("not a schema") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported schema type") + + // Test with empty resource schema + emptyResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{}, + } + _, err = frameworkExtractor.ExtractFields(emptyResourceSchema) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no fields could be extracted from framework schema") + + // Test with empty datasource schema + emptyDataSourceSchema := datasourceschema.Schema{ + Attributes: map[string]datasourceschema.Attribute{}, + } + _, err = frameworkExtractor.ExtractFields(emptyDataSourceSchema) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no fields could be extracted from framework schema") +} + +func TestProcessComplexFieldTypes(t *testing.T) { + t.Parallel() + + // Create a resource with map attributes and lists + resource := &sdkschema.Resource{ + Schema: map[string]*sdkschema.Schema{ + "tags": { + Type: sdkschema.TypeMap, + Elem: &sdkschema.Schema{ + Type: sdkschema.TypeString, + }, + Optional: true, + }, + "nested_lists": { + Type: sdkschema.TypeList, + Optional: true, + Elem: &sdkschema.Resource{ + Schema: map[string]*sdkschema.Schema{ + "inner_list": { + Type: sdkschema.TypeList, + Optional: true, + Elem: &sdkschema.Schema{ + Type: sdkschema.TypeString, + }, + }, + }, + }, + }, + "set_field": { + Type: sdkschema.TypeSet, + Optional: true, + Elem: &sdkschema.Schema{ + Type: sdkschema.TypeString, + }, + }, + }, + } + + extractor := NewSDKFieldExtractor() + fields, err := extractor.ExtractFields(resource) + + assert.NoError(t, err) + assert.Len(t, fields, 3) + + // Verify map field + var mapField *TemplateField + for i := range fields { + if fields[i].Name == "tags" { + mapField = &fields[i] + break + } + } + assert.NotNil(t, mapField) + assert.True(t, mapField.IsMap) + + // Verify nested list + var nestedField *TemplateField + for i := range fields { + if fields[i].Name == "nested_lists" { + nestedField = &fields[i] + break + } + } + assert.NotNil(t, nestedField) + assert.True(t, nestedField.IsCollection) + assert.True(t, nestedField.IsObject) + assert.NotEmpty(t, nestedField.NestedFields) + + // Verify set field + var setField *TemplateField + for i := range fields { + if fields[i].Name == "set_field" { + setField = &fields[i] + break + } + } + assert.NotNil(t, setField) + assert.True(t, setField.IsCollection) + assert.False(t, setField.IsObject) +} diff --git a/internal/acctest/template/functions.go b/internal/acctest/template/functions.go index 972d78456..a95a4783b 100644 --- a/internal/acctest/template/functions.go +++ b/internal/acctest/template/functions.go @@ -54,6 +54,12 @@ func (tf *templateFunctions) registerDefaults() { elements = append(elements, fmt.Sprintf("%v", elem)) } result = fmt.Sprintf("[%s]", strings.Join(elements, ", ")) + case []string: + var elements []string + for _, elem := range val { + elements = append(elements, fmt.Sprintf("%q", elem)) + } + result = fmt.Sprintf("[%s]", strings.Join(elements, ", ")) case map[string]interface{}: var pairs []string keys := make([]string, 0, len(val)) diff --git a/internal/acctest/template/generator.go b/internal/acctest/template/generator.go index 0f6fe46b8..f75eab25e 100644 --- a/internal/acctest/template/generator.go +++ b/internal/acctest/template/generator.go @@ -2,18 +2,16 @@ package template import ( "fmt" - "sort" "strings" - - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) // ResourceKind represents the type of terraform configuration item -type ResourceKind int +type ResourceKind string +// Possible values for ResourceKind const ( - ResourceKindResource ResourceKind = iota - ResourceKindDataSource + ResourceKindResource ResourceKind = "resource" + ResourceKindDataSource ResourceKind = "data" ) // String returns the string representation of ResourceKind @@ -28,315 +26,274 @@ func (k ResourceKind) String() string { } } -type SchemaTemplateGenerator interface { - GenerateTemplate(r *schema.Resource, resourceType string, kind ResourceKind) string +// TemplateGenerator is the base interface for all template generators +type TemplateGenerator interface { + // GenerateTemplate generates a template for the given schema, resource type, and kind + GenerateTemplate(schema interface{}, resourceType string, kind ResourceKind) (string, error) } -// TextTemplateGenerator creates Go templates from Terraform resource schemas for testing purposes. -// It analyzes a resource's schema definition and generates a template that mirrors the structure -// of a Terraform configuration, but with template variables for dynamic values. -type TextTemplateGenerator struct { +// SchemaFieldExtractor is an interface for extracting fields from different schema types +type SchemaFieldExtractor interface { + // ExtractFields extracts fields from a schema + ExtractFields(schema interface{}) ([]TemplateField, error) } -// NewSchemaTemplateGenerator creates a new template generator for a specific resource type -func NewSchemaTemplateGenerator() *TextTemplateGenerator { - return &TextTemplateGenerator{} +// TimeoutsConfig defines which timeouts are configured for a resource +type TimeoutsConfig struct { + Create bool + Read bool + Update bool + Delete bool } -// GenerateTemplate generates a Go template string that resembles Terraform HCL configuration. -// The generated template is not actual Terraform code, but rather a template that can be rendered -// with different values to produce valid Terraform configurations for testing purposes. -// -// The template variables can then be populated using a template.Config to generate -// different variations of the resource configuration for testing. -func (g *TextTemplateGenerator) GenerateTemplate(r *schema.Resource, resourceType string, kind ResourceKind) string { - var b strings.Builder - - // Header differs based on kind - _, _ = fmt.Fprintf(&b, "%s %q %q {\n", kind, resourceType, "{{ required .resource_name }}") +// TemplatePath represents a field access path in a Go template +type TemplatePath struct { + // For the following example: + // + // resource "example" "foo" { + // top { + // nested { + // field = "value" + // } + // } + // } + // + // `components` would be: ["top", "nested", "field"] and + // `isCollection` would be: [true, true, false] + + components []string + isCollection []bool +} - g.generateFields(&b, r.Schema, 1) - g.generateTimeouts(&b, r.Timeouts, 1) - if _, hasDependsOn := r.Schema["depends_on"]; hasDependsOn { - g.generateDependsOn(&b, 1) +// NewTemplatePath creates a new template path with a top-level field +func NewTemplatePath(fieldName string, isCollection bool) TemplatePath { + return TemplatePath{ + components: []string{fieldName}, + isCollection: []bool{isCollection}, } +} - b.WriteString("}") - - return b.String() +// AppendField adds a new field to the path +func (p TemplatePath) AppendField(fieldName string, isCollection bool) TemplatePath { + p.components = append(p.components, fieldName) + p.isCollection = append(p.isCollection, isCollection) + return p } -func (g *TextTemplateGenerator) generateFields(b *strings.Builder, s map[string]*schema.Schema, indent int) { - indentStr := strings.Repeat(" ", indent) +// Expression returns the template expression for accessing this path +func (p TemplatePath) Expression() string { + if len(p.components) == 0 { + return "" + } - // Collect fields by type - var ( - required []string - optional []string - lists []string - maps []string - ) - - for k, field := range s { - if field.Computed && !field.Optional && !field.Required { - continue - } + // Start with the root + expr := "." + p.components[0] - switch field.Type { - case schema.TypeList, schema.TypeSet: - lists = append(lists, k) - case schema.TypeMap: - maps = append(maps, k) - default: - if field.Required { - required = append(required, k) - } else { - optional = append(optional, k) - } + // Build the expression with string-indexed paths + for i := 1; i < len(p.components); i++ { + if p.isCollection[i-1] { + // For collections, use numeric index 0 followed by string index + expr = fmt.Sprintf("(index %s 0 %q)", expr, p.components[i]) + } else { + // For regular fields, use string index + expr = fmt.Sprintf("(index %s %q)", expr, p.components[i]) } } - // Sort all field groups for consistent ordering - sort.Strings(required) - sort.Strings(optional) - sort.Strings(lists) - sort.Strings(maps) + return expr +} + +// CommonTemplateRenderer provides shared template rendering logic +// for both SDK and Framework template generators +type CommonTemplateRenderer struct{} - // Process fields in specified order - for _, field := range required { - g.generateField(b, field, s[field], indentStr) - } +// GenerateTemplate generates a complete Terraform configuration template from extracted fields. +func (r *CommonTemplateRenderer) GenerateTemplate(fields []TemplateField, resourceType string, kind ResourceKind, timeoutsConfig TimeoutsConfig, hasDependsOn bool) (string, error) { + var b strings.Builder + + // Header differs based on kind + _, _ = fmt.Fprintf(&b, "%s %q %q {\n", kind, resourceType, "{{ required .resource_name }}") - for _, field := range optional { - g.generateField(b, field, s[field], indentStr) + // Handle regular fields + for _, field := range fields { + r.RenderField(&b, field, 1, TemplatePath{}) } - for _, field := range lists { - g.generateField(b, field, s[field], indentStr) + // Handle timeouts + // Only generate the timeouts block if at least one timeout is configured + if timeoutsConfig.Create || timeoutsConfig.Read || timeoutsConfig.Update || timeoutsConfig.Delete { + r.RenderTimeouts(&b, 1, timeoutsConfig) } - for _, field := range maps { - g.generateField(b, field, s[field], indentStr) + // Handle depends_on + if hasDependsOn { + r.RenderDependsOn(&b, 1) } -} -func (g *TextTemplateGenerator) generateField(b *strings.Builder, field string, schemaField *schema.Schema, indent string) { - switch schemaField.Type { - case schema.TypeList, schema.TypeSet: - if schemaField.Optional || schemaField.Required { - g.generateNestedBlock(b, field, schemaField, indent, schemaField.Required) - } - case schema.TypeString: - if schemaField.Required { - fmt.Fprintf(b, "%s%s = {{ renderValue (required .%s) }}\n", indent, field, field) - } else { - fmt.Fprintf(b, "%s{{- if .%s }}\n", indent, field) - fmt.Fprintf(b, "%s%s = {{ renderValue .%s }}\n", indent, field, field) - fmt.Fprintf(b, "%s{{- end }}\n", indent) - } + b.WriteString("}") - case schema.TypeBool: - if schemaField.Required { - fmt.Fprintf(b, "%s%s = {{ required .%s }}\n", indent, field, field) - } else { - // For booleans, we want to render false values too - fmt.Fprintf(b, "%s{{- if ne .%s nil }}\n", indent, field) - fmt.Fprintf(b, "%s%s = {{ .%s }}\n", indent, field, field) - fmt.Fprintf(b, "%s{{- end }}\n", indent) - } + return b.String(), nil +} - case schema.TypeInt, schema.TypeFloat: - if schemaField.Required { - fmt.Fprintf(b, "%s%s = {{ required .%s }}\n", indent, field, field) - } else { - fmt.Fprintf(b, "%s{{- if ne .%s nil }}\n", indent, field) - fmt.Fprintf(b, "%s%s = {{ .%s }}\n", indent, field, field) - fmt.Fprintf(b, "%s{{- end }}\n", indent) - } +// RenderTimeouts renders a timeouts block with standard structure +func (r *CommonTemplateRenderer) RenderTimeouts(builder *strings.Builder, indent int, config TimeoutsConfig) { + indentStr := strings.Repeat(" ", indent) - case schema.TypeMap: - if schemaField.Required { - fmt.Fprintf(b, "%s%s = {\n", indent, field) - fmt.Fprintf(b, "%s {{- range $k, $v := (required .%s) }}\n", indent, field) - fmt.Fprintf(b, "%s {{ renderValue $k }} = {{ renderValue $v }}\n", indent) - fmt.Fprintf(b, "%s {{- end }}\n", indent) - fmt.Fprintf(b, "%s}\n", indent) - } else { - fmt.Fprintf(b, "%s{{- if .%s }}\n", indent, field) - fmt.Fprintf(b, "%s%s = {\n", indent, field) - fmt.Fprintf(b, "%s {{- range $k, $v := .%s }}\n", indent, field) - fmt.Fprintf(b, "%s {{ renderValue $k }} = {{ renderValue $v }}\n", indent) - fmt.Fprintf(b, "%s {{- end }}\n", indent) - fmt.Fprintf(b, "%s}\n", indent) - fmt.Fprintf(b, "%s{{- end }}\n", indent) - } + // Add top-level conditional for the entire timeouts block + fmt.Fprintf(builder, "%s{{- if .timeouts }}\n", indentStr) + fmt.Fprintf(builder, "%stimeouts {\n", indentStr) - default: - if schemaField.Required { - fmt.Fprintf(b, "%s%s = {{ renderValue (required .%s) }}\n", indent, field, field) - } else { - fmt.Fprintf(b, "%s{{- if .%s }}\n", indent, field) - fmt.Fprintf(b, "%s%s = {{ renderValue .%s }}\n", indent, field, field) - fmt.Fprintf(b, "%s{{- end }}\n", indent) - } + // Only render the timeouts that are configured in the schema + if config.Create { + fmt.Fprintf(builder, "%s {{- if .timeouts.create }}\n", indentStr) + fmt.Fprintf(builder, "%s create = {{ renderValue .timeouts.create }}\n", indentStr) + fmt.Fprintf(builder, "%s {{- end }}\n", indentStr) } -} -// generateTimeouts AddTemplate timeouts block if timeouts are configured -func (g *TextTemplateGenerator) generateTimeouts(b *strings.Builder, timeouts *schema.ResourceTimeout, indent int) { - if timeouts == nil { - return + if config.Read { + fmt.Fprintf(builder, "%s {{- if .timeouts.read }}\n", indentStr) + fmt.Fprintf(builder, "%s read = {{ renderValue .timeouts.read }}\n", indentStr) + fmt.Fprintf(builder, "%s {{- end }}\n", indentStr) } - indentStr := strings.Repeat(" ", indent) - hasTimeouts := false + if config.Update { + fmt.Fprintf(builder, "%s {{- if .timeouts.update }}\n", indentStr) + fmt.Fprintf(builder, "%s update = {{ renderValue .timeouts.update }}\n", indentStr) + fmt.Fprintf(builder, "%s {{- end }}\n", indentStr) + } - // Check if any timeouts are configured in schema - if timeouts.Create != nil || timeouts.Read != nil || timeouts.Update != nil || timeouts.Delete != nil { - hasTimeouts = true + if config.Delete { + fmt.Fprintf(builder, "%s {{- if .timeouts.delete }}\n", indentStr) + fmt.Fprintf(builder, "%s delete = {{ renderValue .timeouts.delete }}\n", indentStr) + fmt.Fprintf(builder, "%s {{- end }}\n", indentStr) } - // Only generate the timeouts block if there are timeouts configured - if hasTimeouts { - // AddTemplate top-level conditional for the entire timeouts block - fmt.Fprintf(b, "%s{{- if .timeouts }}\n", indentStr) - fmt.Fprintf(b, "%stimeouts {\n", indentStr) + fmt.Fprintf(builder, "%s}\n", indentStr) + fmt.Fprintf(builder, "%s{{- end }}\n", indentStr) +} + +// RenderDependsOn renders a depends_on attribute with standard format +func (r *CommonTemplateRenderer) RenderDependsOn(builder *strings.Builder, indent int) { + indentStr := strings.Repeat(" ", indent) - if timeouts.Create != nil { - fmt.Fprintf(b, "%s {{- if .timeouts.create }}\n", indentStr) - fmt.Fprintf(b, "%s create = {{ renderValue .timeouts.create }}\n", indentStr) - fmt.Fprintf(b, "%s {{- end }}\n", indentStr) - } - if timeouts.Read != nil { - fmt.Fprintf(b, "%s {{- if .timeouts.read }}\n", indentStr) - fmt.Fprintf(b, "%s read = {{ renderValue .timeouts.read }}\n", indentStr) - fmt.Fprintf(b, "%s {{- end }}\n", indentStr) - } - if timeouts.Update != nil { - fmt.Fprintf(b, "%s {{- if .timeouts.update }}\n", indentStr) - fmt.Fprintf(b, "%s update = {{ renderValue .timeouts.update }}\n", indentStr) - fmt.Fprintf(b, "%s {{- end }}\n", indentStr) - } - if timeouts.Delete != nil { - fmt.Fprintf(b, "%s {{- if .timeouts.delete }}\n", indentStr) - fmt.Fprintf(b, "%s delete = {{ renderValue .timeouts.delete }}\n", indentStr) - fmt.Fprintf(b, "%s {{- end }}\n", indentStr) - } + fmt.Fprintf(builder, "%s{{- if .depends_on }}\n", indentStr) + fmt.Fprintf(builder, "%sdepends_on = [%s", indentStr, "") + fmt.Fprintf(builder, "%s", "{{- range $i, $dep := .depends_on }}{{if $i}}, {{end}}{{ renderValue $dep }}{{- end }}]\n") + fmt.Fprintf(builder, "%s{{- end }}\n", indentStr) +} - fmt.Fprintf(b, "%s}\n", indentStr) - fmt.Fprintf(b, "%s{{- end }}\n", indentStr) +// RenderField renders a template field based on its properties +func (r *CommonTemplateRenderer) RenderField(builder *strings.Builder, field TemplateField, indent int, parentPath TemplatePath) { + // Create a path for this field + var path TemplatePath + if parentPath.components == nil { + // This is a top-level field + path = NewTemplatePath(field.Name, field.IsCollection) + } else { + // This is a nested field + path = parentPath.AppendField(field.Name, field.IsCollection) + } + + if field.IsObject && len(field.NestedFields) > 0 { + r.renderBlock(builder, field, path, indent) + } else if field.IsCollection && !field.IsObject { + r.renderCollection(builder, field, path, indent) + } else if field.IsMap { + r.renderMap(builder, field, path, indent) + } else if field.FieldType == FieldTypeBool { + r.renderBool(builder, field, path, indent) + } else if field.FieldType == FieldTypeNumber { + r.renderSimple(builder, field, path, indent) + } else { + r.renderSimple(builder, field, path, indent) } } -// generateDependsOn AddTemplate depends_on block if dependencies are specified -func (g *TextTemplateGenerator) generateDependsOn(b *strings.Builder, indent int) { +// renderFieldWithContent is a helper that handles the common pattern of optional/required fields +func (r *CommonTemplateRenderer) renderFieldWithContent(builder *strings.Builder, field TemplateField, path TemplatePath, indent int, + renderFunc func(*strings.Builder, TemplateField, string, int), +) { indentStr := strings.Repeat(" ", indent) + pathExpr := path.Expression() - fmt.Fprintf(b, "%s{{- if .depends_on }}\n", indentStr) - fmt.Fprintf(b, "%sdepends_on = [%s", indentStr, "") - fmt.Fprintf(b, "%s", "{{- range $i, $dep := .depends_on }}{{if $i}}, {{end}}{{ renderValue $dep }}{{- end }}]\n") - fmt.Fprintf(b, "%s{{- end }}\n", indentStr) -} + if !field.Required || field.Optional { + // Optional fields need an existence check + fmt.Fprintf(builder, "%s{{- if %s }}\n", indentStr, pathExpr) + } -func (g *TextTemplateGenerator) generateNestedBlock(b *strings.Builder, field string, schemaField *schema.Schema, indent string, _ bool) { - elem := schemaField.Elem - switch e := elem.(type) { - case *schema.Resource: - fmt.Fprintf(b, "%s{{- if .%s }}\n", indent, field) - fmt.Fprintf(b, "%s%s {\n", indent, field) - - nestedIndent := indent + " " - - // Filter and sort fields - var fields []string - for k, v := range e.Schema { - // Include field if it's either: - // 1. Not computed, or - // 2. Both computed and optional (but skip purely computed fields) - if !v.Computed || v.Optional { - fields = append(fields, k) - } - } - sort.Strings(fields) - - for _, nestedField := range fields { - nestedSchema := e.Schema[nestedField] - - switch nestedSchema.Type { - case schema.TypeList, schema.TypeSet: - if res, ok := nestedSchema.Elem.(*schema.Resource); ok { - g.generateListBlock(b, field, nestedField, res, nestedIndent) - } else if elemSchema, ok := nestedSchema.Elem.(*schema.Schema); ok { - g.generatePrimitiveList(b, field, nestedField, elemSchema, nestedIndent) - } - case schema.TypeBool: - fmt.Fprintf(b, "%s{{- if ne (index .%s 0 \"%s\") nil }}\n", nestedIndent, field, nestedField) - fmt.Fprintf(b, "%s%s = {{ index .%s 0 \"%s\" }}\n", nestedIndent, nestedField, field, nestedField) - fmt.Fprintf(b, "%s{{- end }}\n", nestedIndent) - default: - fmt.Fprintf(b, "%s{{- if index .%s 0 \"%s\" }}\n", nestedIndent, field, nestedField) - fmt.Fprintf(b, "%s%s = {{ renderValue (index .%s 0 \"%s\") }}\n", nestedIndent, nestedField, field, nestedField) - fmt.Fprintf(b, "%s{{- end }}\n", nestedIndent) - } - } + // Render the specific content for this field type + renderFunc(builder, field, pathExpr, indent) - fmt.Fprintf(b, "%s}\n", indent) - fmt.Fprintf(b, "%s{{- end }}\n", indent) - - case *schema.Schema: - fmt.Fprintf(b, "%s{{- if .%s }}\n", indent, field) - fmt.Fprintf(b, "%s%s = [\n", indent, field) - fmt.Fprintf(b, "%s {{- range $idx, $item := .%s }}\n", indent, field) - fmt.Fprintf(b, "%s {{ renderValue $item }},\n", indent) - fmt.Fprintf(b, "%s {{- end }}\n", indent) - fmt.Fprintf(b, "%s]\n", indent) - fmt.Fprintf(b, "%s{{- end }}\n", indent) + if !field.Required || field.Optional { + // Close the conditional for optional fields + fmt.Fprintf(builder, "%s{{- end }}\n", indentStr) } } -func (g *TextTemplateGenerator) generateListBlock(b *strings.Builder, parentField, field string, res *schema.Resource, indent string) { - fmt.Fprintf(b, "%s{{- if index .%s 0 \"%s\" }}\n", indent, parentField, field) - fmt.Fprintf(b, "%s%s {\n", indent, field) +// renderBlock handles a block with nested fields +func (r *CommonTemplateRenderer) renderBlock(builder *strings.Builder, field TemplateField, path TemplatePath, indent int) { + r.renderFieldWithContent(builder, field, path, indent, func(b *strings.Builder, field TemplateField, _ string, indent int) { + indentStr := strings.Repeat(" ", indent) + fmt.Fprintf(b, "%s%s {\n", indentStr, field.Name) - nestedIndent := indent + " " - - var fields []string - for k, v := range res.Schema { - // Use same filtering logic as in generateNestedBlock - if !v.Computed || v.Optional { - fields = append(fields, k) + for _, nestedField := range field.NestedFields { + // Pass the current path as parent for nested fields + r.RenderField(b, nestedField, indent+1, path) } - } - sort.Strings(fields) - for _, nestedField := range fields { - nestedSchema := res.Schema[nestedField] + fmt.Fprintf(b, "%s}\n", indentStr) + }) +} - if nestedSchema.Type == schema.TypeBool { - fmt.Fprintf(b, "%s{{- if ne (index .%s 0 \"%s\" 0 \"%s\") nil }}\n", - nestedIndent, parentField, field, nestedField) - fmt.Fprintf(b, "%s%s = {{ index .%s 0 \"%s\" 0 \"%s\" }}\n", - nestedIndent, nestedField, parentField, field, nestedField) - fmt.Fprintf(b, "%s{{- end }}\n", nestedIndent) +// renderSimple handles simple fields (strings, etc.) +func (r *CommonTemplateRenderer) renderSimple(builder *strings.Builder, field TemplateField, path TemplatePath, indent int) { + r.renderFieldWithContent(builder, field, path, indent, func(b *strings.Builder, field TemplateField, pathExpr string, indent int) { + indentStr := strings.Repeat(" ", indent) + if field.Required { + // Add required wrapper for required fields + fmt.Fprintf(b, "%s%s = {{ renderValue (required %s) }}\n", indentStr, field.Name, path.Expression()) } else { - fmt.Fprintf(b, "%s{{- if index .%s 0 \"%s\" 0 \"%s\" }}\n", - nestedIndent, parentField, field, nestedField) - fmt.Fprintf(b, "%s%s = {{ renderValue (index .%s 0 \"%s\" 0 \"%s\") }}\n", - nestedIndent, nestedField, parentField, field, nestedField) - fmt.Fprintf(b, "%s{{- end }}\n", nestedIndent) + fmt.Fprintf(b, "%s%s = {{ renderValue %s }}\n", indentStr, field.Name, pathExpr) } + }) +} + +// renderBool handles boolean fields with special null handling +func (r *CommonTemplateRenderer) renderBool(builder *strings.Builder, field TemplateField, path TemplatePath, indent int) { + indentStr := strings.Repeat(" ", indent) + pathExpr := path.Expression() + + if field.Required { + // Just use the standard path expression for required booleans + fmt.Fprintf(builder, "%s%s = {{ %s }}\n", indentStr, field.Name, pathExpr) + } else { + // Build condition with the prefix "ne ... nil" to check existence, not value + fmt.Fprintf(builder, "%s{{- if ne %s nil }}\n", indentStr, pathExpr) + fmt.Fprintf(builder, "%s%s = {{ %s }}\n", indentStr, field.Name, pathExpr) + fmt.Fprintf(builder, "%s{{- end }}\n", indentStr) } +} - fmt.Fprintf(b, "%s}\n", indent) - fmt.Fprintf(b, "%s{{- end }}\n", indent) +// renderMap handles map fields +func (r *CommonTemplateRenderer) renderMap(builder *strings.Builder, field TemplateField, path TemplatePath, indent int) { + r.renderFieldWithContent(builder, field, path, indent, func(b *strings.Builder, field TemplateField, pathExpr string, indent int) { + indentStr := strings.Repeat(" ", indent) + fmt.Fprintf(b, "%s%s = {\n", indentStr, field.Name) + fmt.Fprintf(b, "%s {{- range $k, $v := %s }}\n", indentStr, pathExpr) + fmt.Fprintf(b, "%s {{ renderValue $k }} = {{ renderValue $v }}\n", indentStr) + fmt.Fprintf(b, "%s {{- end }}\n", indentStr) + fmt.Fprintf(b, "%s}\n", indentStr) + }) } -func (g *TextTemplateGenerator) generatePrimitiveList(b *strings.Builder, parentField, field string, _ *schema.Schema, indent string) { - fmt.Fprintf(b, "%s{{- if index .%s 0 \"%s\" }}\n", indent, parentField, field) - fmt.Fprintf(b, "%s%s = [\n", indent, field) - fmt.Fprintf(b, "%s {{- range $idx, $item := index .%s 0 \"%s\" }}\n", indent, parentField, field) - fmt.Fprintf(b, "%s {{ renderValue $item }},\n", indent) - fmt.Fprintf(b, "%s {{- end }}\n", indent) - fmt.Fprintf(b, "%s]\n", indent) - fmt.Fprintf(b, "%s{{- end }}\n", indent) +// renderCollection handles list/set fields of primitive values +func (r *CommonTemplateRenderer) renderCollection(builder *strings.Builder, field TemplateField, path TemplatePath, indent int) { + r.renderFieldWithContent(builder, field, path, indent, func(b *strings.Builder, field TemplateField, pathExpr string, indent int) { + indentStr := strings.Repeat(" ", indent) + fmt.Fprintf(b, "%s%s = [\n", indentStr, field.Name) + fmt.Fprintf(b, "%s {{- range $idx, $item := %s }}\n", indentStr, pathExpr) + fmt.Fprintf(b, "%s {{ renderValue $item }},\n", indentStr) + fmt.Fprintf(b, "%s {{- end }}\n", indentStr) + fmt.Fprintf(b, "%s]\n", indentStr) + }) } diff --git a/internal/acctest/template/generator_framework.go b/internal/acctest/template/generator_framework.go new file mode 100644 index 000000000..521364b27 --- /dev/null +++ b/internal/acctest/template/generator_framework.go @@ -0,0 +1,84 @@ +package template + +import ( + "fmt" + "strings" +) + +// Ensure FrameworkTemplateGenerator implements TemplateGenerator +var _ TemplateGenerator = &FrameworkTemplateGenerator{} + +// FrameworkTemplateGenerator implements TemplateGenerator for Terraform Plugin Framework resources +type FrameworkTemplateGenerator struct { + extractor *FrameworkFieldExtractor + renderer *CommonTemplateRenderer +} + +// NewFrameworkTemplateGenerator creates a template generator for Framework resources +func NewFrameworkTemplateGenerator() *FrameworkTemplateGenerator { + return &FrameworkTemplateGenerator{ + extractor: NewFrameworkFieldExtractor(), + renderer: &CommonTemplateRenderer{}, + } +} + +// GenerateTemplate generates a Go template string that resembles Terraform HCL configuration +// for a Framework resource or data source. +func (g *FrameworkTemplateGenerator) GenerateTemplate(schema interface{}, resourceType string, kind ResourceKind) (string, error) { + fields, err := g.extractor.ExtractFields(schema) + if err != nil { + return "", fmt.Errorf("error extracting fields: %w", err) + } + + var ( + hasTimeouts bool + hasDependsOn bool + timeoutsField TemplateField + regularFields []TemplateField + ) + + for _, field := range fields { + switch field.Name { + case "timeouts": + hasTimeouts = true + timeoutsField = field + case "depends_on": + hasDependsOn = true + default: + regularFields = append(regularFields, field) + } + } + + var timeoutsConfig TimeoutsConfig + if hasTimeouts { + timeoutsConfig = g.extractTimeoutsConfig(timeoutsField) + } + + return g.renderer.GenerateTemplate(regularFields, resourceType, kind, timeoutsConfig, hasDependsOn) +} + +// extractTimeoutsConfig analyzes the timeouts field to determine which timeouts are configured +func (g *FrameworkTemplateGenerator) extractTimeoutsConfig(timeoutsField TemplateField) TimeoutsConfig { + config := TimeoutsConfig{ + Create: false, + Read: false, + Update: false, + Delete: false, + } + + for _, nestedField := range timeoutsField.NestedFields { + fieldName := strings.ToLower(nestedField.Name) + switch fieldName { + case "create": + config.Create = true + case "read": + config.Read = true + case "update": + config.Update = true + case "delete": + config.Delete = true + } + } + + return config +} diff --git a/internal/acctest/template/generator_framework_test.go b/internal/acctest/template/generator_framework_test.go new file mode 100644 index 000000000..fcc6dc532 --- /dev/null +++ b/internal/acctest/template/generator_framework_test.go @@ -0,0 +1,311 @@ +package template + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + datasourceschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/assert" +) + +func TestFrameworkGenerateTemplate(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + tests := []struct { + name string + schema interface{} + resourceType string + kind ResourceKind + want string + }{ + { + name: "resource with boolean fields", + schema: resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "enable_feature": resourceschema.BoolAttribute{ + Required: true, + }, + "disable_feature": resourceschema.BoolAttribute{ + Optional: true, + }, + "settings": resourceschema.SingleNestedAttribute{ + Required: true, + Attributes: map[string]resourceschema.Attribute{ + "feature_one": resourceschema.BoolAttribute{ + Required: true, + }, + "feature_two": resourceschema.BoolAttribute{ + Optional: true, + }, + }, + }, + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + {{- if ne .disable_feature nil }} + disable_feature = {{ .disable_feature }} + {{- end }} + enable_feature = {{ .enable_feature }} + settings { + feature_one = {{ (index .settings "feature_one") }} + {{- if ne (index .settings "feature_two") nil }} + feature_two = {{ (index .settings "feature_two") }} + {{- end }} + } +}`, + }, + { + name: "basic resource with required string field", + schema: resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "name": resourceschema.StringAttribute{ + Required: true, + }, + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + name = {{ renderValue (required .name) }} +}`, + }, + { + name: "resource with optional fields", + schema: resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "name": resourceschema.StringAttribute{ + Required: true, + }, + "description": resourceschema.StringAttribute{ + Optional: true, + }, + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + {{- if .description }} + description = {{ renderValue .description }} + {{- end }} + name = {{ renderValue (required .name) }} +}`, + }, + { + name: "data source with required fields", + schema: datasourceschema.Schema{ + Attributes: map[string]datasourceschema.Attribute{ + "id": datasourceschema.StringAttribute{ + Required: true, + }, + }, + }, + resourceType: "test_datasource", + kind: ResourceKindDataSource, + want: `data "test_datasource" "{{ required .resource_name }}" { + id = {{ renderValue (required .id) }} +}`, + }, + { + name: "resource with map field", + schema: resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "tags": resourceschema.MapAttribute{ + ElementType: types.StringType, + Optional: true, + }, + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + {{- if .tags }} + tags = { + {{- range $k, $v := .tags }} + {{ renderValue $k }} = {{ renderValue $v }} + {{- end }} + } + {{- end }} +}`, + }, + { + name: "resource with nested block", + schema: resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "config": resourceschema.SingleNestedAttribute{ + Required: true, + Attributes: map[string]resourceschema.Attribute{ + "name": resourceschema.StringAttribute{ + Required: true, + }, + "value": resourceschema.StringAttribute{ + Optional: true, + }, + }, + }, + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + config { + name = {{ renderValue (required (index .config "name")) }} + {{- if (index .config "value") }} + value = {{ renderValue (index .config "value") }} + {{- end }} + } +}`, + }, + { + name: "resource with list nested block", + schema: resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "items": resourceschema.ListNestedAttribute{ + Optional: true, + NestedObject: resourceschema.NestedAttributeObject{ + Attributes: map[string]resourceschema.Attribute{ + "name": resourceschema.StringAttribute{ + Required: true, + }, + "value": resourceschema.StringAttribute{ + Optional: true, + }, + }, + }, + }, + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + {{- if .items }} + items { + name = {{ renderValue (required (index .items 0 "name")) }} + {{- if (index .items 0 "value") }} + value = {{ renderValue (index .items 0 "value") }} + {{- end }} + } + {{- end }} +}`, + }, + { + name: "resource with timeouts", + schema: resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "name": resourceschema.StringAttribute{ + Required: true, + }, + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Create: true, + Update: true, + }), + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + name = {{ renderValue (required .name) }} + {{- if .timeouts }} + timeouts { + {{- if .timeouts.create }} + create = {{ renderValue .timeouts.create }} + {{- end }} + {{- if .timeouts.update }} + update = {{ renderValue .timeouts.update }} + {{- end }} + } + {{- end }} +}`, + }, + { + name: "resource with depends_on", + schema: resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "name": resourceschema.StringAttribute{ + Required: true, + }, + "depends_on": resourceschema.StringAttribute{}, + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + name = {{ renderValue (required .name) }} + {{- if .depends_on }} + depends_on = [{{- range $i, $dep := .depends_on }}{{if $i}}, {{end}}{{ renderValue $dep }}{{- end }}] + {{- end }} +}`, + }, + } + + generator := NewFrameworkTemplateGenerator() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := generator.GenerateTemplate(tt.schema, tt.resourceType, tt.kind) + assert.NoError(t, err) + assert.Equal(t, normalizeHCL(tt.want), normalizeHCL(got), "Generated template mismatch") + }) + } +} + +func TestFrameworkGenerateTemplateWithInvalidSchema(t *testing.T) { + generator := NewFrameworkTemplateGenerator() + + // Test with an invalid schema type + got, err := generator.GenerateTemplate("invalid schema", "test_resource", ResourceKindResource) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported schema type") + assert.Equal(t, "", got) +} + +func TestFrameworkTimeoutsRendering(t *testing.T) { + t.Parallel() + + generator := NewFrameworkTemplateGenerator() + + // Create a test field with all timeout types + timeoutsField := TemplateField{ + Name: "timeouts", + NestedFields: []TemplateField{ + {Name: "create"}, + {Name: "read"}, + {Name: "update"}, + {Name: "delete"}, + }, + } + + config := generator.extractTimeoutsConfig(timeoutsField) + + // Verify all timeouts are detected + assert.True(t, config.Create) + assert.True(t, config.Read) + assert.True(t, config.Update) + assert.True(t, config.Delete) + + // Test with only some timeouts + partialTimeoutsField := TemplateField{ + Name: "timeouts", + NestedFields: []TemplateField{ + {Name: "read"}, + {Name: "delete"}, + }, + } + + partialConfig := generator.extractTimeoutsConfig(partialTimeoutsField) + + // Verify only specific timeouts are detected + assert.False(t, partialConfig.Create) + assert.True(t, partialConfig.Read) + assert.False(t, partialConfig.Update) + assert.True(t, partialConfig.Delete) +} diff --git a/internal/acctest/template/generator_sdk.go b/internal/acctest/template/generator_sdk.go new file mode 100644 index 000000000..e75835501 --- /dev/null +++ b/internal/acctest/template/generator_sdk.go @@ -0,0 +1,64 @@ +package template + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// Ensure SDKTemplateGenerator implements TemplateGenerator +var _ TemplateGenerator = &SDKTemplateGenerator{} + +// SDKTemplateGenerator creates Go templates for Terraform SDK resources +type SDKTemplateGenerator struct { + extractor *SDKFieldExtractor + renderer *CommonTemplateRenderer +} + +// NewSDKTemplateGenerator creates a new template generator for SDK resources +func NewSDKTemplateGenerator() *SDKTemplateGenerator { + return &SDKTemplateGenerator{ + extractor: NewSDKFieldExtractor(), + renderer: &CommonTemplateRenderer{}, + } +} + +// GenerateTemplate implements the TemplateGenerator interface +func (g *SDKTemplateGenerator) GenerateTemplate(schemaObj interface{}, resourceType string, kind ResourceKind) (string, error) { + // For SDK resources, we expect a *schema.Resource + resource, ok := schemaObj.(*schema.Resource) + if !ok { + return "", fmt.Errorf("SDK generator received non-SDK schema of type %T", schemaObj) + } + + fields, err := g.extractor.ExtractFields(resource) + if err != nil { + return "", fmt.Errorf("error extracting fields: %w", err) + } + + var timeoutsConfig TimeoutsConfig + if resource.Timeouts != nil { + timeoutsConfig = g.extractTimeoutsConfig(resource.Timeouts) + } + + hasDependsOn := false + if _, ok := resource.Schema["depends_on"]; ok { + hasDependsOn = true + } + + return g.renderer.GenerateTemplate(fields, resourceType, kind, timeoutsConfig, hasDependsOn) +} + +// extractTimeoutsConfig extracts timeout configuration from a SDKv2 schema.ResourceTimeout +func (g *SDKTemplateGenerator) extractTimeoutsConfig(timeouts *schema.ResourceTimeout) TimeoutsConfig { + if timeouts == nil { + return TimeoutsConfig{} + } + + return TimeoutsConfig{ + Create: timeouts.Create != nil, + Read: timeouts.Read != nil, + Update: timeouts.Update != nil, + Delete: timeouts.Delete != nil, + } +} diff --git a/internal/acctest/template/generator_sdk_test.go b/internal/acctest/template/generator_sdk_test.go new file mode 100644 index 000000000..c15342744 --- /dev/null +++ b/internal/acctest/template/generator_sdk_test.go @@ -0,0 +1,325 @@ +package template + +import ( + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/stretchr/testify/assert" +) + +func TestGenerateSDKTemplate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resource *schema.Resource + resourceType string + kind ResourceKind + want string + }{ + { + name: "resource with boolean fields", + resource: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enable_feature": { + Type: schema.TypeBool, + Required: true, + }, + "disable_feature": { + Type: schema.TypeBool, + Optional: true, + }, + "nested_settings": { + Type: schema.TypeList, + MaxItems: 1, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "feature_one": { + Type: schema.TypeBool, + Required: true, + }, + "feature_two": { + Type: schema.TypeBool, + Optional: true, + }, + }, + }, + }, + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + {{- if ne .disable_feature nil }} + disable_feature = {{ .disable_feature }} + {{- end }} + enable_feature = {{ .enable_feature }} + nested_settings { + feature_one = {{ (index .nested_settings 0 "feature_one") }} + {{- if ne (index .nested_settings 0 "feature_two") nil }} + feature_two = {{ (index .nested_settings 0 "feature_two") }} + {{- end }} + } +}`, + }, + { + name: "basic resource with required string field", + resource: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + }, + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + name = {{ renderValue (required .name) }} +}`, + }, + { + name: "resource with optional fields", + resource: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + }, + "description": { + Type: schema.TypeString, + Optional: true, + }, + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + {{- if .description }} + description = {{ renderValue .description }} + {{- end }} + name = {{ renderValue (required .name) }} +}`, + }, + { + name: "data source with required fields", + resource: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Required: true, + }, + }, + }, + resourceType: "test_datasource", + kind: ResourceKindDataSource, + want: `data "test_datasource" "{{ required .resource_name }}" { + id = {{ renderValue (required .id) }} +}`, + }, + { + name: "resource with map field", + resource: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "tags": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + {{- if .tags }} + tags = { + {{- range $k, $v := .tags }} + {{ renderValue $k }} = {{ renderValue $v }} + {{- end }} + } + {{- end }} +}`, + }, + { + name: "resource with timeouts", + resource: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + }, + }, + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(30 * time.Second), + Update: schema.DefaultTimeout(30 * time.Second), + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + name = {{ renderValue (required .name) }} + {{- if .timeouts }} + timeouts { + {{- if .timeouts.create }} + create = {{ renderValue .timeouts.create }} + {{- end }} + {{- if .timeouts.update }} + update = {{ renderValue .timeouts.update }} + {{- end }} + } + {{- end }} +}`, + }, + { + name: "resource with nested block", + resource: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "config": { + Type: schema.TypeList, + MaxItems: 1, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + }, + "value": { + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + config { + name = {{ renderValue (required (index .config 0 "name")) }} + {{- if (index .config 0 "value") }} + value = {{ renderValue (index .config 0 "value") }} + {{- end }} + } +}`, + }, + { + name: "resource with list nested block", + resource: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "items": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + }, + "value": { + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + }, + }, + resourceType: "test_resource", + kind: ResourceKindResource, + want: `resource "test_resource" "{{ required .resource_name }}" { + {{- if .items }} + items { + name = {{ renderValue (required (index .items 0 "name")) }} + {{- if (index .items 0 "value") }} + value = {{ renderValue (index .items 0 "value") }} + {{- end }} + } + {{- end }} +}`, + }, + } + + generator := NewSDKTemplateGenerator() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := generator.GenerateTemplate(tt.resource, tt.resourceType, tt.kind) + assert.NoError(t, err) + assert.Equal(t, normalizeHCL(tt.want), normalizeHCL(got), "Generated template mismatch") + }) + } +} + +func TestResourceKindString(t *testing.T) { + tests := []struct { + name string + kind ResourceKind + want string + }{ + { + name: "resource kind", + kind: ResourceKindResource, + want: "resource", + }, + { + name: "data source kind", + kind: ResourceKindDataSource, + want: "data", + }, + { + name: "unknown kind", + kind: ResourceKind("unknown_kind"), + want: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.kind.String() + assert.Equal(t, tt.want, got, "ResourceKind string representation mismatch") + }) + } +} + +func TestSDKGenerateTemplateWithErrors(t *testing.T) { + t.Parallel() + + generator := NewSDKTemplateGenerator() + + // Test with non-schema input + _, err := generator.GenerateTemplate("not a schema", "test_resource", ResourceKindResource) + assert.Error(t, err) + assert.Contains(t, err.Error(), "non-SDK schema") + + // Create a schema that will cause extraction error by having nil schema + badSchema := &schema.Resource{ + Schema: nil, + } + + _, err = generator.GenerateTemplate(badSchema, "test_resource", ResourceKindResource) + assert.Error(t, err) + assert.Contains(t, err.Error(), "error extracting fields") +} + +func TestExtractTimeoutsConfigWithNil(t *testing.T) { + t.Parallel() + + generator := NewSDKTemplateGenerator() + config := generator.extractTimeoutsConfig(nil) + + // Verify all timeouts are false when input is nil + assert.False(t, config.Create) + assert.False(t, config.Read) + assert.False(t, config.Update) + assert.False(t, config.Delete) +} diff --git a/internal/acctest/template/generator_test.go b/internal/acctest/template/generator_test.go index a2b56acb2..e46faea62 100644 --- a/internal/acctest/template/generator_test.go +++ b/internal/acctest/template/generator_test.go @@ -2,177 +2,41 @@ package template import ( "testing" - "time" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/stretchr/testify/assert" ) -func TestGenerateTemplate(t *testing.T) { +func TestTemplatePathExpression(t *testing.T) { t.Parallel() - tests := []struct { - name string - resource *schema.Resource - resourceType string - kind ResourceKind - want string - }{ - { - name: "basic resource with required string field", - resource: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "name": { - Type: schema.TypeString, - Required: true, - }, - }, - }, - resourceType: "test_resource", - kind: ResourceKindResource, - want: `resource "test_resource" "{{ required .resource_name }}" { - name = {{ renderValue (required .name) }} -}`, - }, - { - name: "resource with optional fields", - resource: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "name": { - Type: schema.TypeString, - Required: true, - }, - "description": { - Type: schema.TypeString, - Optional: true, - }, - }, - }, - resourceType: "test_resource", - kind: ResourceKindResource, - want: `resource "test_resource" "{{ required .resource_name }}" { - name = {{ renderValue (required .name) }} - {{- if .description }} - description = {{ renderValue .description }} - {{- end }} -}`, - }, - { - name: "data source with required fields", - resource: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "id": { - Type: schema.TypeString, - Required: true, - }, - }, - }, - resourceType: "test_datasource", - kind: ResourceKindDataSource, - want: `data "test_datasource" "{{ required .resource_name }}" { - id = {{ renderValue (required .id) }} -}`, - }, - { - name: "resource with map field", - resource: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "tags": { - Type: schema.TypeMap, - Optional: true, - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - }, - }, - }, - resourceType: "test_resource", - kind: ResourceKindResource, - want: `resource "test_resource" "{{ required .resource_name }}" { - {{- if .tags }} - tags = { - {{- range $k, $v := .tags }} - {{ renderValue $k }} = {{ renderValue $v }} - {{- end }} - } - {{- end }} -}`, - }, - { - name: "resource with timeouts", - resource: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "name": { - Type: schema.TypeString, - Required: true, - }, - }, - Timeouts: &schema.ResourceTimeout{ - Create: schema.DefaultTimeout(30 * time.Second), - Update: schema.DefaultTimeout(30 * time.Second), - }, - }, - resourceType: "test_resource", - kind: ResourceKindResource, - want: `resource "test_resource" "{{ required .resource_name }}" { - name = {{ renderValue (required .name) }} - {{- if .timeouts }} - timeouts { - {{- if .timeouts.create }} - create = {{ renderValue .timeouts.create }} - {{- end }} - {{- if .timeouts.update }} - update = {{ renderValue .timeouts.update }} - {{- end }} - } - {{- end }} -}`, - }, - } - - generator := NewSchemaTemplateGenerator() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := generator.GenerateTemplate(tt.resource, tt.resourceType, tt.kind) - assert.Equal(t, normalizeHCL(tt.want), normalizeHCL(got), "Generated template mismatch") - }) - } -} - -func TestResourceKindString(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - kind ResourceKind - want string - }{ - { - name: "resource kind", - kind: ResourceKindResource, - want: "resource", - }, - { - name: "data source kind", - kind: ResourceKindDataSource, - want: "data", - }, - { - name: "unknown kind", - kind: ResourceKind(999), - want: "unknown", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := tt.kind.String() - assert.Equal(t, tt.want, got, "ResourceKind string representation mismatch") - }) - } + // Test empty path + emptyPath := TemplatePath{} + assert.Equal(t, "", emptyPath.Expression()) + + // Test simple path + simplePath := NewTemplatePath("field", false) + assert.Equal(t, ".field", simplePath.Expression()) + + // Test nested path with regular field + nestedPath := NewTemplatePath("parent", false) + nestedPath = nestedPath.AppendField("child", false) + assert.Equal(t, "(index .parent \"child\")", nestedPath.Expression()) + + // Test nested path with collection + collectionPath := NewTemplatePath("items", true) + collectionPath = collectionPath.AppendField("name", false) + assert.Equal(t, "(index .items 0 \"name\")", collectionPath.Expression()) + + // Test complex nested path with mixed collections + complexPath := NewTemplatePath("parent", true) + complexPath = complexPath.AppendField("child", false) + complexPath = complexPath.AppendField("grandchild", true) + + // Should generate a complex path expression for a collection within a collection + expr := complexPath.Expression() + assert.Contains(t, expr, "index") + assert.Contains(t, expr, "parent") + assert.Contains(t, expr, "child") + assert.Contains(t, expr, "grandchild") + assert.Equal(t, "(index (index .parent 0 \"child\") \"grandchild\")", expr) } diff --git a/internal/acctest/template/store.go b/internal/acctest/template/store.go index d4c2f4b7b..310893e68 100644 --- a/internal/acctest/template/store.go +++ b/internal/acctest/template/store.go @@ -1,14 +1,18 @@ package template import ( + "context" "fmt" "os" "strings" "sync" "testing" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + frameworkdatasource "github.com/hashicorp/terraform-plugin-framework/datasource" + frameworkresource "github.com/hashicorp/terraform-plugin-framework/resource" + sdkschema "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/aiven/terraform-provider-aiven/internal/plugin" "github.com/aiven/terraform-provider-aiven/internal/plugin/util" "github.com/aiven/terraform-provider-aiven/internal/sdkprovider/provider" ) @@ -21,17 +25,19 @@ var ( // Store represents a set of related resources and their templates type Store struct { - registry *registry - t testing.TB - generator *TextTemplateGenerator + registry *registry + t testing.TB + sdkGenerator *SDKTemplateGenerator + frameworkGenerator *FrameworkTemplateGenerator } -// NewSDKStore creates a new empty template store for SDK-v2 tests -func NewSDKStore(t testing.TB) *Store { +// NewStore creates a new empty template store for SDK-v2 tests +func NewStore(t testing.TB) *Store { return &Store{ - registry: newTemplateRegistry(t), - t: t, - generator: NewSchemaTemplateGenerator(), + registry: newTemplateRegistry(t), + t: t, + sdkGenerator: NewSDKTemplateGenerator(), + frameworkGenerator: NewFrameworkTemplateGenerator(), } } @@ -116,50 +122,82 @@ func initTemplateStore(t testing.TB) *Store { t.Fatalf("failed to get provider: %v", err) } - set := NewSDKStore(t) + set := NewStore(t) // Register all resources for resourceType, resource := range p.ResourcesMap { - set.registerResource(resourceType, resource, ResourceKindResource) + set.registerSDKResource(resourceType, resource, ResourceKindResource) } // Register all data sources for resourceType, resource := range p.DataSourcesMap { - set.registerResource(resourceType, resource, ResourceKindDataSource) + set.registerSDKResource(resourceType, resource, ResourceKindDataSource) + } + + // Register all framework resources and data sources + aivenProvider := plugin.New("dev")() + + // Get framework resources + for _, resourceFunc := range aivenProvider.Resources(context.Background()) { + r := resourceFunc() + var resp frameworkresource.MetadataResponse + r.Metadata(context.Background(), frameworkresource.MetadataRequest{ProviderTypeName: "aiven"}, &resp) + set.registerFrameworkComponent(resp.TypeName, r, ResourceKindResource) + } + + // Get framework data sources + for _, dataSourceFunc := range aivenProvider.DataSources(context.Background()) { + d := dataSourceFunc() + var resp frameworkdatasource.MetadataResponse + d.Metadata(context.Background(), frameworkdatasource.MetadataRequest{ProviderTypeName: "aiven"}, &resp) + set.registerFrameworkComponent(resp.TypeName, d, ResourceKindDataSource) } for resourceType, resource := range externalTemplates { set.AddExternalTemplate(resourceType, resource) } - set.addExtraTemplates() - return set } -// registerResource handles the registration of a single resource or data source -func (s *Store) registerResource(resourceType string, r *schema.Resource, kind ResourceKind) { +// registerSDKResource handles the registration of a single resource or data source +func (s *Store) registerSDKResource(resourceType string, r *sdkschema.Resource, kind ResourceKind) { // Generate and register the template - template := s.generator.GenerateTemplate(r, resourceType, kind) + template, err := s.sdkGenerator.GenerateTemplate(r, resourceType, kind) + if err != nil { + s.t.Fatalf("failed to generate SDK template for %s %s: %v", kind, resourceType, err) + } s.registry.mustAddTemplate(templateKey(resourceType, kind), template) } -// addExtraTemplates Add some extra templates that are not part of the provider. -// Some of the resources can be implemented in TF plugin. This is a temporary solution to add them. -func (s *Store) addExtraTemplates() { - s.registry.mustAddTemplate(templateKey("aiven_organization", ResourceKindResource), - `resource "aiven_organization" "{{ .resource_name }}" { - name = {{ renderValue (required .name) }} - }`) - - s.registry.mustAddTemplate(templateKey("aiven_organization", ResourceKindDataSource), - `data "aiven_organization" "{{ .resource_name }}" { - name = {{ renderValue (required .name) }} - {{- if .id }} - # id is computed and will be populated automatically - {{- end }} - }`) +// registerFrameworkComponent is a generic function to handle registration of any framework component +func (s *Store) registerFrameworkComponent(resourceType string, schemaProvider interface{}, kind ResourceKind) { + ctx := context.Background() + var schema interface{} + + // Extract schema based on the type of provider + switch p := schemaProvider.(type) { + case frameworkresource.Resource: + // Handle Framework resource + var resp frameworkresource.SchemaResponse + p.Schema(ctx, frameworkresource.SchemaRequest{}, &resp) + schema = resp.Schema + case frameworkdatasource.DataSource: + // Handle Framework datasource + var resp frameworkdatasource.SchemaResponse + p.Schema(ctx, frameworkdatasource.SchemaRequest{}, &resp) + schema = resp.Schema + default: + s.t.Fatalf("unsupported schema provider type: %T", schemaProvider) + return + } + // Generate and register the template + template, err := s.frameworkGenerator.GenerateTemplate(schema, resourceType, kind) + if err != nil { + s.t.Fatalf("failed to generate framework template for %s %s: %v", kind, resourceType, err) + } + s.registry.mustAddTemplate(templateKey(resourceType, kind), template) } // templateKey generates a unique template key based on resource type and kind diff --git a/internal/acctest/template/store_test.go b/internal/acctest/template/store_test.go index 2c9eeec0d..b3ab67109 100644 --- a/internal/acctest/template/store_test.go +++ b/internal/acctest/template/store_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/aiven/terraform-provider-aiven/internal/plugin/service/organization" "github.com/aiven/terraform-provider-aiven/internal/sdkprovider/service/kafka" ) @@ -114,8 +115,8 @@ func TestComplexSchema(t *testing.T) { } }` - set := NewSDKStore(t) - set.registerResource("aiven_pg", pgResource, ResourceKindResource) + set := NewStore(t) + set.registerSDKResource("aiven_pg", pgResource, ResourceKindResource) config := map[string]any{ "resource_name": "complex_pg", @@ -157,7 +158,7 @@ func TestKafkaQuotaResource(t *testing.T) { t.Parallel() ts := InitializeTemplateStore(t) - ts.registerResource( + ts.registerSDKResource( "aiven_kafka_quota", kafka.ResourceKafkaQuota(), ResourceKindResource, @@ -191,6 +192,32 @@ func TestKafkaQuotaResource(t *testing.T) { assert.Equal(t, normalizeHCL(expected), normalizeHCL(res)) } +func TestOrganizationResource(t *testing.T) { + t.Parallel() + + ts := NewStore(t) + ts.registerFrameworkComponent( + "aiven_organization", + organization.NewOrganizationResource(), + ResourceKindResource, + ) + + b := ts.NewBuilder() + + b.AddResource("aiven_organization", map[string]any{ + "resource_name": "test-org", + "name": "Test Organization", + }) + + expected := `resource "aiven_organization" "test-org" { + name = "Test Organization" + }` + + res := b.MustRender(t) + + assert.Equal(t, normalizeHCL(expected), normalizeHCL(res)) +} + func TestPostgresResource(t *testing.T) { t.Parallel() diff --git a/internal/acctest/template/template.go b/internal/acctest/template/template.go index 4a7611bf5..545708cea 100644 --- a/internal/acctest/template/template.go +++ b/internal/acctest/template/template.go @@ -1,3 +1,24 @@ +// Package template provides a framework for generating Terraform configuration templates +// from schema definitions across different Terraform development frameworks. +// +// This package enables automated template generation for both Terraform SDK v2 and +// Plugin Framework schemas through a unified interface. The core workflow is: +// +// 1. Schema Analysis: Extract fields and their properties (required, optional, computed) +// from different schema types (SDK v2 Resources or Plugin Framework Schemas) +// +// 2. Field Processing: Determine field characteristics (type, nested structure, etc.) +// and organize them into a unified TemplateField representation +// +// 3. Template Generation: Convert the structured field data into properly formatted +// Terraform configuration templates with appropriate conditionals and formatting +// +// The package uses a modular design with discrete interfaces for each part of the process: +// - TemplateGenerator: Main entry point for template generation +// - SchemaFieldExtractor: Extracts fields from different schema types +// +// By separating these concerns, the package supports different schema types through +// specialized implementations while maintaining a consistent template generation pipeline. package template import ( diff --git a/internal/acctest/template/template_test.go b/internal/acctest/template/template_test.go index 2f49500af..81d266d8c 100644 --- a/internal/acctest/template/template_test.go +++ b/internal/acctest/template/template_test.go @@ -27,7 +27,7 @@ func normalizeHCL(input string) string { func sortAndRewriteBody(src, dst *hclwrite.Body) { // Get all attributes and sort them attrs := src.Attributes() - var attrNames = make([]string, 0, len(attrs)) + attrNames := make([]string, 0, len(attrs)) for name := range attrs { attrNames = append(attrNames, name) }