Skip to content

Reconcile route server peers against live AWS state - #13

Draft
frobware wants to merge 4 commits into
openshift:mainfrom
frobware:fix-route-server-peer-reconciliation
Draft

frobware wants to merge 4 commits into
openshift:mainfrom
frobware:fix-route-server-peer-reconciliation

Conversation

@frobware

@frobware frobware commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The bug

The operator can report phase: Ready, with every condition True, whilst no BGP session exists and no Route Server peers exist at all.

DescribeRouteServerPeers keeps returning peers long after AWS has deleted them, in state deleted. listAllPeers filtered only on endpoint ID, so those tombstones counted as live peers, their addresses looked already-peered, and the operator created nothing. It then reported success, because the condition describes what the reconcile function did rather than what is true of the world:

AWSResourcesReconciled  True  Route Server peers and source/dest check reconciled

Reproducing it needs nothing exotic. Delete a CUDNBgpConfig and create it again, which is exactly what an e2e run does. The finalizer removes the peers, AWS keeps listing them as deleted, and the next reconcile believes they are still there.

Observed on a live cluster in us-east-2 with three router nodes across three AZs: seven consecutive reconciles over three and a half minutes, nodeCount: 3 logged each time, zero peers created, phase: Ready throughout, all three BGP sessions sat in Connect. With the fix, the peers are recreated on the first reconcile after startup and reach available within ninety seconds.

The same defect made us call DeleteRouteServerPeer on peers AWS had already deleted, which returns IncorrectState. That is what E2E-AWS-03 had been hitting.

Why an allowlist

The obvious fix is to skip peers in deleted, and it is the wrong one. It leaves us guessing at the set of states that mean gone, and silently counts any state AWS introduces later as a working peer. peerIsAlive therefore admits only available and pending and treats everything else, known or not, as absent. Getting that wrong recreates a peer unnecessarily; getting the denylist wrong leaves BGP down whilst claiming success.

pending has to be alive. Peers sit there for minutes after creation, and at a short resync interval treating them as gone means creating a second peer for the same address.

API amplification

Whilst in that function: DescribeRouteServerPeers accepts no endpoint filter, so every call returns every peer in the region regardless of what you asked for. Calling it once per endpoint, twice over, made 2N identical full-region calls per reconcile, twelve on a three-AZ cluster, measured by a test that counts them. It is one call now, bucketed by endpoint.

That matters more than it looks. These rate buckets are per-account (CreateRouteServerPeer and DeleteRouteServerPeer refill at 5/s for the whole account), so on a shared development or CI account the contended resource is the API, not the route servers.

Iteration is sorted throughout, because endpointsByAZ is a map and Go randomises map iteration: peers were created and deleted in a different order on every reconcile, so no failure could be reproduced.

That rewrite lands in the same lines as the liveness fix, so the two are one commit rather than a split describing a state this code was never in.

The reconcile storm

createOrUpdate did Get then Update with no comparison, so every reconcile rewrote its object whether or not anything had changed. Both controllers watch what they write, so each write returned immediately as an event and caused another reconcile, which wrote again.

On a live cluster the routing controller settled into roughly two reconciles a second, indefinitely, rewriting the ClusterUserDefinedNetwork every time. Nothing converged because nothing was meant to: the write was its own trigger.

It reproduces on any cluster with FRR enabled and needs no AWS at all, since config.Spec.AWS != nil gates the cloud work. Apply a CUDNBgpConfig with static availabilityZones, a labelled namespace and a CUDNBgpRouting, then count reconciles.

routing reconciles CUDN resourceVersion
before 213 in 114s moving ~10 per 4s
with #12 100 in 38s still moving
after this commit 1 in 54s static

Measured with the default five minute resync, so the requeue interval is not involved.

Worth being explicit about #12, since it addresses the same symptom: it is not the fix for this, and this is not a criticism of it. #12 guards status writes by comparing against a baseline, which is correct and worth having. This is a spec write on a different object, which #12 does not touch, and the rate is unchanged with it applied. The two are complementary and I would expect both to be wanted.

Configurable resync interval

Both reconcilers requeued after a bare 5 * time.Minute, duplicated and uncommented. That number bounds how long the cluster can sit with drifted AWS state before we repair it, so it now has a name and a --resync-interval flag. The default is unchanged and the existing assertions still pass.

This is not cosmetic. Diagnosing the peer bug at five minutes per attempt is why it went unnoticed; at thirty seconds it took two cycles to see.

The e2e suite

E2E-AWS-03, "should recreate the deleted peer within the reconcile window", is the spec written to catch exactly the bug above. It could never have passed, because the suite's own listManagedPeers contains the identical missing state check. So the test for the bug shared the bug.

Fixing that exposed three more, all of the same family, all assuming the cluster is already in the state you want:

  • the suite could not be re-run after any failure, because Create is not idempotent and there was no AfterSuite, so leftovers made every later run fail on AlreadyExists
  • E2E-AWS-03 deleted peers[0] without waiting for it to be available, racing the operator's own creation; and since allManagedPeers walks a map, peers[0] was a uniformly random peer, a different node in a different AZ on every run
  • the suite hard-coded six minutes per self-healing assertion and printed next 5m cycle regardless of what the operator was actually doing

The namespace is now generated rather than named after the network. The CUDN selects it by the cluster-udn label, never by name, so the fixed name was convention and was the entire source of the collision.

Test plan

Unit tests are red-green: each new test was run against the unfixed code first and fails for the stated reason. TestReconcilePeers_RecreatesPeerNotAlive covers deleted, deleting, failing, failed, an invented future state and the empty string; TestReconcilePeers_DoesNotDuplicateLivePeer pins that available and pending are not duplicated; TestReconcilePeers_DescribesPeersOnce counts API calls (12 before, 1 after); TestReconcilePeers_CreateOrderIsDeterministic runs the reconcile twenty times and compares the sequence.

End to end against a real cluster: OCP 5.0 nightly on AWS us-east-2, three router nodes across three AZs, one Route Server with an endpoint per AZ, operator run locally against the cluster.

E2E-AWS-03 and E2E-AWS-05 pass here for the first time. E2E-AWS-01 also asserts established BGP sessions, so the fix is confirmed all the way through to FRR rather than only at the AWS API.

Full run, verbatim
$ E2E_PROFILE=qe AWS_PROFILE=saml go test ./test/e2e/aws/ -v -timeout 30m -count=1 -ginkgo.v
=== RUN   TestAWSE2E
Running Suite: AWS E2E Suite - .../worktrees/general/test/e2e/aws
Random Seed: 1785776462

Will run 5 of 5 specs
------------------------------
[BeforeSuite]
  STEP: loading CUDNBgpConfig manifest from profile qe @ 08/03/26 18:01:02.586
  STEP: loading CUDNBgpRouting manifest from profile qe @ 08/03/26 18:01:02.586
  STEP: building kubernetes client @ 08/03/26 18:01:02.586
  STEP: building AWS client using default credential chain @ 08/03/26 18:01:02.589
  STEP: reading cluster infrastructure name @ 08/03/26 18:01:02.589
  STEP: discovering route server endpoints from AWS @ 08/03/26 18:01:03.381
[BeforeSuite] PASSED [1.382 seconds]
------------------------------
AWS E2E E2E-AWS-01: Full stack reconcile should apply CRs and reach Ready state with all AWS resources
  STEP: applying CUDNBgpConfig CR @ 08/03/26 18:01:03.968
  STEP: waiting for config phase=Ready @ 08/03/26 18:01:04.076
  STEP: verifying FRRConfigurations exist @ 08/03/26 18:01:14.295
  STEP: verifying status.aws is populated @ 08/03/26 18:01:14.61
  STEP: verifying Route Server peers exist per AZ @ 08/03/26 18:01:14.713
  STEP: verifying SourceDestCheck=false on all router nodes @ 08/03/26 18:01:15.381
  STEP: creating labeled namespace for CUDN @ 08/03/26 18:01:15.873
  created namespace prod-7xdwx
  STEP: applying CUDNBgpRouting CR @ 08/03/26 18:01:15.978
  STEP: waiting for routing phase=Ready @ 08/03/26 18:01:16.084
  STEP: verifying routing resources: CUDN, RouteAdvertisements @ 08/03/26 18:01:26.293
  STEP: verifying FRR pods show established BGP sessions @ 08/03/26 18:01:26.503
* [22.944 seconds]
------------------------------
AWS E2E E2E-AWS-02: Node lifecycle should create peers for new router nodes and remove stale ones
  STEP: recording initial state @ 08/03/26 18:01:27.488
  initial router nodes: 3, initial peers: 3
  STEP: waiting for operator to reconcile (next 5m cycle) @ 08/03/26 18:01:27.488
* [1.632 seconds]
------------------------------
AWS E2E E2E-AWS-03: Route Server peer manually deleted should recreate the deleted peer within the reconcile window
  STEP: finding an available managed peer to delete @ 08/03/26 18:01:28.544
  deleting peer rsp-0070469a53761f8c8 (IP 10.0.33.23)
  STEP: deleting the peer via EC2 API @ 08/03/26 18:02:10.841
  STEP: waiting for operator to recreate it @ 08/03/26 18:02:11.118
* [63.917 seconds]
------------------------------
AWS E2E E2E-AWS-04: SourceDestCheck manually re-enabled should disable SourceDestCheck again within the reconcile window
  STEP: finding a router node to tamper with @ 08/03/26 18:02:32.461
  STEP: re-enabling SourceDestCheck via EC2 API @ 08/03/26 18:02:32.835
  STEP: waiting for operator to disable it again @ 08/03/26 18:02:33.306
* [11.299 seconds]
------------------------------
AWS E2E E2E-AWS-05: Full cleanup lifecycle should block config deletion while routing CR exists, then clean up everything
  STEP: attempting to delete config CR (should be blocked by routing CR) @ 08/03/26 18:02:43.76
  STEP: verifying config CR still exists (finalizer blocks deletion) @ 08/03/26 18:02:43.975
  STEP: deleting routing CR @ 08/03/26 18:03:13.975
  STEP: waiting for routing CR to be fully removed @ 08/03/26 18:03:14.188
  STEP: waiting for config CR to be fully removed @ 08/03/26 18:03:24.406
  STEP: verifying AWS peers are deleted @ 08/03/26 18:05:05.577
  STEP: verifying FRRConfigurations are deleted @ 08/03/26 18:05:33.15
* [169.694 seconds]
------------------------------
[AfterSuite]
  cleanup: deleted namespace/prod-7xdwx
[AfterSuite] PASSED [10.620 seconds]
------------------------------

Ran 5 of 5 Specs in 281.488 seconds
SUCCESS! -- 5 Passed | 0 Failed | 0 Pending | 0 Skipped
--- PASS: TestAWSE2E (281.49s)
ok      github.com/openshift/bgp-cloud-connector/test/e2e/aws    281.499s

A note on E2E_PROFILE

make test-e2e-aws <profile> and the suite's BeforeSuite take a profile naming a directory under test/e2e/manifests/, holding the CUDNBgpConfig and CUDNBgpRouting the run applies. The profile is therefore the fixture: it decides which cluster, which route servers and which ASNs the run targets.

qe above is a local one, not in this branch. None of the three in tree fit a cluster you build yourself: rosa-bgp-poc points at the placeholder rs-0abc123456789abcd in us-east-1, and the two ocp-or18 profiles are non-AWS, aimed at a lab with an external BGP peer at a fixed address. So running the AWS suite against any new cluster means hand-writing a profile with that cluster's real routeServerIDs first.

That is worth addressing separately, and it is part of why this suite appears not to have been run much: there is no documented path from a fresh cluster to a working profile, and the route server IDs inside one die with the cluster that owns them.

Not addressed

Deliberately out of scope, but found on the way and worth separate issues:

internal/platform/aws has no CI coverage at all. make test runs ./internal/controller/... ./api/... ./cmd/..., and CI runs only make verify-vendor and make test. make test-aws exists and is never invoked. Every test in this PR touching the AWS platform would not run in CI. A one-line change to the test target fixes it, and would have caught this bug the moment a test existed for it.

make generate does not reproduce the committed zz_generated.deepcopy.go. It is a deterministic 35-line reordering with the repo's own pinned controller-gen v0.18.0, so every developer running make test, make run or make deploy gets a dirty tree. verify-vendor has no counterpart for generated code, so CI cannot see it.

The operator cannot start on a cluster lacking frrk8s.metallb.io and RouteAdvertisements. controller-runtime abandons the cache sync after two minutes and the process exits:

ERROR setup problem running manager
  {"error": "failed to wait for cudnbgprouting caches to sync kind source:
   *unstructured.Unstructured: timed out waiting for cache to be synced"}

PatchNetworkOperator creates that precondition during reconcile, which cannot help, because reconcile is downstream of a manager that will not start. So every result in this PR required enabling FRR by hand first, with the same patch the operator would have applied itself:

oc patch network.operator.openshift.io cluster --type=merge -p \
  '{"spec":{"additionalRoutingCapabilities":{"providers":["FRR"]},
    "defaultNetwork":{"ovnKubernetesConfig":{"routeAdvertisements":"Enabled"}}}}'

Be aware of what that does before pasting it into a cluster you care about. It is a cluster-wide networking change: CNO rolls out OVN-Kubernetes and stands up an frr-k8s daemonset on every node. On the cluster used here it completed in about two minutes with co/network never leaving Degraded=False.

It is reversible, which I have since tested rather than assumed. Removing additionalRoutingCapabilities and setting routeAdvertisements: Disabled causes CNO to delete the frr-k8s daemonset and its namespace and roll ovnkube-node back across the cluster, again finishing Available=True, Progressing=False, Degraded=False. The frrk8s.metallb.io and RouteAdvertisements CRDs remain afterwards. One observation, on one OCP 5.0 cluster, with no workload depending on the advertised routes.

It has to be done once per cluster before the operator will start at all. I have not added a script for it here on purpose: a workaround in hack/ would make this liveable rather than fixed, and the operator watching types it cannot guarantee exist is the actual defect. Deferring the watches until the CRDs appear, or tolerating their absence at startup, would remove the manual step entirely.

There is no credential mechanism. No CredentialsRequest, no role-arn annotation on the ServiceAccount. A deployed operator gets no AWS credentials from anything in this repo; ccoctl would give roles to the core cluster operators and nothing to this one.

(The reconcile storm that was listed here is now fixed by the fourth commit, described above.)

The general point

Every condition this operator sets describes what its reconcile function did, not what became true. That is why it can report Ready with BGP down, and why the fault was invisible until three sources were compared by hand: the CR status, aws ec2 describe-route-server-peers, and BGPSessionState. The status cannot be used to verify the behaviour, because it is derived from the attempt.

Making the conditions assert observed state would turn this operator into something self-diagnosing rather than confidently wrong, and is a bigger change than this PR.

Summary by CodeRabbit

  • New Features

    • Added the --resync-interval option to control how frequently healthy resources are rechecked, defaulting to five minutes.
    • Added support for configuring reconciliation intervals consistently across networking components.
  • Bug Fixes

    • Improved AWS route-server peer recovery by recreating peers in failed or stale states while avoiding duplicates for active peers.
    • Made resource reconciliation more reliable and deterministic.
    • Reduced unnecessary updates when resources are already unchanged.

Both reconcilers requeued healthy resources after a bare 5 * time.Minute
literal, duplicated, uncommented and unnamed. That number is not
arbitrary: it bounds how long the cluster can sit with drifted AWS state,
a route server peer deleted or SourceDestCheck re-enabled by someone,
before we notice and put it back. It is worth naming.

It is also worth being able to change. Diagnosing anything that depends
on a reconcile costs five minutes per attempt, which is long enough that
you stop investigating and start guessing.

So name it DefaultResyncInterval, give each reconciler a ResyncInterval
field that overrides it, and expose --resync-interval. The default is
unchanged, and the existing assertions that pin 5m still pass, so nothing
moves for anyone who does not ask for it.
DescribeRouteServerPeers keeps returning peers long after AWS has
deleted them, in state "deleted". listAllPeers filtered only on endpoint
ID, so those tombstones counted as working peers. Delete a CUDNBgpConfig
and recreate it, which is what an e2e run does, and the operator finds
the old peers still listed, creates nothing, and reports:

  AWSResourcesReconciled  True  Route Server peers and source/dest check
                                reconciled

with no peers in existence and every BGP session in Connect. Observed on
a live cluster: seven consecutive reconciles over three and a half
minutes, three nodes, zero peers created, phase Ready throughout. It also
made us call DeleteRouteServerPeer on peers AWS had already deleted,
which fails with IncorrectState.

Decide liveness with an allowlist rather than by excluding the states we
happen to know about, so a state AWS adds later is treated as gone and we
recreate, instead of being counted as a working peer and silently
believed. "pending" is alive: peers sit there for minutes after creation
and must not be duplicated on the next resync.

The same pass also stops asking AWS the same question repeatedly.
DescribeRouteServerPeers accepts no endpoint filter, so every call
returns every peer in the region regardless; calling it once per
endpoint, twice over, made 2N identical full-region calls per reconcile,
twelve on a three-AZ cluster. These rate buckets are per-account, so on a
shared development account that amplification is the contended resource.
One call now, bucketed by endpoint.

That rewrite lands in the same lines as the liveness fix, hence one
commit rather than a split that would describe a state this code was
never in.

Iteration is sorted throughout. endpointsByAZ is a map, and Go randomises
map iteration, so peers were created and deleted in a different order on
every reconcile and no failure could be reproduced.

The three existing peer fixtures gain State: available. They model peers
that really exist, and omitting the field now reads as "gone".
The suite could not be re-run after any failure. It creates a
CUDNBgpConfig, a CUDNBgpRouting and a namespace with Create, which is not
idempotent, and had no AfterSuite, so anything a failed run left behind
made every later run fail on AlreadyExists until someone cleared it by
hand. Interrupting a run had the same effect, and interrupting runs is
what you do while iterating.

Four changes, all of the same kind: stop assuming the cluster is already
in the state you want.

Generate the namespace name. The CUDN selects namespaces by the
cluster-udn label, never by name, so naming it after the network was
convention rather than requirement, and it was the whole source of the
collision. A stranded prod-x4k2m also reads as test wreckage in a way a
stranded prod does not.

Add AfterSuite, deleting the routing CR, then the config CR, then the
namespace, tolerating NotFound. Order matters: the config CR's finalizer
blocks its own deletion while a routing CR exists, which E2E-AWS-05
asserts deliberately. E2E_SKIP_CLEANUP keeps the wreckage when you want
to look at it, which is how the bug in the previous commit was found.

Retry the creates. Cleanup completing is not the same as the cluster
having caught up: the config CR's finalizer is only removed on the next
reconcile, so it lingers in Terminating and a run started in that window
died instantly with "object is being deleted".

Wait for a peer to be available before deleting one. E2E-AWS-03 took
peers[0] and deleted it, but AWS rejects DeleteRouteServerPeer on a
pending peer, and E2E-AWS-01 is satisfied by peers merely existing, so
the spec was racing the operator it was testing. It also filters out dead
peers, mirroring the operator, and sorts before choosing: allManagedPeers
walks a map, so peers[0] was a uniformly random peer, a different node in
a different AZ on every run.

Finally, take the reconcile budget from E2E_RESYNC_INTERVAL rather than
assuming the operator's old default. The suite hard-coded six minutes per
self-healing assertion and printed "next 5m cycle" whatever the operator
was actually using.
@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 Aug 3, 2026
createOrUpdate did Get then Update with no comparison, so every
reconcile rewrote its object whether or not anything had changed. Both
controllers watch what they write, so each write came back immediately as
an event and caused another reconcile, which wrote again.

On a live cluster the routing controller settled into roughly two
reconciles a second, indefinitely, rewriting the ClusterUserDefinedNetwork
every time. Nothing converged because nothing was meant to: the write was
its own trigger. Measured over ninety seconds with the default five
minute resync, so the requeue interval had nothing to do with it.

Compare the spec, and the labels we set, against what is already there,
and return without writing when they match. Labels added by anyone else
are ignored rather than treated as drift, since reacting to them would
reopen the same loop from the other side.

Same cluster, same CRs, before and after: 213 reconciles in 114 seconds
becomes 1, and the CUDN's resourceVersion stops moving.
@frobware
frobware marked this pull request as ready for review August 12, 2026 11:17
@frobware frobware changed the title WIP: Reconcile route server peers against live AWS state Reconcile route server peers against live AWS state Aug 12, 2026
@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 Aug 12, 2026
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Walkthrough

The change adds configurable reconciler resynchronization, avoids unchanged FRR writes, and makes AWS route-server peer reconciliation liveness-aware and deterministic. AWS E2E tests now support configurable intervals, generated namespaces, retry-based creation, ordered cleanup, and stable peer selection.

Changes

Controller resync and conditional updates

Layer / File(s) Summary
Configurable resync and conditional updates
internal/controller/constants.go, internal/controller/cudnbgpconfig_controller.go, internal/controller/cudnbgprouting_controller.go, cmd/main.go, internal/controller/frr.go, internal/controller/cudnbgprouting_controller_test.go
The command adds --resync-interval. Both reconcilers use the configured positive duration or the five-minute default. FRR updates now skip unchanged specs and required labels. Tests cover interval propagation and write behavior.

AWS route-server reconciliation

Layer / File(s) Summary
Deterministic route-server peer reconciliation
internal/platform/aws/route_server.go, internal/platform/aws/aws_test.go
Peer discovery uses one bulk API call. Only available and pending peers count as live. Managed peers are grouped and processed in sorted order. Tests cover recreation, duplicate prevention, API call count, and deterministic operation order.

AWS E2E lifecycle

Layer / File(s) Summary
AWS E2E lifecycle and resync controls
test/e2e/aws/aws_e2e_suite_test.go, test/e2e/aws/aws_e2e_test.go
E2E tests parse E2E_RESYNC_INTERVAL, derive timeouts, use generated namespaces, retry resource creation, poll ordered cleanup, filter non-live peers, and select deletion targets by peer ID.

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

Sequence Diagram(s)

sequenceDiagram
  participant RouteServerReconciler
  participant AWSRouteServerAPI
  participant ManagedPeers
  RouteServerReconciler->>AWSRouteServerAPI: DescribeRouteServerPeers once
  AWSRouteServerAPI-->>RouteServerReconciler: Return peer states and tags
  RouteServerReconciler->>ManagedPeers: Filter live managed peers and group by endpoint
  RouteServerReconciler->>AWSRouteServerAPI: Delete stale peers and create missing peers in sorted order
Loading

Suggested reviewers: alebedev87

🚥 Pre-merge checks | ✅ 13 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Test Structure And Quality ⚠️ Warning The added E2E-AWS-03 Eventually assertion at aws_e2e_test.go:289 uses g.Expect(err).NotTo(HaveOccurred()) without a diagnostic message. Add a meaningful message such as "failed to list managed peers while selecting deletion victim" to the new assertion.
✅ Passed checks (13 passed)
Check name Status Explanation
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 Investigation in progress; no verdict submitted yet.
Microshift Test Compatibility ✅ Passed The PR adds no new It/Describe/Context/When declarations; it only modifies existing AWS specs and adds Namespace cleanup, so it introduces no new MicroShift-incompatible test usage.
Single Node Openshift (Sno) Test Compatibility ✅ Passed Changed AWS Ginkgo specs require at least one router node/AZ and allow multiple pods on one node; no test requires distinct nodes, HA, scaling, or failover.
Topology-Aware Scheduling Compatibility ✅ Passed The feature diff changes reconciliation, AWS peer handling, tests, and e2e logic; it adds no deployment manifests or scheduling fields. The existing replicas: 1 setting is unchanged.
Ote Binary Stdout Contract ✅ Passed PR diff adds no direct stdout or klog writes in main, init, or suite setup; new suite messages use GinkgoWriter, which the contract explicitly exempts.
Ipv6 And Disconnected Network Test Compatibility ✅ Passed The PR adds no new Ginkgo It/Describe/Context/When declarations; all five specs existed at the base. Added e2e code has no hardcoded IPv4 values or public-internet access.
No-Weak-Crypto ✅ Passed The PR diff adds no MD5, SHA1, DES, RC4, Blowfish, ECB, crypto imports, custom crypto, or secret/token comparisons.
Container-Privileges ✅ Passed PR additions contain no privileged, hostPID, hostNetwork, hostIPC, SYS_ADMIN, root, or allowPrivilegeEscalation settings; manager manifests enforce non-root and escalation disabled.
No-Sensitive-Data-In-Logs ✅ Passed The PR adds no passwords, tokens, API keys, PII, hostnames, or session IDs to logs; added E2E output contains only test resource names, peer IDs, and private test IPs.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: reconciling Route Server peers with live AWS state.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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: 5

🤖 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 `@cmd/main.go`:
- Around line 86-88: Validate resyncInterval immediately after flag.Parse() in
cmd/main.go and reject zero or negative values before controller startup. Update
the ResyncInterval comments in internal/controller/cudnbgpconfig_controller.go
lines 67-75 and internal/controller/cudnbgprouting_controller.go lines 50-58
from “non-zero” to “positive”; no other changes are required at those sites.

In `@internal/controller/frr.go`:
- Around line 279-282: Update specEqual in internal/controller/frr.go:279-282 to
return NestedMap errors, and propagate them through createOrUpdate so malformed
specs are not overwritten. In
internal/controller/cudnbgprouting_controller_test.go:106 and 393-395, make
ignored Reconcile errors fail the tests; at 422-424 and 464-466, assert
SetNestedMap errors; and at 482, assert the NestedString error.

In `@internal/platform/aws/route_server.go`:
- Around line 95-107: Update listPeersByEndpoint and listAllPeers to iterate
through every DescribeRouteServerPeers page using the SDK paginator or a shared
pagination helper, propagating each returned NextToken until exhausted and
aggregating all peers before reconciliation. Preserve the existing filtering and
endpoint grouping behavior, and add a two-page mock test verifying token
propagation and combined results.

In `@test/e2e/aws/aws_e2e_suite_test.go`:
- Around line 222-230: Update createEventually to use the configured
reconcileTimeout for Eventually instead of the hardcoded three-minute timeout,
ensuring it is at least resyncInterval so finalizer removal and creation retries
can complete after a suite restart.
- Around line 182-193: Update the deleteAndWait cleanup helper to fail the
Ginkgo test when k8sClient.Delete returns a non-NotFound error instead of
logging and returning. Propagate the deletion error through the helper using the
suite’s established Ginkgo failure mechanism, while preserving successful
handling of already-absent resources and the existing wait for deletion.
🪄 Autofix

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: openshift/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 76a973cb-6e3b-41f3-ba36-fc25832f4aa6

📥 Commits

Reviewing files that changed from the base of the PR and between 9a0ee04 and 06d548e.

📒 Files selected for processing (10)
  • cmd/main.go
  • internal/controller/constants.go
  • internal/controller/cudnbgpconfig_controller.go
  • internal/controller/cudnbgprouting_controller.go
  • internal/controller/cudnbgprouting_controller_test.go
  • internal/controller/frr.go
  • internal/platform/aws/aws_test.go
  • internal/platform/aws/route_server.go
  • test/e2e/aws/aws_e2e_suite_test.go
  • test/e2e/aws/aws_e2e_test.go

Comment thread cmd/main.go
Comment on lines +86 to +88
flag.DurationVar(&resyncInterval, "resync-interval", controller.DefaultResyncInterval,
"How long to wait before re-examining a healthy resource. Lower values detect "+
"drifted AWS state sooner at the cost of more API calls.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- relevant symbols and call sites ---'
rg -n -C 4 \
  'resyncInterval|ResyncInterval|DefaultResyncInterval|resync-interval|flag\.Parse|Parse\(\)' \
  cmd/main.go internal/controller/cudnbgpconfig_controller.go \
  internal/controller/cudnbgprouting_controller.go internal/controller/frr.go \
  internal/controller/cudnbgprouting_controller_test.go

printf '%s\n' '--- file outlines ---'
ast-grep outline cmd/main.go
ast-grep outline internal/controller/cudnbgpconfig_controller.go
ast-grep outline internal/controller/cudnbgprouting_controller.go

Repository: openshift/bgp-cloud-connector

Length of output: 10946


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- startup flow and default ---'
sed -n '40,125p' cmd/main.go
rg -n -C 3 'DefaultResyncInterval' --glob '*.go'

printf '%s\n' '--- related tests and documentation ---'
rg -n -C 4 'resync|ResyncInterval|resync-interval' --glob '*_test.go' --glob '*.md' --glob '*.yaml' --glob '*.yml' .

printf '%s\n' '--- Go tool availability ---'
command -v go || true
go version 2>/dev/null || true

Repository: openshift/bgp-cloud-connector

Length of output: 3961


🏁 Script executed:

#!/bin/bash
set -eu

# Read-only standalone probe of standard-library flag.DurationVar semantics.
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

cat >"$tmpdir/probe.go" <<'EOF'
package main

import (
	"flag"
	"fmt"
	"time"
)

func main() {
	for _, arg := range []string{"0s", "-1s", "1s", "not-a-duration"} {
		fs := flag.NewFlagSet("probe", flag.ContinueOnError)
		fs.SetOutput(nil)
		var d time.Duration
		fs.DurationVar(&d, "resync-interval", 5*time.Minute, "")
		err := fs.Parse([]string{"-resync-interval", arg})
		fmt.Printf("%q: value=%s error=%v\n", arg, d, err)
	}
}
EOF

go run "$tmpdir/probe.go"

Repository: openshift/bgp-cloud-connector

Length of output: 488


🏁 Script executed:

#!/bin/bash
set -eu

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

cat >"$tmpdir/probe.go" <<'EOF'
package main

import (
	"flag"
	"fmt"
	"time"
)

func main() {
	for _, arg := range []string{"0s", "-1s", "1s", "not-a-duration"} {
		fs := flag.NewFlagSet("probe", flag.ContinueOnError)
		fs.SetOutput(nil)
		var d time.Duration
		fs.DurationVar(&d, "resync-interval", 5*time.Minute, "")
		err := fs.Parse([]string{"-resync-interval", arg})
		fmt.Printf("%q: value=%s error=%v\n", arg, d, err)
	}
}
EOF

go run "$tmpdir/probe.go"

Repository: openshift/bgp-cloud-connector

Length of output: 488


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- locate default declaration ---'
rg -n -C 5 --glob '*.go' 'DefaultResyncInterval|time\.Minute|RequeueAfter' .

printf '%s\n' '--- all reconciler construction sites ---'
rg -n -C 5 --glob '*.go' \
  'CUDNBgpConfigReconciler\s*\{|CUDNBgpRoutingReconciler\s*\{|ResyncInterval:'

printf '%s\n' '--- all resync documentation ---'
rg -n -i -C 4 --glob '*.go' --glob '*.md' --glob '*.yaml' --glob '*.yml' \
  'resync.?interval|resync|requeueafter' . || true

printf '%s\n' '--- remaining main startup flow ---'
sed -n '125,235p' cmd/main.go

Repository: openshift/bgp-cloud-connector

Length of output: 50386


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

paths = [
    Path("internal/controller/cudnbgpconfig_controller.go"),
    Path("internal/controller/cudnbgprouting_controller.go"),
]
for path in paths:
    text = path.read_text()
    match = re.search(
        r"func \(r \*[^)]*\) resyncInterval\(\) time\.Duration \{\s*"
        r"if r\.ResyncInterval > 0 \{\s*"
        r"return r\.ResyncInterval\s*"
        r"\}\s*"
        r"return DefaultResyncInterval",
        text,
        re.S,
    )
    print(f"{path}: resolver_pattern={'present' if match else 'absent'}")
    if match:
        for value in ("-1ns", "0s", "1ns", "5m0s"):
            # The source condition is > 0; this models the resolver's branch.
            nanoseconds = {
                "-1ns": -1,
                "0s": 0,
                "1ns": 1,
                "5m0s": 300_000_000_000,
            }[value]
            result = value if nanoseconds > 0 else "DefaultResyncInterval"
            print(f"  {value} -> {result}")
PY

Repository: openshift/bgp-cloud-connector

Length of output: 496


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

paths = [
    Path("internal/controller/cudnbgpconfig_controller.go"),
    Path("internal/controller/cudnbgprouting_controller.go"),
]
for path in paths:
    text = path.read_text()
    match = re.search(
        r"func \(r \*[^)]*\) resyncInterval\(\) time\.Duration \{\s*"
        r"if r\.ResyncInterval > 0 \{\s*"
        r"return r\.ResyncInterval\s*"
        r"\}\s*"
        r"return DefaultResyncInterval",
        text,
        re.S,
    )
    print(f"{path}: resolver_pattern={'present' if match else 'absent'}")
    if match:
        for value in ("-1ns", "0s", "1ns", "5m0s"):
            nanoseconds = {
                "-1ns": -1,
                "0s": 0,
                "1ns": 1,
                "5m0s": 300_000_000_000,
            }[value]
            result = value if nanoseconds > 0 else "DefaultResyncInterval"
            print(f"  {value} -> {result}")
PY

Repository: openshift/bgp-cloud-connector

Length of output: 496


Reject non-positive --resync-interval values.

flag.DurationVar accepts 0s and negative durations. Both resolvers replace them with DefaultResyncInterval (5 * time.Minute). Validate resyncInterval > 0 after flag.Parse() and update both ResyncInterval comments from “non-zero” to “positive”.

📍 Affects 3 files
  • cmd/main.go#L86-L88 (this comment)
  • internal/controller/cudnbgpconfig_controller.go#L67-L75
  • internal/controller/cudnbgprouting_controller.go#L50-L58
🤖 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 `@cmd/main.go` around lines 86 - 88, Validate resyncInterval immediately after
flag.Parse() in cmd/main.go and reject zero or negative values before controller
startup. Update the ResyncInterval comments in
internal/controller/cudnbgpconfig_controller.go lines 67-75 and
internal/controller/cudnbgprouting_controller.go lines 50-58 from “non-zero” to
“positive”; no other changes are required at those sites.

Source: Path instructions

Comment on lines +279 to +282
func specEqual(existing, desired *unstructured.Unstructured) bool {
existingSpec, _, _ := unstructured.NestedMap(existing.Object, "spec")
desiredSpec, _, _ := unstructured.NestedMap(desired.Object, "spec")
return equality.Semantic.DeepEqual(existingSpec, desiredSpec)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline internal/controller/frr.go --items all
ast-grep outline internal/controller/cudnbgprouting_controller_test.go --items all

rg -n -C 2 \
  'unstructured\.(NestedMap|SetNestedMap|NestedString)|_, _ = r\.Reconcile' \
  internal/controller/frr.go \
  internal/controller/cudnbgprouting_controller_test.go

Repository: openshift/bgp-cloud-connector

Length of output: 5878


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- frr.go ---'
sed -n '240,310p' internal/controller/frr.go

printf '%s\n' '--- affected test sections ---'
sed -n '80,120p' internal/controller/cudnbgprouting_controller_test.go
sed -n '372,495p' internal/controller/cudnbgprouting_controller_test.go

printf '%s\n' '--- helper call sites and dependency versions ---'
rg -n -C 3 'specEqual\(|createOrUpdate\(|NestedMap\(|SetNestedMap\(|NestedString\(' internal/controller
rg -n 'k8s.io/apimachinery|controller-runtime' go.mod go.sum

Repository: openshift/bgp-cloud-connector

Length of output: 14770


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- local module cache candidates ---'
find "${GOMODCACHE:-/root/go/pkg/mod}" -path '*/k8s.io/apimachinery*' -type f \
  \( -name 'helpers.go' -o -name 'unstructured.go' \) 2>/dev/null | head -20 || true

printf '%s\n' '--- all ignored errors in the affected test file ---'
rg -n -C 2 '(^|[^[:alnum:]_])(_|_, _)[[:space:]]*=[[:space:]]*[^;]*(Reconcile|SetNestedMap|NestedString)' \
  internal/controller/cudnbgprouting_controller_test.go

printf '%s\n' '--- object construction relevant to the tests ---'
sed -n '1,90p' internal/controller/cudnbgprouting_controller_test.go
sed -n '400,490p' internal/controller/cudnbgprouting_controller_test.go

Repository: openshift/bgp-cloud-connector

Length of output: 7202


🌐 Web query:

k8s.io/apimachinery v0.34.0 unstructured NestedMap SetNestedMap NestedString source

💡 Result:

In the k8s.io/apimachinery library (e.g., version 0.34.0), the functions NestedMap, SetNestedMap, and NestedString are helper utilities located in the k8s.io/apimachinery/pkg/apis/meta/v1/unstructured package [1][2]. These functions are designed to generically access or manipulate fields within the nested map structure of an Unstructured Kubernetes object [3][4]. NestedMap This function retrieves a deep copy of a map[string]interface{} value located at a specified nested path [2][5]. Signature: func NestedMap(obj map[string]interface{}, fields...string) (map[string]interface{}, bool, error) Behavior: It returns the map, a boolean indicating if the field was found, and an error if the path contains non-map elements or if the target is not a map[string]interface{} [2][6]. SetNestedMap This function sets the map[string]interface{} value of a nested field [2][5]. Signature: func SetNestedMap(obj map[string]interface{}, value map[string]interface{}, fields...string) error Behavior: It updates the provided object map with the new value at the specified field path [2]. It returns an error if any level of the path cannot be traversed because it is not a map[string]interface{} [2][6]. NestedString This function retrieves the string value of a nested field [2][5]. Signature: func NestedString(obj map[string]interface{}, fields...string) (string, bool, error) Behavior: It returns the string value, a found boolean, and an error if the field is not a string [2][6]. These functions operate directly on the underlying map[string]interface{} (often accessed via the Object field of an Unstructured struct) [3][4]. You can find the source code for these helpers in the k8s.io/apimachinery repository under pkg/apis/meta/v1/unstructured/helpers.go [2][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
curl -fsSL \
  'https://raw.githubusercontent.com/kubernetes/apimachinery/v0.34.0/pkg/apis/meta/v1/unstructured/helpers.go' \
  -o "$tmp"

printf '%s\n' '--- exact helper implementations ---'
rg -n -A35 -B3 \
  'func (NestedMap|SetNestedMap|NestedString)\b' "$tmp"

printf '%s\n' '--- behavioral probe for the shown object shapes ---'
python3 - <<'PY'
def nested_map(obj, *fields):
    cur = obj
    for field in fields:
        if not isinstance(cur, dict):
            return None, False, "cannot traverse non-map"
        if field not in cur:
            return None, False, None
        cur = cur[field]
    if not isinstance(cur, dict):
        return None, False, "value is not a map"
    return dict(cur), True, None

def set_nested_map(obj, value, *fields):
    cur = obj
    for field in fields[:-1]:
        if field not in cur:
            cur[field] = {}
        elif not isinstance(cur[field], dict):
            return "cannot traverse non-map"
        cur = cur[field]
    cur[fields[-1]] = dict(value)
    return None

def nested_string(obj, *fields):
    cur = obj
    for field in fields:
        if not isinstance(cur, dict):
            return "", False, "cannot traverse non-map"
        if field not in cur:
            return "", False, None
        cur = cur[field]
    if not isinstance(cur, str):
        return "", False, "value is not a string"
    return cur, True, None

desired = {}
set_error = set_nested_map(desired, {"topology": "Layer2"}, "spec")
changed = {}
set_error_changed = set_nested_map(changed, {"topology": "Layer3"}, "spec")
topology, found, string_error = nested_string(changed, "spec", "topology")
print({"desired_set_error": set_error,
       "changed_set_error": set_error_changed,
       "nested_string": (topology, found, string_error)})

malformed_existing = {"spec": "not-a-map"}
existing_spec, found, map_error = nested_map(malformed_existing, "spec")
print({"malformed_existing_nested_map": (existing_spec, found, map_error)})
PY

Repository: openshift/bgp-cloud-connector

Length of output: 5117


Propagate reconciliation and unstructured-object errors.

Make specEqual return NestedMap errors so createOrUpdate does not overwrite an object with a malformed spec. Fail the tests on ignored Reconcile errors at lines 106 and 393, SetNestedMap errors at lines 422 and 464, and the NestedString error at line 482.

📍 Affects 2 files
  • internal/controller/frr.go#L279-L282 (this comment)
  • internal/controller/cudnbgprouting_controller_test.go#L393-L395
  • internal/controller/cudnbgprouting_controller_test.go#L422-L424
  • internal/controller/cudnbgprouting_controller_test.go#L464-L466
  • internal/controller/cudnbgprouting_controller_test.go#L482-L482
🤖 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/frr.go` around lines 279 - 282, Update specEqual in
internal/controller/frr.go:279-282 to return NestedMap errors, and propagate
them through createOrUpdate so malformed specs are not overwritten. In
internal/controller/cudnbgprouting_controller_test.go:106 and 393-395, make
ignored Reconcile errors fail the tests; at 422-424 and 464-466, assert
SetNestedMap errors; and at 482, assert the NestedString error.

Source: Path instructions

Comment on lines +95 to +107
output, err := p.ec2Client.DescribeRouteServerPeers(ctx, &ec2.DescribeRouteServerPeersInput{})
if err != nil {
return nil, err
}
byEndpoint := make(map[string][]ec2types.RouteServerPeer)
for _, peer := range output.RouteServerPeers {
if !peerIsAlive(peer.State) {
continue
}
id := aws.ToString(peer.RouteServerEndpointId)
byEndpoint[id] = append(byEndpoint[id], peer)
}
return byEndpoint, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

curl --fail --silent --show-error \
  "https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeRouteServerPeers.html" \
  | grep -E -i -C2 'NextToken|MaxResults'

Repository: openshift/bgp-cloud-connector

Length of output: 537


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate files ---'
git ls-files | grep -E '(^|/)route_server\.go$|go\.mod$|go\.sum$' || true

printf '%s\n' '--- route-server symbols and API calls ---'
rg -n -C 4 'DescribeRouteServerPeers|listPeersByEndpoint|listAllPeers|NextToken|peerIsAlive' --glob '*.go' .

printf '%s\n' '--- AWS SDK module versions ---'
rg -n 'aws-sdk-go-v2|service/ec2' go.mod go.sum 2>/dev/null || true

Repository: openshift/bgp-cloud-connector

Length of output: 50386


🏁 Script executed:

#!/bin/bash
set -euo pipefail

route_file="$(git ls-files | grep 'internal/platform/aws/route_server\.go$' | head -n 1)"
printf '%s\n' "--- $route_file ---"
cat -n "$route_file" | sed -n '1,220p'

printf '%s\n' '--- non-vendor API call sites ---'
rg -n -C 5 --glob '*.go' --glob '!vendor/**' 'DescribeRouteServerPeers|listPeersByEndpoint|listAllPeers'

printf '%s\n' '--- SDK definitions for DescribeRouteServerPeers ---'
sdk_file="$(git ls-files | grep 'vendor/github.com/aws/aws-sdk-go-v2/service/ec2/api_op_DescribeRouteServerPeers\.go$' | head -n 1)"
if [ -n "$sdk_file" ]; then
  cat -n "$sdk_file" | sed -n '1,220p'
else
  rg -l --glob 'vendor/**/*.go' 'type DescribeRouteServerPeersInput struct|type DescribeRouteServerPeersOutput struct' \
    | head -n 5
fi

Repository: openshift/bgp-cloud-connector

Length of output: 9029


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- tracked route-server tests and call sites ---'
git ls-files | grep -E '(^|/)route_server.*\.go$' | while IFS= read -r f; do
  printf '\n--- %s ---\n' "$f"
  rg -n -C 4 'DescribeRouteServerPeers|listPeersByEndpoint|listAllPeers|NextToken' "$f" || true
done

printf '%s\n' '--- tracked SDK operation definition ---'
sdk_file="$(git ls-files | grep -E '/api_op_DescribeRouteServerPeers\.go$' | head -n 1 || true)"
if [ -n "$sdk_file" ]; then
  cat -n "$sdk_file" | sed -n '1,180p'
else
  printf '%s\n' 'SDK operation file not found in tracked files'
  rg -n -m 12 --glob 'vendor/github.com/aws/aws-sdk-go-v2/service/ec2/**/*.go' \
    'type DescribeRouteServerPeers(Input|Output) struct|func \(.*DescribeRouteServerPeers' vendor 2>/dev/null || true
fi

Repository: openshift/bgp-cloud-connector

Length of output: 9112


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- paginator API ---'
sdk_file="$(git ls-files | grep -E '/api_op_DescribeRouteServerPeers\.go$' | head -n 1)"
cat -n "$sdk_file" | sed -n '170,280p'

printf '%s\n' '--- all listManagedPeers/listAllPeers usages ---'
rg -n -C 5 --glob '*.go' --glob '!vendor/**' \
  'listManagedPeers|listAllPeers|listPeersByEndpoint' . || true

printf '%s\n' '--- route-server test files ---'
git ls-files | grep -E '(^|/)internal/platform/aws/.*_test\.go$' | head -n 100

Repository: openshift/bgp-cloud-connector

Length of output: 10311


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

route = Path("internal/platform/aws/route_server.go").read_text()
sdk = next(Path(".").glob("vendor/github.com/aws/aws-sdk-go-v2/service/ec2/api_op_DescribeRouteServerPeers.go")).read_text()

checks = {
    "route_server_has_two_unpaginated_calls": len(re.findall(
        r'DescribeRouteServerPeers\(ctx,\s*&ec2\.DescribeRouteServerPeersInput\{\}\)',
        route,
    )) == 2,
    "sdk_input_has_next_token": bool(re.search(
        r'type DescribeRouteServerPeersInput struct \{.*?NextToken \*string',
        sdk, re.S,
    )),
    "sdk_output_has_next_token": bool(re.search(
        r'type DescribeRouteServerPeersOutput struct \{.*?NextToken \*string',
        sdk, re.S,
    )),
    "sdk_exposes_paginator": "NewDescribeRouteServerPeersPaginator" in sdk,
    "list_all_peers_is_used_by_cleanup": "listManagedPeers(ctx, endpointID)" in route,
}

for name, result in checks.items():
    print(f"{name}={result}")

assert all(checks.values())
PY

Repository: openshift/bgp-cloud-connector

Length of output: 338


Handle all DescribeRouteServerPeers pages.

listPeersByEndpoint and listAllPeers each issue one unfiltered request. If NextToken is returned, reconciliation can create duplicate peers or skip stale-peer deletion. Use the SDK paginator or a shared paginated helper in both methods. Add a two-page mock test that asserts token propagation and result aggregation.

🤖 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/platform/aws/route_server.go` around lines 95 - 107, Update
listPeersByEndpoint and listAllPeers to iterate through every
DescribeRouteServerPeers page using the SDK paginator or a shared pagination
helper, propagating each returned NextToken until exhausted and aggregating all
peers before reconciliation. Preserve the existing filtering and endpoint
grouping behavior, and add a two-page mock test verifying token propagation and
combined results.

Comment on lines +182 to +193
deleteAndWait := func(obj client.Object, what string) {
if err := k8sClient.Delete(ctx, obj); err != nil {
if !apierrors.IsNotFound(err) {
GinkgoWriter.Printf("cleanup: deleting %s: %v\n", what, err)
}
return
}
GinkgoWriter.Printf("cleanup: deleted %s\n", what)
Eventually(func() bool {
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(obj), obj)
return apierrors.IsNotFound(err)
}, 3*time.Minute, 5*time.Second).Should(BeTrue(), "%s should go away", what)

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

Fail cleanup when deletion fails.

Line 187 logs a non-NotFound Delete error and returns. The suite can then pass while the fixed-name resource remains. A later run can fail during creation.

Propagate the error through Ginkgo, or retry deletion until the cleanup timeout expires. As per path instructions, **/*.go: Never ignore error returns.

🤖 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 `@test/e2e/aws/aws_e2e_suite_test.go` around lines 182 - 193, Update the
deleteAndWait cleanup helper to fail the Ginkgo test when k8sClient.Delete
returns a non-NotFound error instead of logging and returning. Propagate the
deletion error through the helper using the suite’s established Ginkgo failure
mechanism, while preserving successful handling of already-absent resources and
the existing wait for deletion.

Source: Path instructions

Comment on lines +222 to +230
func createEventually(ctx context.Context, obj client.Object, what string) {
GinkgoHelper()
Eventually(func() error {
err := k8sClient.Create(ctx, obj)
if apierrors.IsAlreadyExists(err) {
GinkgoWriter.Printf("waiting for previous %s to finish deleting\n", what)
}
return err
}, 3*time.Minute, 5*time.Second).Should(Succeed(), "creating %s", what)

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 | 🟡 Minor | ⚡ Quick win

Use the configured reconciliation timeout.

Line 230 stops creation retries after three minutes. E2E_RESYNC_INTERVAL defaults to five minutes, and this helper documents that finalizer removal can require one resync interval. A restarted suite can time out before the previous CR is removed.

Use reconcileTimeout, or a timeout that is at least resyncInterval.

🤖 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 `@test/e2e/aws/aws_e2e_suite_test.go` around lines 222 - 230, Update
createEventually to use the configured reconcileTimeout for Eventually instead
of the hardcoded three-minute timeout, ensuring it is at least resyncInterval so
finalizer removal and creation retries can complete after a suite restart.

@alebedev87

Copy link
Copy Markdown
Contributor

I think I hit the same issue while trying the e2e, 03 scenario failed to delete a peer which didn't reach available state yet. Let me have a look at the PR and merge it to avoid anybody else hitting this one again.

/assign

@openshift-ci

openshift-ci Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

@frobware: The following tests 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/lint 06d548e link true /test lint
ci/prow/fips-image-scan 06d548e link true /test fips-image-scan

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-ci openshift-ci Bot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Aug 17, 2026
@openshift-ci

openshift-ci Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

PR needs rebase.

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.

@frobware
frobware marked this pull request as draft August 20, 2026 15:00
@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 Aug 20, 2026
aswinsuryan added a commit to aswinsuryan/bgp-cloud-connector that referenced this pull request Aug 21, 2026
createOrUpdate did an unconditional Get-then-Update on every call, and
both controllers watch the objects they write, so every write
re-triggered their own reconcile - an infinite loop (already found and
fixed once, in unmerged upstream PR openshift#13, but never merged to main).
It also fanned out further than that: CUDNBgpConfig's reconcile makes
live AWS calls, and CUDNBgpRouting's shared RouteAdvertisements object
turns one CR's rewrite into a reconcile storm across every CR.

createOrUpdate now skips the write when the spec and our own managed
labels already match, while leaving any labels we don't own untouched.

Comparing the spec exactly doesn't work: FRRConfiguration defaults
neighbors[].dualStackAddressFamily, so what comes back from the API
server always has a field we never set, and an exact comparison would
be false forever, rewriting the object on every single reconcile. The
comparison now only checks that the fields we set are present with the
value we want, and ignores anything only the server added.

The write also replaced metadata wholesale, so anything we don't
manage ourselves - a foreign label, or ovn-kubernetes' own finalizer
or annotations on a CUDN - was silently dropped the first time a real
update happened. Labels and annotations from the existing object are
now merged in before the update instead of being overwritten.

Adds direct unit tests for createOrUpdate/specEqual/labelsSatisfied and
regression tests in both controllers' reconcile tests. The envtest
integration suite from the first pass of this work is left out for now
and will follow separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Aswin Suryanarayanan <asuryana@redhat.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants