Skip to content

Commit a2fcd45

Browse files
authored
Merge pull request #134 from atlassian-labs/dnorman3/scale-down-disabled-annotation
Coordinate Cyclops with Cluster Autoscaler to prevent node upgrade conflicts
2 parents f6265a5 + f944612 commit a2fcd45

8 files changed

Lines changed: 712 additions & 2 deletions

File tree

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM --platform=$BUILDPLATFORM golang:1.23 as builder
1+
FROM --platform=$BUILDPLATFORM golang:1.24 as builder
22
ARG TARGETPLATFORM
33
ARG ENVVAR=CGO_ENABLED=0
44
WORKDIR /go/src/github.com/atlassian-labs/cyclops

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
VERSION = 1.10.2
1+
VERSION = 1.10.3
22
# IMPORTANT! Update api version if a new release affects cnr
33
API_VERSION = 1.0.0
44
IMAGE = cyclops:$(VERSION)

pkg/controller/cyclenoderequest/transitioner/transitioner.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,25 @@ type transitionFunc func() (reconcile.Result, error)
1717
// different request types.
1818
const cycleNodeLabel = "cyclops.atlassian.com/terminate"
1919

20+
// cyclopsManagedAnnotation marks nodes where Cyclops added the scale-down-disabled annotation.
21+
// Used to track which annotations we added vs pre-existing ones (for safe cleanup).
22+
// Only remove the cluster-autoscaler annotation if this marker annotation also exists.
23+
const cyclopsManagedAnnotation = "cyclops.atlassian.com/annotation-managed"
24+
25+
// clusterAutoscalerScaleDownDisabledAnnotation is the annotation key used to prevent
26+
// Cluster Autoscaler from scaling down a node. This is used to protect new nodes
27+
// during the cycling process from being removed by Cluster Autoscaler before
28+
// the corresponding old nodes are fully terminated.
29+
// See: https://github.com/kubernetes/autoscaler/blob/master/cluster-autoscaler/FAQ.md#how-can-i-prevent-cluster-autoscaler-from-scaling-down-a-particular-node
30+
const clusterAutoscalerScaleDownDisabledAnnotation = "cluster-autoscaler.kubernetes.io/scale-down-disabled"
31+
const clusterAutoscalerScaleDownDisabledValue = "true"
32+
33+
// nodeGroupAnnotationKey is the annotation key on NodeGroup resources that controls whether
34+
// Cluster Autoscaler annotation management is enabled or disabled.
35+
// Value: "true" → opt-out (disable annotation management)
36+
// Value: "false" or missing/empty → default enabled (annotation management enabled)
37+
const nodeGroupAnnotationKey = "cyclops.atlassian.com/disable-annotation-management"
38+
2039
var (
2140
transitionDuration = 10 * time.Second
2241
requeueDuration = 30 * time.Second

pkg/controller/cyclenoderequest/transitioner/transitions.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,27 @@ func (t *CycleNodeRequestTransitioner) transitionScalingUp() (reconcile.Result,
408408
}
409409
}
410410

411+
// Add scale-down-disabled annotation to new nodes if enabled
412+
if t.shouldManageAnnotations() {
413+
newNodes := findNodesCreatedAfter(kubeNodes, scaleUpStarted.Time)
414+
for _, newNode := range newNodes {
415+
if err := t.addScaleDownDisabledAnnotation(newNode.Name); err != nil {
416+
// Log warning but don't fail - annotation is best-effort and shouldn't block cycling
417+
t.rm.Logger.Info("Failed to add scale-down-disabled annotation to new node",
418+
"nodeName", newNode.Name,
419+
"error", err)
420+
t.rm.LogEvent(t.cycleNodeRequest, "AnnotationWarning",
421+
"Failed to add scale-down-disabled annotation to node %s: %v", newNode.Name, err)
422+
} else {
423+
t.rm.Logger.Info("Added scale-down-disabled annotation to new node",
424+
"nodeName", newNode.Name)
425+
}
426+
}
427+
} else {
428+
t.rm.Logger.Info("Node annotation management disabled for this CNR",
429+
"cnr", t.cycleNodeRequest.Name)
430+
}
431+
411432
t.rm.LogEvent(t.cycleNodeRequest, "ScalingUpCompleted", "New nodes are now ready")
412433
return t.transitionObject(v1.CycleNodeRequestCordoningNode)
413434
}
@@ -572,6 +593,14 @@ func (t *CycleNodeRequestTransitioner) transitionHealing() (reconcile.Result, er
572593
}
573594
}
574595

596+
// Cleanup scale-down-disabled annotations if enabled
597+
if t.shouldManageAnnotations() {
598+
t.cleanupScaleDownDisabledAnnotations()
599+
} else {
600+
t.rm.Logger.Info("Skipping annotation cleanup (disabled)",
601+
"cnr", t.cycleNodeRequest.Name)
602+
}
603+
575604
return t.transitionToFailed(nil)
576605
}
577606

@@ -599,6 +628,14 @@ func (t *CycleNodeRequestTransitioner) transitionSuccessful() (reconcile.Result,
599628
return reconcile.Result{Requeue: true, RequeueAfter: transitionDuration}, nil
600629
}
601630

631+
// Cleanup scale-down-disabled annotations if enabled
632+
if t.shouldManageAnnotations() {
633+
t.cleanupScaleDownDisabledAnnotations()
634+
} else {
635+
t.rm.Logger.Info("Skipping annotation cleanup (disabled)",
636+
"cnr", t.cycleNodeRequest.Name)
637+
}
638+
602639
// Delete failed sibling CNRs regardless of whether the CNR for the
603640
// transitioner should be deleted. If failed CNRs pile up that will prevent
604641
// Cyclops observer from auto-generating new CNRs for a nodegroup.

pkg/controller/cyclenoderequest/transitioner/util.go

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)