Skip to content

Commit 9877433

Browse files
OpenShift CI Botclaude
andcommitted
fix: address review feedback for HCP finalizer in AWSEndpointService
- Switch HCP watch from handler.Funcs{UpdateFunc:...} to handler.EnqueueRequestsFromMapFunc to handle Create events on controller restart (matching Azure PLS pattern) - Add DeletionTimestamp guard in reconcileHCPDeletion to prevent dual deletion path race under MaxConcurrentReconciles > 1 - Document convergent multi-CR coordination pattern - Use RequeueAfter: time.Second instead of Requeue: true on conflicts to prevent tight retry loops under contention - Fix overstated comment about finalizer guarantee, acknowledge edge case where controller hasn't reconciled before HCP deletion Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e04ee23 commit 9877433

2 files changed

Lines changed: 94 additions & 126 deletions

File tree

control-plane-operator/controllers/awsprivatelink/awsprivatelink_controller.go

Lines changed: 62 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ import (
4646
"sigs.k8s.io/controller-runtime/pkg/client"
4747
"sigs.k8s.io/controller-runtime/pkg/controller"
4848
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
49-
"sigs.k8s.io/controller-runtime/pkg/event"
5049
"sigs.k8s.io/controller-runtime/pkg/handler"
50+
"sigs.k8s.io/controller-runtime/pkg/log"
5151
"sigs.k8s.io/controller-runtime/pkg/manager"
5252
"sigs.k8s.io/controller-runtime/pkg/reconcile"
5353
"sigs.k8s.io/controller-runtime/pkg/source"
@@ -381,7 +381,9 @@ func (r *AWSEndpointServiceReconciler) SetupWithManager(mgr ctrl.Manager) error
381381
RateLimiter: workqueue.NewTypedItemExponentialFailureRateLimiter[reconcile.Request](3*time.Second, 30*time.Second),
382382
MaxConcurrentReconciles: 10,
383383
}).
384-
Watches(&hyperv1.HostedControlPlane{}, handler.Funcs{UpdateFunc: r.enqueueOnHCPChange(mgr)}).
384+
Watches(&hyperv1.HostedControlPlane{}, handler.EnqueueRequestsFromMapFunc(
385+
r.mapHCPToAWSEndpointService(),
386+
)).
385387
Build(r)
386388
if err != nil {
387389
return fmt.Errorf("failed setting up with a controller manager: %w", err)
@@ -391,48 +393,42 @@ func (r *AWSEndpointServiceReconciler) SetupWithManager(mgr ctrl.Manager) error
391393
return nil
392394
}
393395

394-
// enqueueOnHCPChange triggers reconciliation of all AWSEndpointService CRs in the namespace when:
395-
// - the HCP's EndpointAccess value changes, OR
396-
// - the HCP is being deleted and has our finalizer (to trigger AWS resource cleanup)
397-
func (r *AWSEndpointServiceReconciler) enqueueOnHCPChange(mgr ctrl.Manager) func(context.Context, event.UpdateEvent, workqueue.TypedRateLimitingInterface[reconcile.Request]) {
398-
return func(ctx context.Context, e event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) {
399-
logger := mgr.GetLogger()
400-
newHCP, isOk := e.ObjectNew.(*hyperv1.HostedControlPlane)
401-
if !isOk {
402-
logger.Info("WARNING: enqueueOnHCPChange: new resource is not of type HostedControlPlane")
403-
return
404-
}
405-
oldHCP, isOk := e.ObjectOld.(*hyperv1.HostedControlPlane)
406-
if !isOk {
407-
logger.Info("WARNING: enqueueOnHCPChange: old resource is not of type HostedControlPlane")
408-
return
409-
}
410-
411-
shouldEnqueue := false
412-
413-
// Enqueue when HCP is being deleted and has our finalizer, so we can
414-
// clean up AWS resources before HCP credentials are torn down.
415-
if !newHCP.DeletionTimestamp.IsZero() && controllerutil.ContainsFinalizer(newHCP, hcpAWSPrivateLinkFinalizerName) {
416-
shouldEnqueue = true
417-
}
418-
419-
// Enqueue when EndpointAccess changes (existing behavior).
420-
if newHCP.Spec.Platform.AWS != nil && oldHCP.Spec.Platform.AWS != nil && newHCP.Spec.Platform.AWS.EndpointAccess != oldHCP.Spec.Platform.AWS.EndpointAccess {
421-
shouldEnqueue = true
396+
// mapHCPToAWSEndpointService maps HostedControlPlane events to the AWSEndpointService CRs
397+
// in the same namespace. This uses EnqueueRequestsFromMapFunc (instead of handler.Funcs)
398+
// so that all event types — Create, Update, Delete — trigger the mapping. This is critical
399+
// for controller restarts: if the CPO restarts while an HCP is being deleted (DeletionTimestamp
400+
// already set), the informer cache sync generates a Create event, not an Update. Using
401+
// handler.Funcs{UpdateFunc: ...} would miss this, defeating the purpose of the HCP finalizer.
402+
//
403+
// The function only enqueues CRs when the HCP has our finalizer, avoiding unnecessary
404+
// reconciliations for unrelated HCPs. EndpointAccess changes (previously detected via
405+
// old/new comparison) are picked up by the reconciler's periodic 5-minute requeue.
406+
func (r *AWSEndpointServiceReconciler) mapHCPToAWSEndpointService() handler.MapFunc {
407+
return func(ctx context.Context, obj client.Object) []reconcile.Request {
408+
hcp, ok := obj.(*hyperv1.HostedControlPlane)
409+
if !ok {
410+
return nil
422411
}
423412

424-
if !shouldEnqueue {
425-
return
413+
// Only trigger reconciliation when the HCP has our finalizer; this avoids
414+
// unnecessary reconciliations for HCPs that are not related to AWS PrivateLink.
415+
if !controllerutil.ContainsFinalizer(hcp, hcpAWSPrivateLinkFinalizerName) {
416+
return nil
426417
}
427418

428419
awsEndpointServiceList := &hyperv1.AWSEndpointServiceList{}
429-
if err := r.List(ctx, awsEndpointServiceList, client.InNamespace(newHCP.Namespace)); err != nil {
430-
logger.Error(err, "enqueueOnHCPChange: cannot list awsendpointservices")
431-
return
420+
if err := r.List(ctx, awsEndpointServiceList, client.InNamespace(hcp.Namespace)); err != nil {
421+
log.FromContext(ctx).Error(err, "mapHCPToAWSEndpointService: cannot list AWSEndpointService resources", "namespace", hcp.Namespace)
422+
return nil
432423
}
424+
425+
var requests []reconcile.Request
433426
for i := range awsEndpointServiceList.Items {
434-
q.Add(reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&awsEndpointServiceList.Items[i])})
427+
requests = append(requests, reconcile.Request{
428+
NamespacedName: client.ObjectKeyFromObject(&awsEndpointServiceList.Items[i]),
429+
})
435430
}
431+
return requests
436432
}
437433
}
438434

@@ -471,11 +467,12 @@ func (r *AWSEndpointServiceReconciler) Reconcile(ctx context.Context, req ctrl.R
471467
// the non-deletion path. If the HCP still exists, initialize from it so that
472468
// getClients can succeed and deletion can proceed.
473469
//
474-
// The HCP finalizer (hcpAWSPrivateLinkFinalizerName) added during normal
475-
// reconciliation ensures the HCP remains available during this cleanup.
476-
// For SharedVPC clusters, this guarantees the cross-account role ARNs can
477-
// always be read from the HCP spec. See reconcileHCPDeletion for the
478-
// HCP-deletion cleanup path.
470+
// The HCP finalizer (hcpAWSPrivateLinkFinalizerName), when present, blocks HCP
471+
// deletion so the HCP remains available during this cleanup. Note: the finalizer
472+
// is only added after a successful normal reconciliation. If the controller has
473+
// not yet reconciled (e.g., controller was down since cluster creation), the HCP
474+
// may be deleted before the finalizer is placed, and this best-effort initialization
475+
// is the only protection. See reconcileHCPDeletion for the HCP-deletion cleanup path.
479476
hcpList := &hyperv1.HostedControlPlaneList{}
480477
if err := r.List(ctx, hcpList, &client.ListOptions{Namespace: req.Namespace}); err == nil && len(hcpList.Items) == 1 {
481478
r.awsClientBuilder.initializeWithHCP(log, &hcpList.Items[0])
@@ -615,7 +612,9 @@ func (r *AWSEndpointServiceReconciler) ensureHCPFinalizer(ctx context.Context, h
615612
controllerutil.AddFinalizer(hcp, hcpAWSPrivateLinkFinalizerName)
616613
if err := r.Patch(ctx, hcp, client.MergeFromWithOptions(originalHCP, client.MergeFromWithOptimisticLock{})); err != nil {
617614
if apierrors.IsConflict(err) {
618-
return ctrl.Result{Requeue: true}, nil
615+
// Use RequeueAfter to avoid a tight retry loop when multiple AWSEndpointService
616+
// reconcilers concurrently try to patch the same HCP.
617+
return ctrl.Result{RequeueAfter: time.Second}, nil
619618
}
620619
return ctrl.Result{}, fmt.Errorf("failed to add HCP finalizer: %w", err)
621620
}
@@ -626,11 +625,29 @@ func (r *AWSEndpointServiceReconciler) ensureHCPFinalizer(ctx context.Context, h
626625
// and removing the HCP finalizer. This ensures AWS credentials remain valid during
627626
// the cleanup process, which is critical for SharedVPC clusters where the cross-account
628627
// role ARNs are sourced from the HCP spec.
628+
//
629+
// Convergent multi-CR coordination:
630+
// With MaxConcurrentReconciles: 10 and the HCP watch enqueuing ALL CRs on HCP deletion,
631+
// multiple reconcilers run this function concurrently — one per AWSEndpointService CR.
632+
// Each reconciler cleans up its own CR's AWS resources, then checks whether all CRs in
633+
// the namespace are done. Only the last reconciler to finish (the one that finds no pending
634+
// CRs) removes the HCP finalizer. Earlier reconcilers see pending CRs and return
635+
// RequeueAfter to retry; when they re-enter, they find the HCP finalizer already removed
636+
// and return early at the top of this function. This convergent pattern produces a small
637+
// number of no-op requeues but is correct and self-healing.
629638
func (r *AWSEndpointServiceReconciler) reconcileHCPDeletion(ctx context.Context, awsEndpointService *hyperv1.AWSEndpointService, hcp *hyperv1.HostedControlPlane, log logr.Logger) (ctrl.Result, error) {
630639
if !controllerutil.ContainsFinalizer(hcp, hcpAWSPrivateLinkFinalizerName) {
631640
return ctrl.Result{}, nil
632641
}
633642

643+
// If the AWSEndpointService itself is being deleted, let the existing CR deletion
644+
// path (which returns early at the top of Reconcile) handle cleanup. This prevents
645+
// the two deletion paths from racing under MaxConcurrentReconciles > 1, where the
646+
// CR deletion path and HCP deletion path could both try to clean up the same CR.
647+
if !awsEndpointService.DeletionTimestamp.IsZero() {
648+
return ctrl.Result{}, nil
649+
}
650+
634651
log.Info("HCP is being deleted, cleaning up AWS PrivateLink resources before removing HCP finalizer")
635652

636653
// Initialize AWS clients from the HCP — guaranteed to be available because
@@ -680,7 +697,9 @@ func (r *AWSEndpointServiceReconciler) reconcileHCPDeletion(ctx context.Context,
680697
controllerutil.RemoveFinalizer(hcp, hcpAWSPrivateLinkFinalizerName)
681698
if err := r.Patch(ctx, hcp, client.MergeFromWithOptions(originalHCP, client.MergeFromWithOptimisticLock{})); err != nil {
682699
if apierrors.IsConflict(err) {
683-
return ctrl.Result{Requeue: true}, nil
700+
// Use RequeueAfter to avoid a tight retry loop when multiple AWSEndpointService
701+
// reconcilers concurrently try to patch the same HCP.
702+
return ctrl.Result{RequeueAfter: time.Second}, nil
684703
}
685704
return ctrl.Result{}, fmt.Errorf("failed to remove HCP finalizer: %w", err)
686705
}

control-plane-operator/controllers/awsprivatelink/awsprivatelink_controller_test.go

Lines changed: 32 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,14 @@ import (
2626
"k8s.io/apimachinery/pkg/runtime"
2727
"k8s.io/apimachinery/pkg/runtime/schema"
2828
"k8s.io/apimachinery/pkg/types"
29-
"k8s.io/client-go/util/workqueue"
3029

3130
ctrl "sigs.k8s.io/controller-runtime"
3231
crclient "sigs.k8s.io/controller-runtime/pkg/client"
3332
"sigs.k8s.io/controller-runtime/pkg/client/fake"
3433
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
3534
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
36-
"sigs.k8s.io/controller-runtime/pkg/event"
3735
"sigs.k8s.io/controller-runtime/pkg/reconcile"
3836

39-
"github.com/go-logr/logr"
4037
"github.com/google/go-cmp/cmp"
4138
"go.uber.org/mock/gomock"
4239
)
@@ -1820,7 +1817,11 @@ func TestEnsureHCPFinalizer(t *testing.T) {
18201817
} else {
18211818
g.Expect(err).ToNot(HaveOccurred())
18221819
}
1823-
g.Expect(result.Requeue).To(Equal(tc.expectRequeue))
1820+
if tc.expectRequeue {
1821+
g.Expect(result.RequeueAfter).To(Equal(time.Second))
1822+
} else {
1823+
g.Expect(result.IsZero()).To(BeTrue())
1824+
}
18241825

18251826
updatedHCP := &hyperv1.HostedControlPlane{}
18261827
err = fakeClient.Get(ctx, types.NamespacedName{
@@ -2353,34 +2354,24 @@ func TestReconcileHCPDeletionClientErrors(t *testing.T) {
23532354
g.Expect(err).ToNot(HaveOccurred())
23542355
}
23552356
if tc.expectRequeue {
2356-
g.Expect(result.Requeue).To(BeTrue())
2357+
g.Expect(result.RequeueAfter).To(Equal(time.Second))
23572358
}
23582359
})
23592360
}
23602361
}
23612362

2362-
type fakeManagerForLogger struct {
2363-
ctrl.Manager
2364-
logger logr.Logger
2365-
}
2366-
2367-
func (m *fakeManagerForLogger) GetLogger() logr.Logger {
2368-
return m.logger
2369-
}
2370-
2371-
func TestEnqueueOnHCPChange(t *testing.T) {
2363+
func TestMapHCPToAWSEndpointService(t *testing.T) {
23722364
now := metav1.NewTime(time.Now())
23732365

23742366
testCases := []struct {
23752367
name string
2376-
oldHCP *hyperv1.HostedControlPlane
2377-
newHCP *hyperv1.HostedControlPlane
2368+
hcp *hyperv1.HostedControlPlane
23782369
existingCRs []crclient.Object
23792370
expectEnqueued int
23802371
}{
23812372
{
2382-
name: "When HCP is being deleted with our finalizer it should enqueue all CRs",
2383-
oldHCP: &hyperv1.HostedControlPlane{
2373+
name: "When HCP has our finalizer it should enqueue all CRs",
2374+
hcp: &hyperv1.HostedControlPlane{
23842375
ObjectMeta: metav1.ObjectMeta{
23852376
Name: "test-hcp",
23862377
Namespace: "clusters-test",
@@ -2392,37 +2383,21 @@ func TestEnqueueOnHCPChange(t *testing.T) {
23922383
},
23932384
},
23942385
},
2395-
newHCP: &hyperv1.HostedControlPlane{
2396-
ObjectMeta: metav1.ObjectMeta{
2397-
Name: "test-hcp",
2398-
Namespace: "clusters-test",
2399-
DeletionTimestamp: &now,
2400-
Finalizers: []string{hcpAWSPrivateLinkFinalizerName},
2401-
},
2402-
Spec: hyperv1.HostedControlPlaneSpec{
2403-
Platform: hyperv1.PlatformSpec{
2404-
AWS: &hyperv1.AWSPlatformSpec{EndpointAccess: hyperv1.Private},
2405-
},
2406-
},
2407-
},
24082386
existingCRs: []crclient.Object{
24092387
&hyperv1.AWSEndpointService{ObjectMeta: metav1.ObjectMeta{Name: "kube-apiserver-private", Namespace: "clusters-test"}},
24102388
&hyperv1.AWSEndpointService{ObjectMeta: metav1.ObjectMeta{Name: "private-router", Namespace: "clusters-test"}},
24112389
},
24122390
expectEnqueued: 2,
24132391
},
24142392
{
2415-
name: "When EndpointAccess changes it should enqueue all CRs",
2416-
oldHCP: &hyperv1.HostedControlPlane{
2417-
ObjectMeta: metav1.ObjectMeta{Name: "test-hcp", Namespace: "clusters-test"},
2418-
Spec: hyperv1.HostedControlPlaneSpec{
2419-
Platform: hyperv1.PlatformSpec{
2420-
AWS: &hyperv1.AWSPlatformSpec{EndpointAccess: hyperv1.Public},
2421-
},
2393+
name: "When HCP is being deleted with our finalizer it should enqueue all CRs",
2394+
hcp: &hyperv1.HostedControlPlane{
2395+
ObjectMeta: metav1.ObjectMeta{
2396+
Name: "test-hcp",
2397+
Namespace: "clusters-test",
2398+
DeletionTimestamp: &now,
2399+
Finalizers: []string{hcpAWSPrivateLinkFinalizerName},
24222400
},
2423-
},
2424-
newHCP: &hyperv1.HostedControlPlane{
2425-
ObjectMeta: metav1.ObjectMeta{Name: "test-hcp", Namespace: "clusters-test"},
24262401
Spec: hyperv1.HostedControlPlaneSpec{
24272402
Platform: hyperv1.PlatformSpec{
24282403
AWS: &hyperv1.AWSPlatformSpec{EndpointAccess: hyperv1.Private},
@@ -2431,20 +2406,13 @@ func TestEnqueueOnHCPChange(t *testing.T) {
24312406
},
24322407
existingCRs: []crclient.Object{
24332408
&hyperv1.AWSEndpointService{ObjectMeta: metav1.ObjectMeta{Name: "kube-apiserver-private", Namespace: "clusters-test"}},
2409+
&hyperv1.AWSEndpointService{ObjectMeta: metav1.ObjectMeta{Name: "private-router", Namespace: "clusters-test"}},
24342410
},
2435-
expectEnqueued: 1,
2411+
expectEnqueued: 2,
24362412
},
24372413
{
2438-
name: "When HCP is deleted without our finalizer it should not enqueue",
2439-
oldHCP: &hyperv1.HostedControlPlane{
2440-
ObjectMeta: metav1.ObjectMeta{Name: "test-hcp", Namespace: "clusters-test"},
2441-
Spec: hyperv1.HostedControlPlaneSpec{
2442-
Platform: hyperv1.PlatformSpec{
2443-
AWS: &hyperv1.AWSPlatformSpec{EndpointAccess: hyperv1.Private},
2444-
},
2445-
},
2446-
},
2447-
newHCP: &hyperv1.HostedControlPlane{
2414+
name: "When HCP does not have our finalizer it should not enqueue",
2415+
hcp: &hyperv1.HostedControlPlane{
24482416
ObjectMeta: metav1.ObjectMeta{
24492417
Name: "test-hcp",
24502418
Namespace: "clusters-test",
@@ -2462,26 +2430,15 @@ func TestEnqueueOnHCPChange(t *testing.T) {
24622430
expectEnqueued: 0,
24632431
},
24642432
{
2465-
name: "When no relevant HCP changes occur it should not enqueue",
2466-
oldHCP: &hyperv1.HostedControlPlane{
2467-
ObjectMeta: metav1.ObjectMeta{Name: "test-hcp", Namespace: "clusters-test"},
2468-
Spec: hyperv1.HostedControlPlaneSpec{
2469-
Platform: hyperv1.PlatformSpec{
2470-
AWS: &hyperv1.AWSPlatformSpec{EndpointAccess: hyperv1.Private},
2471-
},
2472-
},
2473-
},
2474-
newHCP: &hyperv1.HostedControlPlane{
2475-
ObjectMeta: metav1.ObjectMeta{Name: "test-hcp", Namespace: "clusters-test"},
2476-
Spec: hyperv1.HostedControlPlaneSpec{
2477-
Platform: hyperv1.PlatformSpec{
2478-
AWS: &hyperv1.AWSPlatformSpec{EndpointAccess: hyperv1.Private},
2479-
},
2433+
name: "When HCP has our finalizer but no CRs exist it should return empty",
2434+
hcp: &hyperv1.HostedControlPlane{
2435+
ObjectMeta: metav1.ObjectMeta{
2436+
Name: "test-hcp",
2437+
Namespace: "clusters-test",
2438+
Finalizers: []string{hcpAWSPrivateLinkFinalizerName},
24802439
},
24812440
},
2482-
existingCRs: []crclient.Object{
2483-
&hyperv1.AWSEndpointService{ObjectMeta: metav1.ObjectMeta{Name: "kube-apiserver-private", Namespace: "clusters-test"}},
2484-
},
2441+
existingCRs: nil,
24852442
expectEnqueued: 0,
24862443
},
24872444
}
@@ -2502,20 +2459,12 @@ func TestEnqueueOnHCPChange(t *testing.T) {
25022459
Client: fakeClient,
25032460
}
25042461

2505-
fakeMgr := &fakeManagerForLogger{logger: ctrl.Log.WithName("test")}
2506-
handler := reconciler.enqueueOnHCPChange(fakeMgr)
2507-
2508-
q := workqueue.NewTypedRateLimitingQueue(
2509-
workqueue.NewTypedItemExponentialFailureRateLimiter[reconcile.Request](time.Second, time.Second),
2510-
)
2511-
defer q.ShutDown()
2462+
mapFunc := reconciler.mapHCPToAWSEndpointService()
25122463

2513-
handler(context.Background(), event.UpdateEvent{
2514-
ObjectOld: tc.oldHCP,
2515-
ObjectNew: tc.newHCP,
2516-
}, q)
2464+
ctx := ctrl.LoggerInto(context.Background(), ctrl.Log.WithName("test"))
2465+
requests := mapFunc(ctx, tc.hcp)
25172466

2518-
g.Expect(q.Len()).To(Equal(tc.expectEnqueued))
2467+
g.Expect(len(requests)).To(Equal(tc.expectEnqueued))
25192468
})
25202469
}
25212470
}

0 commit comments

Comments
 (0)