Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
285 changes: 285 additions & 0 deletions hypershift-operator/controllers/nodepool/capi_test.go
Comment thread
jparrill marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -2943,6 +2943,291 @@ func TestPropagateLabelsAndTaintsToMachines(t *testing.T) {
}
}

// TestPauseUnpauseCycle is a regression test for the interaction between pausing
// NodePools and the Cluster Autoscaler (OCPBUGS-78152 / CNTRLPLANE-3040).
//
// It verifies that:
// - When a MachineDeployment/MachineSet is paused, the pause annotation is set
// - When reconcileMachineDeployment/reconcileMachineSet runs after unpause, the
// pause annotation is removed and autoscaler annotations are preserved
// - setMachineDeploymentReplicas clamps replicas within [min, max] bounds regardless
// of external modifications (e.g. CAS decrementing replicas while paused)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't all this really effectively the same than adding a few test cases for TestSetMachineDeploymentReplicas and TestSetMachineSetReplicas?

@jparrill jparrill May 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — the clamping logic itself is the same. The difference is that TestPauseUnpauseCycle exercises the full reconcile flow end-to-end:

Pause annotation set on NodePool
        │
        ▼
External replica modification (CAS decrements while paused)
        │
        ▼
reconcileMachineDeployment / reconcileMachineSet
        │
        ▼
Pause annotation removed
        │
        ▼
Replicas clamped back to [min, max]

Adding cases to TestSetMachineDeploymentReplicas/TestSetMachineSetReplicas would cover the clamping in isolation, but wouldn't validate that all the pieces work together through the reconcile cycle. I think both are worth keeping — unit cases for the function, integration-style test for the flow.

func TestPauseUnpauseCycle(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
upgradeType hyperv1.UpgradeType
platformType hyperv1.PlatformType
initialReplicas int32
autoscalingMin int32
autoscalingMax int32
replicasAtUnpause int32
expectedReplicas int32
}{
{
name: "When a paused MachineDeployment is unpaused it should remove the pause annotation and preserve replicas",
upgradeType: hyperv1.UpgradeTypeReplace,
platformType: hyperv1.AWSPlatform,
initialReplicas: 4,
autoscalingMin: 1,
autoscalingMax: 4,
replicasAtUnpause: 4,
expectedReplicas: 4,
},
{
name: "When a paused MachineDeployment has replicas decremented to min it should keep replicas at min on unpause",
upgradeType: hyperv1.UpgradeTypeReplace,
platformType: hyperv1.AWSPlatform,
initialReplicas: 4,
autoscalingMin: 1,
autoscalingMax: 4,
replicasAtUnpause: 1,
expectedReplicas: 1,
},
{
name: "When a paused MachineDeployment has replicas decremented below min it should clamp to min on unpause",
upgradeType: hyperv1.UpgradeTypeReplace,
platformType: hyperv1.AWSPlatform,
initialReplicas: 4,
autoscalingMin: 2,
autoscalingMax: 4,
replicasAtUnpause: 1,
expectedReplicas: 2,
},
{
name: "When a paused MachineDeployment has replicas incremented above max it should clamp to max on unpause",
upgradeType: hyperv1.UpgradeTypeReplace,
platformType: hyperv1.AWSPlatform,
initialReplicas: 4,
autoscalingMin: 1,
autoscalingMax: 4,
replicasAtUnpause: 6,
expectedReplicas: 4,
},
{
name: "When a paused MachineDeployment on AWS with min 0 it should allow scale to zero on unpause",
upgradeType: hyperv1.UpgradeTypeReplace,
platformType: hyperv1.AWSPlatform,
initialReplicas: 4,
autoscalingMin: 0,
autoscalingMax: 4,
replicasAtUnpause: 0,
expectedReplicas: 0,
},
{
name: "When a paused MachineDeployment on non-AWS with min 0 it should clamp to effective min 1 on unpause",
upgradeType: hyperv1.UpgradeTypeReplace,
platformType: hyperv1.KubevirtPlatform,
initialReplicas: 4,
autoscalingMin: 0,
autoscalingMax: 4,
replicasAtUnpause: 0,
expectedReplicas: 1,
},
{
name: "When a paused MachineSet is unpaused it should remove the pause annotation and preserve replicas",
upgradeType: hyperv1.UpgradeTypeInPlace,
platformType: hyperv1.AWSPlatform,
initialReplicas: 4,
autoscalingMin: 1,
autoscalingMax: 4,
replicasAtUnpause: 4,
expectedReplicas: 4,
},
{
name: "When a paused MachineSet has replicas decremented below min it should clamp to min on unpause",
upgradeType: hyperv1.UpgradeTypeInPlace,
platformType: hyperv1.AWSPlatform,
initialReplicas: 4,
autoscalingMin: 2,
autoscalingMax: 4,
replicasAtUnpause: 1,
expectedReplicas: 2,
},
{
name: "When a paused MachineSet has replicas incremented above max it should clamp to max on unpause",
upgradeType: hyperv1.UpgradeTypeInPlace,
platformType: hyperv1.AWSPlatform,
initialReplicas: 4,
autoscalingMin: 1,
autoscalingMax: 4,
replicasAtUnpause: 6,
expectedReplicas: 4,
},
{
name: "When a paused MachineSet on AWS with min 0 it should allow scale to zero on unpause",
upgradeType: hyperv1.UpgradeTypeInPlace,
platformType: hyperv1.AWSPlatform,
initialReplicas: 4,
autoscalingMin: 0,
autoscalingMax: 4,
replicasAtUnpause: 0,
expectedReplicas: 0,
},
{
name: "When a paused MachineSet on non-AWS with min 0 it should clamp to effective min 1 on unpause",
upgradeType: hyperv1.UpgradeTypeInPlace,
platformType: hyperv1.KubevirtPlatform,
initialReplicas: 4,
autoscalingMin: 0,
autoscalingMax: 4,
replicasAtUnpause: 0,
expectedReplicas: 1,
},
Comment thread
jparrill marked this conversation as resolved.
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
g := NewWithT(t)

nodePool := &hyperv1.NodePool{
ObjectMeta: metav1.ObjectMeta{
Name: "test-nodepool",
Namespace: "test-namespace",
},
Spec: hyperv1.NodePoolSpec{
ClusterName: "test-cluster",
AutoScaling: &hyperv1.NodePoolAutoScaling{
Min: ptr.To[int32](tc.autoscalingMin),
Max: tc.autoscalingMax,
},
Management: hyperv1.NodePoolManagement{
UpgradeType: tc.upgradeType,
Replace: &hyperv1.ReplaceUpgrade{
Strategy: hyperv1.UpgradeStrategyRollingUpdate,
RollingUpdate: &hyperv1.RollingUpdate{
MaxUnavailable: ptr.To(intstr.FromInt(0)),
MaxSurge: ptr.To(intstr.FromInt(1)),
},
},
},
Platform: hyperv1.NodePoolPlatform{
Type: tc.platformType,
},
},
}
if tc.platformType == hyperv1.AWSPlatform {
nodePool.Spec.Platform.AWS = &hyperv1.AWSNodePoolPlatform{AMI: "test-ami"}
}

hostedCluster := &hyperv1.HostedCluster{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cluster",
Namespace: "test-namespace",
},
Spec: hyperv1.HostedClusterSpec{
Platform: hyperv1.PlatformSpec{
Type: tc.platformType,
},
},
}
if tc.platformType == hyperv1.AWSPlatform {
hostedCluster.Spec.Platform.AWS = &hyperv1.AWSPlatformSpec{}
}

controlPlaneNamespace := "test-cp-namespace"
c := fake.NewClientBuilder().WithScheme(api.Scheme).Build()
capiObj := &CAPI{
Token: &Token{
ConfigGenerator: &ConfigGenerator{
nodePool: nodePool,
hostedCluster: hostedCluster,
controlplaneNamespace: controlPlaneNamespace,
Client: c,
rolloutConfig: &rolloutConfig{
releaseImage: &releaseinfo.ReleaseImage{
ImageStream: &imageapi.ImageStream{
ObjectMeta: metav1.ObjectMeta{
Name: "target-version",
},
},
},
},
},
cpoCapabilities: &CPOCapabilities{},
CreateOrUpdateProvider: upsert.New(false),
},
capiClusterName: "test-cluster",
}

// Create MachineDeployment and MachineSet with initial replicas and autoscaler annotations.
md := capiObj.machineDeployment()
md.Spec.Replicas = ptr.To[int32](tc.initialReplicas)
md.Annotations = map[string]string{
autoscalerMinAnnotation: fmt.Sprintf("%d", tc.autoscalingMin),
autoscalerMaxAnnotation: fmt.Sprintf("%d", tc.autoscalingMax),
}
g.Expect(c.Create(t.Context(), md)).To(Succeed())

ms := capiObj.machineSet()
ms.Spec.Replicas = ptr.To[int32](tc.initialReplicas)
ms.Annotations = map[string]string{
autoscalerMinAnnotation: fmt.Sprintf("%d", tc.autoscalingMin),
autoscalerMaxAnnotation: fmt.Sprintf("%d", tc.autoscalingMax),
}
g.Expect(c.Create(t.Context(), ms)).To(Succeed())

// Step 1: Pause.
g.Expect(capiObj.Pause(t.Context())).To(Succeed())

g.Expect(c.Get(t.Context(), client.ObjectKeyFromObject(md), md)).To(Succeed())
g.Expect(md.Annotations).To(HaveKeyWithValue(capiv1.PausedAnnotation, "true"))

g.Expect(c.Get(t.Context(), client.ObjectKeyFromObject(ms), ms)).To(Succeed())
g.Expect(ms.Annotations).To(HaveKeyWithValue(capiv1.PausedAnnotation, "true"))

// Step 2: Simulate replica drift while paused (e.g. CAS decrementing via Scale subresource).
if tc.upgradeType == hyperv1.UpgradeTypeReplace {
md.Spec.Replicas = ptr.To[int32](tc.replicasAtUnpause)
g.Expect(c.Update(t.Context(), md)).To(Succeed())
} else {
ms.Spec.Replicas = ptr.To[int32](tc.replicasAtUnpause)
g.Expect(c.Update(t.Context(), ms)).To(Succeed())
}

// Step 3: Unpause by calling the reconcile functions (simulates the NodePool controller
// reconciling after PausedUntil expires, which calls capi.Reconcile()).
log := ctrl.LoggerFrom(t.Context())
template := &capiaws.AWSMachineTemplate{
ObjectMeta: metav1.ObjectMeta{
Name: "test-template",
Namespace: controlPlaneNamespace,
},
}

// For non-AWS platforms, effectiveMin is clamped to 1 when autoscalingMin is 0.
expectedMinAnnotation := tc.autoscalingMin
if tc.autoscalingMin == 0 && tc.platformType != hyperv1.AWSPlatform {
expectedMinAnnotation = 1
}

if tc.upgradeType == hyperv1.UpgradeTypeReplace {
g.Expect(c.Get(t.Context(), client.ObjectKeyFromObject(md), md)).To(Succeed())
g.Expect(capiObj.reconcileMachineDeployment(t.Context(), log, md, template)).To(Succeed())

// Verify pause annotation removed.
g.Expect(md.Annotations).NotTo(HaveKey(capiv1.PausedAnnotation))
// Verify replicas clamped within autoscaler bounds.
g.Expect(*md.Spec.Replicas).To(Equal(tc.expectedReplicas))
// Verify autoscaler annotations preserved.
g.Expect(md.Annotations).To(HaveKeyWithValue(autoscalerMinAnnotation, fmt.Sprintf("%d", expectedMinAnnotation)))
g.Expect(md.Annotations).To(HaveKeyWithValue(autoscalerMaxAnnotation, fmt.Sprintf("%d", tc.autoscalingMax)))
} else {
g.Expect(c.Get(t.Context(), client.ObjectKeyFromObject(ms), ms)).To(Succeed())
g.Expect(capiObj.reconcileMachineSet(t.Context(), ms, template)).To(Succeed())

// Verify pause annotation removed.
g.Expect(ms.Annotations).NotTo(HaveKey(capiv1.PausedAnnotation))
// Verify replicas clamped within autoscaler bounds.
g.Expect(*ms.Spec.Replicas).To(Equal(tc.expectedReplicas))
// Verify autoscaler annotations preserved.
g.Expect(ms.Annotations).To(HaveKeyWithValue(autoscalerMinAnnotation, fmt.Sprintf("%d", expectedMinAnnotation)))
g.Expect(ms.Annotations).To(HaveKeyWithValue(autoscalerMaxAnnotation, fmt.Sprintf("%d", tc.autoscalingMax)))
}
})
}
}

func TestNewCAPI(t *testing.T) {
t.Parallel()
testCases := []struct {
Expand Down
Loading