Skip to content

Commit a957266

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

File tree

3 files changed

+260
-40
lines changed

3 files changed

+260
-40
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: 149 additions & 17 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,83 @@ 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
365+
// If API doesn't return it, preserve the configured/state value
293366
if resp.JSON200.AdminFeaturesPackage != nil {
294367
pkgStr := string(*resp.JSON200.AdminFeaturesPackage)
295-
adminFeaturesPkg = &pkgStr
368+
model.AdminFeaturesPackage = basetypes.NewStringValue(pkgStr)
369+
} else if model.AdminFeaturesPackage.IsNull() || model.AdminFeaturesPackage.IsUnknown() {
370+
// Only set to null if it wasn't already configured
371+
model.AdminFeaturesPackage = basetypes.NewStringNull()
296372
}
297-
model.AdminFeaturesPackage = basetypes.NewStringPointerValue(adminFeaturesPkg)
373+
// Otherwise, preserve the existing configured value
298374

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

316443
productTypesList, diags := types.ListValue(
@@ -322,7 +449,12 @@ func (sec securityApi) Read(ctx context.Context, id string, model resource_secur
322449
}
323450
model.ProductTypes = productTypesList
324451
} else {
325-
model.ProductTypes = types.ListNull(resource_security_project.ProductTypesValue{}.Type(ctx))
452+
// If API doesn't return product_types, preserve the configured/state value
453+
if model.ProductTypes.IsNull() || model.ProductTypes.IsUnknown() {
454+
// Only set to null if it wasn't already configured
455+
model.ProductTypes = types.ListNull(resource_security_project.ProductTypesValue{}.Type(ctx))
456+
}
457+
// Otherwise, preserve the existing configured value
326458
}
327459

328460
return true, model, nil

ec/ecresource/projectresource/security_test.go

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ import (
3232
"github.com/hashicorp/terraform-plugin-framework/attr"
3333
"github.com/hashicorp/terraform-plugin-framework/diag"
3434
"github.com/hashicorp/terraform-plugin-framework/resource"
35+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
36+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
37+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
3538
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
3639
"github.com/hashicorp/terraform-plugin-framework/types"
3740
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
@@ -47,7 +50,17 @@ func TestSecurityModelReader_Schema(t *testing.T) {
4750
mr.Schema(context.Background(), resource.SchemaRequest{}, &resp)
4851

4952
require.False(t, resp.Diagnostics.HasError())
50-
require.Equal(t, resource_security_project.SecurityProjectResourceSchema(context.Background()), resp.Schema)
53+
54+
// Verify that plan modifiers are added to admin_features_package
55+
adminFeaturesAttr := resp.Schema.Attributes["admin_features_package"].(schema.StringAttribute)
56+
require.Len(t, adminFeaturesAttr.PlanModifiers, 1)
57+
require.IsType(t, stringplanmodifier.UseStateForUnknown(), adminFeaturesAttr.PlanModifiers[0])
58+
59+
// Verify that plan modifiers are added to product_types
60+
productTypesAttr := resp.Schema.Attributes["product_types"].(schema.ListNestedAttribute)
61+
require.Len(t, productTypesAttr.PlanModifiers, 2)
62+
require.IsType(t, listplanmodifier.UseStateForUnknown(), productTypesAttr.PlanModifiers[0])
63+
require.IsType(t, productTypesOrderInsensitivePlanModifier{}, productTypesAttr.PlanModifiers[1])
5164
}
5265

5366
func TestSecurityModelReader_ReadFrom(t *testing.T) {
@@ -1089,6 +1102,103 @@ func TestSecurityApi_Read(t *testing.T) {
10891102
ProductTypes: types.ListValueMust(resource_security_project.ProductTypesValue{}.Type(ctx), expectedProductTypes),
10901103
}
10911104

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

0 commit comments

Comments
 (0)