Skip to content

Commit 6a66d36

Browse files
twolff-ghclaude
andcommitted
fix(nodepool): watch cloud config ConfigMaps for convergence
Cloud config ConfigMap changes (azure-cloud-config, openstack-cloud-config) in the control plane namespace were not triggering nodepool reconciliation. The existing enqueueNodePoolsForConfig handler lists NodePools in the ConfigMap's namespace, but cloud config CMs live in the CP namespace while NodePools live in the management namespace. Add a dedicated watch handler that resolves CP namespace -> HostedCluster -> NodePools via the HostedControlPlane annotation, with a name predicate to short-circuit irrelevant ConfigMap events. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 50e651b commit 6a66d36

2 files changed

Lines changed: 157 additions & 0 deletions

File tree

hypershift-operator/controllers/nodepool/nodepool_controller.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import (
5151
"sigs.k8s.io/controller-runtime/pkg/controller"
5252
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
5353
"sigs.k8s.io/controller-runtime/pkg/handler"
54+
"sigs.k8s.io/controller-runtime/pkg/predicate"
5455
"sigs.k8s.io/controller-runtime/pkg/reconcile"
5556

5657
"github.com/blang/semver"
@@ -143,6 +144,10 @@ func (r *NodePoolReconciler) SetupWithManager(mgr ctrl.Manager) error {
143144
Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(enqueueParentNodePool), builder.WithPredicates(supportutil.PredicatesForHostedClusterAnnotationScoping(mgr.GetClient()))).
144145
// We want to reconcile when the ConfigMaps referenced by the spec.config and also the core ones change.
145146
Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(r.enqueueNodePoolsForConfig), builder.WithPredicates(supportutil.PredicatesForHostedClusterAnnotationScoping(mgr.GetClient()))).
147+
// We want to reconcile when cloud provider config ConfigMaps change in the control plane namespace.
148+
Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(r.enqueueNodePoolsForCloudConfig), builder.WithPredicates(predicate.NewPredicateFuncs(func(obj client.Object) bool {
149+
return obj.GetName() == "azure-cloud-config" || obj.GetName() == "openstack-cloud-config"
150+
}))).
146151
WithOptions(controller.Options{
147152
RateLimiter: workqueue.NewTypedItemExponentialFailureRateLimiter[reconcile.Request](1*time.Second, 10*time.Second),
148153
MaxConcurrentReconciles: 10,
@@ -956,6 +961,33 @@ func (r *NodePoolReconciler) enqueueNodePoolsForConfig(ctx context.Context, obj
956961
return result
957962
}
958963

964+
func (r *NodePoolReconciler) enqueueNodePoolsForCloudConfig(ctx context.Context, obj client.Object) []reconcile.Request {
965+
hcpList := &hyperv1.HostedControlPlaneList{}
966+
if err := r.List(ctx, hcpList, client.InNamespace(obj.GetNamespace())); err != nil || len(hcpList.Items) == 0 {
967+
return nil
968+
}
969+
hcName, ok := hcpList.Items[0].Annotations[k8sutil.HostedClusterAnnotation]
970+
if !ok {
971+
return nil
972+
}
973+
hc := supportutil.ParseNamespacedName(hcName)
974+
975+
nodePoolList := &hyperv1.NodePoolList{}
976+
if err := r.List(ctx, nodePoolList, client.InNamespace(hc.Namespace)); err != nil {
977+
return nil
978+
}
979+
980+
var result []reconcile.Request
981+
for i := range nodePoolList.Items {
982+
if nodePoolList.Items[i].Spec.ClusterName == hc.Name {
983+
result = append(result, reconcile.Request{
984+
NamespacedName: client.ObjectKeyFromObject(&nodePoolList.Items[i]),
985+
})
986+
}
987+
}
988+
return result
989+
}
990+
959991
// getNodePoolNamespace returns the namespaced name of a NodePool, given the NodePools name
960992
// and the control plane namespace name for the hosted cluster that this NodePool is a part of.
961993
func (r *NodePoolReconciler) getNodePoolNamespacedName(nodePoolName string, controlPlaneNamespace string) (types.NamespacedName, error) {

hypershift-operator/controllers/nodepool/nodepool_controller_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3747,3 +3747,128 @@ func TestNodePoolReconciler_reconcile(t *testing.T) {
37473747
})
37483748
}
37493749
}
3750+
3751+
func TestEnqueueNodePoolsForCloudConfig(t *testing.T) {
3752+
t.Parallel()
3753+
hcNamespace := "clusters"
3754+
hcName := "my-cluster"
3755+
cpNamespace := "clusters-my-cluster"
3756+
3757+
hcp := &hyperv1.HostedControlPlane{
3758+
ObjectMeta: metav1.ObjectMeta{
3759+
Name: hcName,
3760+
Namespace: cpNamespace,
3761+
Annotations: map[string]string{
3762+
k8sutil.HostedClusterAnnotation: hcNamespace + "/" + hcName,
3763+
},
3764+
},
3765+
}
3766+
3767+
matchingNodePool := &hyperv1.NodePool{
3768+
ObjectMeta: metav1.ObjectMeta{
3769+
Name: "np-1",
3770+
Namespace: hcNamespace,
3771+
},
3772+
Spec: hyperv1.NodePoolSpec{
3773+
ClusterName: hcName,
3774+
},
3775+
}
3776+
3777+
unrelatedNodePool := &hyperv1.NodePool{
3778+
ObjectMeta: metav1.ObjectMeta{
3779+
Name: "np-other",
3780+
Namespace: hcNamespace,
3781+
},
3782+
Spec: hyperv1.NodePoolSpec{
3783+
ClusterName: "other-cluster",
3784+
},
3785+
}
3786+
3787+
testCases := []struct {
3788+
name string
3789+
cm *corev1.ConfigMap
3790+
objects []client.Object
3791+
expected []reconcile.Request
3792+
}{
3793+
{
3794+
name: "When azure-cloud-config changes, it should enqueue matching NodePools",
3795+
cm: &corev1.ConfigMap{
3796+
ObjectMeta: metav1.ObjectMeta{
3797+
Name: "azure-cloud-config",
3798+
Namespace: cpNamespace,
3799+
},
3800+
},
3801+
objects: []client.Object{hcp, matchingNodePool, unrelatedNodePool},
3802+
expected: []reconcile.Request{
3803+
{NamespacedName: types.NamespacedName{Name: "np-1", Namespace: hcNamespace}},
3804+
},
3805+
},
3806+
{
3807+
name: "When openstack-cloud-config changes, it should enqueue matching NodePools",
3808+
cm: &corev1.ConfigMap{
3809+
ObjectMeta: metav1.ObjectMeta{
3810+
Name: "openstack-cloud-config",
3811+
Namespace: cpNamespace,
3812+
},
3813+
},
3814+
objects: []client.Object{hcp, matchingNodePool},
3815+
expected: []reconcile.Request{
3816+
{NamespacedName: types.NamespacedName{Name: "np-1", Namespace: hcNamespace}},
3817+
},
3818+
},
3819+
{
3820+
name: "When no HostedControlPlane exists in the namespace, it should return nil",
3821+
cm: &corev1.ConfigMap{
3822+
ObjectMeta: metav1.ObjectMeta{
3823+
Name: "azure-cloud-config",
3824+
Namespace: cpNamespace,
3825+
},
3826+
},
3827+
objects: []client.Object{matchingNodePool},
3828+
expected: nil,
3829+
},
3830+
{
3831+
name: "When HostedControlPlane has no cluster annotation, it should return nil",
3832+
cm: &corev1.ConfigMap{
3833+
ObjectMeta: metav1.ObjectMeta{
3834+
Name: "azure-cloud-config",
3835+
Namespace: cpNamespace,
3836+
},
3837+
},
3838+
objects: []client.Object{
3839+
&hyperv1.HostedControlPlane{
3840+
ObjectMeta: metav1.ObjectMeta{
3841+
Name: hcName,
3842+
Namespace: cpNamespace,
3843+
},
3844+
},
3845+
matchingNodePool,
3846+
},
3847+
expected: nil,
3848+
},
3849+
{
3850+
name: "When no NodePools match the HostedCluster, it should return empty",
3851+
cm: &corev1.ConfigMap{
3852+
ObjectMeta: metav1.ObjectMeta{
3853+
Name: "azure-cloud-config",
3854+
Namespace: cpNamespace,
3855+
},
3856+
},
3857+
objects: []client.Object{hcp, unrelatedNodePool},
3858+
expected: nil,
3859+
},
3860+
}
3861+
3862+
for _, tc := range testCases {
3863+
t.Run(tc.name, func(t *testing.T) {
3864+
t.Parallel()
3865+
g := NewWithT(t)
3866+
3867+
c := fake.NewClientBuilder().WithScheme(api.Scheme).WithObjects(tc.objects...).Build()
3868+
r := &NodePoolReconciler{Client: c}
3869+
3870+
result := r.enqueueNodePoolsForCloudConfig(context.Background(), tc.cm)
3871+
g.Expect(result).To(Equal(tc.expected))
3872+
})
3873+
}
3874+
}

0 commit comments

Comments
 (0)