@@ -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.
629638func (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 }
0 commit comments