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
96 changes: 89 additions & 7 deletions convert/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ func GoToCtyValue(val any) (cty.Value, error) {
ctyMap["meta"] = metaVal

// Transform labeled blocks to support both named and indexed access
transformLabeledBlocks(reflect.ValueOf(val), ctyMap)
// Pass parent resource ID so elements can have their own meta.id
parentID := r.Metadata().ID
transformLabeledBlocks(reflect.ValueOf(val), ctyMap, parentID)

ctyVal = cty.ObjectVal(ctyMap)
}
Expand All @@ -64,7 +66,9 @@ func GoToCtyValue(val any) (cty.Value, error) {

// transformLabeledBlocks converts slice fields with HCL block+label tags to maps.
// This allows named access like resource.cloud_account.prod.user.admin instead of user[0].
func transformLabeledBlocks(v reflect.Value, ctyMap map[string]cty.Value) {
// parentID is the resource ID (e.g., "resource.aws_account.sandbox") used to build
// meta.id for each labeled block element.
func transformLabeledBlocks(v reflect.Value, ctyMap map[string]cty.Value, parentID string) {
// Dereference pointer
if v.Kind() == reflect.Ptr {
if v.IsNil() {
Expand Down Expand Up @@ -142,8 +146,12 @@ func transformLabeledBlocks(v reflect.Value, ctyMap map[string]cty.Value) {
continue
}

// Build the element's meta.id: parentID.blockType.label
// e.g., "resource.aws_account.sandbox.user.admin"
elementID := fmt.Sprintf("%s.%s.%s", parentID, hclName, label)

// Convert element to cty AS ITS ACTUAL TYPE (not ResourceBase)
elemCty, err := elementToCty(elem)
elemCty, err := elementToCtyWithMeta(elem, elementID)
if err != nil {
continue
}
Expand Down Expand Up @@ -175,8 +183,9 @@ func findLabelFieldIndex(t reflect.Type) int {
return -1
}

// elementToCty converts a struct element to a cty.Value
func elementToCty(v reflect.Value) (cty.Value, error) {
// elementToCtyWithMeta converts a struct element to a cty.Value with meta.id populated.
// This is used for labeled block elements to include the reference path in meta.id.
func elementToCtyWithMeta(v reflect.Value, elementID string) (cty.Value, error) {
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return cty.NilVal, fmt.Errorf("nil pointer")
Expand All @@ -199,16 +208,89 @@ func elementToCty(v reflect.Value) (cty.Value, error) {
return cty.NilVal, err
}

// If the value is an object, recursively transform labeled blocks within it
// If the value is an object, add meta and transform nested labeled blocks
if ctyVal.Type().IsObjectType() {
valueMap := ctyVal.AsValueMap()
transformLabeledBlocks(v, valueMap)

// Add the label field to the CTY map if it exists
// The label field (hcl:",label") is not included by gocty because
// it doesn't have an HCL attribute name
addLabelFieldToCtyMap(v, valueMap)

// Add meta with the element's ID (reference path)
// Use gocty to convert a proper Meta struct - this ensures the CTY structure
// matches what gocty expects when decoding into types.ResourceBase
meta := types.Meta{ID: elementID}
metaTyp, err := gocty.ImpliedType(meta)
if err != nil {
return cty.NilVal, fmt.Errorf("failed to get meta type: %w", err)
}
metaVal, err := gocty.ToCtyValue(meta, metaTyp)
if err != nil {
return cty.NilVal, fmt.Errorf("failed to convert meta: %w", err)
}
valueMap["meta"] = metaVal

// Also add disabled and depends_on for ResourceBase compatibility
valueMap["disabled"] = cty.False
valueMap["depends_on"] = cty.ListValEmpty(cty.String)

// Recursively transform nested labeled blocks (pass empty parentID for nested)
transformLabeledBlocks(v, valueMap, elementID)

return cty.ObjectVal(valueMap), nil
}

return ctyVal, nil
}

// addLabelFieldToCtyMap finds the label field in a struct and adds it to the CTY map
// with an appropriate key name (using json tag or lowercase field name)
func addLabelFieldToCtyMap(v reflect.Value, valueMap map[string]cty.Value) {
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return
}
v = v.Elem()
}

if v.Kind() != reflect.Struct {
return
}

t := v.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
hclTag := field.Tag.Get("hcl")

// Check if this is the label field
if !strings.Contains(hclTag, ",label") {
continue
}

// Get the field value
fieldVal := v.Field(i)
if fieldVal.Kind() != reflect.String {
continue
}

// Determine the key name to use:
// 1. Use json tag if available
// 2. Fall back to lowercase field name
keyName := strings.ToLower(field.Name)
if jsonTag := field.Tag.Get("json"); jsonTag != "" {
parts := strings.Split(jsonTag, ",")
if parts[0] != "" && parts[0] != "-" {
keyName = parts[0]
}
}

// Add to the map
valueMap[keyName] = cty.StringVal(fieldVal.String())
return
}
}

func CtyToGo(val cty.Value, target any) error {
return gocty.FromCtyValue(val, target)
}
49 changes: 49 additions & 0 deletions convert/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,52 @@ func TestTransformLabeledBlocks(t *testing.T) {
devMap := devVal.AsValueMap()
require.Equal(t, "dev@example.com", devMap["email"].AsString())
}

func TestLabeledBlockCtyStructure(t *testing.T) {
// Test to understand the CTY structure of labeled blocks
account := &structs.CloudAccount{}
account.Meta.ID = "resource.cloud_account.test"
account.Meta.Type = "cloud_account"
account.Meta.Name = "test"
account.Provider = "aws"
account.Users = []structs.CloudUser{
{
Name: "admin",
Email: "admin@example.com",
Roles: []string{"admin"},
IamPolicy: "arn:aws:iam::aws:policy/AdministratorAccess",
},
}

// Convert to cty
ctyVal, err := GoToCtyValue(account)
require.NoError(t, err)

valueMap := ctyVal.AsValueMap()
userVal := valueMap["user"]
userMap := userVal.AsValueMap()
adminVal := userMap["admin"]
adminMap := adminVal.AsValueMap()

// Log all keys in the admin user CTY map
t.Logf("Admin user CTY map keys:")
for k, v := range adminMap {
t.Logf(" %q: %s (%v)", k, v.Type().FriendlyName(), v.GoString())
}

// Check if "name" key exists (from json tag)
_, hasName := adminMap["name"]
_, hasNameCap := adminMap["Name"]
t.Logf("Has 'name' key: %v", hasName)
t.Logf("Has 'Name' key: %v", hasNameCap)

// Check if "meta" key exists - this is what ResourceBase needs
_, hasMeta := adminMap["meta"]
t.Logf("Has 'meta' key: %v", hasMeta)

// Check if "disabled" or "depends_on" exist (ResourceBase fields)
_, hasDisabled := adminMap["disabled"]
_, hasDependsOn := adminMap["depends_on"]
t.Logf("Has 'disabled' key: %v", hasDisabled)
t.Logf("Has 'depends_on' key: %v", hasDependsOn)
}
161 changes: 161 additions & 0 deletions dag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ func TestLabeledBlockAccess(t *testing.T) {

p := setupParser(t)
p.RegisterType(structs.TypeCloudAccount, &structs.CloudAccount{})
p.RegisterType(structs.TypeCloudTeam, &structs.CloudTeam{})
p.RegisterType(structs.TypeCloudCredentials, &structs.CloudCredentials{})

c, err := p.ParseFile(absolutePath)
require.NoError(t, err)
Expand Down Expand Up @@ -156,6 +158,8 @@ func TestLabeledBlockOutputs(t *testing.T) {

p := setupParser(t)
p.RegisterType(structs.TypeCloudAccount, &structs.CloudAccount{})
p.RegisterType(structs.TypeCloudTeam, &structs.CloudTeam{})
p.RegisterType(structs.TypeCloudCredentials, &structs.CloudCredentials{})

c, err := p.ParseFile(absolutePath)
require.NoError(t, err)
Expand Down Expand Up @@ -285,6 +289,8 @@ func TestLabeledBlockValidation(t *testing.T) {

p := setupParser(t)
p.RegisterType(structs.TypeCloudAccount, &structs.CloudAccount{})
p.RegisterType(structs.TypeCloudTeam, &structs.CloudTeam{})
p.RegisterType(structs.TypeCloudCredentials, &structs.CloudCredentials{})

c, err := p.ParseFile(absolutePath)
require.NoError(t, err)
Expand Down Expand Up @@ -384,3 +390,158 @@ func containsInMiddle(s, substr string) bool {
}
return false
}

// TestLabeledBlockReferenceSlice tests referencing labeled blocks as slice elements
// This tests: members = [resource.cloud_account.source.user.admin, ...]
func TestLabeledBlockReferenceSlice(t *testing.T) {
absolutePath, err := filepath.Abs("./test_fixtures/labeled_blocks/valid.hcl")
require.NoError(t, err)

p := setupParser(t)
p.RegisterType(structs.TypeCloudAccount, &structs.CloudAccount{})
p.RegisterType(structs.TypeCloudTeam, &structs.CloudTeam{})
p.RegisterType(structs.TypeCloudCredentials, &structs.CloudCredentials{})

c, err := p.ParseFile(absolutePath)
require.NoError(t, err)

// Verify the source cloud account
source, err := c.FindResource("resource.cloud_account.source")
require.NoError(t, err)
sourceAccount := source.(*structs.CloudAccount)
require.Len(t, sourceAccount.Users, 3)

// Test 1: Engineering team with named references
eng, err := c.FindResource("resource.cloud_team.engineering")
require.NoError(t, err)
engTeam := eng.(*structs.CloudTeam)

require.Equal(t, "Engineering Team", engTeam.Name)

// Debug: log what we got
t.Logf("Engineering team Members count: %d", len(engTeam.Members))
for i, m := range engTeam.Members {
t.Logf(" Member[%d]: Name=%q Email=%q Roles=%v IamPolicy=%q", i, m.Name, m.Email, m.Roles, m.IamPolicy)
}
if engTeam.Lead != nil {
t.Logf("Lead: Name=%q Email=%q", engTeam.Lead.Name, engTeam.Lead.Email)
} else {
t.Logf("Lead: nil")
}
t.Logf("MemberEmails: %v", engTeam.MemberEmails)

// Verify members slice was populated with labeled block references
// CloudUserRef uses hcl:"name" so it can receive the label from CTY attribute
require.Len(t, engTeam.Members, 2, "Expected 2 members from labeled block references")
require.Equal(t, "admin", engTeam.Members[0].Name, "Name should be populated from CTY 'name' attribute")
require.Equal(t, "admin@example.com", engTeam.Members[0].Email)
require.Equal(t, "developer", engTeam.Members[1].Name, "Name should be populated from CTY 'name' attribute")
require.Equal(t, "dev@example.com", engTeam.Members[1].Email)

// Verify lead (single reference to labeled block)
require.NotNil(t, engTeam.Lead)
require.Equal(t, "admin", engTeam.Lead.Name, "Lead Name should be populated from CTY 'name' attribute")
require.Equal(t, "admin@example.com", engTeam.Lead.Email)

// Verify member_emails (field extraction from labeled blocks)
require.Len(t, engTeam.MemberEmails, 3)
require.Equal(t, "admin@example.com", engTeam.MemberEmails[0])
require.Equal(t, "dev@example.com", engTeam.MemberEmails[1])
require.Equal(t, "viewer@example.com", engTeam.MemberEmails[2])

// Test 2: Operations team with numeric index references
ops, err := c.FindResource("resource.cloud_team.operations")
require.NoError(t, err)
opsTeam := ops.(*structs.CloudTeam)

require.Len(t, opsTeam.Members, 2)
// user.0 should be admin, user.2 should be viewer
require.Equal(t, "admin", opsTeam.Members[0].Name)
require.Equal(t, "viewer", opsTeam.Members[1].Name)

// user.1 should be developer
require.NotNil(t, opsTeam.Lead)
require.Equal(t, "developer", opsTeam.Lead.Name)

// Test 3: Mixed team with both named and numeric access
mixed, err := c.FindResource("resource.cloud_team.mixed")
require.NoError(t, err)
mixedTeam := mixed.(*structs.CloudTeam)

require.Len(t, mixedTeam.Members, 3)
require.Equal(t, "admin", mixedTeam.Members[0].Name) // user.admin
require.Equal(t, "developer", mixedTeam.Members[1].Name) // user.1
require.Equal(t, "viewer", mixedTeam.Members[2].Name) // user.viewer

// Verify outputs
out, err := c.FindResource("output.engineering_lead_email")
require.NoError(t, err)
require.Equal(t, "admin@example.com", out.(*resources.Output).Value)

out, err = c.FindResource("output.engineering_first_member_email")
require.NoError(t, err)
require.Equal(t, "admin@example.com", out.(*resources.Output).Value)
}

// TestLabeledBlockResourceBaseSlice documents that using actual types ([]CloudUser)
// for labeled block references results in a full round-trip with all data preserved.
//
// This test demonstrates: when the receiving type matches the labeled block element type,
// gohcl can decode the full object including all fields.
//
// If you need to pass labeled block references to another resource, you should:
// 1. Use the same struct type (but with hcl:"name" instead of hcl:",label" for receiving)
// 2. Extract specific fields (e.g., member_emails = [resource...user.admin.email])
// 3. Use a custom type that matches the expected CTY structure
func TestLabeledBlockResourceBaseSlice(t *testing.T) {
absolutePath, err := filepath.Abs("./test_fixtures/labeled_blocks/valid.hcl")
require.NoError(t, err)

p := setupParser(t)
p.RegisterType(structs.TypeCloudAccount, &structs.CloudAccount{})
p.RegisterType(structs.TypeCloudTeam, &structs.CloudTeam{})
p.RegisterType(structs.TypeCloudCredentials, &structs.CloudCredentials{})

c, err := p.ParseFile(absolutePath)
require.NoError(t, err)

// Verify source account
source, err := c.FindResource("resource.cloud_account.test")
require.NoError(t, err)
sourceAccount := source.(*structs.CloudAccount)
require.Len(t, sourceAccount.Users, 2)

// IMPORTANT: This test demonstrates that you CANNOT decode CloudUser CTY values
// into []ResourceBase because the fields don't match.
//
// ResourceBase expects: depends_on, disabled, meta
// CloudUser provides: name, email, roles, iam_policy, mfa_enabled
//
// There's no field overlap, so the slice stays empty.
primary, err := c.FindResource("resource.cloud_credentials.primary")
require.NoError(t, err)
primaryCreds := primary.(*structs.CloudCredentials)

require.Equal(t, "Primary Credentials", primaryCreds.Name)

// Using actual types (CloudUser) instead of ResourceBase - full round-trip works!
t.Logf("Primary creds Users count: %d", len(primaryCreds.Users))
for i, u := range primaryCreds.Users {
t.Logf(" User[%d]: Name=%q Email=%q Roles=%v IamPolicy=%q", i, u.Name, u.Email, u.Roles, u.IamPolicy)
}

// Should have 2 users with all their data
require.Len(t, primaryCreds.Users, 2, "Should decode actual CloudUser objects")

// Verify we have access to ALL user fields - not just a reference!
require.Equal(t, "admin", primaryCreds.Users[0].Name)
require.Equal(t, "admin@test.com", primaryCreds.Users[0].Email)
require.Equal(t, []string{"admin"}, primaryCreds.Users[0].Roles)
require.Equal(t, "arn:aws:iam::aws:policy/AdministratorAccess", primaryCreds.Users[0].IamPolicy)

require.Equal(t, "developer", primaryCreds.Users[1].Name)
require.Equal(t, "dev@test.com", primaryCreds.Users[1].Email)

// The Accounts slice should also work
t.Logf("Primary creds Accounts count: %d", len(primaryCreds.Accounts))
}
Loading