Skip to content

Commit 9644ee7

Browse files
committed
Preserve configured admin_features_package and product_types when API doesn't return them
1 parent c656c5b commit 9644ee7

File tree

3 files changed

+237
-38
lines changed

3 files changed

+237
-38
lines changed

ec/acc/security_project_test.go

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -92,17 +92,6 @@ func testAccBasicSecurityProject(id string, name string, region string) string {
9292
resource ec_security_project "%s" {
9393
name = "%s"
9494
region_id = "%s"
95-
admin_features_package = "standard"
96-
product_types = [{
97-
product_line = "security"
98-
product_tier = "essentials"
99-
}, {
100-
product_line = "cloud"
101-
product_tier = "essentials"
102-
}, {
103-
product_line = "endpoint"
104-
product_tier = "essentials"
105-
}]
10695
}
10796
`, id, name, region)
10897
}
@@ -113,17 +102,6 @@ resource ec_security_project "%s" {
113102
name = "%s"
114103
region_id = "%s"
115104
alias = "%s"
116-
admin_features_package = "standard"
117-
product_types = [{
118-
product_line = "security"
119-
product_tier = "essentials"
120-
}, {
121-
product_line = "cloud"
122-
product_tier = "essentials"
123-
}, {
124-
product_line = "endpoint"
125-
product_tier = "essentials"
126-
}]
127105
}
128106
`, id, name, region, alias)
129107
}

ec/ecresource/projectresource/security.go

Lines changed: 140 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import (
2929
"github.com/hashicorp/terraform-plugin-framework/attr"
3030
"github.com/hashicorp/terraform-plugin-framework/diag"
3131
"github.com/hashicorp/terraform-plugin-framework/resource"
32+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
33+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
34+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
35+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
3236
"github.com/hashicorp/terraform-plugin-framework/types"
3337
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
3438
)
@@ -43,8 +47,77 @@ func NewSecurityProjectResource() *Resource[resource_security_project.SecurityPr
4347

4448
type securityModelReader struct{}
4549

50+
// productTypesOrderInsensitivePlanModifier ignores order differences in product_types list
51+
type productTypesOrderInsensitivePlanModifier struct{}
52+
53+
func (m productTypesOrderInsensitivePlanModifier) Description(ctx context.Context) string {
54+
return "Ignores order differences in product_types list when semantically equivalent"
55+
}
56+
57+
func (m productTypesOrderInsensitivePlanModifier) MarkdownDescription(ctx context.Context) string {
58+
return "Ignores order differences in product_types list when semantically equivalent"
59+
}
60+
61+
func (m productTypesOrderInsensitivePlanModifier) PlanModifyList(ctx context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) {
62+
// If either value is null or unknown, don't modify
63+
if req.PlanValue.IsNull() || req.PlanValue.IsUnknown() || req.StateValue.IsNull() || req.StateValue.IsUnknown() {
64+
return
65+
}
66+
67+
// Get both lists
68+
var planItems, stateItems []resource_security_project.ProductTypesValue
69+
req.PlanValue.ElementsAs(ctx, &planItems, false)
70+
req.StateValue.ElementsAs(ctx, &stateItems, false)
71+
72+
// If different lengths, they're actually different
73+
if len(planItems) != len(stateItems) {
74+
return
75+
}
76+
77+
// Create maps of product_line -> product_tier for comparison
78+
planMap := make(map[string]string)
79+
for _, item := range planItems {
80+
planMap[item.ProductLine.ValueString()] = item.ProductTier.ValueString()
81+
}
82+
83+
stateMap := make(map[string]string)
84+
for _, item := range stateItems {
85+
stateMap[item.ProductLine.ValueString()] = item.ProductTier.ValueString()
86+
}
87+
88+
// If maps are equal, use state value (same content, different order)
89+
mapsEqual := len(planMap) == len(stateMap)
90+
if mapsEqual {
91+
for k, v := range planMap {
92+
if stateMap[k] != v {
93+
mapsEqual = false
94+
break
95+
}
96+
}
97+
}
98+
99+
if mapsEqual {
100+
resp.PlanValue = req.StateValue
101+
}
102+
}
103+
46104
func (sec securityModelReader) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
47105
resp.Schema = resource_security_project.SecurityProjectResourceSchema(ctx)
106+
107+
// Add plan modifiers to admin_features_package and product_types to preserve state values
108+
// when these fields are not configured. The API returns these values, and they may change
109+
// over time (e.g., tier upgrades), but if not explicitly configured we should keep the
110+
// current state value rather than forcing a recomputation.
111+
adminFeaturesAttr := resp.Schema.Attributes["admin_features_package"].(schema.StringAttribute)
112+
adminFeaturesAttr.PlanModifiers = append(adminFeaturesAttr.PlanModifiers, stringplanmodifier.UseStateForUnknown())
113+
resp.Schema.Attributes["admin_features_package"] = adminFeaturesAttr
114+
115+
productTypesAttr := resp.Schema.Attributes["product_types"].(schema.ListNestedAttribute)
116+
productTypesAttr.PlanModifiers = append(productTypesAttr.PlanModifiers,
117+
listplanmodifier.UseStateForUnknown(),
118+
productTypesOrderInsensitivePlanModifier{},
119+
)
120+
resp.Schema.Attributes["product_types"] = productTypesAttr
48121
}
49122

50123
func (sec securityModelReader) ReadFrom(ctx context.Context, getter modelGetter) (*resource_security_project.SecurityProjectModel, diag.Diagnostics) {
@@ -288,29 +361,80 @@ func (sec securityApi) Read(ctx context.Context, id string, model resource_secur
288361
model.RegionId = basetypes.NewStringValue(resp.JSON200.RegionId)
289362
model.Type = basetypes.NewStringValue(string(resp.JSON200.Type))
290363

291-
// Populate admin_features_package from API response (matching pattern used for suspended_reason)
292-
var adminFeaturesPkg *string
364+
// Populate admin_features_package from API response when available
293365
if resp.JSON200.AdminFeaturesPackage != nil {
294366
pkgStr := string(*resp.JSON200.AdminFeaturesPackage)
295-
adminFeaturesPkg = &pkgStr
367+
model.AdminFeaturesPackage = basetypes.NewStringValue(pkgStr)
368+
} else {
369+
model.AdminFeaturesPackage = basetypes.NewStringNull()
296370
}
297-
model.AdminFeaturesPackage = basetypes.NewStringPointerValue(adminFeaturesPkg)
298371

299-
// Populate product_types from API response
372+
// Populate product_types from API response when available
300373
if resp.JSON200.ProductTypes != nil {
374+
// If we have product_types in the state/config, we want to preserve that ordering
375+
// to avoid inconsistent results. Otherwise, use API ordering.
376+
var sourceProductTypes []resource_security_project.ProductTypesValue
377+
if !model.ProductTypes.IsNull() && !model.ProductTypes.IsUnknown() {
378+
model.ProductTypes.ElementsAs(ctx, &sourceProductTypes, false)
379+
}
380+
301381
productTypeValues := []attr.Value{}
302-
for _, pt := range *resp.JSON200.ProductTypes {
303-
productTypeValue, diags := resource_security_project.NewProductTypesValue(
304-
resource_security_project.ProductTypesValue{}.AttributeTypes(ctx),
305-
map[string]attr.Value{
306-
"product_line": basetypes.NewStringValue(string(pt.ProductLine)),
307-
"product_tier": basetypes.NewStringValue(string(pt.ProductTier)),
308-
},
309-
)
310-
if diags.HasError() {
311-
return false, model, diags
382+
383+
if len(sourceProductTypes) > 0 {
384+
// Use the ordering from state/config, but with values from API
385+
apiProductTypesMap := make(map[string]serverless.SecurityProductType)
386+
for _, pt := range *resp.JSON200.ProductTypes {
387+
apiProductTypesMap[string(pt.ProductLine)] = pt
388+
}
389+
390+
// Build result in the same order as source
391+
for _, sourcePt := range sourceProductTypes {
392+
productLine := sourcePt.ProductLine.ValueString()
393+
if apiPt, exists := apiProductTypesMap[productLine]; exists {
394+
productTypeValue, diags := resource_security_project.NewProductTypesValue(
395+
resource_security_project.ProductTypesValue{}.AttributeTypes(ctx),
396+
map[string]attr.Value{
397+
"product_line": basetypes.NewStringValue(string(apiPt.ProductLine)),
398+
"product_tier": basetypes.NewStringValue(string(apiPt.ProductTier)),
399+
},
400+
)
401+
if diags.HasError() {
402+
return false, model, diags
403+
}
404+
productTypeValues = append(productTypeValues, productTypeValue)
405+
delete(apiProductTypesMap, productLine)
406+
}
407+
}
408+
409+
// Add any new product types from API that weren't in source
410+
for _, apiPt := range apiProductTypesMap {
411+
productTypeValue, diags := resource_security_project.NewProductTypesValue(
412+
resource_security_project.ProductTypesValue{}.AttributeTypes(ctx),
413+
map[string]attr.Value{
414+
"product_line": basetypes.NewStringValue(string(apiPt.ProductLine)),
415+
"product_tier": basetypes.NewStringValue(string(apiPt.ProductTier)),
416+
},
417+
)
418+
if diags.HasError() {
419+
return false, model, diags
420+
}
421+
productTypeValues = append(productTypeValues, productTypeValue)
422+
}
423+
} else {
424+
// No source ordering, use API ordering
425+
for _, pt := range *resp.JSON200.ProductTypes {
426+
productTypeValue, diags := resource_security_project.NewProductTypesValue(
427+
resource_security_project.ProductTypesValue{}.AttributeTypes(ctx),
428+
map[string]attr.Value{
429+
"product_line": basetypes.NewStringValue(string(pt.ProductLine)),
430+
"product_tier": basetypes.NewStringValue(string(pt.ProductTier)),
431+
},
432+
)
433+
if diags.HasError() {
434+
return false, model, diags
435+
}
436+
productTypeValues = append(productTypeValues, productTypeValue)
312437
}
313-
productTypeValues = append(productTypeValues, productTypeValue)
314438
}
315439

316440
productTypesList, diags := types.ListValue(

ec/ecresource/projectresource/security_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,103 @@ func TestSecurityApi_Read(t *testing.T) {
10891089
ProductTypes: types.ListValueMust(resource_security_project.ProductTypesValue{}.Type(ctx), expectedProductTypes),
10901090
}
10911091

1092+
mockApiClient := mocks.NewMockClientWithResponsesInterface(ctrl)
1093+
mockApiClient.EXPECT().
1094+
GetSecurityProjectWithResponse(ctx, id).
1095+
Return(&serverless.GetSecurityProjectResponse{
1096+
JSON200: readModel,
1097+
}, nil)
1098+
1099+
return testData{
1100+
client: mockApiClient,
1101+
id: id,
1102+
initialModel: initialModel,
1103+
expectedModel: expectedModel,
1104+
expectedFound: true,
1105+
}
1106+
},
1107+
},
1108+
{
1109+
name: "should preserve configured admin_features_package and product_types when API doesn't return them",
1110+
testData: func(ctx context.Context) testData {
1111+
id := "project id"
1112+
1113+
// Initial model has configured values (simulating what comes from the plan/config)
1114+
configuredProductTypes := []attr.Value{
1115+
resource_security_project.NewProductTypesValueMust(
1116+
resource_security_project.ProductTypesValue{}.AttributeTypes(ctx),
1117+
map[string]attr.Value{
1118+
"product_line": basetypes.NewStringValue("security"),
1119+
"product_tier": basetypes.NewStringValue("essentials"),
1120+
},
1121+
),
1122+
resource_security_project.NewProductTypesValueMust(
1123+
resource_security_project.ProductTypesValue{}.AttributeTypes(ctx),
1124+
map[string]attr.Value{
1125+
"product_line": basetypes.NewStringValue("cloud"),
1126+
"product_tier": basetypes.NewStringValue("essentials"),
1127+
},
1128+
),
1129+
}
1130+
1131+
initialModel := resource_security_project.SecurityProjectModel{
1132+
Id: types.StringValue(id),
1133+
AdminFeaturesPackage: basetypes.NewStringValue("standard"),
1134+
ProductTypes: types.ListValueMust(resource_security_project.ProductTypesValue{}.Type(ctx), configuredProductTypes),
1135+
}
1136+
1137+
// API response doesn't include admin_features_package or product_types
1138+
readModel := &serverless.SecurityProject{
1139+
Id: id,
1140+
Alias: "expected-alias-" + id[0:6],
1141+
CloudId: "cloud-id",
1142+
Endpoints: serverless.SecurityProjectEndpoints{
1143+
Elasticsearch: "es-endpoint",
1144+
Kibana: "kib-endpoint",
1145+
Ingest: "ingest-endpoint",
1146+
},
1147+
Metadata: serverless.ProjectMetadata{
1148+
CreatedAt: time.Now(),
1149+
CreatedBy: "me",
1150+
OrganizationId: "1",
1151+
},
1152+
Name: "project-name",
1153+
RegionId: "nether",
1154+
Type: "security",
1155+
AdminFeaturesPackage: nil, // API doesn't return this
1156+
ProductTypes: nil, // API doesn't return this
1157+
}
1158+
1159+
// Expected model should preserve the configured values
1160+
expectedModel := resource_security_project.SecurityProjectModel{
1161+
Id: types.StringValue(id),
1162+
Alias: types.StringValue("expected-alias"),
1163+
CloudId: types.StringValue(readModel.CloudId),
1164+
Endpoints: resource_security_project.NewEndpointsValueMust(
1165+
initialModel.Endpoints.AttributeTypes(ctx),
1166+
map[string]attr.Value{
1167+
"elasticsearch": basetypes.NewStringValue(readModel.Endpoints.Elasticsearch),
1168+
"kibana": basetypes.NewStringValue(readModel.Endpoints.Kibana),
1169+
"ingest": basetypes.NewStringValue(readModel.Endpoints.Ingest),
1170+
},
1171+
),
1172+
Metadata: resource_security_project.NewMetadataValueMust(
1173+
initialModel.Metadata.AttributeTypes(ctx),
1174+
map[string]attr.Value{
1175+
"created_at": basetypes.NewStringValue(readModel.Metadata.CreatedAt.String()),
1176+
"created_by": basetypes.NewStringValue(readModel.Metadata.CreatedBy),
1177+
"organization_id": basetypes.NewStringValue(readModel.Metadata.OrganizationId),
1178+
"suspended_at": basetypes.NewStringNull(),
1179+
"suspended_reason": basetypes.NewStringNull(),
1180+
},
1181+
),
1182+
Name: types.StringValue(readModel.Name),
1183+
RegionId: types.StringValue(readModel.RegionId),
1184+
Type: types.StringValue(string(readModel.Type)),
1185+
AdminFeaturesPackage: basetypes.NewStringValue("standard"),
1186+
ProductTypes: types.ListValueMust(resource_security_project.ProductTypesValue{}.Type(ctx), configuredProductTypes),
1187+
}
1188+
10921189
mockApiClient := mocks.NewMockClientWithResponsesInterface(ctrl)
10931190
mockApiClient.EXPECT().
10941191
GetSecurityProjectWithResponse(ctx, id).

0 commit comments

Comments
 (0)