Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changes/v1.14/BUG FIXES-20250926-113318.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: BUG FIXES
body: 'query: generate unique resource identifiers for results of expanded list resources'
time: 2025-09-26T11:33:18.241184+02:00
custom:
Issue: "37681"
13 changes: 11 additions & 2 deletions internal/genconfig/generate_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ type ResourceListElement struct {
Config cty.Value

Identity cty.Value

// ExpansionEnum is a unique enumeration of the list resource address relative to its expanded siblings.
ExpansionEnum int
}

func GenerateListResourceContents(addr addrs.AbsResourceInstance,
Expand All @@ -158,12 +161,18 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance,
Resource: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: addr.Resource.Resource.Type,
Name: fmt.Sprintf("%s_%d", addr.Resource.Resource.Name, idx),
},
Key: addr.Resource.Key,
},
}

// If the list resource instance is keyed, the expansion counter is included in the address
// to ensure uniqueness across the entire configuration.
if addr.Resource.Key == addrs.NoKey {
resAddr.Resource.Resource.Name = fmt.Sprintf("%s_%d", addr.Resource.Resource.Name, idx)
} else {
resAddr.Resource.Resource.Name = fmt.Sprintf("%s_%d_%d", addr.Resource.Resource.Name, res.ExpansionEnum, idx)
}

content, gDiags := GenerateResourceContents(resAddr, schema, pc, res.Config, true)
if gDiags.HasErrors() {
diags = diags.Append(gDiags)
Expand Down
11 changes: 11 additions & 0 deletions internal/instances/expander.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package instances

import (
"fmt"
"slices"
"sort"
"sync"

Expand Down Expand Up @@ -338,6 +339,16 @@ func (e *Expander) ExpandResource(resourceAddr addrs.AbsResource) []addrs.AbsRes
return ret
}

// ResourceExpansionEnum returns the expansion enum for the given resource instance address
// within the sorted list of resource instances belonging to the same resource config within
// the same module instance.
func (e *Expander) ResourceExpansionEnum(resourceAddr addrs.AbsResourceInstance) int {
res := e.ExpandResource(resourceAddr.ContainingResource())
return slices.IndexFunc(res, func(addr addrs.AbsResourceInstance) bool {
return addr.Equal(resourceAddr)
})
}

// UnknownResourceInstances finds a set of patterns that collectively cover
// all of the possible resource instance addresses that could appear for the
// given static resource once all of the intermediate module expansions are
Expand Down
233 changes: 233 additions & 0 deletions internal/terraform/context_plan_query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,239 @@ func getListProviderSchemaResp() *providers.GetProviderSchemaResponse {
})
}

func TestContext2Plan_queryListConfigGeneration(t *testing.T) {
listResourceFn := func(request providers.ListResourceRequest) providers.ListResourceResponse {
instanceTypes := []string{"ami-123456", "ami-654321", "ami-789012"}
madeUp := []cty.Value{}
for i := range len(instanceTypes) {
madeUp = append(madeUp, cty.ObjectVal(map[string]cty.Value{"instance_type": cty.StringVal(instanceTypes[i])}))
}

ids := []cty.Value{}
for i := range madeUp {
ids = append(ids, cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal(fmt.Sprintf("i-v%d", i+1)),
}))
}

resp := []cty.Value{}
for i, v := range madeUp {
mp := map[string]cty.Value{
"identity": ids[i],
"display_name": cty.StringVal(fmt.Sprintf("Instance %d", i+1)),
}
if request.IncludeResourceObject {
mp["state"] = v
}
resp = append(resp, cty.ObjectVal(mp))
}

ret := map[string]cty.Value{
"data": cty.TupleVal(resp),
}
for k, v := range request.Config.AsValueMap() {
if k != "data" {
ret[k] = v
}
}

return providers.ListResourceResponse{Result: cty.ObjectVal(ret)}
}

mainConfig := `
terraform {
required_providers {
test = {
source = "hashicorp/test"
version = "1.0.0"
}
}
}
`
queryConfig := `
variable "input" {
type = string
default = "foo"
}

list "test_resource" "test2" {
for_each = toset(["§us-east-2", "§us-west-1"])
provider = test

config {
filter = {
attr = var.input
}
}
}
`

configFiles := map[string]string{"main.tf": mainConfig}
configFiles["main.tfquery.hcl"] = queryConfig

mod := testModuleInline(t, configFiles, configs.MatchQueryFiles())
providerAddr := addrs.NewDefaultProvider("test")
provider := testProvider("test")
provider.ConfigureProvider(providers.ConfigureProviderRequest{})
provider.GetProviderSchemaResponse = getListProviderSchemaResp()

var requestConfigs = make(map[string]cty.Value)
provider.ListResourceFn = func(request providers.ListResourceRequest) providers.ListResourceResponse {
if request.Config.IsNull() || request.Config.GetAttr("config").IsNull() {
t.Fatalf("config should never be null, got null for %s", request.TypeName)
}
requestConfigs[request.TypeName] = request.Config
return listResourceFn(request)
}

ctx, diags := NewContext(&ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
providerAddr: testProviderFuncFixed(provider),
},
})
tfdiags.AssertNoDiagnostics(t, diags)

diags = ctx.Validate(mod, &ValidateOpts{
Query: true,
})
tfdiags.AssertNoDiagnostics(t, diags)

generatedPath := t.TempDir()
plan, diags := ctx.Plan(mod, states.NewState(), &PlanOpts{
Mode: plans.NormalMode,
SetVariables: testInputValuesUnset(mod.Module.Variables),
Query: true,
GenerateConfigPath: generatedPath,
})
tfdiags.AssertNoDiagnostics(t, diags)

sch, err := ctx.Schemas(mod, states.NewState())
if err != nil {
t.Fatalf("failed to get schemas: %s", err)
}

expectedResources := []string{
`list.test_resource.test2["§us-east-2"]`,
`list.test_resource.test2["§us-west-1"]`,
}
actualResources := make([]string, 0)
generatedCfgs := make([]string, 0)
uniqCfgs := make(map[string]struct{})

for _, change := range plan.Changes.Queries {
actualResources = append(actualResources, change.Addr.String())
schema := sch.Providers[providerAddr].ListResourceTypes[change.Addr.Resource.Resource.Type]
cs, err := change.Decode(schema)
if err != nil {
t.Fatalf("failed to decode change: %s", err)
}

// Verify data. If the state is included, we check that, otherwise we check the id.
expectedData := []string{"ami-123456", "ami-654321", "ami-789012"}
includeState := change.Addr.String() == "list.test_resource.test"
if !includeState {
expectedData = []string{"i-v1", "i-v2", "i-v3"}
}
actualData := make([]string, 0)
obj := cs.Results.Value.GetAttr("data")
if obj.IsNull() {
t.Fatalf("Expected 'data' attribute to be present, but it is null")
}
obj.ForEachElement(func(key cty.Value, val cty.Value) bool {
if includeState {
val = val.GetAttr("state")
if val.IsNull() {
t.Fatalf("Expected 'state' attribute to be present, but it is null")
}
if val.GetAttr("instance_type").IsNull() {
t.Fatalf("Expected 'instance_type' attribute to be present, but it is missing")
}
actualData = append(actualData, val.GetAttr("instance_type").AsString())
} else {
val = val.GetAttr("identity")
if val.IsNull() {
t.Fatalf("Expected 'identity' attribute to be present, but it is null")
}
if val.GetAttr("id").IsNull() {
t.Fatalf("Expected 'id' attribute to be present, but it is missing")
}
actualData = append(actualData, val.GetAttr("id").AsString())
}
return false
})
sort.Strings(actualData)
sort.Strings(expectedData)
if diff := cmp.Diff(expectedData, actualData); diff != "" {
t.Fatalf("Expected instance types to match, but they differ: %s", diff)
}

generatedCfgs = append(generatedCfgs, change.Generated.String())
uniqCfgs[change.Addr.String()] = struct{}{}
}

sort.Strings(actualResources)
sort.Strings(expectedResources)
if diff := cmp.Diff(expectedResources, actualResources); diff != "" {
t.Fatalf("Expected resources to match, but they differ: %s", diff)
}

// Verify no managed resources are created
if len(plan.Changes.Resources) != 0 {
t.Fatalf("Expected no managed resources, but got %d", len(plan.Changes.Resources))
}

// Verify generated configs match expected
expected := `resource "test_resource" "test2_0_0" {
provider = test
instance_type = "ami-123456"
}

import {
to = test_resource.test2_0_0
provider = test
identity = {
id = "i-v1"
}
}

resource "test_resource" "test2_0_1" {
provider = test
instance_type = "ami-654321"
}

import {
to = test_resource.test2_0_1
provider = test
identity = {
id = "i-v2"
}
}

resource "test_resource" "test2_0_2" {
provider = test
instance_type = "ami-789012"
}

import {
to = test_resource.test2_0_2
provider = test
identity = {
id = "i-v3"
}
}
`
joinedCfgs := strings.Join(generatedCfgs, "\n")
if !strings.Contains(joinedCfgs, expected) {
t.Fatalf("Expected config to contain expected resource, but it does not: %s", cmp.Diff(expected, joinedCfgs))
}

// Verify that the generated config is valid.
// The function panics if the config is invalid.
testModuleInline(t, map[string]string{
"main.tf": strings.Join(generatedCfgs, "\n"),
})
}

var (
testResourceCfg = `resource "test_resource" "test_0" {
provider = test
Expand Down
5 changes: 4 additions & 1 deletion internal/terraform/node_resource_plan_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,9 @@ func (n *NodePlannableResourceInstance) generateHCLListResourceDef(ctx EvalConte

var listElements []genconfig.ResourceListElement

expander := ctx.InstanceExpander()
enum := expander.ResourceExpansionEnum(addr)

iter := state.ElementIterator()
for iter.Next() {
_, val := iter.Element()
Expand All @@ -955,7 +958,7 @@ func (n *NodePlannableResourceInstance) generateHCLListResourceDef(ctx EvalConte
}
idVal := val.GetAttr("identity")

listElements = append(listElements, genconfig.ResourceListElement{Config: config, Identity: idVal})
listElements = append(listElements, genconfig.ResourceListElement{Config: config, Identity: idVal, ExpansionEnum: enum})
}

return genconfig.GenerateListResourceContents(addr, schema.Body, schema.Identity, providerAddr, listElements)
Expand Down