Skip to content

Observe/Update hardening: adopt-by-name fallback, durable identity via status, error propagation, NbNetworkRouter.Update - #4

Merged
dfry merged 5 commits into
netbirdio:mainfrom
callum-stakater:pr/nb-observe-iter2
Jun 30, 2026
Merged

Observe/Update hardening: adopt-by-name fallback, durable identity via status, error propagation, NbNetworkRouter.Update#4
dfry merged 5 commits into
netbirdio:mainfrom
callum-stakater:pr/nb-observe-iter2

Conversation

@callum-stakater

@callum-stakater callum-stakater commented Jun 11, 2026

Copy link
Copy Markdown

Follow-up to #3, broadening the Observe recovery pattern across the remaining controllers and fixing several Update-path bugs. Three commits:

1. fix(nb*): Observe error handling + external-name recovery across remaining controllers
Rolls out the #3 pattern (lookup-ID resolver with status fallback, transient errors propagated instead of swallowed into ResourceExists: false, WithInitializers() to disable NameAsExternalName) to NbGroup, NbSetupKey, NbPolicy, NbNameserver, NbUser, NbAccessToken, NbNetwork.

2. feat(nbaccount): support settings.extra.user_approval_required
Bumps the netbird SDK to v0.71.4 and exposes settings.extra.user_approval_required on NbAccount, so an account's default approval gate for new SSO users can be managed declaratively.

3. fix(nb*): adopt-by-name fallback after not-found, persist identity via status, swallowed Update errors, implement NbNetworkRouter.Update

  • Observe now treats the crossplane.io/external-name annotation as untrusted: it may legitimately hold a display name (set by a user or by tooling as an adoption hint) or a stale ID. After a not-found on the ID lookup, Observe falls back to adoption — by name scoped to the parent network for network resources, by PeerGroupName/Peer for routers, by name under the resolved user for PATs — instead of reporting not-exists and driving a duplicate Create. Live failure mode this fixes: a Create succeeds remotely but the external-name persist is lost, after which every reconcile re-Creates and fails with resource with name "..." already exists, wedging the MR permanently.
  • Adoption records status.atProvider.id. crossplane-runtime does not persist metadata changes made during Observe (only the late-init and Create paths persist), so status is the durable identity; the lookup resolvers already prefer it.
  • Mint-only resources (NbSetupKey, NbAccessToken) adopt as up-to-date, because their Update implementations rotate (delete + re-mint) — that path stays reserved for expired/revoked credentials. Setup-key adoption skips revoked/expired keys so rotation always converges on Create.
  • NbNetworkResource.Update returned the wrong (nil) error variable and NbPolicy.Update discarded the API error entirely — both reported Synced while the remote write had failed. Errors now propagate wrapped.
  • NbNetworkRouter.Update was an unimplemented no-op; it now PUTs the desired spec (enabled/masquerade/metric/peer/peer-groups), and Observe gained real up-to-date checks for routers and network resources so in-place changes actually reconcile.
  • Router adoption previously matched any router that had peer groups; it now requires the resolved peer-group ID to be present on the router.
  • NbNetworkRouter.Create no longer requires a peer group for peer-identified routers (peer and peer_groups are mutually exclusive in the API).

Tests: table-driven httptest coverage added for the adoption fallbacks (stale-ID and display-name external-names), drift detection, Update error propagation, and the router Update implementation. make generate produces no CRD diff except the NbAccount field from commit 2.

Validated on a live cluster: three MRs wedged on the duplicate-Create loop adopted their existing remote resources and went Ready/Synced with no manual intervention after rolling out this build.

Notes for reviewers

Dependency footprint. Commit 2 bumps github.com/netbirdio/netbird from v0.50.1 to v0.71.4. This moves the client import paths under shared/ and — because Go does not inherit a dependency's replace directives — requires mirroring netbird's replace set into this go.mod (dex fork, wireguard-go, ice, circl, easyjson, systray, service). Consuming the SDK at this version also pulls a large slice of the netbird management-server dependency tree transitively (redis, gorm, the SQL drivers, dex, etc.), even though the provider only uses the REST client — a build-time, image-size, and CVE-surface cost worth weighing. If netbird exposes a lighter client-only module, consuming that instead would be preferable; happy to adjust to maintainer preference. The go directive moves to 1.25.5 to match the SDK and its transitive requirements (golang.org/x/*, go.opentelemetry.io/otel/*), and CI's GO_VERSION is bumped to match so the coverage step's toolchain has covdata.

Behavior change (upgrade note). Adding managed.WithInitializers() (empty) disables crossplane-runtime's NameAsExternalName initializer, so a managed resource's crossplane.io/external-name is no longer defaulted to the Kubernetes object name. Existing resources whose external-name was auto-defaulted recover automatically on the first Observe after upgrade — falling back to status.atProvider.id and then to adopt-by-name — but operators should be aware the annotation semantics change. No CRD/spec changes are required; the one new field (NbAccount settings.extra.user_approval_required) is optional.

Summary by CodeRabbit

  • New Features

    • Account settings: restrict network traffic logs to specific groups; require admin approval for new users.
  • Bug Fixes

    • Tighter error handling and wrapped errors during observe/update/delete to avoid silent failures.
    • More robust resource identification and repair of stale external-name annotations; safer adoption fallbacks.
  • Tests

    • Added and expanded unit tests covering adoption, lookup-resolve behavior, updates, and reconciliation flows.

Callum MacDonald and others added 3 commits May 25, 2026 22:25
…ining controllers

Extends PR netbirdio#3's pattern (originally applied to NbNetwork / NbNetworkResource /
NbNetworkRouter) to the eight controllers that were still vulnerable to the
orphan-creation bug:

  - nbaccesstoken (critical: orphan PATs are leaked credentials)
  - nbsetupkey    (critical: orphan setup keys are leaked credentials)
  - nbpolicy      (high: duplicate ACL policies silently broaden access)
  - nbgroup       (high: duplicate groups misroute peers)
  - nbnameserver  (medium)
  - nbuser        (medium)
  - nbaccount     (low — singleton, consistency only)
  - nbdnssetting  (low — singleton, consistency only)

For each non-singleton controller:

  1. isXNotFoundError(err) discriminates the netbird REST API's plaintext
     "<type>: <id> not found" / "<type> not found" messages from transient
     errors. Observe only returns ResourceExists: false on explicit not-found;
     other errors are wrapped so Crossplane requeues instead of invoking Create.

  2. resolveXLookupID(cr) prefers the external-name annotation but falls back
     to cr.Status.AtProvider.Id when external-name is empty or was defaulted
     to the K8s object name by an older reconcile.

  3. managed.WithInitializers() disables Crossplane's NameAsExternalName
     default so the K8s object name doesn't get used as a netbird ID.

  4. Stale external-name annotations are repaired in-place after a successful
     by-ID lookup.

  5. The external struct's authManager field is now an authClient interface,
     enabling future httptest-driven Observe tests in the same shape as the
     PR netbirdio#3 tests.

  6. Update and Delete switched to the resolver.

Controller-specific care:

  - nbsetupkey and nbaccesstoken Update intentionally deletes the upstream
    resource and clears external-name so the next Observe drives Create
    (key rotation). With the new resolver this also clears
    cr.Status.AtProvider.Id — otherwise the resolver would fall back to the
    now-deleted ID and Observe would loop on isXNotFoundError.

  - nbuser's third Observe branch uses List + local filter (the netbird SDK
    has no Users.Get), so it never sees a 404. The fix here is to stop
    swallowing List errors — they now propagate as wrapped errors. Adoption
    branches are also error-wrapped consistently.

  - nbpolicy NbPolicyObservation.Id is *string; the resolver nil-checks
    before dereferencing.

  - nbaccount and nbdnssetting are singletons with no-op Create; they
    receive only the wrapped-error fix for consistency.

Builds clean and the existing test stubs still pass; httptest-backed Observe
tests will land in a follow-up to keep this diff focused on the controller
fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…p netbird SDK to v0.71.4)

The NbAccount CRD could not manage user_approval_required because the vendored
netbird SDK (v0.50.1) predates the field — its generated api.AccountExtraSettings
had no such member, so the value never reached the wire on PUT /api/accounts/{id}
nor was read back on GET. Properly supporting it requires the field to exist in
the SDK's generated model, so this bumps the dependency rather than hand-rolling
a parallel request type.

SDK bump v0.50.1 -> v0.71.4:
  - Import paths moved under shared/:
      management/server/http/api  -> shared/management/http/api
      management/client/rest      -> shared/management/client/rest
    Rewritten across all 14 controllers + apis + mocks.
  - netbird's go.mod replace directives are not inherited by consumers, so the
    relevant ones (dex fork, wireguard-go, ice, circl, easyjson, systray,
    service) are mirrored into our go.mod. Without the dex replace, module
    resolution picks dexidp/dex@latest which lacks server/signer and breaks
    `go mod tidy`.
  - Only one source-level API drift across the whole provider: the
    AccountExtraSettings layout changed (NetworkTrafficLogsGroups and
    UserApprovalRequired added), which broke the pointer-cast in Observe.

user_approval_required wiring (NbAccount):
  - v1alpha1.AccountExtraSettings gains UserApprovalRequired (the feature) and
    NetworkTrafficLogsGroups (also new in v0.71.4 — mapped so the wholesale PUT
    doesn't silently clear it).
  - Replaced the fragile (*v1alpha1.AccountExtraSettings)(p.Extra) struct cast
    with an explicit ApitoNbAccountExtraSettings mapper, decoupling the CRD type
    from upstream field-layout changes.
  - NbToApiAccountSettings now sends both new fields; isUpToDate compares them
    (slice compare treats nil/empty as equal to avoid drift flapping).
  - Regenerated deepcopy + CRD (go generate ./apis/...). Both new fields are
    optional in the CRD.

Builds clean, go vet clean, all controller unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…a status, swallowed Update errors, implement NbNetworkRouter.Update

Observe across NbNetwork/NbNetworkResource/NbNetworkRouter/NbPolicy/NbSetupKey/
NbAccessToken now treats the external-name annotation as untrusted: compositions
stamp it with the netbird display name (adoption hint) and the composite
controller keeps re-applying that value, so a name-valued or stale external-name
must fall back to adoption (by name, scoped to the parent network for network
resources; by PeerGroupName/Peer for routers; by name under the resolved user
for PATs) instead of reporting not-exists and driving a duplicate Create.

Adoption now also records status.atProvider.id: external-name set during
Observe is not persisted by crossplane-runtime (only late-init/Create paths
persist metadata), so status is the durable identity and the lookup resolvers
already prefer it. Mint-only resources (setup keys, PATs) adopt as up-to-date
so the rotation-style Update (delete + re-mint) stays reserved for the
expired/revoked path; setup key adoption skips revoked/expired keys.

Also:
- NbNetworkResource.Update returned the wrong (nil) error variable, and
  NbPolicy.Update discarded the API error entirely - both reported Synced
  while the remote was unchanged. Errors now propagate wrapped.
- NbNetworkRouter.Update was an unimplemented no-op; in-place re-points
  (peer group / peer / metric / masquerade / enabled) now PUT to the API,
  and Observe gained a real up-to-date check to drive them.
- NbNetworkResource Observe gained a spec-vs-API up-to-date check
  (name/address/enabled/description/groups) so in-place edits reconcile.
- Router adoption previously matched ANY router with peer groups; it now
  requires the resolved peer group id to be present on the router.
- Remaining swallowed transient list errors in Observe adopt paths
  (networks/resources/routers) now propagate so Crossplane requeues
  instead of calling Create.
- NbNetworkRouter.Create no longer requires a peer group for peer-typed
  routers (peer and peer_groups are mutually exclusive in the API).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Migrates imports to NetBird shared management packages, bumps Go/module versions, adds two NbAccount extra settings, and refactors controllers to use lookup-ID-first observation with adoption fallbacks, improved error handling, and additional adoption/unit tests.

Changes

NetBird Shared Management Migration & Controller Refactoring

Layer / File(s) Summary
Schema & Type System Updates
apis/vpn/v1alpha1/nbaccount_types.go, apis/vpn/v1alpha1/nbgroup_types.go, apis/vpn/v1alpha1/zz_generated.deepcopy.go, package/crds/vpn.netbird.crossplane.io_nbaccounts.yaml, .gitignore
Adds NetworkTrafficLogsGroups and UserApprovalRequired to AccountExtraSettings and CRD schema; updates netbird API import paths in types and generated deepcopy to handle slice copying; adds /bin and /up to .gitignore.
Go Module & Core Auth Import Updates
go.mod, internal/controller/nb/auth.go, internal/controller/nb/mock/mock_auth.go
Bumps go directive to 1.25.5, upgrades netbird and other modules, adds replace directives mirroring upstream NetBird, and switches core rest/auth imports to shared/management paths.
NbAccessToken Controller: Token Lookup & Adoption
internal/controller/nbaccesstoken/nbaccesstoken.go, internal/controller/nbaccesstoken/nbaccesstoken_test.go
Implements resolveAccessTokenLookupID, adoption-by-name under user, not-found detection, clears external-name/status on delete/update to force recreate, introduces authClient abstraction, and adds httptest-backed adoption tests.
NbAccount Controller: Settings & Validation Updates
internal/controller/nbaccount/nbaccount.go
Switches to shared API imports, returns wrapped errors for list failures, extends isUpToDate to compare new Extra fields with nil/empty-slice equality, and adds explicit Extra settings mappers.
NbDnsSetting & NbNameServer: Lookup-Based Observation
internal/controller/nbdnssetting/nbdnssetting.go, internal/controller/nbnameserver/nbnameserver.go
Both use authClient, resolve lookup IDs, attempt ID-based Get with adoption fallback, persist recovered IDs, repair external-name annotations, and return wrapped errors for transient failures.
NbGroup & NbPolicy: ID Resolution with Adoption Fallback
internal/controller/nbgroup/nbgroup.go, internal/controller/nbpolicy/nbpolicy.go
Introduce resolve*LookupID helpers, fetch-by-ID first then adopt by name on not-found, centralize not-found detection, wrap non-auth errors for requeue, and repair external-name when recovering from status ID.
NbSetupKey Controller: Key Identity & Adoption
internal/controller/nbsetupkey/nbsetupkey.go, internal/controller/nbsetupkey/nbsetupkey_test.go
Implements resolveSetupKeyLookupID, adopts valid non-revoked/non-expired keys by name, repairs external-name/status, deletes by resolved ID on Update/Delete, and adds httptest-backed adoption tests.
NbUser & NbNetwork: Lookup-Based Adoption & Recovery
internal/controller/nbuser/nbuser.go, internal/controller/nbnetwork/nbnetwork.go, internal/controller/nbnetwork/nbnetwork_test.go
Controllers compute lookupID, prefer ID-based Get, fall back to adoption-by-name, repair external-name/status on recovery, and add tests for stale-ID/adoption scenarios.
NbNetworkResource: ID-First Lookup with Comprehensive Tests
internal/controller/nbnetworkresource/nbnetworkresource.go, internal/controller/nbnetworkresource/nbnetworkresource_test.go
Prefer resolveNetworkResourceLookupID and direct Get, fall back to adoption-by-name, implement isNetworkResourceUpToDate, repair external-name/status, wrap transient errors, and add broad adoption/repair/update tests.
NbNetworkRouter: Peer-Group Routing & Update Implementation
internal/controller/nbnetworkrouter/nbnetworkrouter.go, internal/controller/nbnetworkrouter/nbnetworkrouter_test.go
Refactors Observe/Create/Update for ID-first lookup with adoption-by-peer-group fallback, introduces resolvePeerGroups helper, implements isNetworkRouterUpToDate and Update, and adds tests verifying peer-group repointing and error propagation.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • dfry

Poem

🐰 I hopped through types and imports wide,
Swapped paths so netbirds fly in stride,
Two extra fields tucked in the spec,
Controllers learn to find and correct,
Tests sing adoption — happy tide!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: controller hardening through adopt-by-name fallback patterns, durable identity persistence via status, improved error propagation, and implementing NbNetworkRouter.Update.
Docstring Coverage ✅ Passed Docstring coverage is 90.24% which is sufficient. The required threshold is 80.00%.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 and usage tips.

@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

Caution

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

⚠️ Outside diff range comments (3)
internal/controller/nbaccount/nbaccount.go (2)

176-196: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Handle nil Extra safely in compare and update conversion paths.

Both paths dereference Extra without nil checks. If apiaccount.Settings.Extra or p.Extra is nil, Observe/Update can panic.

Suggested fix
 func isUpToDate(nbaccount v1alpha1.NbAccount, apiaccount api.Account, c *external) bool {
-  if !cmp.Equal(nbaccount.Spec.ForProvider.Settings.Extra.NetworkTrafficLogsEnabled, apiaccount.Settings.Extra.NetworkTrafficLogsEnabled) {
+  desiredExtra := v1alpha1.AccountExtraSettings{}
+  if nbaccount.Spec.ForProvider.Settings.Extra != nil {
+    desiredExtra = *nbaccount.Spec.ForProvider.Settings.Extra
+  }
+  actualExtra := api.AccountExtraSettings{}
+  if apiaccount.Settings.Extra != nil {
+    actualExtra = *apiaccount.Settings.Extra
+  }
+  if !cmp.Equal(desiredExtra.NetworkTrafficLogsEnabled, actualExtra.NetworkTrafficLogsEnabled) {
     c.log.Info("extra settings NetworkTrafficLogsEnabled not equal")
     return false
   }
-  if !cmp.Equal(nbaccount.Spec.ForProvider.Settings.Extra.NetworkTrafficPacketCounterEnabled, apiaccount.Settings.Extra.NetworkTrafficPacketCounterEnabled) {
+  if !cmp.Equal(desiredExtra.NetworkTrafficPacketCounterEnabled, actualExtra.NetworkTrafficPacketCounterEnabled) {
     c.log.Info("extra settings NetworkTrafficPacketCounterEnabled not equal")
     return false
   }
-  if !cmp.Equal(nbaccount.Spec.ForProvider.Settings.Extra.PeerApprovalEnabled, apiaccount.Settings.Extra.PeerApprovalEnabled) {
+  if !cmp.Equal(desiredExtra.PeerApprovalEnabled, actualExtra.PeerApprovalEnabled) {
     c.log.Info("extra settings PeerApprovalEnabled not equal")
     return false
   }
-  if !cmp.Equal(nbaccount.Spec.ForProvider.Settings.Extra.UserApprovalRequired, apiaccount.Settings.Extra.UserApprovalRequired) {
+  if !cmp.Equal(desiredExtra.UserApprovalRequired, actualExtra.UserApprovalRequired) {
     c.log.Info("extra settings UserApprovalRequired not equal")
     return false
   }
-  if !stringSlicesEqual(nbaccount.Spec.ForProvider.Settings.Extra.NetworkTrafficLogsGroups, apiaccount.Settings.Extra.NetworkTrafficLogsGroups) {
+  if !stringSlicesEqual(desiredExtra.NetworkTrafficLogsGroups, actualExtra.NetworkTrafficLogsGroups) {
     c.log.Info("extra settings NetworkTrafficLogsGroups not equal")
     return false
   }
 func NbToApiAccountSettings(p v1alpha1.AccountSettings) *api.AccountSettings {
-  extrasettings := api.AccountExtraSettings{
-    NetworkTrafficLogsEnabled:          p.Extra.NetworkTrafficLogsEnabled,
-    NetworkTrafficLogsGroups:           p.Extra.NetworkTrafficLogsGroups,
-    NetworkTrafficPacketCounterEnabled: p.Extra.NetworkTrafficPacketCounterEnabled,
-    PeerApprovalEnabled:                p.Extra.PeerApprovalEnabled,
-    UserApprovalRequired:               p.Extra.UserApprovalRequired,
-  }
+  var extrasettings *api.AccountExtraSettings
+  if p.Extra != nil {
+    extrasettings = &api.AccountExtraSettings{
+      NetworkTrafficLogsEnabled:          p.Extra.NetworkTrafficLogsEnabled,
+      NetworkTrafficLogsGroups:           p.Extra.NetworkTrafficLogsGroups,
+      NetworkTrafficPacketCounterEnabled: p.Extra.NetworkTrafficPacketCounterEnabled,
+      PeerApprovalEnabled:                p.Extra.PeerApprovalEnabled,
+      UserApprovalRequired:               p.Extra.UserApprovalRequired,
+    }
+  }
   accountsettings := api.AccountSettings{
-    Extra:                           &extrasettings,
+    Extra:                           extrasettings,

Also applies to: 337-344

🤖 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/nbaccount/nbaccount.go` around lines 176 - 196, The
comparison code in isUpToDate (and the Observe/Update conversion paths)
dereferences nbaccount.Spec.ForProvider.Settings.Extra and
apiaccount.Settings.Extra (and p.Extra) without nil checks which can cause
panics; update isUpToDate and the conversion/observe/update functions to first
guard against nil by treating nil and empty as equivalent (e.g., consider a nil
Extra as an empty struct or short-circuit comparisons) before accessing fields
like NetworkTrafficLogsEnabled, NetworkTrafficPacketCounterEnabled,
PeerApprovalEnabled, UserApprovalRequired and NetworkTrafficLogsGroups, and
reuse stringSlicesEqual only after ensuring the slice refs are non-nil to avoid
panics.

129-137: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Guard empty account list before indexing.

accounts[0] on Line 157 can panic when the API returns an empty list. Return a controlled error (or explicit not-found behavior) instead of crashing reconcile.

Suggested fix
 accounts, err := client.Accounts.List(ctx)
 if err != nil {
   if auth.IsTokenInvalidError(err) {
     c.authManager.ForceRefresh(ctx)
     return managed.ExternalObservation{}, err
   }
   // Don't swallow transient errors — Crossplane should requeue, not call Create.
   return managed.ExternalObservation{}, errors.Wrap(err, "failed to list accounts")
 }
+if len(accounts) == 0 {
+  return managed.ExternalObservation{}, errors.New("failed to observe account: API returned no accounts")
+}
 
 accountusers, err := client.Users.List(ctx)

Also applies to: 157-157

🤖 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/nbaccount/nbaccount.go` around lines 129 - 137, The code
assumes accounts[0] exists after calling client.Accounts.List and can panic on
an empty response; add a guard after the call to client.Accounts.List that
checks len(accounts) == 0 and return a controlled error or explicit not-found
observation (e.g., return managed.ExternalObservation{}, errors.New("no accounts
found") or the Crossplane NotFound flow) instead of indexing into accounts; keep
the existing auth token handling (auth.IsTokenInvalidError and
c.authManager.ForceRefresh) intact and reference the accounts slice and the
managed.ExternalObservation{} return to locate where to add the check.
internal/controller/nbaccesstoken/nbaccesstoken.go (1)

149-173: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unify user-resolution rules across Observe and mutate paths.

Line 162 restricts Observe to IsServiceUser == true, but getUserID (used by Update) and Create/Delete resolve by name without that filter. This can reconcile one user while rotating/deleting against another in ambiguous-name cases.

Also applies to: 294-309

🤖 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/nbaccesstoken/nbaccesstoken.go` around lines 149 - 173,
The Observe path currently filters users by IsServiceUser when resolving a
username, which differs from getUserID (used by Create/Update/Delete) and can
cause mismatches; change Observe to reuse the same resolution logic as getUserID
(or call getUserID directly) so name-based lookup does not require IsServiceUser
== true, preserve the existing fallback to cr.Spec.ForProvider.UserId when
provided, and make the same change in the other similar observe block to ensure
Create/Update/Delete and Observe all resolve the same user identifier (affecting
functions Observe and any duplicate resolution around the later block).
🧹 Nitpick comments (4)
internal/controller/nbnetworkresource/nbnetworkresource.go (1)

254-271: 💤 Low value

Asymmetric group comparison may miss drift when API has extra groups.

The comparison checks that every spec group exists in the API response, but doesn't verify the reverse. If the API resource has additional groups not present in the spec (e.g., added out-of-band), this function still returns true (up-to-date), preventing an Update that would remove the extra groups.

If this is intentional (e.g., to allow external additions), consider adding a comment. Otherwise, compare lengths first or check both directions.

🤖 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/nbnetworkresource/nbnetworkresource.go` around lines 254
- 271, The current group comparison only ensures every spec group exists in
res.Groups but not the reverse, allowing extra API groups to be ignored; update
the check in nbnetworkresource.go (the block referencing spec.Groups and
res.Groups) to perform a symmetric comparison: first verify lengths match, then
ensure every group in spec has a matching group in res (by Id or Name) and every
group in res has a matching group in spec (by Id or Name); alternatively build
lookup maps keyed by Id/Name and compare keys both ways so any extra groups in
res are detected and cause a false (not up-to-date) result.
internal/controller/nbnetworkrouter/nbnetworkrouter.go (1)

364-368: 💤 Low value

Missing break after finding the network.

The Update method (line 435) includes a break after finding the matching network, but Create does not. While network names are expected to be unique, adding break here would be consistent with Update and avoid unnecessary iterations.

♻️ Suggested fix
 	for _, network := range networks {
 		if network.Name == cr.Spec.ForProvider.NetworkName {
 			apinetwork = &network
+			break
 		}
 	}
🤖 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/nbnetworkrouter/nbnetworkrouter.go` around lines 364 -
368, In the Create logic where you loop "for _, network := range networks" to
locate the network matching cr.Spec.ForProvider.NetworkName and assign
apinetwork = &network, add a break immediately after the assignment so the loop
stops once a match is found (mirroring the Update method behavior) to avoid
unnecessary iterations; this change targets the loop that sets apinetwork in the
Create flow.
internal/controller/nbsetupkey/nbsetupkey_test.go (1)

105-105: ⚡ Quick win

Variable auth shadows the package import.

The local variable auth shadows the imported package auth "github.com/crossplane/netbird-crossplane-provider/internal/controller/nb", which can cause confusion when reading the code.

♻️ Rename local variable to avoid shadowing
 	t.Run("EmptyLookupAdoptsValidKeyByName", func(t *testing.T) {
-		auth, srv := newFakeAuth(t, func(w http.ResponseWriter, r *http.Request) {
+		fakeAuth, srv := newFakeAuth(t, func(w http.ResponseWriter, r *http.Request) {
 			if r.URL.Path == "/api/setup-keys" && r.Method == http.MethodGet {
 				w.Header().Set("Content-Type", "application/json")
 				w.Write([]byte(`[{"id":"sk-1","name":"bootstrap-key","revoked":false,"expires":"2099-01-01T00:00:00Z","last_used":"2026-01-01T00:00:00Z","state":"valid","type":"reusable","auto_groups":[]}]`))
 				return
 			}
 			t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
 		})
 		defer srv.Close()

-		e := external{authManager: auth}
+		e := external{authManager: fakeAuth}

Apply the same rename in the second subtest.

Also applies to: 135-135

🤖 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/nbsetupkey/nbsetupkey_test.go` at line 105, The local
test variable named auth shadows the imported package alias auth; rename the
local variable returned by newFakeAuth (e.g., fakeAuth or authSrv) and update
all references (including the companion srv variable use) in both subtests to
avoid shadowing; ensure you apply the same rename in the second subtest where
newFakeAuth is called so the imported package auth remains unshadowed.
internal/controller/nbsetupkey/nbsetupkey.go (1)

326-341: 💤 Low value

Consider ignoring not-found errors when deleting during rotation.

If the setup key was already deleted externally, the Delete call will fail, causing unnecessary retries. Since the goal is to clear the key so a new one can be created, a not-found error could be treated as success.

♻️ Optional: swallow not-found on rotation delete
-	if err := client.SetupKeys.Delete(ctx, setupKeyId); err != nil {
-		return managed.ExternalUpdate{}, errors.Wrap(err, "failed to delete expired setupkey")
+	if err := client.SetupKeys.Delete(ctx, setupKeyId); err != nil && !isSetupKeyNotFoundError(err) {
+		return managed.ExternalUpdate{}, errors.Wrap(err, "failed to delete expired setupkey")
 	}
🤖 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/nbsetupkey/nbsetupkey.go` around lines 326 - 341, The
Delete call in the rotation path should ignore "not found" errors so an
already-removed setup key doesn't cause retries: inside the block where you call
client.SetupKeys.Delete(ctx, setupKeyId) (after resolveSetupKeyLookupID returns
a non-empty id), detect the provider's not-found error type or value and treat
it as a successful delete (i.e., continue clearing external-name and
cr.Status.AtProvider.Id and return the managed.ExternalUpdate) instead of
returning an error; keep the existing meta.SetExternalName and status-clearing
logic and only wrap/return non-not-found errors as failures.
🤖 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 `@apis/vpn/v1alpha1/nbaccount_types.go`:
- Around line 54-56: The UserApprovalRequired field in nbaccount_types.go is
currently a non-pointer bool so it cannot represent "unset"; change the field
type to *bool and update the JSON tag to include omitempty (i.e.,
UserApprovalRequired *bool `json:"user_approval_required,omitempty"`), so
controllers can distinguish omitted vs explicit false; update any
construction/usage sites (e.g., where New NBAccount or defaulting/validation
logic touches UserApprovalRequired) to handle nil pointers appropriately.

In `@internal/controller/nbaccesstoken/nbaccesstoken_test.go`:
- Around line 113-124: The test handler passed into newFakeAuth must not call
t.Fatalf from the server goroutine; replace the default case so the handler
writes an HTTP error (e.g., w.WriteHeader(http.StatusInternalServerError) and a
small message) and send the unexpected-request details to the test goroutine via
a channel (e.g., create errCh := make(chan string,1) in the test and in the
handler do errCh <- fmt.Sprintf("unexpected request: %s %s", r.Method,
r.URL.Path)); after exercising the client, read from errCh (non-blocking or with
close) in the test goroutine and call t.Fatalf/t.Errorf there if a message was
received. Ensure you update the code around newFakeAuth and the inline handler
to use the channel and remove the t.Fatalf from the handler.

In `@internal/controller/nbaccesstoken/nbaccesstoken.go`:
- Around line 400-403: In the Delete handler, do not hard-fail when
resolveAccessTokenLookupID(cr) returns an empty string; instead treat an empty
lookupID as already-deleted/no-op by skipping any external-provider deletion and
returning nil (optionally log a debug/info message). Update the logic in the
method that calls resolveAccessTokenLookupID so that when lookupID == "" it does
not return errors.New(...) but returns nil after ensuring any finalizer removal
or local cleanup still occurs.

In `@internal/controller/nbgroup/nbgroup.go`:
- Around line 226-234: The observe logic in nbgroup.go currently returns
ResourceExists:false when isGroupNotFoundError(err) is true, which prevents the
adoption-by-name fallback; change the control flow in the Observe method so that
when isGroupNotFoundError(err) is true you do not return immediately but
continue into the adoption-by-name branch (the same pattern used in nbpolicy.go
around lines 163-169): remove the early return, allow the code to attempt a
name-based lookup using lookupID/name, and only return ResourceExists:false
after both ID and name adoption attempts fail; keep the existing wrapped error
return for non-not-found errors.

In `@internal/controller/nbuser/nbuser.go`:
- Around line 297-307: The resolver resolveUserLookupID currently treats an
external name equal to the Kubernetes object name as a legacy default and
returns status.AtProvider.Id, but it fails to treat an external name equal to
spec.forProvider.name the same way; update resolveUserLookupID so the recovery
branch checks if cr.Status.AtProvider.Id != "" && (externalName == cr.GetName()
|| externalName == cr.Spec.ForProvider.Name) && cr.Status.AtProvider.Id !=
externalName and then return cr.Status.AtProvider.Id; ensure callers
(Users.Update and Users.Delete) continue to use resolveUserLookupID so they
receive the real ID rather than spec.forProvider.name.

---

Outside diff comments:
In `@internal/controller/nbaccesstoken/nbaccesstoken.go`:
- Around line 149-173: The Observe path currently filters users by IsServiceUser
when resolving a username, which differs from getUserID (used by
Create/Update/Delete) and can cause mismatches; change Observe to reuse the same
resolution logic as getUserID (or call getUserID directly) so name-based lookup
does not require IsServiceUser == true, preserve the existing fallback to
cr.Spec.ForProvider.UserId when provided, and make the same change in the other
similar observe block to ensure Create/Update/Delete and Observe all resolve the
same user identifier (affecting functions Observe and any duplicate resolution
around the later block).

In `@internal/controller/nbaccount/nbaccount.go`:
- Around line 176-196: The comparison code in isUpToDate (and the Observe/Update
conversion paths) dereferences nbaccount.Spec.ForProvider.Settings.Extra and
apiaccount.Settings.Extra (and p.Extra) without nil checks which can cause
panics; update isUpToDate and the conversion/observe/update functions to first
guard against nil by treating nil and empty as equivalent (e.g., consider a nil
Extra as an empty struct or short-circuit comparisons) before accessing fields
like NetworkTrafficLogsEnabled, NetworkTrafficPacketCounterEnabled,
PeerApprovalEnabled, UserApprovalRequired and NetworkTrafficLogsGroups, and
reuse stringSlicesEqual only after ensuring the slice refs are non-nil to avoid
panics.
- Around line 129-137: The code assumes accounts[0] exists after calling
client.Accounts.List and can panic on an empty response; add a guard after the
call to client.Accounts.List that checks len(accounts) == 0 and return a
controlled error or explicit not-found observation (e.g., return
managed.ExternalObservation{}, errors.New("no accounts found") or the Crossplane
NotFound flow) instead of indexing into accounts; keep the existing auth token
handling (auth.IsTokenInvalidError and c.authManager.ForceRefresh) intact and
reference the accounts slice and the managed.ExternalObservation{} return to
locate where to add the check.

---

Nitpick comments:
In `@internal/controller/nbnetworkresource/nbnetworkresource.go`:
- Around line 254-271: The current group comparison only ensures every spec
group exists in res.Groups but not the reverse, allowing extra API groups to be
ignored; update the check in nbnetworkresource.go (the block referencing
spec.Groups and res.Groups) to perform a symmetric comparison: first verify
lengths match, then ensure every group in spec has a matching group in res (by
Id or Name) and every group in res has a matching group in spec (by Id or Name);
alternatively build lookup maps keyed by Id/Name and compare keys both ways so
any extra groups in res are detected and cause a false (not up-to-date) result.

In `@internal/controller/nbnetworkrouter/nbnetworkrouter.go`:
- Around line 364-368: In the Create logic where you loop "for _, network :=
range networks" to locate the network matching cr.Spec.ForProvider.NetworkName
and assign apinetwork = &network, add a break immediately after the assignment
so the loop stops once a match is found (mirroring the Update method behavior)
to avoid unnecessary iterations; this change targets the loop that sets
apinetwork in the Create flow.

In `@internal/controller/nbsetupkey/nbsetupkey_test.go`:
- Line 105: The local test variable named auth shadows the imported package
alias auth; rename the local variable returned by newFakeAuth (e.g., fakeAuth or
authSrv) and update all references (including the companion srv variable use) in
both subtests to avoid shadowing; ensure you apply the same rename in the second
subtest where newFakeAuth is called so the imported package auth remains
unshadowed.

In `@internal/controller/nbsetupkey/nbsetupkey.go`:
- Around line 326-341: The Delete call in the rotation path should ignore "not
found" errors so an already-removed setup key doesn't cause retries: inside the
block where you call client.SetupKeys.Delete(ctx, setupKeyId) (after
resolveSetupKeyLookupID returns a non-empty id), detect the provider's not-found
error type or value and treat it as a successful delete (i.e., continue clearing
external-name and cr.Status.AtProvider.Id and return the managed.ExternalUpdate)
instead of returning an error; keep the existing meta.SetExternalName and
status-clearing logic and only wrap/return non-not-found errors as failures.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2a4347a5-40c9-492a-a46b-9dc88f491536

📥 Commits

Reviewing files that changed from the base of the PR and between c02c6fc and 4e9001c.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (24)
  • .gitignore
  • apis/vpn/v1alpha1/nbaccount_types.go
  • apis/vpn/v1alpha1/nbgroup_types.go
  • apis/vpn/v1alpha1/zz_generated.deepcopy.go
  • go.mod
  • internal/controller/nb/auth.go
  • internal/controller/nb/mock/mock_auth.go
  • internal/controller/nbaccesstoken/nbaccesstoken.go
  • internal/controller/nbaccesstoken/nbaccesstoken_test.go
  • internal/controller/nbaccount/nbaccount.go
  • internal/controller/nbdnssetting/nbdnssetting.go
  • internal/controller/nbgroup/nbgroup.go
  • internal/controller/nbnameserver/nbnameserver.go
  • internal/controller/nbnetwork/nbnetwork.go
  • internal/controller/nbnetwork/nbnetwork_test.go
  • internal/controller/nbnetworkresource/nbnetworkresource.go
  • internal/controller/nbnetworkresource/nbnetworkresource_test.go
  • internal/controller/nbnetworkrouter/nbnetworkrouter.go
  • internal/controller/nbnetworkrouter/nbnetworkrouter_test.go
  • internal/controller/nbpolicy/nbpolicy.go
  • internal/controller/nbsetupkey/nbsetupkey.go
  • internal/controller/nbsetupkey/nbsetupkey_test.go
  • internal/controller/nbuser/nbuser.go
  • package/crds/vpn.netbird.crossplane.io_nbaccounts.yaml

Comment on lines +54 to +56
// UserApprovalRequired Enables manual approval for new users joining via domain matching. When enabled, users are blocked with pending approval status until explicitly approved by an admin.
// +optional
UserApprovalRequired bool `json:"user_approval_required"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Optional scalar cannot preserve “unset” semantics

UserApprovalRequired is marked optional, but bool without omitempty cannot distinguish omitted vs explicit false. This can cause unintended state overwrites during reconcile/update flows.

Suggested fix
-	// UserApprovalRequired Enables manual approval for new users joining via domain matching. When enabled, users are blocked with pending approval status until explicitly approved by an admin.
-	// +optional
-	UserApprovalRequired bool `json:"user_approval_required"`
+	// UserApprovalRequired Enables manual approval for new users joining via domain matching. When enabled, users are blocked with pending approval status until explicitly approved by an admin.
+	// +optional
+	UserApprovalRequired *bool `json:"user_approval_required,omitempty"`
🤖 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 `@apis/vpn/v1alpha1/nbaccount_types.go` around lines 54 - 56, The
UserApprovalRequired field in nbaccount_types.go is currently a non-pointer bool
so it cannot represent "unset"; change the field type to *bool and update the
JSON tag to include omitempty (i.e., UserApprovalRequired *bool
`json:"user_approval_required,omitempty"`), so controllers can distinguish
omitted vs explicit false; update any construction/usage sites (e.g., where New
NBAccount or defaulting/validation logic touches UserApprovalRequired) to handle
nil pointers appropriately.

Comment on lines +113 to +124
auth, srv := newFakeAuth(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/users" && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[{"id":"u-1","name":"svc-user","is_service_user":true,"email":"","role":"admin","auto_groups":[],"is_blocked":false,"issued":"api","status":"active"}]`))
case r.URL.Path == "/api/users/u-1/tokens" && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[{"id":"t-1","name":"bootstrap-pat","expiration_date":"2099-01-01T00:00:00Z","created_by":"u-1","created_at":"2026-01-01T00:00:00Z"}]`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In Go's testing package, can t.Fatalf (or FailNow) be safely called from a goroutine spawned outside the test function, such as an httptest server handler?

💡 Result:

No, calling t.Fatalf (or t.FailNow, t.Fatal, and related methods) from a goroutine spawned outside the test function is not safe and is explicitly prohibited by the Go testing package [1][2]. According to the official documentation, these methods must be called only from the goroutine running the Test function [1]. When you call t.Fatalf, it invokes t.FailNow, which in turn calls runtime.Goexit to terminate the execution of the current goroutine [1][3]. If called from a spawned goroutine, it will only terminate that specific goroutine, not the test itself, often leading to confusing test results, silent failures, or unexpected panics if the test function continues or completes prematurely [4][5]. To handle errors within concurrent code or goroutines (such as an httptest server handler), you should use thread-safe reporting methods that do not terminate the test execution immediately, such as t.Error or t.Errorf [1][6]. These methods allow you to record the failure while permitting the test function to continue and manage its own synchronization [1]. You can then synchronize the completion of your goroutines with the test function (e.g., using sync.WaitGroup or channels) to ensure all errors are reported before the test finishes [6]. The Go toolchain includes an analyzer called testinggoroutine (often run as part of go vet) that specifically detects and warns against incorrect calls to t.Fatal/Fatalf from non-test goroutines [7][8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="internal/controller/nbaccesstoken/nbaccesstoken_test.go"
python3 - <<'PY'
import itertools
path="internal/controller/nbaccesstoken/nbaccesstoken_test.go"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
for start,end in [(90,140),(140,190)]:
    for i in range(start-1,end):
        if 0<=i<len(lines):
            print(f"{i+1}\t{lines[i].rstrip()}")
PY
echo "----"
rg -n "t\.Fatalf\(" internal/controller/nbaccesstoken/nbaccesstoken_test.go || true

Repository: netbirdio/netbird-crossplane-provider

Length of output: 1996


Don’t call t.Fatalf from httptest HTTP handlers

default: t.Fatalf(...) runs in the HTTP server goroutine; t.Fatalf/FailNow is explicitly not safe from non-test goroutines and can cause flaky/incorrect test results. In the handler, return an error response instead and report the failure from the test goroutine (e.g., via t.Errorf or by sending the error over a channel/WaitGroup).

🤖 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/nbaccesstoken/nbaccesstoken_test.go` around lines 113 -
124, The test handler passed into newFakeAuth must not call t.Fatalf from the
server goroutine; replace the default case so the handler writes an HTTP error
(e.g., w.WriteHeader(http.StatusInternalServerError) and a small message) and
send the unexpected-request details to the test goroutine via a channel (e.g.,
create errCh := make(chan string,1) in the test and in the handler do errCh <-
fmt.Sprintf("unexpected request: %s %s", r.Method, r.URL.Path)); after
exercising the client, read from errCh (non-blocking or with close) in the test
goroutine and call t.Fatalf/t.Errorf there if a message was received. Ensure you
update the code around newFakeAuth and the inline handler to use the channel and
remove the t.Fatalf from the handler.

Comment on lines +400 to 403
lookupID := resolveAccessTokenLookupID(cr)
if lookupID == "" {
return errors.New("can't find access token id")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid hard-failing Delete when provider lookup ID is absent.

At Line 401, returning an error for empty lookupID can leave the MR stuck in deletion (finalizer never clears) when no external ID was ever persisted. Treat this as already-deleted/no-op.

Suggested fix
  lookupID := resolveAccessTokenLookupID(cr)
  if lookupID == "" {
-   return errors.New("can't find access token id")
+   c.log.Info("no access token id recorded; treating delete as no-op")
+   return nil
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
lookupID := resolveAccessTokenLookupID(cr)
if lookupID == "" {
return errors.New("can't find access token id")
}
lookupID := resolveAccessTokenLookupID(cr)
if lookupID == "" {
c.log.Info("no access token id recorded; treating delete as no-op")
return nil
}
🤖 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/nbaccesstoken/nbaccesstoken.go` around lines 400 - 403,
In the Delete handler, do not hard-fail when resolveAccessTokenLookupID(cr)
returns an empty string; instead treat an empty lookupID as
already-deleted/no-op by skipping any external-provider deletion and returning
nil (optionally log a debug/info message). Update the logic in the method that
calls resolveAccessTokenLookupID so that when lookupID == "" it does not return
errors.New(...) but returns nil after ensuring any finalizer removal or local
cleanup still occurs.

Comment on lines +226 to 234
if isGroupNotFoundError(err) {
c.log.Info("group not found", "lookup-id", lookupID)
return managed.ExternalObservation{
ResourceExists: false,
}, nil
}
// Don't swallow transient errors — Crossplane should requeue, not call Create.
return managed.ExternalObservation{}, errors.Wrapf(err, "failed to observe group %q", lookupID)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing adopt-by-name fallback after ID not-found.

When isGroupNotFoundError is true, this branch returns ResourceExists: false immediately without falling back to adoption by name. This is inconsistent with the pattern in nbpolicy.go (lines 163-169), which continues to the adoption-by-name path when the ID lookup fails.

If a group's ID becomes stale (e.g., the group was deleted and recreated externally), this controller will attempt to create a duplicate instead of adopting the existing group with the matching name.

Consider restructuring to match the policy controller's pattern: try ID lookup first, then fall back to adoption-by-name on not-found.

Suggested approach
 c.log.Info("external name set, fetching by ID", "lookupID", lookupID)
-apigroup, err := client.Groups.Get(ctx, lookupID)
+var group *nbapi.Group
+apigroup, err := client.Groups.Get(ctx, lookupID)
 if err != nil {
     if auth.IsTokenInvalidError(err) {
         c.authManager.ForceRefresh(ctx)
         return managed.ExternalObservation{}, err
     }
-    if isGroupNotFoundError(err) {
-        c.log.Info("group not found", "lookup-id", lookupID)
-        return managed.ExternalObservation{
-            ResourceExists: false,
-        }, nil
+    if !isGroupNotFoundError(err) {
+        return managed.ExternalObservation{}, errors.Wrapf(err, "failed to observe group %q", lookupID)
     }
-    // Don't swallow transient errors — Crossplane should requeue, not call Create.
-    return managed.ExternalObservation{}, errors.Wrapf(err, "failed to observe group %q", lookupID)
+    c.log.Info("group not found by id, attempting adoption by name", "lookup-id", lookupID)
+} else {
+    group = apigroup
+}
+
+if group == nil {
+    // Adoption by Name fallback
+    groups, err := client.Groups.List(ctx)
+    if err != nil {
+        if auth.IsTokenInvalidError(err) {
+            c.authManager.ForceRefresh(ctx)
+            return managed.ExternalObservation{}, err
+        }
+        return managed.ExternalObservation{}, errors.Wrap(err, "failed to list groups for adoption")
+    }
+    for _, apigroup := range groups {
+        if apigroup.Name == cr.Spec.ForProvider.Name {
+            c.log.Info("found existing group for adoption", "groupid", apigroup.Id)
+            group = &apigroup
+            break
+        }
+    }
+    if group == nil {
+        return managed.ExternalObservation{ResourceExists: false}, nil
+    }
 }
-group := *apigroup
🤖 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/nbgroup/nbgroup.go` around lines 226 - 234, The observe
logic in nbgroup.go currently returns ResourceExists:false when
isGroupNotFoundError(err) is true, which prevents the adoption-by-name fallback;
change the control flow in the Observe method so that when
isGroupNotFoundError(err) is true you do not return immediately but continue
into the adoption-by-name branch (the same pattern used in nbpolicy.go around
lines 163-169): remove the early return, allow the code to attempt a name-based
lookup using lookupID/name, and only return ResourceExists:false after both ID
and name adoption attempts fail; keep the existing wrapped error return for
non-not-found errors.

Comment on lines +297 to +307
func resolveUserLookupID(cr *v1alpha1.NbUser) string {
externalName := meta.GetExternalName(cr)
switch {
case externalName == "":
return cr.Status.AtProvider.Id
case cr.Status.AtProvider.Id != "" && externalName == cr.GetName() && cr.Status.AtProvider.Id != externalName:
// Recover from older reconciles that defaulted the external name to the Kubernetes object name.
return cr.Status.AtProvider.Id
default:
return externalName
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Recover from spec.forProvider.name in resolveUserLookupID to prevent invalid Update/Delete IDs.

Line 302 only recovers when external-name equals the Kubernetes object name. When external-name equals spec.forProvider.name, the resolver returns the name instead of status.atProvider.id, so Lines 349 and 379 can call Users.Update/Delete with a non-ID value.

Suggested fix
func resolveUserLookupID(cr *v1alpha1.NbUser) string {
	externalName := meta.GetExternalName(cr)
	switch {
	case externalName == "":
		return cr.Status.AtProvider.Id
-	case cr.Status.AtProvider.Id != "" && externalName == cr.GetName() && cr.Status.AtProvider.Id != externalName:
-		// Recover from older reconciles that defaulted the external name to the Kubernetes object name.
+	case cr.Status.AtProvider.Id != "" &&
+		cr.Status.AtProvider.Id != externalName &&
+		(externalName == cr.GetName() || externalName == cr.Spec.ForProvider.Name):
+		// Recover when external-name is object name (older reconciles)
+		// or spec name (adoption hint), using durable provider ID from status.
		return cr.Status.AtProvider.Id
	default:
		return externalName
	}
}

Also applies to: 349-352, 379-383

🤖 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/nbuser/nbuser.go` around lines 297 - 307, The resolver
resolveUserLookupID currently treats an external name equal to the Kubernetes
object name as a legacy default and returns status.AtProvider.Id, but it fails
to treat an external name equal to spec.forProvider.name the same way; update
resolveUserLookupID so the recovery branch checks if cr.Status.AtProvider.Id !=
"" && (externalName == cr.GetName() || externalName == cr.Spec.ForProvider.Name)
&& cr.Status.AtProvider.Id != externalName and then return
cr.Status.AtProvider.Id; ensure callers (Users.Update and Users.Delete) continue
to use resolveUserLookupID so they receive the real ID rather than
spec.forProvider.name.

Callum MacDonald and others added 2 commits June 13, 2026 23:40
The netbird SDK v0.71.4 bump (92df245) raised the go.mod go directive to
1.25.5, but CI still pinned GO_VERSION=1.21. setup-go then installed 1.21 and
the 1.25.5 directive forced a toolchain auto-download whose `covdata` tool is
not present, so the coverage step (`go test -covermode=count -coverprofile`)
failed with `go: no such tool "covdata"` even though every test passed.
Pinning GO_VERSION to the go.mod version makes setup-go install the matching
toolchain directly. Verified locally: CI's exact coverage command is green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generalize comments and test data for upstream readability — no behavior
change:
- Comments framed the name-valued external-name case specifically around
  compositions/composite controllers stamping the display name. Reword to
  describe the general mechanism (external-name may hold a defaulted object
  name, or a display name used as an adoption hint).
- Test fixtures used environment-specific group names ("bao-routers");
  replace with generic names ("routers"/"clients", "g-routers").
- Drop a stray cluster reference in a test comment and rename the subtest
  accordingly.

Comment/fixture-only; all controller tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dfry
dfry merged commit c45c47b into netbirdio:main Jun 30, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants