Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/provider/config_data_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ var imageConfigurationSchema basetypes.ObjectType
var imageConfigurationsSchema basetypes.ObjectType

func init() {
// generateType now uses generateSchemaAttributes internally for tag-aware processing
// This ensures consistent handling of tfgen tags and experimental fields
sch, err := generateType(apkotypes.ImageConfiguration{})
if err != nil {
panic(err)
Expand Down
325 changes: 310 additions & 15 deletions internal/provider/reflect.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,79 @@

"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
)

// FieldMetadata contains Terraform schema properties extracted from struct tags

Check failure on line 14 in internal/provider/reflect.go

View workflow job for this annotation

GitHub Actions / lint

Comment should end in a period (godot)
type FieldMetadata struct {
Name string // Field name from yaml tag
Type attr.Type // Terraform attribute type
Optional bool // Can be set in config (default: true)
Required bool // Must be set in config
Computed bool // Computed by provider
Sensitive bool // Sensitive value (passwords, tokens)
Description string // Field description
}

// generateType converts a Go value to a Terraform attribute type using reflection.
//
// For struct types, fields are processed with tag-aware schema generation to ensure
// consistent handling of field properties. All structs (including nested structs) go
// through the same tag processing pipeline.
//
// Supported struct tags:
//
// yaml:"field_name" - Specifies the Terraform field name. Use yaml:"-" to skip a field.
// tfgen:"required" - Marks the field as required in Terraform config.
// tfgen:"computed" - Marks the field as computed by the provider.
// tfgen:"optional" - Explicitly marks the field as optional (default behavior).
// tfgen:"sensitive" - Marks the field as sensitive (e.g., passwords, tokens).
// tfgen:"desc=..." - Adds an inline description to the field.
// apko:"experimental" - Skips the field from schema generation entirely.
//
// Fields default to optional unless explicitly tagged with tfgen:"required" or tfgen:"computed".
// Multiple tfgen options can be combined with commas: tfgen:"optional,computed,sensitive"
//
// Examples:
//
// type Config struct {
// Name string `yaml:"name" tfgen:"required"` // Required field
// Token string `yaml:"token" tfgen:"optional,sensitive"` // Optional + sensitive
// Status string `yaml:"status" tfgen:"computed"` // Computed field
// Optional string `yaml:"optional"` // Defaults to optional
// Internal string `yaml:"-"` // Skipped
// Beta string `yaml:"beta" apko:"experimental"` // Skipped (experimental)
// }
//
// The function recursively processes nested structs, slices, and maps to generate
// the complete Terraform schema type structure.
func generateType(v any) (attr.Type, error) {
return generateTypeReflect(reflect.TypeOf(v))
}

// convertSchemaAttributeToType extracts attr.Type from a schema.Attribute

Check failure on line 61 in internal/provider/reflect.go

View workflow job for this annotation

GitHub Actions / lint

Comment should end in a period (godot)
func convertSchemaAttributeToType(attr schema.Attribute) (attr.Type, error) {
switch a := attr.(type) {
case schema.StringAttribute:
return basetypes.StringType{}, nil
case schema.BoolAttribute:
return basetypes.BoolType{}, nil
case schema.Int64Attribute:
return basetypes.Int64Type{}, nil
case schema.Float64Attribute:
return basetypes.Float64Type{}, nil
case schema.ListAttribute:
return basetypes.ListType{ElemType: a.ElementType}, nil
case schema.MapAttribute:
return basetypes.MapType{ElemType: a.ElementType}, nil
case schema.ObjectAttribute:
return basetypes.ObjectType{AttrTypes: a.AttributeTypes}, nil
default:
return nil, fmt.Errorf("unsupported schema attribute type: %T", attr)
}
}

func generateTypeReflect(t reflect.Type) (attr.Type, error) {
switch t.Kind() {
case reflect.String:
Expand Down Expand Up @@ -48,26 +114,23 @@
}, nil

case reflect.Struct:
ot := basetypes.ObjectType{
AttrTypes: make(map[string]attr.Type, t.NumField()),
// For structs, use generateSchemaAttributes to ensure consistent tag processing
attrs, err := generateSchemaAttributes(t)
if err != nil {
return nil, err
}
for i := 0; i < t.NumField(); i++ {
sf := t.Field(i)
tag := yamlName(sf)
if tag == nil {
continue
}
if experimental(sf) {
continue
}

ft, err := generateTypeReflect(maybeDeref(sf.Type))
// Convert schema.Attribute to attr.Type for each field
attrTypes := make(map[string]attr.Type, len(attrs))
for name, attr := range attrs {
attrType, err := convertSchemaAttributeToType(attr)
if err != nil {
return nil, fmt.Errorf("struct %w", err)
return nil, err
}
ot.AttrTypes[*tag] = ft
attrTypes[name] = attrType
}
return ot, nil

return basetypes.ObjectType{AttrTypes: attrTypes}, nil
case reflect.Pointer:
return generateTypeReflect(maybeDeref(t))

Expand Down Expand Up @@ -409,6 +472,238 @@
return false
}

// extractFieldMetadata extracts Terraform schema metadata from a struct field's tags.
// Returns nil if the field should be skipped (experimental, unexported, etc.).
func extractFieldMetadata(field reflect.StructField) (*FieldMetadata, error) {
// Parse yaml tag for field name
name := yamlName(field)
if name == nil {
return nil, nil // Skip unexported fields or yaml:"-"
}

// Check for experimental flag (existing behavior)
if experimental(field) {
return nil, nil // Skip experimental fields
}

metadata := &FieldMetadata{
Name: *name,
Optional: true, // Default to optional
}

// Parse tfgen tag for properties
tfgenTag := field.Tag.Get("tfgen")
if tfgenTag == "" {
// No tfgen tag, use defaults (optional)
return metadata, nil
}

// Parse comma-separated options
hasExplicitOptional := false
for _, opt := range strings.Split(tfgenTag, ",") {

Check failure on line 503 in internal/provider/reflect.go

View workflow job for this annotation

GitHub Actions / lint

stringsseq: Ranging over SplitSeq is more efficient (modernize)
opt = strings.TrimSpace(opt)
if opt == "" {
continue
}

switch {
case opt == "required":
metadata.Required = true
metadata.Optional = false
case opt == "optional":
hasExplicitOptional = true
metadata.Optional = true
case opt == "computed":
metadata.Computed = true
case opt == "sensitive":
metadata.Sensitive = true
case strings.HasPrefix(opt, "desc="):
metadata.Description = strings.TrimPrefix(opt, "desc=")
default:
return nil, fmt.Errorf("unknown tfgen tag option: %s", opt)
}
}

// Validation: required and optional are mutually exclusive
if metadata.Required && hasExplicitOptional {
return nil, fmt.Errorf("field %s: cannot be both required and optional", *name)
}

return metadata, nil
}

// metadataToSchemaAttribute converts FieldMetadata to a Terraform schema.Attribute.
func metadataToSchemaAttribute(meta *FieldMetadata) (schema.Attribute, error) {
// Determine the attribute type and create appropriate schema
switch t := meta.Type.(type) {
case basetypes.StringType:
return schema.StringAttribute{
MarkdownDescription: meta.Description,
Optional: meta.Optional,
Required: meta.Required,
Computed: meta.Computed,
Sensitive: meta.Sensitive,
}, nil

case basetypes.BoolType:
return schema.BoolAttribute{
MarkdownDescription: meta.Description,
Optional: meta.Optional,
Required: meta.Required,
Computed: meta.Computed,
Sensitive: meta.Sensitive,
}, nil

case basetypes.Int64Type:
return schema.Int64Attribute{
MarkdownDescription: meta.Description,
Optional: meta.Optional,
Required: meta.Required,
Computed: meta.Computed,
Sensitive: meta.Sensitive,
}, nil

case basetypes.Float64Type:
return schema.Float64Attribute{
MarkdownDescription: meta.Description,
Optional: meta.Optional,
Required: meta.Required,
Computed: meta.Computed,
Sensitive: meta.Sensitive,
}, nil

case basetypes.ListType:
return schema.ListAttribute{
MarkdownDescription: meta.Description,
Optional: meta.Optional,
Required: meta.Required,
Computed: meta.Computed,
Sensitive: meta.Sensitive,
ElementType: t.ElemType,
}, nil

case basetypes.MapType:
return schema.MapAttribute{
MarkdownDescription: meta.Description,
Optional: meta.Optional,
Required: meta.Required,
Computed: meta.Computed,
Sensitive: meta.Sensitive,
ElementType: t.ElemType,
}, nil

case basetypes.ObjectType:
return schema.ObjectAttribute{
MarkdownDescription: meta.Description,
Optional: meta.Optional,
Required: meta.Required,
Computed: meta.Computed,
Sensitive: meta.Sensitive,
AttributeTypes: t.AttrTypes,
}, nil

default:
return nil, fmt.Errorf("unsupported attribute type: %T", meta.Type)
}
}

// generateSchemaAttributes generates Terraform schema attributes from a struct type
// with full support for tfgen tags to control field properties.
//
// This function returns a map of Terraform schema.Attribute objects that can be used
// directly in Terraform provider schema definitions. It processes struct tags to
// automatically configure field properties such as Optional, Required, Computed, and Sensitive.
//
// Tag Processing:
//
// yaml:"field_name" - Determines the Terraform attribute name (required for all fields)
// yaml:"-" - Skips the field entirely
// tfgen:"required" - Makes the field required in Terraform configurations
// tfgen:"computed" - Marks the field as computed by the provider
// tfgen:"optional" - Explicitly marks as optional (default if no tfgen tag)
// tfgen:"sensitive" - Marks the field as sensitive (passwords, API keys, etc.)
// tfgen:"desc=text" - Sets the field's MarkdownDescription
// apko:"experimental" - Excludes the field from the schema
//
// Default Behavior:
// - Fields without tfgen tags default to Optional: true
// - Pointer types (*T) are treated the same as non-pointer types
// - Nested structs are processed recursively with the same tag rules
// - Experimental and unexported fields are automatically excluded
//
// Tag Combinations:
// - Multiple properties can be comma-separated: tfgen:"optional,computed,sensitive"
// - Cannot combine "required" with "optional" (validation error)
// - Can combine "optional" with "computed" for fields that can be set or computed
//
// Example usage:
//
// type MyResource struct {
// ID string `yaml:"id" tfgen:"computed"`
// Name string `yaml:"name" tfgen:"required,desc=The resource name"`
// Token string `yaml:"token" tfgen:"optional,sensitive"`
// Tags []string `yaml:"tags"` // Defaults to optional
// Annotations map[string]string `yaml:"annotations"` // Defaults to optional
// Internal string `yaml:"-"` // Skipped
// }
//
// attrs, err := generateSchemaAttributes(reflect.TypeOf(MyResource{}))
// if err != nil {
// return err
// }
//
// resp.Schema = schema.Schema{
// Attributes: attrs,
// }
//
// The function handles all Terraform-supported types including primitives (string, bool,
// int64, float64), collections (lists, maps), and nested objects.
func generateSchemaAttributes(t reflect.Type) (map[string]schema.Attribute, error) {
// Dereference pointers
if t.Kind() == reflect.Pointer {
t = t.Elem()
}

// Validate input is a struct
if t.Kind() != reflect.Struct {
return nil, fmt.Errorf("expected struct, got %v", t.Kind())
}

attrs := make(map[string]schema.Attribute, t.NumField())

// Iterate through struct fields
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)

// Extract metadata from tags
meta, err := extractFieldMetadata(field)
if err != nil {
return nil, fmt.Errorf("field %s: %w", field.Name, err)
}
if meta == nil {
continue // Skip this field (experimental, unexported, etc.)
}

// Generate attribute type
fieldType := maybeDeref(field.Type)
attrType, err := generateTypeReflect(fieldType)
if err != nil {
return nil, fmt.Errorf("field %s: %w", field.Name, err)
}
meta.Type = attrType

// Convert to schema.Attribute
attr, err := metadataToSchemaAttribute(meta)
if err != nil {
return nil, fmt.Errorf("field %s: %w", field.Name, err)
}

attrs[meta.Name] = attr
}

return attrs, nil
}

// indirect walks down v allocating pointers as needed,
// until it gets to a non-pointer.
// If it encounters an Unmarshaler, indirect stops and returns that.
Expand Down
Loading
Loading