Skip to content

CNTRLPLANE-3978: Fix CPO finalizer race leaving orphaned Azure Private Endpoint resources - #9194

Merged
openshift-merge-bot[bot] merged 1 commit into
openshift:mainfrom
Nirshal:CNTRLPLANE-3978
Aug 7, 2026
Merged

CNTRLPLANE-3978: Fix CPO finalizer race leaving orphaned Azure Private Endpoint resources#9194
openshift-merge-bot[bot] merged 1 commit into
openshift:mainfrom
Nirshal:CNTRLPLANE-3978

Conversation

@Nirshal

@Nirshal Nirshal commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What this PR does / why we need it:

When a HostedCluster with endpointAccess=Private and
oauthPublishingStrategy=LoadBalancer is deleted, two AzurePrivateLinkService CRs
exist in the HCP namespace (one for private-router, one for oauth-openshift).
Both share a single HCP finalizer (hypershift.openshift.io/azure-pls-endpoint-cleanup),
but reconcileHCPDeletion only cleans up the CR that reconciles first, then removes
the shared finalizer. The second CR's Azure resources (Private Endpoint, DNS zone,
VNet link) are orphaned, and its per-CR finalizer blocks namespace deletion.

The orphaned Private Endpoint then blocks management cluster resource group deletion
with Azure 409: PrivateLinkServiceWithPrivateEndpointConnectionsCannotBeDeleted.

This fix makes reconcileHCPDeletion list and clean up ALL AzurePrivateLinkService
CRs in a single pass before removing the shared HCP finalizer. Per-CR finalizers are
also removed during HCP deletion so they do not block namespace cleanup after Azure
resources are already gone.

Which issue(s) this PR fixes:

Fixes https://redhat.atlassian.net/browse/CNTRLPLANE-3978

Special notes for your reviewer:

  • reconcileDelete is idempotent (checks IsAzureNotFoundError), so processing
    all CRs in a single pass is safe.
  • Per-CR finalizers are normally processed by step 2 in Reconcile when CRs are
    garbage-collected during namespace cleanup. Removing them during HCP deletion
    avoids the race where CPO is torn down before the second CR reconciles.
  • Confirmed in CI (PR CNTRLPLANE-3277: Add Azure OAuth LoadBalancer private topology e2e test #8584): destroy-guests completes in ~12m47s vs the
    previous 40-minute timeout.
  • This is a pre-existing bug never triggered before because no CI test or
    production scenario combined Private endpoint access with LoadBalancer
    OAuth publishing strategy until CNTRLPLANE-3277.

Checklist:

  • Subject and description added to both, commit and PR.
  • Relevant issues have been referenced.
  • This change includes docs.
  • This change includes unit tests.

Summary by CodeRabbit

  • Bug Fixes

    • Improved cleanup of all Azure private link resources when their associated HostedControlPlane is deleted.
    • Removed stale finalizers when the associated HostedControlPlane is missing.
    • Prevented finalizers from being re-added while deletion is blocked or in progress.
    • Ensured normal reconciliation proceeds only for active HostedControlPlanes.
  • Tests

    • Added coverage for deletion, orphan cleanup, conflict handling, blocked deletion, and multiple related resources.

@openshift-merge-bot

Copy link
Copy Markdown
Contributor

Pipeline controller notification
This repo is configured to use the pipeline controller. Second-stage tests will be triggered either automatically or after lgtm label is added, depending on the repository configuration. The pipeline controller will automatically detect which contexts are required and will utilize /test Prow commands to trigger the second stage.

For optional jobs, comment /test ? to see a list of all defined jobs. To trigger manually all jobs from second stage use /pipeline required command.

This repository is configured in: LGTM mode

@openshift-ci-robot openshift-ci-robot added the jira/valid-reference Indicates that this PR references a valid Jira ticket of any type. label Jul 31, 2026
@openshift-ci-robot

openshift-ci-robot commented Jul 31, 2026

Copy link
Copy Markdown

@Nirshal: This pull request references CNTRLPLANE-3978 which is a valid jira issue.

Warning: The referenced jira issue has an invalid target version for the target branch this PR targets: expected the bug to target the "5.0.0" version, but no target version was set.

Details

In response to this:

What this PR does / why we need it:

When a HostedCluster with endpointAccess=Private and
oauthPublishingStrategy=LoadBalancer is deleted, two AzurePrivateLinkService CRs
exist in the HCP namespace: one for private-router (KAS) and one for
oauth-openshift. Both CRs share a single HCP finalizer
(hypershift.openshift.io/azure-pls-endpoint-cleanup), but reconcileHCPDeletion
only cleaned up the CR that reconciled first, then removed the shared finalizer. The
second CR's Azure resources (Private Endpoint, NIC, data) were never cleaned up, and
its per-CR finalizer blocked namespace deletion indefinitely.

This fix makes reconcileHCPDeletion list and clean up ALL AzurePrivateLinkService
CRs in a single pass before removing the shared HCP finalizer. Per-CR finalizers are
also removed during HCP deletion so they do not block namespace cleanup after Azure
resources are already gone.

Which issue(s) this PR fixes:

Fixes https://redhat.atlassian.net/browse/CNTRLPLANE-3978

Special notes for your reviewer:

  • reconcileDelete is idempotent (checks IsAzureNotFoundError), so cleaning up
    resources for a CR that was already cleaned up is a safe no-op.
  • Per-CR finalizers are normally processed by step 2 in Reconcile when CRs are
    garbage-collected during namespace cleanup. By also removing them during HCP
    deletion, we avoid the race where CPO is torn down before the second CR gets
    to reconcile its per-CR finalizer.
  • Confirmed in CI: destroy-guests now completes in ~12m47s vs the previous
    40-minute timeout.

Checklist:

  • Subject and description added to both, commit and PR.
  • Relevant issues have been referenced.
  • This change includes docs.
  • This change includes unit tests.

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository.

@openshift-ci openshift-ci Bot added the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Jul 31, 2026
@openshift-ci

openshift-ci Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with /test all

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Reconciliation resolves the owning HostedControlPlane before it adds a per-resource finalizer. It removes orphaned finalizers when the HostedControlPlane is absent. During HostedControlPlane deletion, it cleans up Azure resources for sibling AzurePrivateLinkService objects, removes their finalizers, and then removes the shared HostedControlPlane finalizer. Tests cover successful cleanup, cleanup failures, orphaned resources, and blocked deletion.

Sequence Diagram(s)

sequenceDiagram
  participant AzurePrivateLinkServiceReconciler
  participant KubernetesAPI
  participant Azure
  AzurePrivateLinkServiceReconciler->>KubernetesAPI: Resolve HostedControlPlane
  AzurePrivateLinkServiceReconciler->>KubernetesAPI: List sibling AzurePrivateLinkService resources
  AzurePrivateLinkServiceReconciler->>Azure: Clean up Azure dependencies
  AzurePrivateLinkServiceReconciler->>KubernetesAPI: Remove per-resource finalizers
  AzurePrivateLinkServiceReconciler->>KubernetesAPI: Remove shared HostedControlPlane finalizer
Loading

Suggested reviewers: cblecker, sdminonne, bryan-cox

🚥 Pre-merge checks | ✅ 11
✅ Passed checks (11 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the fix for orphaned Azure Private Endpoint resources caused by a CPO finalizer race.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Stable And Deterministic Test Names ✅ Passed The PR adds four static Go test names; the changed test file has no Ginkgo title APIs or runtime title interpolation, and titles contain no generated values.
Test Structure And Quality ✅ Passed Added tests use isolated testing.T/Gomega cases with per-test fake clients, no cluster waits or Eventually calls, and meaningful messages on all new assertions; no cleanup is needed for in-memory r...
Topology-Aware Scheduling Compatibility ✅ Passed The diff only refactors Azure resource cleanup and finalizer handling; it adds no pod templates, replicas, affinity, topology spread, node selectors, tolerations, or PDBs.
Ipv6 And Disconnected Network Test Compatibility ✅ Passed The commit adds only a standard Go unit test using testing.T and fake clients; it adds no Ginkgo e2e test or external network/IPv4 assumption.
No-Weak-Crypto ✅ Passed The PR changes only Azure resource cleanup and finalizer handling; added-line and changed-file scans found no weak crypto APIs or secret/token comparisons.
Container-Privileges ✅ Passed The PR changes only two Go files; added-line and modified-file searches found no privilege indicators or Kubernetes security fields.
No-Sensitive-Data-In-Logs ✅ Passed PR-added logs report cleanup status and AzurePrivateLinkService names only; scans found no passwords, tokens, API keys, PII, session IDs, hostnames, or customer payloads.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@openshift-ci openshift-ci Bot added area/control-plane-operator Indicates the PR includes changes for the control plane operator - in an OCP release area/platform/azure PR/issue for Azure (AzurePlatform) platform and removed do-not-merge/needs-area labels Jul 31, 2026
@Nirshal
Nirshal marked this pull request as ready for review July 31, 2026 14:05
@openshift-ci openshift-ci Bot removed the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Jul 31, 2026
@openshift-ci
openshift-ci Bot requested review from cblecker and sdminonne July 31, 2026 14:06

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
control-plane-operator/controllers/azureprivatelinkservice/controller.go (1)

424-495: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider splitting reconcileHCPDeletion into smaller helpers.

reconcileHCPDeletion performs four distinct jobs in one function: the finalizer guard check, Azure cleanup across all sibling CRs, per-CR finalizer removal across all sibling CRs, and shared HCP finalizer removal. Extract the cleanup loop and the finalizer-removal loop into two small helper methods (for example cleanupSiblingAzureResources and removeSiblingFinalizers), each returning an aggregated error. This keeps reconcileHCPDeletion focused on orchestration and makes each step independently testable.

As per coding guidelines, "Keep functions small and focused."

♻️ Suggested extraction
 func (r *AzurePrivateLinkServiceReconciler) reconcileHCPDeletion(ctx context.Context, azPLS *hyperv1.AzurePrivateLinkService, hcp *hyperv1.HostedControlPlane, log logr.Logger) (ctrl.Result, error) {
 	if !controllerutil.ContainsFinalizer(hcp, hcpAzurePLSFinalizerName) {
 		return ctrl.Result{}, nil
 	}

 	log.Info("HCP is being deleted, cleaning up Azure resources before removing HCP finalizer")

 	var allPLS hyperv1.AzurePrivateLinkServiceList
 	if err := r.List(ctx, &allPLS, client.InNamespace(azPLS.Namespace)); err != nil {
 		return ctrl.Result{}, fmt.Errorf("failed to list AzurePrivateLinkService resources: %w", err)
 	}

-	var errs []error
-	for i := range allPLS.Items {
-		pls := &allPLS.Items[i]
-		log.Info("Cleaning up Azure resources for AzurePrivateLinkService", "name", pls.Name)
-		if err := r.reconcileDelete(ctx, pls, log); err != nil {
-			errs = append(errs, fmt.Errorf("failed to clean up %s: %w", pls.Name, err))
-		}
-	}
-
-	if err := utilerrors.NewAggregate(errs); err != nil {
+	if err := r.cleanupSiblingAzureResources(ctx, allPLS.Items, log); err != nil {
 		return ctrl.Result{}, fmt.Errorf("failed to clean up Azure resources during HCP deletion: %w", err)
 	}

-	for i := range allPLS.Items {
-		pls := &allPLS.Items[i]
-		if !controllerutil.ContainsFinalizer(pls, azurePrivateLinkServiceFinalizer) {
-			continue
-		}
-		log.Info("Removing per-CR finalizer from AzurePrivateLinkService", "name", pls.Name)
-		controllerutil.RemoveFinalizer(pls, azurePrivateLinkServiceFinalizer)
-		if err := r.Update(ctx, pls); err != nil {
-			errs = append(errs, fmt.Errorf("failed to remove per-CR finalizer from %s: %w", pls.Name, err))
-		}
-	}
-
-	if err := utilerrors.NewAggregate(errs); err != nil {
+	if err := r.removeSiblingFinalizers(ctx, allPLS.Items, log); err != nil {
 		return ctrl.Result{}, fmt.Errorf("failed to remove per-CR finalizers during HCP deletion: %w", err)
 	}

 	// Remove the HCP finalizer to unblock HCP deletion
 	log.Info("Azure resource cleanup complete for all AzurePrivateLinkService CRs, removing HCP finalizer")
 	originalHCP := hcp.DeepCopy()
 	controllerutil.RemoveFinalizer(hcp, hcpAzurePLSFinalizerName)
 	if err := r.Patch(ctx, hcp, client.MergeFromWithOptions(originalHCP, client.MergeFromWithOptimisticLock{})); err != nil {
 		if apierrors.IsConflict(err) {
 			return ctrl.Result{RequeueAfter: time.Second}, nil
 		}
 		return ctrl.Result{}, fmt.Errorf("failed to remove HCP finalizer: %w", err)
 	}

 	return ctrl.Result{}, nil
 }
+
+func (r *AzurePrivateLinkServiceReconciler) cleanupSiblingAzureResources(ctx context.Context, items []hyperv1.AzurePrivateLinkService, log logr.Logger) error {
+	var errs []error
+	for i := range items {
+		pls := &items[i]
+		log.Info("Cleaning up Azure resources for AzurePrivateLinkService", "name", pls.Name)
+		if err := r.reconcileDelete(ctx, pls, log); err != nil {
+			errs = append(errs, fmt.Errorf("failed to clean up %s: %w", pls.Name, err))
+		}
+	}
+	return utilerrors.NewAggregate(errs)
+}
+
+func (r *AzurePrivateLinkServiceReconciler) removeSiblingFinalizers(ctx context.Context, items []hyperv1.AzurePrivateLinkService, log logr.Logger) error {
+	var errs []error
+	for i := range items {
+		pls := &items[i]
+		if !controllerutil.ContainsFinalizer(pls, azurePrivateLinkServiceFinalizer) {
+			continue
+		}
+		log.Info("Removing per-CR finalizer from AzurePrivateLinkService", "name", pls.Name)
+		controllerutil.RemoveFinalizer(pls, azurePrivateLinkServiceFinalizer)
+		if err := r.Update(ctx, pls); err != nil {
+			errs = append(errs, fmt.Errorf("failed to remove per-CR finalizer from %s: %w", pls.Name, err))
+		}
+	}
+	return utilerrors.NewAggregate(errs)
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@control-plane-operator/controllers/azureprivatelinkservice/controller.go`
around lines 424 - 495, Refactor reconcileHCPDeletion so it remains focused on
orchestration: retain the finalizer guard, sibling-list retrieval, helper calls,
and shared HCP finalizer removal. Extract the Azure cleanup iteration into a
helper such as cleanupSiblingAzureResources and the per-CR finalizer update
iteration into removeSiblingFinalizers; each helper should accept the sibling
resources and relevant context, aggregate all item errors, and return a single
error while preserving the current error messages and behavior.
control-plane-operator/controllers/azureprivatelinkservice/controller_test.go (1)

1380-1428: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Strengthen TestReconcileHCPDeletion_WhenMultipleCRsExist_ItShouldRemoveAllPerCRFinalizers coverage.

This test verifies only that per-CR finalizers are removed. It does not assert that the shared HCP finalizer (hcpAzurePLSFinalizerName) is removed from hcp, which is reconcileHCPDeletion's final and primary outcome. Add an assertion that fetches or checks hcp.Finalizers after the call.

Also add a companion test for the partial-failure path: when Azure cleanup or the per-CR finalizer update fails for one sibling CR, verify that no per-CR finalizers and the HCP finalizer are removed. This is the core new error-aggregation behavior in reconcileHCPDeletion and currently has no direct test coverage.

As per coding guidelines, "Unit test any code changes and additions."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@control-plane-operator/controllers/azureprivatelinkservice/controller_test.go`
around lines 1380 - 1428, Strengthen
TestReconcileHCPDeletion_WhenMultipleCRsExist_ItShouldRemoveAllPerCRFinalizers
by asserting that the fetched HCP no longer contains hcpAzurePLSFinalizerName
after successful reconciliation. Add a companion partial-failure test that makes
Azure cleanup or a sibling per-CR finalizer update fail, then verify
reconciliation returns the error and preserves every AzurePrivateLinkService
finalizer and the HCP finalizer.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@control-plane-operator/controllers/azureprivatelinkservice/controller_test.go`:
- Around line 1380-1428: Strengthen
TestReconcileHCPDeletion_WhenMultipleCRsExist_ItShouldRemoveAllPerCRFinalizers
by asserting that the fetched HCP no longer contains hcpAzurePLSFinalizerName
after successful reconciliation. Add a companion partial-failure test that makes
Azure cleanup or a sibling per-CR finalizer update fail, then verify
reconciliation returns the error and preserves every AzurePrivateLinkService
finalizer and the HCP finalizer.

In `@control-plane-operator/controllers/azureprivatelinkservice/controller.go`:
- Around line 424-495: Refactor reconcileHCPDeletion so it remains focused on
orchestration: retain the finalizer guard, sibling-list retrieval, helper calls,
and shared HCP finalizer removal. Extract the Azure cleanup iteration into a
helper such as cleanupSiblingAzureResources and the per-CR finalizer update
iteration into removeSiblingFinalizers; each helper should accept the sibling
resources and relevant context, aggregate all item errors, and return a single
error while preserving the current error messages and behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Enterprise

Run ID: 17503a62-168b-4db0-899f-cecffbf827bd

📥 Commits

Reviewing files that changed from the base of the PR and between 161f6d0 and 6a357a6.

📒 Files selected for processing (2)
  • control-plane-operator/controllers/azureprivatelinkservice/controller.go
  • control-plane-operator/controllers/azureprivatelinkservice/controller_test.go

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.94118% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 44.98%. Comparing base (0cd0254) to head (30d6ce5).
⚠️ Report is 40 commits behind head on main.

Files with missing lines Patch % Lines
.../controllers/azureprivatelinkservice/controller.go 77.94% 10 Missing and 5 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9194      +/-   ##
==========================================
+ Coverage   44.96%   44.98%   +0.01%     
==========================================
  Files         778      778              
  Lines       97452    97510      +58     
==========================================
+ Hits        43820    43863      +43     
- Misses      50607    50617      +10     
- Partials     3025     3030       +5     
Files with missing lines Coverage Δ
.../controllers/azureprivatelinkservice/controller.go 91.04% <77.94%> (-1.34%) ⬇️
Flag Coverage Δ
cmd-support 38.62% <ø> (ø)
cpo-hostedcontrolplane 47.28% <ø> (ø)
cpo-other 45.79% <77.94%> (+0.12%) ⬆️
hypershift-operator 55.00% <ø> (ø)
other 34.30% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Nirshal

Nirshal commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

/hold

@openshift-ci openshift-ci Bot added the do-not-merge/hold Indicates that a PR should not merge because someone has issued a /hold command. label Jul 31, 2026
@Nirshal

Nirshal commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

/area control-plane-operator

@Nirshal

Nirshal commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

/area platform/azure

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@control-plane-operator/controllers/azureprivatelinkservice/controller_test.go`:
- Around line 1417-1425: Extend the HCP deletion reconciliation test after the
existing updated1 and updated2 assertions to fetch the HCP resource and verify
that its Finalizers no longer contain hcpAzurePLSFinalizerName. Keep the
existing per-resource finalizer checks unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Enterprise

Run ID: 957ca4da-1c24-49b5-a882-797269fde664

📥 Commits

Reviewing files that changed from the base of the PR and between 6a357a6 and 1e5a421.

📒 Files selected for processing (1)
  • control-plane-operator/controllers/azureprivatelinkservice/controller_test.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
control-plane-operator/controllers/azureprivatelinkservice/controller_test.go (1)

1442-1497: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a partial-failure test case to verify independent per-resource finalizer removal.

This test only covers the case where Azure cleanup fails for both siblings (RecordSets.deleteErr triggers on every call). The PR objective states that per-resource finalizers are removed independently "to prevent namespace cleanup from being blocked after Azure resources are deleted," which implies that if cleanup succeeds for one sibling but fails for another, the succeeding sibling's finalizer should be removed while the failing sibling's finalizer stays. No test in this segment exercises that partial-success case.

Add a case where cleanup succeeds for azPLS1 but fails for azPLS2 (or vice versa), and assert that only the failing sibling's finalizer remains, and the shared HCP finalizer also remains, since one sibling did not complete cleanup.

// Example additional scenario to add alongside the existing failure test
func TestReconcileHCPDeletion_WhenOneSiblingSucceedsAndOneFails_ItShouldRemoveOnlySucceedingFinalizer(t *testing.T) {
	// azPLS1 cleanup succeeds, azPLS2 cleanup fails (e.g., a mock keyed by CR name)
	// Assert: azPLS1 finalizer removed, azPLS2 finalizer preserved, HCP finalizer preserved.
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@control-plane-operator/controllers/azureprivatelinkservice/controller_test.go`
around lines 1442 - 1497, Add a partial-success test alongside
TestReconcileHCPDeletion_WhenSiblingCleanupFails_ItShouldPreserveAllFinalizers,
configuring cleanup to succeed for one sibling and fail for the other. Assert
the successful sibling’s azurePrivateLinkServiceFinalizer is removed, the
failing sibling’s remains, and the HCP’s hcpAzurePLSFinalizerName remains; also
verify reconciliation returns an error.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@control-plane-operator/controllers/azureprivatelinkservice/controller_test.go`:
- Around line 1442-1497: Add a partial-success test alongside
TestReconcileHCPDeletion_WhenSiblingCleanupFails_ItShouldPreserveAllFinalizers,
configuring cleanup to succeed for one sibling and fail for the other. Assert
the successful sibling’s azurePrivateLinkServiceFinalizer is removed, the
failing sibling’s remains, and the HCP’s hcpAzurePLSFinalizerName remains; also
verify reconciliation returns an error.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Enterprise

Run ID: e80609ed-cde7-4216-b7f6-23c18a87cdfb

📥 Commits

Reviewing files that changed from the base of the PR and between 995904e and 81515ee.

📒 Files selected for processing (2)
  • control-plane-operator/controllers/azureprivatelinkservice/controller.go
  • control-plane-operator/controllers/azureprivatelinkservice/controller_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • control-plane-operator/controllers/azureprivatelinkservice/controller.go

@Nirshal

Nirshal commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Re: review comment suggesting a partial-success test where one sibling's cleanup succeeds and the other fails:

This doesn't match the actual code flow. When cleanupSiblingAzureResources returns an error (even if only one CR failed), reconcileHCPDeletion returns early before calling removeSiblingFinalizers. So no per-CR finalizers are removed regardless of which CRs succeeded or failed. This is intentional: reconcileDelete is idempotent (checks IsAzureNotFoundError), so on retry the already-cleaned-up CR is a safe no-op.

The existing TestReconcileHCPDeletion_WhenSiblingCleanupFails_ItShouldPreserveAllFinalizers already validates this behavior (all finalizers preserved when cleanup fails).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
control-plane-operator/controllers/azureprivatelinkservice/controller_test.go (2)

1442-1496: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the propagated error to confirm the intended failure path.

Line 1475 only checks that an error occurred. It does not check the error content. Add an assertion that the returned error contains the injected message. This confirms the test exercises the sibling-cleanup failure path, not an unrelated error.

💡 Proposed test strengthening
 	_, err := r.reconcileHCPDeletion(t.Context(), azPLS1, hcp, testr.New(t))
 	g.Expect(err).To(HaveOccurred(), "expected error when sibling Azure cleanup fails")
+	g.Expect(err.Error()).To(ContainSubstring("simulated Azure API failure"),
+		"error should originate from the injected Azure cleanup failure")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@control-plane-operator/controllers/azureprivatelinkservice/controller_test.go`
around lines 1442 - 1496, Strengthen the error assertion in
TestReconcileHCPDeletion_WhenSiblingCleanupFails_ItShouldPreserveAllFinalizers
by verifying that err contains the injected “simulated Azure API failure”
message, while retaining the existing assertion that an error occurred.

1380-1577: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting shared reconciler/fake-client setup.

The four new tests in this range repeat the same AzurePrivateLinkServiceReconciler construction with mock APIs. Extract a small helper, for example newTestReconciler(fakeClient client.Client) *AzurePrivateLinkServiceReconciler, to reduce duplication across these tests.

As per coding guidelines, "Use table-driven tests where possible" for **/*_test.go; a shared constructor helper is a smaller step toward that goal without forcing these differently-shaped scenarios into one table.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@control-plane-operator/controllers/azureprivatelinkservice/controller_test.go`
around lines 1380 - 1577, Extract the repeated AzurePrivateLinkServiceReconciler
construction from the four tests into a shared helper such as
newTestReconciler(client.Client), initializing all mock API dependencies
consistently. Replace each inline reconciler literal in
TestReconcileHCPDeletion_WhenMultipleCRsExist_ItShouldRemoveAllPerCRFinalizers,
TestReconcileHCPDeletion_WhenSiblingCleanupFails_ItShouldPreserveAllFinalizers,
TestReconcile_WhenHCPIsGone_ItShouldRemoveOrphanedPerCRFinalizer, and
TestReconcile_WhenHCPIsBeingDeleted_ItShouldNotReAddPerCRFinalizer with the
helper, preserving the custom RecordSets delete error in the failure case.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@control-plane-operator/controllers/azureprivatelinkservice/controller_test.go`:
- Around line 1442-1496: Strengthen the error assertion in
TestReconcileHCPDeletion_WhenSiblingCleanupFails_ItShouldPreserveAllFinalizers
by verifying that err contains the injected “simulated Azure API failure”
message, while retaining the existing assertion that an error occurred.
- Around line 1380-1577: Extract the repeated AzurePrivateLinkServiceReconciler
construction from the four tests into a shared helper such as
newTestReconciler(client.Client), initializing all mock API dependencies
consistently. Replace each inline reconciler literal in
TestReconcileHCPDeletion_WhenMultipleCRsExist_ItShouldRemoveAllPerCRFinalizers,
TestReconcileHCPDeletion_WhenSiblingCleanupFails_ItShouldPreserveAllFinalizers,
TestReconcile_WhenHCPIsGone_ItShouldRemoveOrphanedPerCRFinalizer, and
TestReconcile_WhenHCPIsBeingDeleted_ItShouldNotReAddPerCRFinalizer with the
helper, preserving the custom RecordSets delete error in the failure case.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Enterprise

Run ID: 71c93aa6-a5f2-4fb4-b302-46d6032a28ec

📥 Commits

Reviewing files that changed from the base of the PR and between 81515ee and 12be13c.

📒 Files selected for processing (2)
  • control-plane-operator/controllers/azureprivatelinkservice/controller.go
  • control-plane-operator/controllers/azureprivatelinkservice/controller_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • control-plane-operator/controllers/azureprivatelinkservice/controller.go

@Nirshal

Nirshal commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

/test e2e-v2-azure-self-managed

@Nirshal

Nirshal commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

/unhold

@openshift-ci openshift-ci Bot removed the do-not-merge/hold Indicates that a PR should not merge because someone has issued a /hold command. label Aug 3, 2026
@Nirshal

Nirshal commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Addressing the two nitpick comments from this review:

  1. Assert error content in partial-failure test: added ContainSubstring("simulated Azure API failure") to confirm the error originates from the injected mock, not an unrelated failure.

  2. Extract shared reconciler setup: introduced newTestReconciler(fakeClient) helper and replaced inline AzurePrivateLinkServiceReconciler construction in all 4 new tests. The partial-failure test overrides r.RecordSets after construction.

Incoming push.

@Nirshal

Nirshal commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Re: review suggesting partial-success test (one sibling succeeds, the other fails):

Already addressed in this comment. The code returns early from reconcileHCPDeletion if cleanupSiblingAzureResources returns any error, so removeSiblingFinalizers is never called. No per-CR finalizers are removed regardless of which CRs succeeded. This is correct because reconcileDelete is idempotent, and the existing TestReconcileHCPDeletion_WhenSiblingCleanupFails already validates this.

@Nirshal

Nirshal commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

/test all

@Nirshal

Nirshal commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

/test e2e-v2-azure-self-managed

@Nirshal

Nirshal commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

e2e-v2-azure-self-managed failure analysis (build 2084917155651915776)

Failed test

[sig-hypershift][Jira:Hypershift] Hosted Cluster Security
  when [Feature:WebhookValidation] a webhook targeting a control plane service
  is created in the hosted cluster [It] should be automatically deleted
  hosted_cluster_security_test.go:98

1 Failed | 425 Passed | 540 Skipped - failure is in the public test group only. All other groups passed (oauth-lb, private, upgrade, etcd-chaos, autoscaling, external-oidc).

What the test does

The test (hosted_cluster_security_test.go:55-99) creates a ValidatingWebhookConfiguration named test-malicious-webhook in the guest cluster targeting etcd-client:2379, then waits 60 seconds for HCCO (Hosted Cluster Config Operator) to automatically delete it. The timeout expired because HCCO did not remove the webhook in time.

Why this is unrelated to this PR

This PR modifies control-plane-operator/controllers/azureprivatelinkservice/controller.go and its tests. The failure involves a completely different component and flow:

This PR Failed test
Component CPO AzurePrivateLinkService controller HCCO webhook cleanup controller
Binary control-plane-operator hosted-cluster-config-operator
Cluster Management cluster (HCP namespace) Guest cluster (hosted cluster API)
Resource AzurePrivateLinkService CRs ValidatingWebhookConfiguration
Operation Finalizer-based Azure resource cleanup Automatic deletion of malicious webhooks
Test group N/A (CPO unit tests only) public cluster

No code path is shared between the AzurePrivateLinkService reconciler and the HCCO webhook deletion controller. This PR does not modify HCCO, does not touch webhook handling, and does not affect the guest cluster API.

Cross-check with other PRs

Checked e2e-v2-azure-self-managed results across recently merged PRs:

PR Result Description
#9198 pass
#9201 aborted Revert of unit test PR (no functional code)
#9189 pass
#9186 pass
#9185 pass
#9174 pass
#9172 pass

The failure is sporadic and not correlated with any specific code change. It appears to be a timing-sensitive flaky in the HCCO webhook cleanup reconciliation (60s timeout may be too tight under load).

@Nirshal

Nirshal commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

CPO finalizer fix validated by downstream e2e

PR #8584 (CNTRLPLANE-3277) is rebased on top of this PR and includes all 5 CPO commits in its base. Its e2e-v2-azure-self-managed job passed, exercising the full Azure self-managed flow (including private topology with multiple AzurePrivateLinkService CRs) with the finalizer race fix in place.

This serves as additional e2e validation for this PR's changes, beyond the unit tests included here.

return ctrl.Result{}, nil
}

func (r *AzurePrivateLinkServiceReconciler) cleanupSiblingAzureResources(ctx context.Context, items []hyperv1.AzurePrivateLinkService, log logr.Logger) error {

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.

Nit: The "sibling" framing assumes there's always more than one CR, but the common case is a single private-router CR — the second oauth-openshift CR only exists with oauthPublishingStrategy=LoadBalancer. When there's one CR, "sibling" reads oddly.

Consider cleanupAllAzureResources / removeAllCRFinalizers — count-neutral and still accurate.

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.

Good catch, renamed to cleanupAllAzureResources and removeAllCRFinalizers. The "sibling" framing was indeed misleading since the function operates on all CRs unconditionally.

}

if err := r.cleanupSiblingAzureResources(ctx, allPLS.Items, log); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to clean up Azure resources during HCP deletion: %w", err)

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.

When cleanupSiblingAzureResources calls reconcileDelete for each CR, deleteBaseDomainResources calls hasSiblingCR to decide whether to delete the base domain zone. During HCP deletion, neither CR has a DeletionTimestamp set (only the HCP does), so:

  • private-router cleanup: sees oauth-openshift as an active sibling → skips zone deletion
  • oauth-openshift cleanup: sees private-router as an active sibling → skips zone deletion

After both cleanups, per-CR finalizers are removed, CRs are garbage-collected without their own finalizer logic running, and the base domain DNS zone is never deleted.

This is the same class of bug this PR is fixing — just for the base domain zone instead of the PE. Could you either skip the hasSiblingCR check when called from the HCP deletion path, or add a dedicated base domain zone cleanup pass after all CRs are processed?

If resource group deletion is expected to clean this up, a comment documenting that assumption would be helpful.

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.

You're right, this is a real bug. During HCP deletion neither CR has DeletionTimestamp set, so hasSiblingCR returns true for both and the base domain zone is never deleted.

Before fixing, I verified that resource group deletion wouldn't clean this up implicitly. The guest resource group (azPLS.Spec.ResourceGroupName) is only deleted by the CLI path (hypershift destroy cluster azure). During normal HCP-driven teardown, the HO controller does not delete the resource group: AzureCluster is annotated managed-by: external so CAPZ doesn't manage it either, and the HO's delete() function only removes individual resources via finalizers. So the zone would remain orphaned in the customer's subscription.

I considered adding a flag parameter to reconcileDelete (e.g. skipSiblingCheck bool) to bypass hasSiblingCR when called from the HCP deletion path, but that would mean threading a boolean through two levels of calls (reconcileDelete -> deleteBaseDomainResources). As a fan of Uncle Bob Martin's Clean Code, I'd rather avoid flag arguments that change a function's behavior based on a boolean - it's a sign the function is doing two things.

Instead I went with a dedicated deleteBaseDomainDNSZone pass in reconcileHCPDeletion, called after cleanupAllAzureResources. At that point all per-CR A records and VNet links are already cleaned up, so the zone is empty and safe to delete. The call is idempotent: in the single-CR case, deleteBaseDomainResources already deleted the zone (no siblings), and deleteBaseDomainDNSZone gets a NotFound which is handled gracefully.

Added tests for: multi-CR deletion (zone gets deleted), single-CR idempotency, zone deletion failure preserving the HCP finalizer, and a no-BaseDomain assertion in the existing multi-CR test.

// is gone (NotFound), it removes any orphaned per-CR finalizer so the CR can be
// garbage-collected with the namespace. Returns (nil, nil) when the HCP is gone and
// cleanup succeeded; the caller should return immediately.
func (r *AzurePrivateLinkServiceReconciler) getHCPOrCleanupOrphan(ctx context.Context, azPLS *hyperv1.AzurePrivateLinkService) (*hyperv1.HostedControlPlane, error) {

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.

This function removes a finalizer via r.Update (a mutating side effect) but doesn't log that it did so. Every other helper that performs side effects (cleanupSiblingAzureResources, removeSiblingFinalizers, reconcileHCPDeletion) accepts a logr.Logger and logs its actions. Consider adding a logger parameter and a log.Info("HCP is gone, removed orphaned per-CR finalizer") inside the finalizer-removal branch for observability.

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.

Added a logr.Logger parameter and a log.Info("HostedControlPlane not found, removing orphaned per-CR finalizer") call before the finalizer removal. The call site already has log in scope so it's a clean pass-through.

log.Info("Removing per-CR finalizer from AzurePrivateLinkService", "name", pls.Name)
controllerutil.RemoveFinalizer(pls, azurePrivateLinkServiceFinalizer)
if err := r.Update(ctx, pls); err != nil {
errs = append(errs, fmt.Errorf("failed to remove per-CR finalizer from %s: %w", pls.Name, err))

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.

Minor: This Update uses the object from the List snapshot at the top of reconcileHCPDeletion. If the CR is modified between the List and this Update, it's a last-writer-wins with no optimistic lock. The HCP finalizer removal at line 464 uses MergeFromWithOptimisticLock — consider the same pattern here for consistency. With MaxConcurrentReconciles: 1 and this being a deletion path the risk is low, but it would be more defensive.

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.

Switched from r.Update to r.Patch with MergeFromWithOptimisticLock in removeAllCRFinalizers, consistent with how ensureHCPFinalizer and the HCP finalizer removal in reconcileHCPDeletion already work.

@Nirshal

Nirshal commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

/test e2e-v2-azure-self-managed

@Nirshal

Nirshal commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

/restruture-commits

When a hosted cluster with endpointAccess=Private and
oauthPublishingStrategy=LoadBalancer is deleted, two
AzurePrivateLinkService CRs exist (private-router and
oauth-openshift). The shared HCP finalizer was removed after
cleaning up only the first CR to reconcile, orphaning the second
CR's Azure resources (PE, DNS zone, VNet link).

Replace single-CR cleanup with an all-CR pass in
reconcileHCPDeletion: list all AzurePrivateLinkService CRs, call
reconcileDelete for each, remove per-CR finalizers so namespace
deletion is not blocked, then remove the shared HCP finalizer.

Additionally fix the base domain DNS zone cleanup: during HCP
deletion neither CR has DeletionTimestamp set, so hasSiblingCR
returns true for both and neither deletes the shared zone. Add an
explicit deleteBaseDomainDNSZone pass after all per-CR resources
are cleaned up. The guest resource group is not deleted by the HO
controller during normal teardown, so without this fix the zone
remains orphaned in the customer's subscription.

Other improvements from review feedback:
- Rename cleanupSiblingAzureResources/removeSiblingFinalizers to
  cleanupAllAzureResources/removeAllCRFinalizers
- Add logging to getHCPOrCleanupOrphan for orphaned finalizer removal
- Use MergeFromWithOptimisticLock in removeAllCRFinalizers

Signed-off-by: Alessandro Rossi <alesross@redhat.com>
Commit-Message-Assisted-by: Claude (via Claude Code)

@bryan-cox bryan-cox left a comment

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.

/approve

@openshift-ci

openshift-ci Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: bryan-cox, Nirshal

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@openshift-ci openshift-ci Bot added the approved Indicates a PR has been approved by an approver from all required OWNERS files. label Aug 5, 2026
@Nirshal

Nirshal commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

/retest

@clebs

clebs commented Aug 6, 2026

Copy link
Copy Markdown
Member

/lgtm

@openshift-ci openshift-ci Bot added the lgtm Indicates that a PR is ready to be merged. label Aug 6, 2026
@openshift-merge-bot

Copy link
Copy Markdown
Contributor

Scheduling tests matching the pipeline_run_if_changed or not excluded by pipeline_skip_if_only_changed parameters:
/test e2e-aks
/test e2e-aws
/test e2e-aws-upgrade-hypershift-operator
/test e2e-kubevirt-aws-ovn-reduced
/test e2e-v2-aws
/test e2e-v2-azure-self-managed
/test e2e-v2-gke

@Nirshal

Nirshal commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

/verified by e2e

@openshift-ci-robot openshift-ci-robot added the verified Signifies that the PR passed pre-merge verification criteria label Aug 7, 2026
@openshift-ci-robot

Copy link
Copy Markdown

@Nirshal: This PR has been marked as verified by e2e.

Details

In response to this:

/verified by e2e

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository.

@openshift-ci

openshift-ci Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@Nirshal: all tests passed!

Full PR test history. Your PR dashboard.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. I understand the commands that are listed here.

@openshift-merge-bot
openshift-merge-bot Bot merged commit 54dfce9 into openshift:main Aug 7, 2026
43 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Indicates a PR has been approved by an approver from all required OWNERS files. area/control-plane-operator Indicates the PR includes changes for the control plane operator - in an OCP release area/platform/azure PR/issue for Azure (AzurePlatform) platform jira/valid-reference Indicates that this PR references a valid Jira ticket of any type. lgtm Indicates that a PR is ready to be merged. verified Signifies that the PR passed pre-merge verification criteria

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants