@@ -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
4448type 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+
46104func (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
50123func (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
0 commit comments