Skip to content

Commit f4bd337

Browse files
authored
query: generate unique resource identifiers for results of expanded list resources (#37681)
1 parent ba92fc9 commit f4bd337

File tree

5 files changed

+264
-3
lines changed

5 files changed

+264
-3
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: BUG FIXES
2+
body: 'query: generate unique resource identifiers for results of expanded list resources'
3+
time: 2025-09-26T11:33:18.241184+02:00
4+
custom:
5+
Issue: "37681"

internal/genconfig/generate_config.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ type ResourceListElement struct {
138138
Config cty.Value
139139

140140
Identity cty.Value
141+
142+
// ExpansionEnum is a unique enumeration of the list resource address relative to its expanded siblings.
143+
ExpansionEnum int
141144
}
142145

143146
func GenerateListResourceContents(addr addrs.AbsResourceInstance,
@@ -158,12 +161,18 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance,
158161
Resource: addrs.Resource{
159162
Mode: addrs.ManagedResourceMode,
160163
Type: addr.Resource.Resource.Type,
161-
Name: fmt.Sprintf("%s_%d", addr.Resource.Resource.Name, idx),
162164
},
163-
Key: addr.Resource.Key,
164165
},
165166
}
166167

168+
// If the list resource instance is keyed, the expansion counter is included in the address
169+
// to ensure uniqueness across the entire configuration.
170+
if addr.Resource.Key == addrs.NoKey {
171+
resAddr.Resource.Resource.Name = fmt.Sprintf("%s_%d", addr.Resource.Resource.Name, idx)
172+
} else {
173+
resAddr.Resource.Resource.Name = fmt.Sprintf("%s_%d_%d", addr.Resource.Resource.Name, res.ExpansionEnum, idx)
174+
}
175+
167176
content, gDiags := GenerateResourceContents(resAddr, schema, pc, res.Config, true)
168177
if gDiags.HasErrors() {
169178
diags = diags.Append(gDiags)

internal/instances/expander.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package instances
55

66
import (
77
"fmt"
8+
"slices"
89
"sort"
910
"sync"
1011

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

342+
// ResourceExpansionEnum returns the expansion enum for the given resource instance address
343+
// within the sorted list of resource instances belonging to the same resource config within
344+
// the same module instance.
345+
func (e *Expander) ResourceExpansionEnum(resourceAddr addrs.AbsResourceInstance) int {
346+
res := e.ExpandResource(resourceAddr.ContainingResource())
347+
return slices.IndexFunc(res, func(addr addrs.AbsResourceInstance) bool {
348+
return addr.Equal(resourceAddr)
349+
})
350+
}
351+
341352
// UnknownResourceInstances finds a set of patterns that collectively cover
342353
// all of the possible resource instance addresses that could appear for the
343354
// given static resource once all of the intermediate module expansions are

internal/terraform/context_plan_query_test.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,239 @@ func getListProviderSchemaResp() *providers.GetProviderSchemaResponse {
10421042
})
10431043
}
10441044

1045+
func TestContext2Plan_queryListConfigGeneration(t *testing.T) {
1046+
listResourceFn := func(request providers.ListResourceRequest) providers.ListResourceResponse {
1047+
instanceTypes := []string{"ami-123456", "ami-654321", "ami-789012"}
1048+
madeUp := []cty.Value{}
1049+
for i := range len(instanceTypes) {
1050+
madeUp = append(madeUp, cty.ObjectVal(map[string]cty.Value{"instance_type": cty.StringVal(instanceTypes[i])}))
1051+
}
1052+
1053+
ids := []cty.Value{}
1054+
for i := range madeUp {
1055+
ids = append(ids, cty.ObjectVal(map[string]cty.Value{
1056+
"id": cty.StringVal(fmt.Sprintf("i-v%d", i+1)),
1057+
}))
1058+
}
1059+
1060+
resp := []cty.Value{}
1061+
for i, v := range madeUp {
1062+
mp := map[string]cty.Value{
1063+
"identity": ids[i],
1064+
"display_name": cty.StringVal(fmt.Sprintf("Instance %d", i+1)),
1065+
}
1066+
if request.IncludeResourceObject {
1067+
mp["state"] = v
1068+
}
1069+
resp = append(resp, cty.ObjectVal(mp))
1070+
}
1071+
1072+
ret := map[string]cty.Value{
1073+
"data": cty.TupleVal(resp),
1074+
}
1075+
for k, v := range request.Config.AsValueMap() {
1076+
if k != "data" {
1077+
ret[k] = v
1078+
}
1079+
}
1080+
1081+
return providers.ListResourceResponse{Result: cty.ObjectVal(ret)}
1082+
}
1083+
1084+
mainConfig := `
1085+
terraform {
1086+
required_providers {
1087+
test = {
1088+
source = "hashicorp/test"
1089+
version = "1.0.0"
1090+
}
1091+
}
1092+
}
1093+
`
1094+
queryConfig := `
1095+
variable "input" {
1096+
type = string
1097+
default = "foo"
1098+
}
1099+
1100+
list "test_resource" "test2" {
1101+
for_each = toset(["§us-east-2", "§us-west-1"])
1102+
provider = test
1103+
1104+
config {
1105+
filter = {
1106+
attr = var.input
1107+
}
1108+
}
1109+
}
1110+
`
1111+
1112+
configFiles := map[string]string{"main.tf": mainConfig}
1113+
configFiles["main.tfquery.hcl"] = queryConfig
1114+
1115+
mod := testModuleInline(t, configFiles, configs.MatchQueryFiles())
1116+
providerAddr := addrs.NewDefaultProvider("test")
1117+
provider := testProvider("test")
1118+
provider.ConfigureProvider(providers.ConfigureProviderRequest{})
1119+
provider.GetProviderSchemaResponse = getListProviderSchemaResp()
1120+
1121+
var requestConfigs = make(map[string]cty.Value)
1122+
provider.ListResourceFn = func(request providers.ListResourceRequest) providers.ListResourceResponse {
1123+
if request.Config.IsNull() || request.Config.GetAttr("config").IsNull() {
1124+
t.Fatalf("config should never be null, got null for %s", request.TypeName)
1125+
}
1126+
requestConfigs[request.TypeName] = request.Config
1127+
return listResourceFn(request)
1128+
}
1129+
1130+
ctx, diags := NewContext(&ContextOpts{
1131+
Providers: map[addrs.Provider]providers.Factory{
1132+
providerAddr: testProviderFuncFixed(provider),
1133+
},
1134+
})
1135+
tfdiags.AssertNoDiagnostics(t, diags)
1136+
1137+
diags = ctx.Validate(mod, &ValidateOpts{
1138+
Query: true,
1139+
})
1140+
tfdiags.AssertNoDiagnostics(t, diags)
1141+
1142+
generatedPath := t.TempDir()
1143+
plan, diags := ctx.Plan(mod, states.NewState(), &PlanOpts{
1144+
Mode: plans.NormalMode,
1145+
SetVariables: testInputValuesUnset(mod.Module.Variables),
1146+
Query: true,
1147+
GenerateConfigPath: generatedPath,
1148+
})
1149+
tfdiags.AssertNoDiagnostics(t, diags)
1150+
1151+
sch, err := ctx.Schemas(mod, states.NewState())
1152+
if err != nil {
1153+
t.Fatalf("failed to get schemas: %s", err)
1154+
}
1155+
1156+
expectedResources := []string{
1157+
`list.test_resource.test2["§us-east-2"]`,
1158+
`list.test_resource.test2["§us-west-1"]`,
1159+
}
1160+
actualResources := make([]string, 0)
1161+
generatedCfgs := make([]string, 0)
1162+
uniqCfgs := make(map[string]struct{})
1163+
1164+
for _, change := range plan.Changes.Queries {
1165+
actualResources = append(actualResources, change.Addr.String())
1166+
schema := sch.Providers[providerAddr].ListResourceTypes[change.Addr.Resource.Resource.Type]
1167+
cs, err := change.Decode(schema)
1168+
if err != nil {
1169+
t.Fatalf("failed to decode change: %s", err)
1170+
}
1171+
1172+
// Verify data. If the state is included, we check that, otherwise we check the id.
1173+
expectedData := []string{"ami-123456", "ami-654321", "ami-789012"}
1174+
includeState := change.Addr.String() == "list.test_resource.test"
1175+
if !includeState {
1176+
expectedData = []string{"i-v1", "i-v2", "i-v3"}
1177+
}
1178+
actualData := make([]string, 0)
1179+
obj := cs.Results.Value.GetAttr("data")
1180+
if obj.IsNull() {
1181+
t.Fatalf("Expected 'data' attribute to be present, but it is null")
1182+
}
1183+
obj.ForEachElement(func(key cty.Value, val cty.Value) bool {
1184+
if includeState {
1185+
val = val.GetAttr("state")
1186+
if val.IsNull() {
1187+
t.Fatalf("Expected 'state' attribute to be present, but it is null")
1188+
}
1189+
if val.GetAttr("instance_type").IsNull() {
1190+
t.Fatalf("Expected 'instance_type' attribute to be present, but it is missing")
1191+
}
1192+
actualData = append(actualData, val.GetAttr("instance_type").AsString())
1193+
} else {
1194+
val = val.GetAttr("identity")
1195+
if val.IsNull() {
1196+
t.Fatalf("Expected 'identity' attribute to be present, but it is null")
1197+
}
1198+
if val.GetAttr("id").IsNull() {
1199+
t.Fatalf("Expected 'id' attribute to be present, but it is missing")
1200+
}
1201+
actualData = append(actualData, val.GetAttr("id").AsString())
1202+
}
1203+
return false
1204+
})
1205+
sort.Strings(actualData)
1206+
sort.Strings(expectedData)
1207+
if diff := cmp.Diff(expectedData, actualData); diff != "" {
1208+
t.Fatalf("Expected instance types to match, but they differ: %s", diff)
1209+
}
1210+
1211+
generatedCfgs = append(generatedCfgs, change.Generated.String())
1212+
uniqCfgs[change.Addr.String()] = struct{}{}
1213+
}
1214+
1215+
sort.Strings(actualResources)
1216+
sort.Strings(expectedResources)
1217+
if diff := cmp.Diff(expectedResources, actualResources); diff != "" {
1218+
t.Fatalf("Expected resources to match, but they differ: %s", diff)
1219+
}
1220+
1221+
// Verify no managed resources are created
1222+
if len(plan.Changes.Resources) != 0 {
1223+
t.Fatalf("Expected no managed resources, but got %d", len(plan.Changes.Resources))
1224+
}
1225+
1226+
// Verify generated configs match expected
1227+
expected := `resource "test_resource" "test2_0_0" {
1228+
provider = test
1229+
instance_type = "ami-123456"
1230+
}
1231+
1232+
import {
1233+
to = test_resource.test2_0_0
1234+
provider = test
1235+
identity = {
1236+
id = "i-v1"
1237+
}
1238+
}
1239+
1240+
resource "test_resource" "test2_0_1" {
1241+
provider = test
1242+
instance_type = "ami-654321"
1243+
}
1244+
1245+
import {
1246+
to = test_resource.test2_0_1
1247+
provider = test
1248+
identity = {
1249+
id = "i-v2"
1250+
}
1251+
}
1252+
1253+
resource "test_resource" "test2_0_2" {
1254+
provider = test
1255+
instance_type = "ami-789012"
1256+
}
1257+
1258+
import {
1259+
to = test_resource.test2_0_2
1260+
provider = test
1261+
identity = {
1262+
id = "i-v3"
1263+
}
1264+
}
1265+
`
1266+
joinedCfgs := strings.Join(generatedCfgs, "\n")
1267+
if !strings.Contains(joinedCfgs, expected) {
1268+
t.Fatalf("Expected config to contain expected resource, but it does not: %s", cmp.Diff(expected, joinedCfgs))
1269+
}
1270+
1271+
// Verify that the generated config is valid.
1272+
// The function panics if the config is invalid.
1273+
testModuleInline(t, map[string]string{
1274+
"main.tf": strings.Join(generatedCfgs, "\n"),
1275+
})
1276+
}
1277+
10451278
var (
10461279
testResourceCfg = `resource "test_resource" "test_0" {
10471280
provider = test

internal/terraform/node_resource_plan_instance.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,9 @@ func (n *NodePlannableResourceInstance) generateHCLListResourceDef(ctx EvalConte
938938

939939
var listElements []genconfig.ResourceListElement
940940

941+
expander := ctx.InstanceExpander()
942+
enum := expander.ResourceExpansionEnum(addr)
943+
941944
iter := state.ElementIterator()
942945
for iter.Next() {
943946
_, val := iter.Element()
@@ -955,7 +958,7 @@ func (n *NodePlannableResourceInstance) generateHCLListResourceDef(ctx EvalConte
955958
}
956959
idVal := val.GetAttr("identity")
957960

958-
listElements = append(listElements, genconfig.ResourceListElement{Config: config, Identity: idVal})
961+
listElements = append(listElements, genconfig.ResourceListElement{Config: config, Identity: idVal, ExpansionEnum: enum})
959962
}
960963

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

0 commit comments

Comments
 (0)