Skip to content

Commit 53a7fd4

Browse files
committed
query: generate unique resource identifiers for results of expanded list resources
1 parent ba92fc9 commit 53a7fd4

File tree

6 files changed

+263
-6
lines changed

6 files changed

+263
-6
lines changed

internal/genconfig/generate_config.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,16 @@ type ResourceListElement struct {
140140
Identity cty.Value
141141
}
142142

143-
func GenerateListResourceContents(addr addrs.AbsResourceInstance,
143+
// UniqueAddr is a unique address for each resource instance
144+
type UniqueAddr struct {
145+
// Addr is the address of the resource instance.
146+
Addr addrs.AbsResourceInstance
147+
148+
// ExpansionCounter is the enumeration of the address during expansion.
149+
ExpansionCounter int
150+
}
151+
152+
func GenerateListResourceContents(uniqAddr UniqueAddr,
144153
schema *configschema.Block,
145154
idSchema *configschema.Object,
146155
pc addrs.LocalProviderConfig,
@@ -150,6 +159,8 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance,
150159
var diags tfdiags.Diagnostics
151160
ret := ImportGroup{}
152161

162+
addr := uniqAddr.Addr
163+
153164
for idx, res := range resources {
154165
// Generate a unique resource name for each instance in the list.
155166
resAddr := addrs.AbsResourceInstance{
@@ -158,12 +169,18 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance,
158169
Resource: addrs.Resource{
159170
Mode: addrs.ManagedResourceMode,
160171
Type: addr.Resource.Resource.Type,
161-
Name: fmt.Sprintf("%s_%d", addr.Resource.Resource.Name, idx),
162172
},
163-
Key: addr.Resource.Key,
164173
},
165174
}
166175

176+
// If the list resource instance is keyed, the resource address needs to contain that key for it to be unique
177+
// across the entire configuration.
178+
if addr.Resource.Key == addrs.NoKey {
179+
resAddr.Resource.Resource.Name = fmt.Sprintf("%s_%d", addr.Resource.Resource.Name, idx)
180+
} else {
181+
resAddr.Resource.Resource.Name = fmt.Sprintf("%s_%d_%d", addr.Resource.Resource.Name, uniqAddr.ExpansionCounter, idx)
182+
}
183+
167184
content, gDiags := GenerateResourceContents(resAddr, schema, pc, res.Config, true)
168185
if gDiags.HasErrors() {
169186
diags = diags.Append(gDiags)

internal/genconfig/generate_config_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -983,7 +983,7 @@ func TestGenerateResourceAndIDContents(t *testing.T) {
983983
}
984984

985985
// Generate content
986-
content, diags := GenerateListResourceContents(instAddr1, schema, idSchema, pc, listElements)
986+
content, diags := GenerateListResourceContents(UniqueAddr{Addr: instAddr1, ExpansionCounter: 0}, schema, idSchema, pc, listElements)
987987
// Check for diagnostics
988988
if diags.HasErrors() {
989989
t.Fatalf("unexpected diagnostics: %s", diags.Err())

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_abstract_instance.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ type NodeAbstractResourceInstance struct {
5252

5353
// override is set by the graph itself, just before this node executes.
5454
override *configs.Override
55+
56+
// expansionCounter tracks the index of the resource instance within the resource.
57+
// While the index is an enumerated value, it does not represent the evaluation order
58+
// of the instances. It is currently used to generate unique hcl identifiers, because
59+
// for_each keys are not guaranteed to be valid identifiers.
60+
expansionCounter int
5561
}
5662

5763
// NewNodeAbstractResourceInstance creates an abstract resource instance graph

internal/terraform/node_resource_plan_instance.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -958,7 +958,7 @@ func (n *NodePlannableResourceInstance) generateHCLListResourceDef(ctx EvalConte
958958
listElements = append(listElements, genconfig.ResourceListElement{Config: config, Identity: idVal})
959959
}
960960

961-
return genconfig.GenerateListResourceContents(addr, schema.Body, schema.Identity, providerAddr, listElements)
961+
return genconfig.GenerateListResourceContents(genconfig.UniqueAddr{Addr: addr, ExpansionCounter: n.expansionCounter}, schema.Body, schema.Identity, providerAddr, listElements)
962962
}
963963

964964
func (n *NodePlannableResourceInstance) generateResourceConfig(ctx EvalContext, state cty.Value) (cty.Value, tfdiags.Diagnostics) {

internal/terraform/transform_resource_count.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ type ResourceCountTransformer struct {
2424
}
2525

2626
func (t *ResourceCountTransformer) Transform(g *Graph) error {
27-
for _, addr := range t.InstanceAddrs {
27+
for idx, addr := range t.InstanceAddrs {
2828
abstract := NewNodeAbstractResourceInstance(addr)
2929
abstract.Schema = t.Schema
30+
abstract.expansionCounter = idx
3031
var node dag.Vertex = abstract
3132
if f := t.Concrete; f != nil {
3233
node = f(abstract)

0 commit comments

Comments
 (0)