Conversation
Every PulsarNamespace that did not declare `bookieAffinityGroup` issued an unconditional DELETE .../persistence/bookieAffinity on each reconcile, with no IsNotFound tolerance -- unlike the neighbouring RemoveTopicAutoCreation and RemoveInactiveTopicPolicies branches in the same function. Pulsar implements the delete as a set of a null group (NamespacesBase#internalDeleteBookieAffinityGroupAsync), and both the setter and the getter call validateSuperUserAccessAsync(). So a tenant-admin connection got 401/403 on that DELETE, ApplyNamespace failed, and the namespace never reached Ready -- for users who never touched bookie affinity at all. It also created the local-policies znode with default bundle data when there was nothing to delete. Read the current group first and write only on an actual diff. An unset-to-unset transition now touches Pulsar not at all, which removes the superuser dependency for namespaces that do not use the feature. Both of Pulsar's "no group" answers are normalized: 404 "Namespace local-policies does not exist" when the namespace has no local policies, and 200 with an empty group when they were cleared. Also: - Correct the BookieAffinityGroup godoc, which described PulsarNSIsolationPolicy -- a different feature -- and made the field read as something it is not. - Require MinLength=1 on both group names. An empty primary passed CRD validation and was pushed to Pulsar, where BookieRackAffinityMapping cannot place ledgers. - Document the end-to-end carve-out: PulsarNSIsolationPolicy for brokers paired with bookieAffinityGroup for bookies, including the rack-metadata prerequisite and the superuser requirement. The CRD schema was updated by hand rather than via `make manifests`: the Makefile pins controller-gen v0.17.0 but the committed CRDs were generated with v0.15.0, so regenerating rewrites all 19 CRDs and strips their license headers. The bookieAffinityGroup block matches the generator output exactly. The version skew is pre-existing and needs its own change. Fixes streamnative#420 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@david-streamlio:Thanks for your contribution. For this PR, do we need to update docs? |
freeznet
left a comment
There was a problem hiding this comment.
The read-before-write approach reduces redundant writes, but the tenant-admin reconciliation failure remains because the new read requires the same superuser permission. The existing affinity unit tests pass; additional HTTP-backed cases with an omitted group and a 401/403 GET both fail. Please address the permission boundary before merging.
| // so an unset-to-unset transition must not touch Pulsar at all. | ||
| func (p *PulsarAdminClient) applyBookieAffinityGroup(completeNSName string, | ||
| desired *v1alpha1.BookieAffinityGroupData) error { | ||
| current, err := p.getBookieAffinityGroup(completeNSName) |
There was a problem hiding this comment.
[P1] Avoid failing on the privileged read when no affinity group is requested
This GET is unconditional, including when desired == nil. Pulsar's NamespacesBase.internalGetBookieAffinityGroupAsync() calls validateSuperUserAccessAsync() before reading local policies, so a tenant-admin connection still receives 401 (or 403) even when no group exists. The error returned here still aborts applyNamespacePolicies before subsequent policies and prevents the namespace from becoming Ready; replacing DELETE with GET therefore does not fix the permission problem described in this PR. Please make permission failures non-blocking for the omitted-field path (with a log explaining that an existing group may be retained), while preserving failures for explicitly requested groups and other errors. Add cases that call applyNamespacePolicies with BookieAffinityGroup: nil, return 401/403 from the affinity endpoint, and assert that a later policy is applied. I ran those two cases against this head and both fail; the existing TestApplyBookieAffinityGroup* tests pass because they do not cover this combination.
There was a problem hiding this comment.
Fixed in cc0ed65.
applyBookieAffinityGroup now splits the denial by intent rather than treating every read error the same way:
desired == nil— nothing to converge, so a 401/403 on the read is logged and skipped instead of returned. This is the path that was blocking reconcile for namespaces that never touch the feature.- group requested — unchanged, the failure still surfaces. Skipping it would leave the CR claiming a configuration Pulsar never received.
Everything else propagates as before.
The test is a new IsPermissionDenied(err) next to IsNotFound in pkg/admin/errors.go. ReasonUnauthorized and ReasonForbidden were already declared there but had no consumers anywhere in the repo, so an inline status-code comparison would just have left the same gap for the next person.
Tests are the two you specified, driven through applyNamespacePolicies rather than the helper — you were right that the isolation was the reason the existing cases missed this, since the breaking combination is "field omitted" and "read denied" together:
- 401 and 403 with
BookieAffinityGroup: nil→ no error, andautoTopicCreation(the policy immediately after the affinity block) still applied. - 401 and 403 with a group requested → still fails, and the reconcile does not continue past the write it could not make.
IsPermissionDeniedacross 401/403/404/500, wrapped and pointerrest.Error, non-REST errors.
I confirmed both tolerance cases fail on 754d4cb and pass on cc0ed65 — matching what you saw. go build ./..., go vet, and go test ./pkg/... are green.
I also corrected the PR description: the "removes the superuser dependency" claim was wrong, and it now says plainly that read-before-write alone just moved the requirement from a DELETE to a GET.
Still open from my earlier comment, and I did not implement it unilaterally: the retained-group case is currently visible only in operator logs, so a tenant-admin user who removes bookieAffinityGroup gets a Ready CR while Pulsar keeps the old group. Want a status condition on the PulsarNamespace alongside the log, or is the log sufficient for now? Happy to add it in this PR or leave it for a follow-up.
|
@freeznet Thanks — the P1 is correct, and it's a real hole in this PR. I traced it end to end and every link holds:
So consequence #1 in the PR description — the headline bug — is not fixed here. I swapped an unconditional superuser-gated DELETE for an unconditional superuser-gated GET. Consequences #2 (spurious Proposed approachYour shape is the right one, and I want to be explicit about why the two obvious alternatives are worse:
So: tolerate permission failures only on the omitted-field path, keep them fatal when a group is explicitly requested. Concretely —
One thing I'd like your read onYour suggestion was a log line explaining that an existing group may be retained. My concern is that this creates spec/reality drift that's only visible in operator logs: a tenant-admin user who removes Would you prefer a status condition on the TestsTaking your guidance as written — driving
The existing Two areas still unreviewedWhile you're back in here, these didn't draw comment and I'd value a look:
I'll push the fix once you've weighed in on the status-condition question. |
Reading the bookie affinity group is superuser-only in Pulsar (internalGetBookieAffinityGroupAsync calls validateSuperUserAccessAsync, same as the setter), so the read-before-write introduced earlier in this branch did not lift the superuser requirement it claimed to — it moved it from an unconditional DELETE to an unconditional GET. A tenant-admin connection is denied on both, and the error aborts applyNamespacePolicies ahead of every policy that follows, so the namespace never reaches Ready. Handle the denial by intent. When no group is requested there is nothing to converge, so a 401/403 on the read is logged and skipped; a group set out of band is retained rather than removed, which the log states. When a group is requested the operator has been told to write, so a permission failure still surfaces instead of silently skipping a required write. Every other read failure propagates as before. IsPermissionDenied joins IsNotFound in pkg/admin/errors.go. ReasonUnauthorized and ReasonForbidden were already declared there with no consumers in the repo; this is the first. The new tests drive applyNamespacePolicies rather than the helper in isolation, because the combination that broke is "field omitted" and "read denied" together — which the existing TestApplyBookieAffinityGroup cases never cover. Both tolerance cases fail on the previous head. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CL4S6vJN47N2K56vh37Hcw
freeznet
left a comment
There was a problem hiding this comment.
Re-reviewed cc0ed65. The previous P1 is addressed: an omitted affinity group now tolerates a denied read (401/403) and continues applying subsequent namespace policies, while explicitly requested groups and other read errors still fail. The retained-group behavior is logged.
Validation: go test ./pkg/admin -count=1 passed, including the two independent omitted-field 401/403 reproductions that failed on the previous head. The affinity and permission tests also passed with -race. All current CI checks are successful, including both operator E2E jobs. I did not run a live tenant-admin cluster locally.
No new blocking findings. Logging the retained-group case is sufficient for this scoped fix; a dedicated status condition can be considered separately.
…ng (#421) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #420.
spec.bookieAffinityGroupalready existed onPulsarNamespaceand was already reconciled, so this is not a new feature — it is a fix to the reconcile path, plus the validation and docs the field was missing. Rationale for the reframing is in this issue comment.The bug
Every
PulsarNamespacethat did not declarebookieAffinityGroupissued an unconditionalDELETE .../persistence/bookieAffinityon each reconcile, with noIsNotFoundtolerance — unlike the neighbouringRemoveTopicAutoCreationandRemoveInactiveTopicPoliciesbranches in the same function:Pulsar implements that delete as a set of a null group (
NamespacesBase#internalDeleteBookieAffinityGroupAsync→internalSetBookieAffinityGroupAsync(null)), and both the setter and the getter callvalidateSuperUserAccessAsync(). Consequences:ApplyNamespacefails, and the namespace never reachesReady— for users who never touched bookie affinity at all.setLocalPoliciesWithCreatecreates the local-policies znode with default bundle data even when there was nothing to delete.The fix
applyBookieAffinityGroupreads the current group first and writes only on an actual diff. Both of Pulsar's "no group" answers are normalized to nil: a 404"Namespace local-policies does not exist"when the namespace has no local policies, and a 200 carrying an empty group when they were cleared.Reading is superuser-gated too (
internalGetBookieAffinityGroupAsynccallsvalidateSuperUserAccessAsync, same as the setter), so read-before-write on its own does not lift the superuser requirement — it just moves it from a DELETE to a GET. A tenant-admin connection is denied either way, and the error still abortsapplyNamespacePoliciesbefore every policy that follows.So the denial is handled explicitly, split by intent:
bookieAffinityGroupabsent) — there is nothing to converge, so a 401/403 on the read is logged and skipped rather than failed. This is what actually unblocks reconcile for namespaces that never use the feature. The trade-off is that a group set out of band is retained rather than removed; the log says so.Any other read failure still propagates rather than being mistaken for "unset".
The 401/403 test is
IsPermissionDenied(err), a new helper inpkg/admin/errors.go.ReasonUnauthorizedandReasonForbiddenwere already declared there but had no consumers anywhere in the repo; this is the first.Also in this PR
BookieAffinityGroupgodoc. It read "is the name of the namespace isolation policy to apply to the namespace", which describesPulsarNSIsolationPolicy— a different feature. That wording is why Expose bookie affinity groups (namespace-level bookie isolation) as a managed resource #420 was filed as "not exposed through any CRD".MinLength=1on both group names. An empty primary passed CRD validation and was pushed to Pulsar, whereBookieRackAffinityMappingcannot place ledgers. Only""becomes invalid, so no working configuration is rejected.docs/pulsar_namespace.mdpairingPulsarNSIsolationPolicy(brokers) withbookieAffinityGroup(bookies), including the rack-metadata prerequisite and the superuser requirement. Cross-linked fromdocs/pulsar_ns_isolation_policy.md.Tests
pkg/admin/bookie_affinity_group_test.go— 7 table cases over anhttptestserver in the style ofnamespace_backlog_quota_test.go: unset stays unset without writing, cleared group is not deleted again, removal deletes, set when absent, matching group left alone, changed group rewritten, dropped secondary rewritten. Plus read-failure propagation.tests/operator/resources_test.go— aPulsarNamespace Bookie Affinity Groupcontext covering create → update → empty-primary rejection → field removal, mirroring the offload-policies coverage from feat(namespace): add offload policies support #413.pkg/admin/bookie_affinity_group_test.go— the permission cases from @freeznet's review, driven throughapplyNamespacePoliciesrather than the helper in isolation, because the combination that broke is "field omitted" and "read denied": affinity endpoint returns 401/403 with no group requested → no error and a later policy still applied; the same denial with a group requested → still fails, and the reconcile does not continue past a write it could not make. Both tolerance cases fail on the previous head of this branch.pkg/admin/errors_test.go—IsPermissionDeniedacross 401/403/404/500, wrapped and pointerrest.Error, and non-REST errors.Verified:
go build ./...,go test ./pkg/... ./api/...(including the existingapplyNamespacePoliciesbacklog-quota tests, which now traverse the new read path),make fmt vet,make license-check(479 files, 0 invalid). E2E not executed — needs a live cluster viaADMIN_SERVICE_URL.Note for reviewers
The CRD schema was updated by hand, deliberately.
make manifestspulls controller-gen v0.17.0 (pinned atMakefile:201) but the committed CRDs were generated with v0.15.0. Running it rewrites all 19 CRDs — ~1300 lines of unrelated churn — and strips the Apache license header from every YAML. Only thebookieAffinityGroupblock was applied here, byte-matching the generator's output. The Makefile/CRD version skew is pre-existing and wants its own PR.The sn-operator half of #420 (declarative bookie-to-isolation-group membership on
BookKeeperCluster) is a genuinely separate gap and belongs on that repo — nothing here can express it.🤖 Generated with Claude Code