@@ -18,6 +18,8 @@ import (
1818
1919 v1 "github.com/atlassian-labs/cyclops/pkg/apis/atlassian/v1"
2020 "github.com/atlassian-labs/cyclops/pkg/cloudprovider"
21+ "github.com/atlassian-labs/cyclops/pkg/k8s"
22+ "github.com/atlassian-labs/cyclops/pkg/metrics"
2123)
2224
2325// transitionToHealing transitions the current cycleNodeRequest to healing which will always transiting to failed
@@ -589,3 +591,255 @@ func countNodesCreatedAfter(nodes map[string]corev1.Node, cutoffTime time.Time)
589591 }
590592 return count
591593}
594+
595+ // findNodesCreatedAfter returns all nodes in the given map that are created after the specified time
596+ func findNodesCreatedAfter (nodes map [string ]corev1.Node , cutoffTime time.Time ) []corev1.Node {
597+ var result []corev1.Node
598+ for _ , node := range nodes {
599+ if node .CreationTimestamp .Time .After (cutoffTime ) {
600+ result = append (result , node )
601+ }
602+ }
603+ return result
604+ }
605+
606+ // getNodegroupForMetrics returns a nodegroup identifier for metrics labeling.
607+ // Since a CycleNodeRequest can have multiple nodegroups, we use the first one or "unknown" if none.
608+ func (t * CycleNodeRequestTransitioner ) getNodegroupForMetrics () string {
609+ nodeGroups := t .cycleNodeRequest .GetNodeGroupNames ()
610+ if len (nodeGroups ) > 0 {
611+ return nodeGroups [0 ]
612+ }
613+ return "unknown"
614+ }
615+
616+ // getNodeGroup finds the NodeGroup resource that matches this CNR.
617+ func (t * CycleNodeRequestTransitioner ) getNodeGroup () (* v1.NodeGroup , error ) {
618+ var nodeGroupList v1.NodeGroupList
619+ if err := t .rm .Client .List (context .TODO (), & nodeGroupList ); err != nil {
620+ return nil , err
621+ }
622+
623+ for i := range nodeGroupList .Items {
624+ ng := & nodeGroupList .Items [i ]
625+ if t .cycleNodeRequest .IsFromNodeGroup (* ng ) {
626+ return ng , nil
627+ }
628+ }
629+
630+ return nil , nil
631+ }
632+
633+ // shouldManageAnnotations checks if Cyclops should manage cluster-autoscaler annotations on nodes.
634+ // Returns true if annotations should be added/removed (default behavior).
635+ // Returns false if opt-out is enabled via NodeGroup annotation.
636+ //
637+ // Opt-out mechanism:
638+ // - NodeGroup annotation: cyclops.atlassian.com/disable-annotation-management
639+ // - Value "true" → opt-out (do NOT add/remove annotations)
640+ // - Value "false" or missing → default enabled (add/remove annotations)
641+ //
642+ // Default behavior (when NodeGroup not found or annotation missing): enabled (return true)
643+ func (t * CycleNodeRequestTransitioner ) shouldManageAnnotations () bool {
644+ nodeGroup , err := t .getNodeGroup ()
645+ if err != nil {
646+ t .rm .Logger .Info ("Failed to get NodeGroup, defaulting to annotation management enabled" ,
647+ "cnr" , t .cycleNodeRequest .Name ,
648+ "error" , err )
649+ return true
650+ }
651+ if nodeGroup == nil {
652+ t .rm .Logger .Info ("NodeGroup not found for CNR, defaulting to annotation management enabled" ,
653+ "cnr" , t .cycleNodeRequest .Name ,
654+ "nodeGroupNames" , t .cycleNodeRequest .GetNodeGroupNames ())
655+ return true
656+ }
657+
658+ annotationValue := nodeGroup .Annotations [nodeGroupAnnotationKey ]
659+ t .rm .Logger .Info ("Checking NodeGroup annotation for opt-out" ,
660+ "cnr" , t .cycleNodeRequest .Name ,
661+ "nodeGroup" , nodeGroup .Name ,
662+ "annotation" , nodeGroupAnnotationKey ,
663+ "value" , annotationValue )
664+ if annotationValue == "true" {
665+ t .rm .Logger .Info ("Node annotations disabled via NodeGroup annotation" ,
666+ "nodeGroup" , nodeGroup .Name ,
667+ "annotation" , nodeGroupAnnotationKey )
668+ return false
669+ }
670+
671+ return true
672+ }
673+
674+ // addScaleDownDisabledAnnotation adds the cluster-autoscaler scale-down-disabled annotation
675+ // to a node to prevent Cluster Autoscaler from removing it during cycling.
676+ // This is a best-effort operation and failures should not block the cycling process.
677+ // Note: This function should only be called when shouldManageAnnotations() returns true.
678+ // If the annotation already exists, it is preserved (not overwritten) for backward compatibility.
679+ func (t * CycleNodeRequestTransitioner ) addScaleDownDisabledAnnotation (nodeName string ) error {
680+ // Defensive check: ensure annotation management is enabled
681+ if ! t .shouldManageAnnotations () {
682+ t .rm .Logger .Info ("Skipping annotation addition - management disabled" ,
683+ "nodeName" , nodeName ,
684+ "cnr" , t .cycleNodeRequest .Name )
685+ return nil
686+ }
687+
688+ // Check if annotation already exists - preserve existing value for backward compatibility
689+ node , err := t .rm .RawClient .CoreV1 ().Nodes ().Get (context .TODO (), nodeName , metav1.GetOptions {})
690+ if err == nil && node .Annotations != nil {
691+ if existingValue , exists := node .Annotations [clusterAutoscalerScaleDownDisabledAnnotation ]; exists {
692+ t .rm .Logger .Info ("Annotation already exists on node, preserving existing value" ,
693+ "nodeName" , nodeName ,
694+ "existingValue" , existingValue ,
695+ "cnr" , t .cycleNodeRequest .Name )
696+ return nil
697+ }
698+ }
699+
700+ nodegroup := t .getNodegroupForMetrics ()
701+
702+ err = k8s .AddAnnotationToNode (
703+ nodeName ,
704+ clusterAutoscalerScaleDownDisabledAnnotation ,
705+ clusterAutoscalerScaleDownDisabledValue ,
706+ t .rm .RawClient ,
707+ )
708+
709+ if err != nil {
710+ // Extract error type for better categorization
711+ errorType := "unknown"
712+ errStr := err .Error ()
713+ if strings .Contains (errStr , "not found" ) {
714+ errorType = "node_not_found"
715+ } else if strings .Contains (errStr , "forbidden" ) || strings .Contains (errStr , "unauthorized" ) {
716+ errorType = "permission_denied"
717+ } else if strings .Contains (errStr , "conflict" ) {
718+ errorType = "conflict"
719+ }
720+
721+ metrics .AnnotationAddFailure .WithLabelValues (nodegroup , errorType ).Inc ()
722+ return err
723+ }
724+
725+ // Add marker annotation to track that we added the cluster-autoscaler annotation
726+ // This ensures we only remove annotations we added, not pre-existing ones
727+ if markerErr := k8s .AddAnnotationToNode (
728+ nodeName ,
729+ cyclopsManagedAnnotation ,
730+ "true" ,
731+ t .rm .RawClient ,
732+ ); markerErr != nil {
733+ // Log but don't fail - marker is for tracking, not critical
734+ t .rm .Logger .Info ("Failed to add managed annotation marker to node" ,
735+ "nodeName" , nodeName ,
736+ "error" , markerErr )
737+ } else {
738+ t .rm .Logger .Info ("Added managed annotation marker to node" ,
739+ "nodeName" , nodeName ,
740+ "cnr" , t .cycleNodeRequest .Name )
741+ }
742+
743+ metrics .AnnotationAddSuccess .WithLabelValues (nodegroup ).Inc ()
744+ metrics .NodesWithAnnotation .WithLabelValues (nodegroup , nodeName ).Set (1 )
745+
746+ return nil
747+ }
748+
749+ // removeScaleDownDisabledAnnotation removes the cluster-autoscaler scale-down-disabled annotation
750+ // from a node to allow Cluster Autoscaler to consider it for scale-down again.
751+ // This is a best-effort operation and failures should not block the cycling process.
752+ // Uses retry logic to handle transient API errors.
753+ func (t * CycleNodeRequestTransitioner ) removeScaleDownDisabledAnnotation (nodeName string ) error {
754+ nodegroup := t .getNodegroupForMetrics ()
755+
756+ err := retry .RetryOnConflict (retry .DefaultRetry , func () error {
757+ return k8s .RemoveAnnotationFromNode (
758+ nodeName ,
759+ clusterAutoscalerScaleDownDisabledAnnotation ,
760+ t .rm .RawClient ,
761+ )
762+ })
763+
764+ if err != nil {
765+ // Extract error type for better categorization
766+ errorType := "unknown"
767+ errStr := err .Error ()
768+ if strings .Contains (errStr , "not found" ) {
769+ errorType = "node_not_found"
770+ } else if strings .Contains (errStr , "forbidden" ) || strings .Contains (errStr , "unauthorized" ) {
771+ errorType = "permission_denied"
772+ } else if strings .Contains (errStr , "conflict" ) {
773+ errorType = "conflict"
774+ }
775+
776+ metrics .AnnotationRemoveFailure .WithLabelValues (nodegroup , errorType ).Inc ()
777+ return err
778+ }
779+
780+ // Remove the marker annotation since we're removing the cluster-autoscaler annotation
781+ if markerErr := k8s .RemoveAnnotationFromNode (
782+ nodeName ,
783+ cyclopsManagedAnnotation ,
784+ t .rm .RawClient ,
785+ ); markerErr != nil {
786+ // Log but don't fail - marker cleanup is best-effort
787+ t .rm .Logger .Info ("Failed to remove managed annotation marker from node" ,
788+ "nodeName" , nodeName ,
789+ "error" , markerErr )
790+ }
791+
792+ metrics .AnnotationRemoveSuccess .WithLabelValues (nodegroup ).Inc ()
793+ metrics .NodesWithAnnotation .WithLabelValues (nodegroup , nodeName ).Set (0 )
794+
795+ return nil
796+ }
797+
798+ // cleanupScaleDownDisabledAnnotations removes the cluster-autoscaler scale-down-disabled annotation
799+ // from nodes that were created during the cycle. This is called during both Healing and Successful
800+ // phases to ensure annotations are cleaned up regardless of how the cycle completes.
801+ // This is a best-effort operation and failures should not block the transition.
802+ // Uses listNodes instead of listReadyNodes to ensure cleanup happens even if nodes are not Ready yet,
803+ // which is important when cycling fails in the Healing phase.
804+ func (t * CycleNodeRequestTransitioner ) cleanupScaleDownDisabledAnnotations () {
805+ if t .cycleNodeRequest .Status .ScaleUpStarted == nil {
806+ return
807+ }
808+
809+ kubeNodes , err := t .listNodes (true )
810+ if err != nil {
811+ return
812+ }
813+
814+ phase := string (t .cycleNodeRequest .Status .Phase )
815+ nodesCreatedDuringCycle := findNodesCreatedAfter (kubeNodes , t .cycleNodeRequest .Status .ScaleUpStarted .Time )
816+ for _ , newNode := range nodesCreatedDuringCycle {
817+ if newNode .Annotations != nil {
818+ if val , exists := newNode .Annotations [clusterAutoscalerScaleDownDisabledAnnotation ]; exists && val == clusterAutoscalerScaleDownDisabledValue {
819+ // Only remove annotations we added (marked with cyclopsManagedAnnotation).
820+ // This ensures we don't remove pre-existing annotations that may have been set by
821+ // ASG Launch Template user data, cloud-init, or other bootstrap scripts.
822+ if newNode .Annotations [cyclopsManagedAnnotation ] != "true" {
823+ t .rm .Logger .Info ("Skipping annotation removal - annotation was not added by Cyclops" ,
824+ "phase" , phase ,
825+ "nodeName" , newNode .Name )
826+ continue
827+ }
828+
829+ if err := t .removeScaleDownDisabledAnnotation (newNode .Name ); err != nil {
830+ // Log warning but don't fail - annotation removal is best-effort
831+ t .rm .Logger .Info ("Failed to remove scale-down-disabled annotation from node" ,
832+ "phase" , phase ,
833+ "nodeName" , newNode .Name ,
834+ "error" , err )
835+ t .rm .LogEvent (t .cycleNodeRequest , "AnnotationCleanupWarning" ,
836+ "Failed to remove scale-down-disabled annotation from node %s during %s: %v" , newNode .Name , phase , err )
837+ } else {
838+ t .rm .Logger .Info ("Removed scale-down-disabled annotation from node" ,
839+ "phase" , phase ,
840+ "nodeName" , newNode .Name )
841+ }
842+ }
843+ }
844+ }
845+ }
0 commit comments