@@ -11,6 +11,7 @@ import (
1111 "time"
1212
1313 jsonpatch "github.com/evanphx/json-patch"
14+ "github.com/google/uuid"
1415 "github.com/hashicorp/go-cty/cty"
1516 "github.com/hashicorp/terraform-plugin-sdk/v2/diag"
1617 "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
@@ -72,7 +73,10 @@ func resourceAutoscaler() *schema.Resource {
7273 UpdateContext : resourceCastaiAutoscalerUpdate ,
7374 DeleteContext : resourceCastaiAutoscalerDelete ,
7475 CustomizeDiff : resourceCastaiAutoscalerDiff ,
75- Description : "CAST AI autoscaler resource to manage autoscaler settings" ,
76+ Importer : & schema.ResourceImporter {
77+ StateContext : autoscalerStateImporter ,
78+ },
79+ Description : "CAST AI autoscaler resource to manage autoscaler settings" ,
7680
7781 Timeouts : & schema.ResourceTimeout {
7882 Create : schema .DefaultTimeout (2 * time .Minute ),
@@ -97,6 +101,7 @@ func resourceAutoscaler() *schema.Resource {
97101 Type : schema .TypeString ,
98102 Computed : true ,
99103 Description : "computed value to store full policies configuration" ,
104+ Deprecated : "This field is deprecated and will be removed in the next major version. Use autoscaler_settings to configure and manage autoscaler policies." ,
100105 },
101106 FieldAutoscalerSettings : {
102107 Type : schema .TypeList ,
@@ -513,6 +518,210 @@ func resourceCastaiAutoscalerDelete(ctx context.Context, data *schema.ResourceDa
513518 return nil
514519}
515520
521+ func autoscalerStateImporter (ctx context.Context , d * schema.ResourceData , meta interface {}) ([]* schema.ResourceData , error ) {
522+ clusterID := d .Id ()
523+
524+ // Validate cluster ID is a UUID
525+ if _ , err := uuid .Parse (clusterID ); err != nil {
526+ return nil , fmt .Errorf ("expected cluster_id to be a valid UUID, got: %q" , clusterID )
527+ }
528+
529+ // Set the cluster_id field
530+ if err := d .Set (FieldClusterId , clusterID ); err != nil {
531+ return nil , fmt .Errorf ("setting cluster_id: %w" , err )
532+ }
533+
534+ // Fetch current policies from API
535+ client := meta .(* ProviderConfig ).api
536+ currentPolicies , err := getCurrentPolicies (ctx , client , clusterID )
537+ if err != nil {
538+ return nil , fmt .Errorf ("fetching autoscaler policies for cluster %s: %w" , clusterID , err )
539+ }
540+
541+ // Filter out volatile fields that change independently on API side
542+ currentPolicies = filterVolatileFields (currentPolicies )
543+
544+ // Set the computed autoscaler_policies field
545+ if err := d .Set (FieldAutoscalerPolicies , string (currentPolicies )); err != nil {
546+ return nil , fmt .Errorf ("setting autoscaler_policies: %w" , err )
547+ }
548+
549+ // Convert API response to autoscaler_settings structure
550+ settings , err := flattenAutoscalerSettings (currentPolicies )
551+ if err != nil {
552+ return nil , fmt .Errorf ("converting policies to autoscaler_settings: %w" , err )
553+ }
554+
555+ if settings != nil {
556+ if err := d .Set (FieldAutoscalerSettings , settings ); err != nil {
557+ return nil , fmt .Errorf ("setting autoscaler_settings: %w" , err )
558+ }
559+ }
560+
561+ return []* schema.ResourceData {d }, nil
562+ }
563+
564+ // flattenAutoscalerSettings converts API JSON response to autoscaler_settings Terraform structure.
565+ // It omits deprecated fields to encourage users to migrate to castai_node_template.
566+ func flattenAutoscalerSettings (policiesJSON []byte ) ([]map [string ]interface {}, error ) {
567+ var apiPolicy map [string ]interface {}
568+ if err := json .Unmarshal (policiesJSON , & apiPolicy ); err != nil {
569+ return nil , fmt .Errorf ("unmarshaling policies JSON: %w" , err )
570+ }
571+
572+ settings := make (map [string ]interface {})
573+
574+ // Top-level fields
575+ if v , ok := apiPolicy ["enabled" ]; ok {
576+ settings [FieldEnabled ] = v
577+ }
578+ if v , ok := apiPolicy ["isScopedMode" ]; ok {
579+ settings [FieldIsScopedMode ] = v
580+ }
581+ if v , ok := apiPolicy ["nodeTemplatesPartialMatchingEnabled" ]; ok {
582+ settings [FieldNodeTemplatesPartialMatchingEnabled ] = v
583+ }
584+
585+ // Unschedulable Pods (excluding deprecated fields: headroom, headroomSpot, nodeConstraints, customInstancesEnabled)
586+ if unschedulablePods , ok := apiPolicy ["unschedulablePods" ].(map [string ]interface {}); ok {
587+ up := flattenUnschedulablePods (unschedulablePods )
588+ if len (up ) > 0 {
589+ settings [FieldUnschedulablePods ] = []interface {}{up }
590+ }
591+ }
592+
593+ // Cluster Limits
594+ if clusterLimits , ok := apiPolicy ["clusterLimits" ].(map [string ]interface {}); ok {
595+ cl := flattenClusterLimits (clusterLimits )
596+ if len (cl ) > 0 {
597+ settings [FieldClusterLimits ] = []interface {}{cl }
598+ }
599+ }
600+
601+ // Node Downscaler (excluding spot_instances which is deprecated at top level)
602+ if nodeDownscaler , ok := apiPolicy ["nodeDownscaler" ].(map [string ]interface {}); ok {
603+ nd := flattenNodeDownscaler (nodeDownscaler )
604+ if len (nd ) > 0 {
605+ settings [FieldNodeDownscaler ] = []interface {}{nd }
606+ }
607+ }
608+
609+ // Note: Omitting spotInstances entirely (deprecated)
610+
611+ return []map [string ]interface {}{settings }, nil
612+ }
613+
614+ // flattenUnschedulablePods converts the unschedulablePods API response to Terraform structure.
615+ // Deprecated fields (headroom, headroomSpot, nodeConstraints, customInstancesEnabled) are omitted.
616+ // podPinner is included if present in API response so users can track changes made via UI/API.
617+ func flattenUnschedulablePods (unschedulablePods map [string ]interface {}) map [string ]interface {} {
618+ up := make (map [string ]interface {})
619+
620+ if v , ok := unschedulablePods ["enabled" ]; ok {
621+ up [FieldEnabled ] = v
622+ }
623+
624+ // Note: Omitting headroom, headroomSpot, nodeConstraints, customInstancesEnabled (deprecated)
625+
626+ if podPinner , ok := unschedulablePods ["podPinner" ].(map [string ]interface {}); ok {
627+ if enabled , ok := podPinner ["enabled" ].(bool ); ok {
628+ pp := map [string ]interface {}{
629+ FieldEnabled : enabled ,
630+ }
631+ up [FieldPodPinner ] = []interface {}{pp }
632+ }
633+ }
634+
635+ return up
636+ }
637+
638+ // flattenClusterLimits converts the clusterLimits API response to Terraform structure.
639+ func flattenClusterLimits (clusterLimits map [string ]interface {}) map [string ]interface {} {
640+ cl := make (map [string ]interface {})
641+
642+ if v , ok := clusterLimits ["enabled" ]; ok {
643+ cl [FieldEnabled ] = v
644+ }
645+
646+ if cpu , ok := clusterLimits ["cpu" ].(map [string ]interface {}); ok {
647+ cpuMap := make (map [string ]interface {})
648+ if v , ok := cpu ["minCores" ]; ok {
649+ cpuMap [FieldMinCores ] = v
650+ }
651+ if v , ok := cpu ["maxCores" ]; ok {
652+ cpuMap [FieldMaxCores ] = v
653+ }
654+ if len (cpuMap ) > 0 {
655+ cl [FieldCPU ] = []interface {}{cpuMap }
656+ }
657+ }
658+
659+ return cl
660+ }
661+
662+ // flattenNodeDownscaler converts the nodeDownscaler API response to Terraform structure.
663+ func flattenNodeDownscaler (nodeDownscaler map [string ]interface {}) map [string ]interface {} {
664+ nd := make (map [string ]interface {})
665+
666+ if v , ok := nodeDownscaler ["enabled" ]; ok {
667+ nd [FieldEnabled ] = v
668+ }
669+
670+ if emptyNodes , ok := nodeDownscaler ["emptyNodes" ].(map [string ]interface {}); ok {
671+ en := make (map [string ]interface {})
672+ if v , ok := emptyNodes ["enabled" ]; ok {
673+ en [FieldEnabled ] = v
674+ }
675+ if v , ok := emptyNodes ["delaySeconds" ]; ok {
676+ en [FieldDelaySeconds ] = v
677+ }
678+ if len (en ) > 0 {
679+ nd [FieldEmptyNodes ] = []interface {}{en }
680+ }
681+ }
682+
683+ if evictor , ok := nodeDownscaler ["evictor" ].(map [string ]interface {}); ok {
684+ ev := flattenEvictor (evictor )
685+ if len (ev ) > 0 {
686+ nd [FieldEvictor ] = []interface {}{ev }
687+ }
688+ }
689+
690+ return nd
691+ }
692+
693+ // flattenEvictor converts the evictor API response to Terraform structure.
694+ func flattenEvictor (evictor map [string ]interface {}) map [string ]interface {} {
695+ ev := make (map [string ]interface {})
696+
697+ if v , ok := evictor ["enabled" ]; ok {
698+ ev [FieldEnabled ] = v
699+ }
700+ if v , ok := evictor ["dryRun" ]; ok {
701+ ev [FieldEvictorDryRun ] = v
702+ }
703+ if v , ok := evictor ["aggressiveMode" ]; ok {
704+ ev [FieldEvictorAggressiveMode ] = v
705+ }
706+ if v , ok := evictor ["scopedMode" ]; ok {
707+ ev [FieldEvictorScopedMode ] = v
708+ }
709+ if v , ok := evictor ["cycleInterval" ]; ok {
710+ ev [FieldEvictorCycleInterval ] = v
711+ }
712+ if v , ok := evictor ["nodeGracePeriodMinutes" ]; ok {
713+ ev [FieldEvictorNodeGracePeriodMinutes ] = v
714+ }
715+ if v , ok := evictor ["podEvictionFailureBackOffInterval" ]; ok {
716+ ev [FieldEvictorPodEvictionFailureBackOffInterval ] = v
717+ }
718+ if v , ok := evictor ["ignorePodDisruptionBudgets" ]; ok {
719+ ev [FieldEvictorIgnorePodDisruptionBudgets ] = v
720+ }
721+
722+ return ev
723+ }
724+
516725func resourceCastaiAutoscalerDiff (ctx context.Context , d * schema.ResourceDiff , meta interface {}) error {
517726 clusterId := getClusterId (d )
518727 if clusterId == "" {
@@ -527,6 +736,9 @@ func resourceCastaiAutoscalerDiff(ctx context.Context, d *schema.ResourceDiff, m
527736 return nil
528737 }
529738
739+ // Filter out volatile fields that change independently on API side
740+ policies = filterVolatileFields (policies )
741+
530742 return d .SetNew (FieldAutoscalerPolicies , string (policies ))
531743}
532744
@@ -639,6 +851,9 @@ func readAutoscalerPolicies(ctx context.Context, data *schema.ResourceData, meta
639851 return err
640852 }
641853
854+ // Filter out volatile fields that change independently on API side
855+ currentPolicies = filterVolatileFields (currentPolicies )
856+
642857 err = data .Set (FieldAutoscalerPolicies , string (currentPolicies ))
643858 if err != nil {
644859 log .Printf ("[ERROR] Failed to set field: %v" , err )
@@ -657,6 +872,43 @@ func getClusterId(data types.ResourceProvider) string {
657872 return value .(string )
658873}
659874
875+ // filterVolatileFields removes API-computed fields that change independently and cause unnecessary drift.
876+ // This includes fields that are bidirectionally synced with node templates.
877+ func filterVolatileFields (policiesJSON []byte ) []byte {
878+ var policies map [string ]interface {}
879+ if err := json .Unmarshal (policiesJSON , & policies ); err != nil {
880+ return policiesJSON // return as-is if unmarshal fails
881+ }
882+
883+ // Remove fields that change due to policy↔node template sync
884+ delete (policies , "defaultNodeTemplateVersion" )
885+ delete (policies , "spotInstances" ) // synced with node template spot settings
886+
887+ // Remove nested deprecated fields that sync with node templates
888+ if up , ok := policies ["unschedulablePods" ].(map [string ]interface {}); ok {
889+ delete (up , "nodeConstraints" ) // synced with node template min/max CPU/RAM
890+ delete (up , "customInstancesEnabled" ) // synced with node template
891+
892+ // Remove API-computed status fields that change based on cluster state
893+ if podPinner , ok := up ["podPinner" ].(map [string ]interface {}); ok {
894+ delete (podPinner , "status" )
895+ }
896+ }
897+
898+ // Remove API-computed status field from evictor
899+ if nd , ok := policies ["nodeDownscaler" ].(map [string ]interface {}); ok {
900+ if evictor , ok := nd ["evictor" ].(map [string ]interface {}); ok {
901+ delete (evictor , "status" )
902+ }
903+ }
904+
905+ filtered , err := json .Marshal (policies )
906+ if err != nil {
907+ return policiesJSON
908+ }
909+ return filtered
910+ }
911+
660912func getChangedPolicies (ctx context.Context , data types.ResourceProvider , meta interface {}, clusterId string ) ([]byte , error ) {
661913 policyChangesJSON , isPoliciesJSONExist := data .GetOk (FieldAutoscalerPoliciesJSON )
662914 _ , isPoliciesSettingsExist := data .GetOk (FieldAutoscalerSettings )
@@ -749,11 +1001,42 @@ func createValidationError(field, value string) error {
7491001// and their values are now stored in pre-existing states, so we cannot use the policies stored in state as-is. We null
7501002// those fields in case they are not specified in the configuration.
7511003func adjustPolicyForDrift (data types.ResourceProvider , policy * types.AutoscalerPolicy ) {
752- if policy != nil && policy .UnschedulablePods != nil {
753- val , d := data .GetRawConfigAt (cty .GetAttrPath (FieldAutoscalerSettings ).IndexInt (0 ).GetAttr (FieldUnschedulablePods ).IndexInt (0 ).GetAttr (FieldCustomInstancesEnabled ))
1004+ if policy == nil {
1005+ return
1006+ }
1007+
1008+ // Handle deprecated spot_instances field
1009+ val , d := data .GetRawConfigAt (cty .GetAttrPath (FieldAutoscalerSettings ).IndexInt (0 ).GetAttr (FieldSpotInstances ))
1010+ if ! d .HasError () && val .IsNull () {
1011+ policy .SpotInstances = nil
1012+ }
1013+
1014+ if policy .UnschedulablePods != nil {
1015+ unschedulablePodsPath := cty .GetAttrPath (FieldAutoscalerSettings ).IndexInt (0 ).GetAttr (FieldUnschedulablePods ).IndexInt (0 )
1016+
1017+ // Handle custom_instances_enabled (deprecated)
1018+ val , d := data .GetRawConfigAt (unschedulablePodsPath .GetAttr (FieldCustomInstancesEnabled ))
7541019 if ! d .HasError () && val .IsNull () {
7551020 policy .UnschedulablePods .CustomInstances = nil
7561021 }
1022+
1023+ // Handle headroom (deprecated)
1024+ val , d = data .GetRawConfigAt (unschedulablePodsPath .GetAttr (FieldHeadroom ))
1025+ if ! d .HasError () && val .IsNull () {
1026+ policy .UnschedulablePods .Headroom = nil
1027+ }
1028+
1029+ // Handle headroom_spot (deprecated)
1030+ val , d = data .GetRawConfigAt (unschedulablePodsPath .GetAttr (FieldHeadroomSpot ))
1031+ if ! d .HasError () && val .IsNull () {
1032+ policy .UnschedulablePods .HeadroomSpot = nil
1033+ }
1034+
1035+ // Handle node_constraints (deprecated)
1036+ val , d = data .GetRawConfigAt (unschedulablePodsPath .GetAttr (FieldNodeConstraints ))
1037+ if ! d .HasError () && val .IsNull () {
1038+ policy .UnschedulablePods .NodeConstraints = nil
1039+ }
7571040 }
7581041}
7591042
0 commit comments