Skip to content
This repository was archived by the owner on Sep 9, 2026. It is now read-only.

OSAC-3132: migrate ExternalIP and ExternalIPAttachment feedback controllers to Bridge - #403

Closed
vladikr wants to merge 2 commits into
osac-project:mainfrom
vladikr:refactor/OSAC-3132-migrate-externalip-feedback
Closed

vladikr wants to merge 2 commits into
osac-project:mainfrom
vladikr:refactor/OSAC-3132-migrate-externalip-feedback

Conversation

@vladikr

@vladikr vladikr commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Final migration PR — ExternalIP and ExternalIPAttachment feedback controllers now use the feedback.Bridge. This completes the migration of all feedback controllers (excluding BareMetalInstance, which is a fundamentally different signal-only pattern).

Commit Controller Notes
1 ExternalIP Uses IsNotFound for sentinel ErrExternalIPNotFound
2 ExternalIPAttachment Uses IsNotFound, PostSaveOnDelete, and two gRPC clients

Bridge API change

PostSaveOnDelete signature updated from func(ctx, obj O) error to func(ctx, obj O, remote R) error. The remote proto is now passed so callbacks can reference proto spec fields — ExternalIPAttachment needs the parent ExternalIP ID from remote.GetSpec().GetExternalIp() to clear the parent's attached flag.

Bridge tests updated to match.

ExternalIPAttachment details

This controller is the most complex feedback controller:

  • SyncUpdate captures eipClient to set attached=true on the parent ExternalIP when the attachment reaches Ready, and to sync the parent's address
  • SyncDelete maps phase to DELETING/FAILED state
  • PostSaveOnDelete clears attached=false on the parent ExternalIP after the attachment's DELETING state is persisted — this is the cross-resource side effect that motivated the PostSaveOnDelete hook in PR OSAC-3132: extract feedback.Bridge and migrate Subnet feedback controller #387

Cleanup

Removed the clone[M]/equal[M] generic helpers from feedback_controller.go — they have no remaining callers now that all feedback controllers use the Bridge (which calls proto.Clone/proto.Equal directly).

Migration complete

All feedback controllers now use feedback.Bridge:

Controller PR Extension points used
Subnet #387 —
VirtualNetwork #401 —
SecurityGroup #401 —
NATGateway #401 —
ExternalIPPool #401 —
ComputeInstance #402 —
ClusterOrder #402 —
ExternalIP this PR IsNotFound
ExternalIPAttachment this PR IsNotFound, PostSaveOnDelete
BareMetalInstance N/A Signal-only, stays standalone

Related bugs found during migration

  • OSAC-3452 — ComputeInstance delete not forcing DELETING state
  • OSAC-3453 — ClusterOrder conditions all mapping to PROGRESSING

Test plan

  • make lint — 0 issues
  • make test — all pass (controller coverage 72.3%, bridge coverage 94.0%)
  • All existing ExternalIP and ExternalIPAttachment feedback tests pass unchanged

Jira: https://redhat.atlassian.net/browse/OSAC-3132

Summary by CodeRabbit

  • Refactor
    • Standardized external IP and external IP attachment reconciliation through a shared feedback workflow.
    • Streamlined update/delete synchronization and state/address propagation, including attachment-to-parent attached flag handling.
    • Enhanced deletion handling by enabling post-delete hooks to access the persisted remote record.
  • Bug Fixes
    • Improved handling of missing external IP attachments in the fulfillment service.
    • Preserved consistent error propagation during deletion-related processing.
  • Tests
    • Updated bridge and reconciliation tests for the new post-delete hook signature and expectations.

Delegates to feedback.Bridge with IsNotFound set to match the
sentinel ErrExternalIPNotFound error. The Fetch callback preserves
the original error-wrapping behavior (gRPC NotFound and nil object
both wrap into ErrExternalIPNotFound).

Assisted-by: Claude Code <noreply@anthropic.com>
Signed-off-by: Vladik Romanovsky <vromanso@redhat.com>
@openshift-ci-robot

openshift-ci-robot commented Jul 30, 2026 •

Copy link
Copy Markdown

@vladikr: This pull request references OSAC-3132 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 task to target the "5.0.0" version, but no target version was set.

Details

In response to this:

Summary

Final migration PR — ExternalIP and ExternalIPAttachment feedback controllers now use the feedback.Bridge. This completes the migration of all feedback controllers (excluding BareMetalInstance, which is a fundamentally different signal-only pattern).

Commit Controller Notes
1 ExternalIP Uses IsNotFound for sentinel ErrExternalIPNotFound
2 ExternalIPAttachment Uses IsNotFound, PostSaveOnDelete, and two gRPC clients

Bridge API change

PostSaveOnDelete signature updated from func(ctx, obj O) error to func(ctx, obj O, remote R) error. The remote proto is now passed so callbacks can reference proto spec fields — ExternalIPAttachment needs the parent ExternalIP ID from remote.GetSpec().GetExternalIp() to clear the parent's attached flag.

Bridge tests updated to match.

ExternalIPAttachment details

This controller is the most complex feedback controller:

  • SyncUpdate captures eipClient to set attached=true on the parent ExternalIP when the attachment reaches Ready, and to sync the parent's address
  • SyncDelete maps phase to DELETING/FAILED state
  • PostSaveOnDelete clears attached=false on the parent ExternalIP after the attachment's DELETING state is persisted — this is the cross-resource side effect that motivated the PostSaveOnDelete hook in PR OSAC-3132: extract feedback.Bridge and migrate Subnet feedback controller #387

Cleanup

Removed the clone[M]/equal[M] generic helpers from feedback_controller.go — they have no remaining callers now that all feedback controllers use the Bridge (which calls proto.Clone/proto.Equal directly).

Migration complete

All feedback controllers now use feedback.Bridge:

Controller PR Extension points used
Subnet #387 —
VirtualNetwork #401 —
SecurityGroup #401 —
NATGateway #401 —
ExternalIPPool #401 —
ComputeInstance #402 —
ClusterOrder #402 —
ExternalIP this PR IsNotFound
ExternalIPAttachment this PR IsNotFound, PostSaveOnDelete
BareMetalInstance N/A Signal-only, stays standalone

Related bugs found during migration

  • OSAC-3452 — ComputeInstance delete not forcing DELETING state
  • OSAC-3453 — ClusterOrder conditions all mapping to PROGRESSING

Test plan

  • make lint — 0 issues
  • make test — all pass (controller coverage 72.3%, bridge coverage 94.0%)
  • All existing ExternalIP and ExternalIPAttachment feedback tests pass unchanged

Jira: https://redhat.atlassian.net/browse/OSAC-3132

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 Jul 30, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: vladikr

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

@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: osac-project/coderabbit/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bdee378b-7a8d-4486-ad9c-15c86eb77897

📥 Commits

Reviewing files that changed from the base of the PR and between 0f77785 and 6d39f1e.

📒 Files selected for processing (4)
  • internal/controller/externalipattachment_feedback_controller.go
  • internal/controller/feedback/bridge.go
  • internal/controller/feedback/bridge_test.go
  • internal/controller/feedback_controller.go
💤 Files with no reviewable changes (1)
  • internal/controller/feedback_controller.go

Walkthrough

ExternalIP and ExternalIPAttachment feedback controllers now delegate reconciliation to feedback.Bridge. Synchronization callbacks handle state, address, parent attachment status, and delete behavior. The bridge delete hook now receives the persisted remote record, with updated tests.

Changes

Feedback bridge migration

Layer / File(s) Summary
Bridge delete callback contract
internal/controller/feedback/bridge.go, internal/controller/feedback/bridge_test.go
PostSaveOnDelete receives the persisted remote object; tests cover ordering, error propagation, and update-path behavior.
ExternalIP bridge reconciliation
internal/controller/externalip_feedback_controller.go
The controller configures feedback.Bridge, delegates Reconcile, and synchronizes fulfillment state and address fields.
ExternalIPAttachment bridge reconciliation
internal/controller/externalipattachment_feedback_controller.go
Bridge callbacks synchronize attachment state, parent address, attached status, not-found handling, and delete behavior.
Feedback helper cleanup
internal/controller/feedback_controller.go
Unused protobuf clone/equality helpers and the associated import are removed.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested labels: ok-to-test

Suggested reviewers: akshaynadkarni, eliorerz, tzvatot

🚥 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 accurately summarizes the main change: migrating the ExternalIP feedback controllers to the shared Bridge abstraction.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
No-Hardcoded-Secrets ✅ Passed No hardcoded secrets, embedded-credential URLs, or credential-like string literals were added in the touched files.
No-Weak-Crypto ✅ Passed Touched files and a repo-wide sweep found no MD5/SHA1/DES/RC4/3DES/Blowfish/ECB or ConstantTimeCompare usage; only proto.Equal in the bridge.
No-Injection-Vectors ✅ Passed Modified Go files only refactor reconciliation; no eval/exec, shell, SQL, yaml.load, os.system, or innerHTML patterns were introduced.
Container-Privileges ✅ Passed PR only changes Go controller code; no container/K8s manifest files were modified, and no privileged settings were introduced.
No-Sensitive-Data-In-Logs ✅ Passed No added logs emit secrets, PII, session IDs, hostnames, or customer data; only resource IDs, kinds, phases, and finalizer info are logged.
Ai-Attribution ✅ Passed Both PR commits include Assisted-by: Claude Code <noreply@anthropic.com>; no AI-related Co-Authored-By appears in the PR range.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/controller/feedback/bridge_test.go (1)

408-415: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the remote record actually reaches the hook.

This is the only test exercising the new parameter, and it discards it. Since syncDeleteFn already sets SUBNET_STATE_DELETING, verifying the hook receives that mutated remote locks in the contract the signature change exists for.

♻️ Suggested assertion
-			bridge.PostSaveOnDelete = func(_ context.Context, _ *v1alpha1.Subnet, _ *privatev1.Subnet) error {
+			bridge.PostSaveOnDelete = func(_ context.Context, _ *v1alpha1.Subnet, remote *privatev1.Subnet) error {
+				Expect(remote).NotTo(BeNil())
+				Expect(remote.GetStatus().GetState()).To(Equal(privatev1.SubnetState_SUBNET_STATE_DELETING))
 				Expect(trk.saveCalls).To(Equal(1))
🤖 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 `@internal/controller/feedback/bridge_test.go` around lines 408 - 415, Update
the PostSaveOnDelete callback in the deletion test to retain the received
*privatev1.Subnet argument and assert that its state is SUBNET_STATE_DELETING
before completing the existing assertions. Keep the current hook invocation and
finalizer checks unchanged.
internal/controller/externalipattachment_feedback_controller.go (1)

185-219: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Avoid unguarded full-object updates on the shared parent ExternalIP.

syncAttachedOnParentExternalIP mutates externalIP.Status.Attached and then calls ExternalIPsUpdateRequest_builder{Object: externalIP} without a field mask or lock, so concurrent updates to the same parent, including feedback controller saves writing other status fields, can clobber each other’s changes. Use a partial/status update path, or enable locking/resource-version checks before sending the object.

🤖 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 `@internal/controller/externalipattachment_feedback_controller.go` around lines
185 - 219, The syncAttachedOnParentExternalIP function must avoid unguarded
full-object updates when changing the shared parent ExternalIP status. Replace
the ExternalIPsUpdateRequest_builder update with a partial/status-only update or
add locking/resource-version checks so concurrent status changes are not
overwritten, while preserving the existing attached-state comparison and error
behavior.
🤖 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 `@internal/controller/externalipattachment_feedback_controller.go`:
- Around line 114-123: Update the feedback callback around
syncExternalIPAttachmentAddress and syncAttachedOnParentExternalIP to fetch the
parent ExternalIP once, reuse that result for both synchronization steps, and
propagate any fetch error from address synchronization so SyncUpdate returns the
error and triggers a retry. Preserve the existing Ready-phase handling and
logging behavior for parent-attachment failures.

---

Outside diff comments:
In `@internal/controller/externalipattachment_feedback_controller.go`:
- Around line 185-219: The syncAttachedOnParentExternalIP function must avoid
unguarded full-object updates when changing the shared parent ExternalIP status.
Replace the ExternalIPsUpdateRequest_builder update with a partial/status-only
update or add locking/resource-version checks so concurrent status changes are
not overwritten, while preserving the existing attached-state comparison and
error behavior.

In `@internal/controller/feedback/bridge_test.go`:
- Around line 408-415: Update the PostSaveOnDelete callback in the deletion test
to retain the received *privatev1.Subnet argument and assert that its state is
SUBNET_STATE_DELETING before completing the existing assertions. Keep the
current hook invocation and 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: osac-project/coderabbit/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d10c03ae-e0a8-412d-9260-d6523dd00660

📥 Commits

Reviewing files that changed from the base of the PR and between 48f1e47 and 0f77785.

📒 Files selected for processing (5)
  • internal/controller/externalip_feedback_controller.go
  • internal/controller/externalipattachment_feedback_controller.go
  • internal/controller/feedback/bridge.go
  • internal/controller/feedback/bridge_test.go
  • internal/controller/feedback_controller.go
💤 Files with no reviewable changes (1)
  • internal/controller/feedback_controller.go

Comment on lines +114 to 123
return func(ctx context.Context, obj *v1alpha1.ExternalIPAttachment, remote *privatev1.ExternalIPAttachment) error {
syncExternalIPAttachmentState(ctx, obj, remote)
syncExternalIPAttachmentAddress(ctx, eipClient, remote)

if obj.Status.Phase == v1alpha1.ExternalIPAttachmentPhaseReady {
if err := syncAttachedOnParentExternalIP(ctx, eipClient, remote, true); err != nil {
ctrllog.FromContext(ctx).Error(err, "Failed to set attached on parent ExternalIP, will retry")
return err
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Parent ExternalIP is fetched twice, and the address-sync fetch failure is silently dropped.

On a Ready attachment this issues two eipClient.Get RPCs for the same parent in a single pass. Worse, syncExternalIPAttachmentAddress logs-and-returns on error, so SyncUpdate returns nil, the Bridge saves the attachment without externalIpAddress, and nothing requeues — the address can stay unset until an unrelated event fires. Fetch the parent once and propagate the error so the reconcile retries.

🔧 Sketch: single fetch, propagated error
 	return func(ctx context.Context, obj *v1alpha1.ExternalIPAttachment, remote *privatev1.ExternalIPAttachment) error {
 		syncExternalIPAttachmentState(ctx, obj, remote)
-		syncExternalIPAttachmentAddress(ctx, eipClient, remote)
-
-		if obj.Status.Phase == v1alpha1.ExternalIPAttachmentPhaseReady {
-			if err := syncAttachedOnParentExternalIP(ctx, eipClient, remote, true); err != nil {
-				ctrllog.FromContext(ctx).Error(err, "Failed to set attached on parent ExternalIP, will retry")
-				return err
-			}
-		}
-		return nil
+		ready := obj.Status.Phase == v1alpha1.ExternalIPAttachmentPhaseReady
+		// fetch the parent once, reuse for address + attached sync
+		return syncParentExternalIP(ctx, eipClient, remote, ready)
 	}
🤖 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 `@internal/controller/externalipattachment_feedback_controller.go` around lines
114 - 123, Update the feedback callback around syncExternalIPAttachmentAddress
and syncAttachedOnParentExternalIP to fetch the parent ExternalIP once, reuse
that result for both synchronization steps, and propagate any fetch error from
address synchronization so SyncUpdate returns the error and triggers a retry.
Preserve the existing Ready-phase handling and logging behavior for
parent-attachment failures.

Delegates to feedback.Bridge with IsNotFound for the sentinel error,
and PostSaveOnDelete for clearing the parent ExternalIP's attached
flag after the attachment's DELETING state is persisted.

SyncUpdate captures eipClient for setting attached=true on Ready and
syncing the parent's address to the attachment.

Bridge API change: PostSaveOnDelete now receives the remote proto in
addition to the K8s object, so callbacks can reference proto spec
fields (e.g. the parent ExternalIP ID).

Also removes the clone/equal generic helpers from feedback_controller.go
since all feedback controllers now use the Bridge (which calls
proto.Clone/proto.Equal directly).

Assisted-by: Claude Code <noreply@anthropic.com>
Signed-off-by: Vladik Romanovsky <vromanso@redhat.com>
@vladikr

vladikr commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

moving to osac-project/osac#121

@vladikr vladikr closed this Aug 3, 2026

This branch was previously deployed

1 inactive deployment
e2e-test — 6d39f1e6 Deployed Jul 30, 2026 by vladikr via e2e-vmaas-full-install / e2e #386
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants