Skip to content

feat: observe managed resource group and gate cluster deletion on it - #6648

Open
Chai-bot (redhat-chai-bot) wants to merge 3 commits into
Azure:mainfrom
redhat-chai-bot:chai/mrg-observe-controller
Open

feat: observe managed resource group and gate cluster deletion on it#6648
Chai-bot (redhat-chai-bot) wants to merge 3 commits into
Azure:mainfrom
redhat-chai-bot:chai/mrg-observe-controller

Conversation

@redhat-chai-bot

@redhat-chai-bot Chai-bot (redhat-chai-bot) commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an observe-only backend controller, ObserveManagedResourceGroup, that reflects the existence of a cluster's managed resource group (MRG) in Azure onto ServiceProviderCluster.Status.AzureResources.ManagedResourceGroup, and gates ServiceProviderCluster deletion until that MRG is gone.

This begins populating the AzureResources section of ServiceProviderClusterStatus with resources that Cluster Service creates, so we can inspect them during deletion and watch them disappear as cleanup proceeds.

Cluster Service owns the MRG lifecycle — this controller never creates or deletes a resource group (CreateOrUpdate/BeginDelete are never called). It only mirrors observed state. The MRG resource ID is derived the same way as in the reference PR #6282 (coreapi.ToResourceGroupResourceID(subscriptionID, CustomerProperties.Platform.ManagedResourceGroup)), and existence is checked with the FPA credential via FirstPartyApplicationClientBuilder.ResourceGroupsClient(...).Get.

Behavior

SyncOnce gates on NeedsWork and then splits into reconcile vs. delete based on ServiceProviderProperties.DeletionTimestamp.

NeedsWork(cluster, spc)

if cluster.DeletionTimestamp != nil {
    return ref.PendingAzureResource != nil || ref.AzureResource != nil
}
return ref.AzureResource == nil

Reconcile (not deleting)

  • Empty MRG name → return an error (the MRG should always exist).
  • If PendingAzureResource isn't set, set it and persist it before the Get, so the delete gate is populated even for a cluster that deletes immediately.
  • switch on the Get:
    • not found → do nothing (TODO: someday create; pending stays set)
    • other error → return TrackError(err)
    • exists → if the MRG is owned by another cluster (!controllerutil.ResourceIDsEqual) → return an error (references untouched); otherwise clear PendingAzureResource, set AzureResource, persist.

Deletion (only reached when a reference is set)

  • switch on the Get:
    • not found → clear both references, persist
    • other error → return TrackError(err)
    • exists → do nothing (TODO: someday start the delete)

Writes only happen on an actual change (persistIfChanged), and PreconditionFailed on Replace is treated as a no-op retry.

Deletion gate

In clusterChildResourcesCleanupController.extraDeleteGateShouldDeleteServiceProviderCluster, after the ServiceProviderCluster is fetched: if Status.AzureResources.ManagedResourceGroup.AzureResource != nil or PendingAzureResource != nil, the gate logs and returns (false, nil) — the ServiceProviderCluster document is not deleted while the MRG is still reflected as present.

Changes

  • New controller backend/pkg/controllers/cluster/azureresources/managed_resource_group_controller.go (cluster-watching ClusterSyncer, 5m resync).
  • Deletion gate in backend/pkg/controllers/cluster/deletion/cluster_child_resources_cleanup_controller.go.
  • Wiring in backend/pkg/app/backend.go (constructed alongside the other FPA cluster controllers; started under leader election).
  • Updated the // Written by: annotation on the ManagedResourceGroup field and docs/cosmos-data-flow.md.
  • Unit tests for NeedsWork, the reconcile switch (not-found, exists/owned, exists/owned-by-other → error, empty name), and the deletion switch (clears both when gone).

Testing

  • go build ./... — pass
  • go vet ./... — pass
  • go test ./backend/pkg/controllers/cluster/azureresources/... — pass
  • golangci-lint (v2.5.0, repo-pinned) — 0 issues

Notes / open questions for reviewers

  • Gate predicate blocks on pending OR actual being non-nil.
  • Ownership handling (needs reviewer alignment): reconcile now returns an error when the MRG is owned by another cluster. The deletion switch has no ownership case, so a foreign-but-present MRG keeps the gate closed (deletion blocked). This diverges from the earlier request to clear the references for a foreign MRG during deletion — flagged on the review threads for the reviewers to settle.
  • This version relies on the controller resync interval (5m) and does not manage the EarliestRecheckTime cooldown field on AzureReference. Happy to add cooldown/jitter if preferred.
  • Distinct from feat: add Managed Resource Group creation and deletion controller #6282, which implements full MRG create/delete lifecycle; this PR is observation + deletion gating only.

AI-generated. Review for accuracy.

Copilot AI 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.

Pull request overview

This PR adds an observe-only backend controller that checks whether a cluster’s managed resource group (MRG) exists in Azure and mirrors that state onto ServiceProviderCluster.Status.AzureResources.ManagedResourceGroup. It also adds a deletion gate so the ServiceProviderCluster Cosmos document is not deleted while the MRG is still reflected as present (pending or confirmed).

Changes:

  • Introduces ObserveManagedResourceGroup (cluster-watching) to reflect MRG existence into ServiceProviderCluster.Status.AzureResources.ManagedResourceGroup.
  • Gates ServiceProviderCluster document deletion on the reflected MRG state in ClusterChildResourcesCleanupController.
  • Wires the controller into backend startup and adds unit tests + data-flow documentation updates.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
internal/api/coreapi/types_serviceprovider_cluster.go Adds // Written by: annotation for the ManagedResourceGroup status field.
docs/cosmos-data-flow.md Documents the new controller and the new deletion gate behavior.
backend/pkg/controllers/cluster/deletion/cluster_child_resources_cleanup_mrg_gate_test.go Adds unit tests verifying the new deletion gate behavior.
backend/pkg/controllers/cluster/deletion/cluster_child_resources_cleanup_controller.go Blocks ServiceProviderCluster deletion while MRG is reflected as pending/present.
backend/pkg/controllers/cluster/azureresources/managed_resource_group_controller.go Adds the observe-only controller that queries Azure RG existence and updates SPC status.
backend/pkg/controllers/cluster/azureresources/managed_resource_group_controller_test.go Adds unit tests for the syncer’s state transitions (non-deleting vs deleting).
backend/pkg/app/backend.go Constructs and starts the new controller under leader election.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +133 to +136
// Deletion fast-path: nothing left to reflect once both references are cleared.
if isDeleting && existingReference.AzureResource == nil && existingReference.PendingAzureResource == nil {
return nil
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch. Fixed in 5019103: removed the deletion fast-path so we always query Azure during deletion. If the MRG still exists we now set AzureResource (so the gate blocks) even when the reference was previously empty — e.g. a cluster deleted shortly after creation. Both references are cleared only once the RG is confirmed gone.


AI-generated. Review for accuracy.

Comment on lines +191 to +195
{
name: "deleting and resource group gone clears both",
deleting: true,
initialReference: coreapi.AzureReference{AzureResource: mrgID},
getResponse: armresources.ResourceGroupsClientGetResponse{},

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in 5019103: a regression test "deleting and resource group present with empty reference sets actual" — deletion in progress, initial ManagedResourceGroup reference empty, RG still exists in Azure → asserts AzureResource is set and PendingAzureResource is nil (so the gate would block).


AI-generated. Review for accuracy.

Comment thread docs/cosmos-data-flow.md Outdated
**Trigger:** Cluster informer, 5-minute resync
**Behavior:** Observe-only — never creates or deletes the managed resource group.
- Not deleting: records the managed resource group as `PendingAzureResource` while it is missing; sets `AzureResource` (and clears `PendingAzureResource`) once it exists.
- Deleting: clears both references once the managed resource group is gone; leaves reflected state untouched while it still exists.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Chai-bot (@redhat-chai-bot) if the MRG still exists but not managed by this aro-hcp cluster (compare resources IDs ignoring casing) then unblock deletion by setting the resourceIDs as nil

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 43d6eb1. SyncOnce now captures the Get response and treats the MRG as "owned & present" only when resp.ManagedBy != nil && strings.EqualFold(*resp.ManagedBy, cluster.ID.String()) (case-insensitive). A resource group that exists but isn't managed by this cluster is treated the same as missing:

  • Deleting → clear both references (unblock deletion) instead of holding the gate on a foreign RG.
  • Not deleting → set PendingAzureResource (we only promote to AzureResource once it exists and is owned by this cluster).

Added tests for both "exists but foreign ManagedBy" paths.


AI-generated. Review for accuracy.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Heads-up Manyanda Chitimbo (@machi1990): the ownership handling was restructured in 510b664 per David Eads (@deads2k)'s switch-based review, and it now differs from this original request — flagging so you two can align:

  • Reconcile (not deleting): MRG exists but owned by another cluster → return an error (does not set AzureResource). Previously this set PendingAzureResource / treated it as not-ours.
  • Deletion: the deletion switch has no ownership case — a foreign-but-present MRG → do nothing, so the references stay set and the gate keeps blocking deletion. The earlier "foreign MRG → clear the resource IDs to unblock deletion" behavior you requested is not in the current revision.

Could you and David Eads (@deads2k) confirm the desired deletion behavior for a foreign MRG (clear refs to unblock vs. hold/error)? Happy to implement whichever you settle on.


AI-generated. Review for accuracy.


AI-generated. Review for accuracy.

Chai-bot (redhat-chai-bot) added a commit to redhat-chai-bot/Azure_ARO-HCP that referenced this pull request Aug 21, 2026
Address PR Azure#6648 review: the ObserveManagedResourceGroup controller could
let the ServiceProviderCluster be deleted while the managed resource group
still existed in Azure.

Two paths caused the race:
- the deletion fast-path returned early (without querying Azure) whenever the
  cluster was deleting and both MRG references were already nil, and
- the deletion branch returned without recording AzureResource when the
  resource group still existed.

If a cluster was deleted shortly after creation (before the reference was ever
populated), the deletion gate in the cluster child resources cleanup
controller never saw a set reference and allowed removal.

SyncOnce now always queries Azure and reflects observed existence: an existing
MRG is recorded as AzureResource regardless of deletion state (so the gate
blocks), and once the MRG is gone during deletion both references are cleared
(opening the gate). The DeepEqual no-op guard, IsPreconditionFailedError
handling, and empty-managed-resource-group-name guard are unchanged.

Add a regression test covering deletion in progress with an empty initial
reference while the resource group still exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 21, 2026 15:04

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread docs/cosmos-data-flow.md Outdated
|---|--------|--------|
| Read | `HCPOpenShiftCluster` | <ul><li>`CustomerProperties.Platform.ManagedResourceGroup` (SyncOnce: skipped when empty)</li><li>`ServiceProviderProperties.DeletionTimestamp` (branches deletion vs non-deletion)</li><li>`ID` (subscription / resource group / name)</li></ul> |
| Read | `Subscription` | <ul><li>`Properties.TenantId` (to build the FPA ResourceGroups client)</li></ul> |
| Read | `ServiceProviderCluster` | <ul><li>`Status.AzureResources.ManagedResourceGroup` (compared before write; deletion fast-path when both references already nil)</li></ul> |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 43d6eb1. The data-flow doc no longer mentions a "deletion fast-path when both references are nil" — it now states the controller always queries Azure during deletion.


AI-generated. Review for accuracy.

Comment thread docs/cosmos-data-flow.md Outdated
Comment on lines +1160 to +1161
- Not deleting: records the managed resource group as `PendingAzureResource` while it is missing; sets `AzureResource` (and clears `PendingAzureResource`) once it exists.
- Deleting: clears both references once the managed resource group is gone; leaves reflected state untouched while it still exists.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Updated in 43d6eb1. The doc now describes the actual SyncOnce behavior: during deletion the controller reflects observed existence (sets AzureResource when the RG exists and is owned by this cluster, even from an empty/pending state) and clears both references when the RG is missing or not owned by this cluster.


AI-generated. Review for accuracy.

Chai-bot (redhat-chai-bot) added a commit to redhat-chai-bot/Azure_ARO-HCP that referenced this pull request Aug 21, 2026
Address PR Azure#6648 maintainer review: the ObserveManagedResourceGroup controller
treated any existing resource group with the cluster's managed resource group
name as the cluster's own, without verifying ownership. It could therefore
claim (or block deletion on) a resource group that happens to share the name
but is managed by something else.

SyncOnce now captures the Get response and considers the managed resource group
"owned and present" only when it exists AND its ManagedBy equals the cluster's
resource ID (case-insensitive via strings.EqualFold). A resource group that
exists but is not owned by this cluster is treated the same as missing:

- Not deleting: owned & present -> AzureResource set, Pending cleared; otherwise
  (missing or not owned) -> PendingAzureResource set, AzureResource cleared.
- Deleting: owned & present -> AzureResource set so the deletion gate blocks;
  otherwise (missing or not owned) -> both references cleared, opening the gate.

The empty-managed-resource-group-name guard, DeepCopy + equality.Semantic
no-op guard, IsPreconditionFailedError handling, and the non-not-found Get
error return are unchanged.

Add tests for "present but owned by a different cluster" in both the
non-deletion (expects pending) and deletion (expects both cleared) paths, set
ManagedBy on the present-response helper, and update docs/cosmos-data-flow.md to
describe the ownership rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 21, 2026 15:24

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

backend/pkg/controllers/cluster/azureresources/managed_resource_group_controller.go:152

  • This controller unconditionally calls Azure ResourceGroupsClient.Get on every resync (5m) for every cluster, even when the managed resource group is already confirmed and the cluster is not deleting. At scale this can create unnecessary Azure ARM traffic and increase the chance of throttling; the AzureReference type includes EarliestRecheckTime specifically to support backing off repeated Azure checks (internal/api/coreapi/types_serviceprovider_cluster.go:436-443). Consider honoring and setting EarliestRecheckTime (with substantial jitter) in the non-deleting, stable "AzureResource set" state, while still always querying during deletion.
	// Always query Azure to observe the managed resource group, including during
	// deletion. We must detect a still-existing resource group so the
	// ServiceProviderCluster deletion gate keeps blocking until the MRG is
	// actually gone, even when the reference was never populated before deletion
	// started (for example when a cluster is deleted shortly after creation).
	getResponse, getErr := rgClient.Get(ctx, managedResourceGroupID.Name, nil)
	resourceGroupMissing := azureclient.IsResourceGroupNotFoundErr(getErr)
	if getErr != nil && !resourceGroupMissing {
		return utils.TrackError(fmt.Errorf("failed to get managed resource group %q: %w", managedResourceGroupName, getErr))

backend/pkg/controllers/cluster/deletion/cluster_child_resources_cleanup_controller.go:276

  • This log statement dereferences spc.ResourceID; if the stored document is ever missing ResourceID (or is partially deserialized), this could panic. Since the method already has serviceProviderClusterResourceID, logging that value avoids the extra dependency and is guaranteed non-nil here.
		logger.Info("waiting for the managed resource group to be deleted before removing the ServiceProviderCluster document",
			"serviceProviderClusterResourceID", spc.ResourceID.String())
		return false, nil

Chai-bot (redhat-chai-bot) added a commit to redhat-chai-bot/Azure_ARO-HCP that referenced this pull request Aug 21, 2026
…lected and the cluster is not deleting

Address PR Azure#6648 maintainer optimization: in SyncOnce, when the cluster is not
being deleted and the ServiceProviderCluster already reflects the managed
resource group as AzureResource (matching the derived MRG resource ID,
case-insensitive), short-circuit before building the FPA ResourceGroups client
or calling Get. There is nothing new to observe in that steady state.

The deletion path is intentionally not short-circuited: it must always re-query
Azure to detect when the resource group is finally gone. When the cluster is
not deleting and the resource group is not yet reflected, the controller still
queries Azure and applies the ManagedBy ownership check. The DeepCopy +
equality.Semantic.DeepEqual no-op guard, IsPreconditionFailedError handling,
and non-not-found Get error return are unchanged.

Add a test asserting that, when AzureResource is already reflected and the
cluster is not deleting, neither the FPA client builder nor ResourceGroupsClient
Get is called (EXPECT().Times(0)) and the reference is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 21, 2026 15:50

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

}

managedResourceGroupName := cluster.CustomerProperties.Platform.ManagedResourceGroup
if managedResourceGroupName == "" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The MRG should always be there; we are better off returning an error here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 6d6747a. SyncOnce now returns an error when the managed resource group name is empty instead of silently returning:

return utils.TrackError(fmt.Errorf("managed resource group name is empty for cluster %q", cluster.ID.String()))

so the syncer retries rather than treating a missing name as a no-op.


AI-generated. Review for accuracy.

Chai-bot (redhat-chai-bot) added a commit to redhat-chai-bot/Azure_ARO-HCP that referenced this pull request Aug 21, 2026
… resource group is immutable

Address PR Azure#6648 maintainer feedback: the managed resource group is immutable,
so the non-deletion short-circuit does not need to compare the reflected
AzureResource against the derived resource ID. A non-nil AzureResource is
sufficient to know there is nothing new to observe.

Simplify the early return in SyncOnce to:

    if !isDeleting && existingReference.AzureResource != nil { return nil }

removing only the strings.EqualFold(existingReference.AzureResource.String(),
managedResourceGroupID.String()) comparison. The `strings` import is retained
(still used by the ManagedBy ownership check). All other behavior is unchanged:
the deletion path still always re-queries Azure, the ManagedBy ownership check
still gates pending/actual, and the DeepEqual no-op guard and
IsPreconditionFailedError handling are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 21, 2026 16:19
// missing, so we neither claim it nor block this cluster's deletion on it.
ownedAndPresent := getErr == nil &&
getResponse.ManagedBy != nil &&
strings.EqualFold(*getResponse.ManagedBy, cluster.ID.String())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There is utils function that checks for resource IDs equality use that...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 6d6747a. Switched the ownership check to controllerutil.ResourceIDsEqual (internal/controllerutils). Since ManagedBy is a *string, I parse it first and treat a nil/unparseable value as not-owned:

var managedByID *azcorearm.ResourceID
var parseErr error
if getResponse.ManagedBy != nil {
    managedByID, parseErr = azcorearm.ParseResourceID(*getResponse.ManagedBy)
}
ownedAndPresent := getErr == nil && getResponse.ManagedBy != nil && parseErr == nil &&
    controllerutil.ResourceIDsEqual(managedByID, cluster.ID)

Removed the now-unused strings import. One heads-up: ResourceIDsEqual compares canonically via ParseResourceID (which is case-sensitive on the subscription/RG/name segments), whereas my earlier version used a case-insensitive EqualFold. In practice ARM sets ManagedBy to the cluster's own resource ID so this should match, but let me know if you'd prefer an explicit case-insensitive compare.


AI-generated. Review for accuracy.

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread docs/cosmos-data-flow.md Outdated

**File:** [managed_resource_group_controller.go](../backend/pkg/controllers/cluster/azureresources/managed_resource_group_controller.go)
**Trigger:** Cluster informer, 5-minute resync
**Behavior:** Observe-only — never creates or deletes the managed resource group. It always queries Azure (there is no early-return fast-path during deletion). The managed resource group is treated as "owned and present" only when it exists AND its `ManagedBy` equals this cluster's resource ID (case-insensitive); a resource group that exists but is owned by something else is treated the same as missing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Updated in 6d6747a. The data-flow doc now documents the non-deletion short-circuit: when the cluster is not deleting and ManagedResourceGroup.AzureResource is already set, the controller returns early without querying Azure (the MRG is immutable).


AI-generated. Review for accuracy.

Chai-bot (redhat-chai-bot) added a commit to redhat-chai-bot/Azure_ARO-HCP that referenced this pull request Aug 21, 2026
… use ResourceIDsEqual for ManagedBy ownership; document non-deletion short-circuit

Round-4 PR Azure#6648 maintainer review:

- A cluster should always have a managed resource group name, so SyncOnce now
  returns a wrapped error (utils.TrackError) instead of silently returning nil
  when CustomerProperties.Platform.ManagedResourceGroup is empty, letting the
  syncer retry.

- The ManagedBy ownership check now uses the repo helper
  controllerutil.ResourceIDsEqual (internal/controllerutils) instead of
  strings.EqualFold. ManagedBy is a *string, so it is first parsed with
  azcorearm.ParseResourceID; a nil ManagedBy or a parse error is treated as not
  owned. The now-unused strings import is removed.

- docs/cosmos-data-flow.md documents the non-deletion short-circuit (when the
  cluster is not deleting and AzureResource is already set, the controller
  returns early without querying Azure because the managed resource group is
  immutable) and the ManagedBy/ResourceIDsEqual ownership rule.

Tests set ManagedBy to the cluster ID for owned/present cases and to a valid but
different resource ID for foreign cases, add an unparseable-ManagedBy case
(treated as not owned: pending when not deleting, both cleared when deleting),
and assert the empty-name path now returns an error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 21, 2026 18:53

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Chai-bot (redhat-chai-bot) added a commit to redhat-chai-bot/Azure_ARO-HCP that referenced this pull request Aug 21, 2026
…ring deletion

Round-5 PR Azure#6648 review: during deletion, if rgClient.Get returned a non-404
error while the ServiceProviderCluster's ManagedResourceGroup reference was
empty (AzureResource == nil && PendingAzureResource == nil), SyncOnce returned
the error without writing anything. The cluster child-resources cleanup gate
then saw a nil/nil reference and could delete the ServiceProviderCluster even
though the managed resource group might still exist (transient
Azure-unavailability race).

SyncOnce now fails closed: on a non-404 Get error during deletion with an empty
reference, it first records PendingAzureResource = managedResourceGroupID (via
the existing Replace path, treating a precondition failure as already-handled)
and then returns the wrapped error so the syncer retries. This keeps the
deletion gate closed until Azure can be queried successfully. Non-deletion
behavior on non-404 errors is unchanged (still just returns the error), and a
deletion error when a reference already holds the gate closed makes no extra
write.

Add tests: (a) deleting + empty reference + transient Get error -> pending set
and error returned; (b) deleting + transient Get error + existing AzureResource
-> no change and error returned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@openshift-merge-bot

Copy link
Copy Markdown
Contributor

/retest-required

Remaining retests: 0 against base HEAD 0eba6aa and 2 for PR HEAD d5a9f48 in total

@openshift-merge-bot

Copy link
Copy Markdown
Contributor

/retest-required

Remaining retests: 0 against base HEAD a500a9d and 1 for PR HEAD d5a9f48 in total

@openshift-merge-bot

Copy link
Copy Markdown
Contributor

/retest-required

Remaining retests: 0 against base HEAD cb92a98 and 0 for PR HEAD d5a9f48 in total

@openshift-merge-bot

Copy link
Copy Markdown
Contributor

/hold

Revision d5a9f48 was retested 3 times: holding

ARM resource IDs are case-insensitive for their provider namespaces and
resource types, but ResourceIDsEqual compared canonical string forms with
case-sensitive equality. Azure returns the managed resource group's
ManagedBy as ".../Microsoft.RedHatOpenshift/..." while our internal types
use ".../Microsoft.RedHatOpenShift/...", so the observe
ManagedResourceGroup controller's ownedByAnotherCluster check treated the
cluster's own MRG as owned by another cluster and hot-looped.

Compare with strings.EqualFold so IDs that differ only by casing compare
equal. The fix lives in the shared helper so every caller benefits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 25, 2026 13:46
@openshift-ci openshift-ci Bot removed the lgtm label Aug 25, 2026
@redhat-chai-bot

Copy link
Copy Markdown
Collaborator Author

Pushed 6dae18c — fixes the observemanagedresourcegroup requeue hot-loop (the BackendControllerRetryHotLoop alert seen in e2e).

Root cause: controllerutil.ResourceIDsEqual compared resource IDs with a case-sensitive a.String() == b.String(). A cluster's own MRG whose ManagedBy differed only by ARM provider-namespace casing (Microsoft.RedHatOpenshift from Azure vs Microsoft.RedHatOpenShift from coreapi's cluster.ID) was therefore wrongly deemed "owned by another cluster" → error → infinite requeue.

Fix (in the shared helper): ResourceIDsEqual now compares the canonical .String() forms case-insensitively via strings.EqualFold (nil handling unchanged), so every caller gets correct case-insensitive ARM-ID equality. ownedByAnotherCluster is unchanged — it still calls the helper.

Tests: added a TestResourceIDsEqual unit test (casing-only diff ⇒ equal; different resource name ⇒ not equal; nil cases) and a controller regression test reproducing the exact ManagedBy casing mismatch (⇒ owned-by-us ⇒ no error, pending cleared, actual set). Verified adversarially: reverting to the case-sensitive compare makes both new tests fail (the controller test reproduces the exact hot-loop error), then restored the fix.

go build, go vet, go test ./internal/controllerutils/... ./backend/..., and golangci-lint all pass locally.


AI-generated. Review for accuracy.

@deads2k

Copy link
Copy Markdown
Collaborator

/lgtm
/hold cancel

Copilot AI 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.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

backend/pkg/controllers/cluster/azureresources/managed_resource_group_controller.go:103

  • During deletion, NeedsWork returns false when both ManagedResourceGroup references are nil. That means SyncOnce won’t run on a deleting cluster that never had Pending/AzureResource populated (e.g., cluster deleted soon after creation), and the delete gate can then fail-open and remove the ServiceProviderCluster document even while the MRG still exists in Azure. NeedsWork/deleteManagedResourceGroup should ensure a deleting cluster with an empty reference still performs at least one Azure check and persists a blocking marker (pending/actual) on errors or existence until the RG is confirmed gone.
func (c *managedResourceGroupSyncer) NeedsWork(cluster *coreapi.HCPOpenShiftCluster, serviceProviderCluster *coreapi.ServiceProviderCluster) bool {
	managedResourceGroup := serviceProviderCluster.Status.AzureResources.ManagedResourceGroup
	if cluster.ServiceProviderProperties.DeletionTimestamp != nil {
		return managedResourceGroup.PendingAzureResource != nil || managedResourceGroup.AzureResource != nil
	}

backend/pkg/controllers/cluster/azureresources/managed_resource_group_controller_test.go:393

  • This test encodes the current (problematic) behavior that a deleting cluster with an empty ManagedResourceGroup reference has “no work”. That expectation allows the observe controller to skip deletion-time observation entirely when the reference was never populated, which is exactly the race that can make the delete gate fail-open. The test should be updated to expect work in this case, and ideally add an assertion that deletion with an empty reference persists a blocking marker when Azure state can’t yet be confirmed.
		{
			name:      "deleting and empty reference has no work",
			deleting:  true,
			reference: coreapi.AzureReference{},
			expect:    false,

Comment on lines +121 to +125
// The comparison is case-insensitive: ARM resource IDs are case-insensitive for
// their provider namespaces and resource types (for example Azure may return
// "Microsoft.RedHatOpenshift" where our internal types use
// "Microsoft.RedHatOpenShift"), so two IDs that differ only by casing represent
// the same resource and must compare equal.
return utils.TrackError(err)
case getErr != nil:
return utils.TrackError(getErr)
default:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 4277637 (fresh commit on top of 6dae18c, no squash/force).

The deletion path now has the ownership case, using the same ownedByAnotherCluster helper as reconcile (parses ManagedBy, compares via controllerutil.ResourceIDsEqual, which is now case-insensitive). deleteManagedResourceGroup captures the Get response and switches:

switch {
case isNotFound(err):
    // RG gone -> clear both refs -> unblock
case getErr != nil:
    return utils.TrackError(err) // gate stays closed
case ownedByAnotherCluster(getResponse.ManagedBy, cluster.ID):
    // exists but not ours -> clear both refs -> unblock   (NEW)
default: // exists and owned by this cluster
    // do nothing, gate holds (TODO: begin deletion)
}

So a foreign / pre-existing MRG that Cluster Service won't delete no longer keeps the gate closed forever — the references are cleared and cluster deletion proceeds, exactly as you asked. Owned-by-us + present still holds the gate until CS deletes the RG (then we see the 404 and clear).

Ref-clearing goes through the existing persistIfChanged path wrapped in utils.TrackError, extracted into a small clearManagedResourceGroupReferences helper so the not-found and ownership cases don't duplicate the clear-and-persist block. Reconcile is untouched; the observe-only contract is preserved (never Create/BeginDelete). Added a deletion regression test (deleting + MRG exists + foreign ManagedBy ⇒ both refs cleared, gate opens) and kept the owned-by-this-cluster case (refs unchanged, gate holds). Local build/vet/test/lint all green.


AI-generated. Review for accuracy.


AI-generated. Review for accuracy.

@machi1990

Manyanda Chitimbo (machi1990) commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

/hold

Until #6648 (comment) is addressed

… another cluster

During cluster deletion the observe controller waited for the managed
resource group to disappear before clearing the ServiceProviderCluster
references that gate cluster deletion. If the resource group exists but is
owned by another cluster (a foreign / pre-existing resource group that
Cluster Service will not delete on our behalf) that wait never ends, so the
references are never cleared and cluster deletion is stuck forever.

Mirror the reconcile path's ownedByAnotherCluster check in the deletion
switch: a present-but-foreign resource group is not ours to wait on, so
clear both references and open the deletion gate. A resource group owned by
this cluster still holds the gate closed until it is actually gone. The
controller remains observe-only and never creates or deletes the resource
group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 25, 2026 14:38
@openshift-ci openshift-ci Bot removed the lgtm label Aug 25, 2026

Copilot AI 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.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Suppressed comments (4)

backend/pkg/controllers/cluster/azureresources/managed_resource_group_controller.go:105

  • NeedsWork returns false during deletion when both PendingAzureResource and AzureResource are nil. In that state, SyncOnce never enters the deletion path, so the controller can’t populate a blocking marker and the ServiceProviderCluster delete gate can fail-open even if the managed resource group still exists in Azure (e.g., deletion starts before any prior reflection).
func (c *managedResourceGroupSyncer) NeedsWork(cluster *coreapi.HCPOpenShiftCluster, serviceProviderCluster *coreapi.ServiceProviderCluster) bool {
	managedResourceGroup := serviceProviderCluster.Status.AzureResources.ManagedResourceGroup
	if cluster.ServiceProviderProperties.DeletionTimestamp != nil {
		return managedResourceGroup.PendingAzureResource != nil || managedResourceGroup.AzureResource != nil
	}
	return managedResourceGroup.AzureResource == nil
}

backend/pkg/controllers/cluster/azureresources/managed_resource_group_controller.go:231

  • deleteManagedResourceGroup assumes a managed resource group reference is already set (via NeedsWork). If deletion starts before any reflection, this function can’t fail-closed by persisting a pending marker, and if NeedsWork is updated to run during deletion it will also hit a nil-reference path. Consider deriving the MRG ID from the cluster when references are empty and persisting PendingAzureResource before any fallible Azure/client construction so the delete gate stays closed.
	// A reference is guaranteed set here (see NeedsWork). Prefer the confirmed
	// AzureResource, falling back to the PendingAzureResource marker.
	currentReference := existingServiceProviderCluster.Status.AzureResources.ManagedResourceGroup
	managedResourceGroupID := currentReference.AzureResource
	if managedResourceGroupID == nil {

backend/pkg/controllers/cluster/azureresources/managed_resource_group_controller_test.go:273

  • Unit tests don’t cover the case where deletion starts with an empty ManagedResourceGroup reference but the managed resource group still exists in Azure. That scenario is the critical race the delete gate is meant to protect against; adding a regression test will prevent fail-open behavior from being reintroduced.
		{
			name:             "deleting and resource group gone clears both",
			deleting:         true,
			initialReference: coreapi.AzureReference{AzureResource: mrgID},
			getResponse:      armresources.ResourceGroupsClientGetResponse{},
			getErr:           resourceGroupNotFoundError(),
			expectAzure:      nil,
			expectPending:    nil,
		},

docs/cosmos-data-flow.md:1171

  • This table row says ManagedBy is inspected only in the non-deletion path, but the controller also inspects it during deletion to decide whether to clear references for a foreign resource group.
| Read | Azure (ResourceGroupsClient) | <ul><li>`Get` on the managed resource group -> exists / ResourceGroupNotFound; `ManagedBy` (ownership check) is inspected only in the non-deletion path when the resource group exists</li></ul> |

Comment on lines +246 to +247
case getErr != nil:
return utils.TrackError(getErr)
Comment thread docs/cosmos-data-flow.md
- **not found** → does nothing, leaving the pending marker in place (Cluster Service owns creation; this controller is observe-only).
- **other error** → returns the error so the sync retries.
- **exists** → if the resource group is owned by another cluster (its `ManagedBy` is set and does not equal this cluster's ID via the `ResourceIDsEqual` helper) it returns an error and does **not** set `AzureResource`; otherwise (owned by this cluster, or `ManagedBy` absent) it clears `PendingAzureResource` and records the resource group as `AzureResource`.
- Deleting: derives the resource group ID from the reference still on the document (guaranteed set by `NeedsWork`), queries Azure and switches on the result: once the resource group is gone it clears both references so the deletion gate opens; on any other error it returns the error so the gate stays closed until the state is known; while the resource group still exists it does nothing (it does not set `AzureResource`, write a pending marker, or perform the ownership check).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

/lgtm

@openshift-ci

openshift-ci Bot commented Aug 25, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: deads2k, machi1990, redhat-chai-bot

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

@machi1990

Copy link
Copy Markdown
Collaborator

/hold cancel

@openshift-ci

openshift-ci Bot commented Aug 25, 2026

Copy link
Copy Markdown

Chai-bot (@redhat-chai-bot): The following test failed, say /retest to rerun all failed tests or /retest-required to rerun all mandatory failed tests:

Test name Commit Details Required Rerun command
ci/prow/e2e-parallel 4277637 link true /test e2e-parallel

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.

@redhat-chai-bot

Copy link
Copy Markdown
Collaborator Author

/retest

e2e-parallel failed on 4277637 for an unrelated infrastructure flake, not this change:

  • The only failure in the observability alert gate is KubePodNotReady does not fire — 1 unknown firing for pod router-57477b9b98-d4bgr, non-ready 15:55:35→16:14:33 UTC then Resolved (a kubernetes-infrastructure alert during HCP provisioning; the other 19 firings are known-issue, not counted).
  • BackendControllerRetryHotLoop{name="observemanagedresourcegroup"} did not fire — the case-insensitive ResourceIDsEqual fix (6dae18c) holds, and the deletion-ownership change (4277637) did not reintroduce the hot-loop.

Retesting.


AI-generated. Review for accuracy.


AI-generated. Review for accuracy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants