Skip to content

fix(api): apply labelSelector in TenantSecret Watch - #2542

Merged
Aleksei Sviridkin (lexfrei) merged 2 commits into
mainfrom
fix/tenantsecret-watch-labelselector
Apr 30, 2026
Merged

Aleksei Sviridkin (lexfrei) merged 2 commits into
mainfrom
fix/tenantsecret-watch-labelselector

Conversation

@IvanHunters

@IvanHunters IvanHunters commented Apr 29, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

  • Merges opts.LabelSelector into the underlying watch selector in TenantSecret.Watch(), mirroring the behaviour List() already has.
  • Handles non-selectable selectors (e.g. labels.Nothing()) by returning a watcher with a closed result channel.
  • Adds a defensive post-filter in the streaming goroutine so events never escape the requested selector even if a future client implementation skips label filtering on watch.
  • Adds rest_test.go covering label-selector merging in List and the new Watch filtering behaviour.

Watch() previously ignored opts.LabelSelector entirely; only the required internal.cozystack.io/tenantresource=true label was passed through. As a result, watch consumers (e.g. dashboards using cached client subscriptions) saw every tenant Secret in the namespace regardless of the selector they requested. List() already merged the user selector correctly, so the inconsistency was easy to miss.

The Watch unit test creates Secrets after the watch starts because sigs.k8s.io/controller-runtime/pkg/client/fake.Client.Watch does not emit initial ADDED events for objects already in the tracker; this still exercises the merged-selector path through the defensive post-filter.

Screenshots

Not applicable — backend-only change.

Release note

fix(api): TenantSecret watch endpoint now honors `labelSelector`, returning only matching Secrets in event streams instead of every tenant Secret in the namespace.

Summary by CodeRabbit

  • Bug Fixes

    • Improved label selector handling for tenant secret listing and watching: special selectors are recognized, non-selectable selectors return empty results or a closed watch, and watched events are defensively filtered so only matching items are delivered.
  • Tests

    • Added comprehensive tests covering listing and watch behaviors across various label selector scenarios to prevent leaks and ensure correct event delivery.

Watch() in the TenantSecret REST handler hard-coded the selector to the
internal.cozystack.io/tenantresource=true label and discarded
opts.LabelSelector entirely. Watch streams therefore returned every
tenant Secret in the namespace regardless of the user-provided selector,
while List() filtered correctly. Clients that switch to Watch after the
initial List surfaced unrelated Secrets.

Merge opts.LabelSelector into the underlying watch selector the same
way List() already does, handle non-selectable selectors by returning a
closed watcher, and add a defensive post-filter in the streaming
goroutine so events never escape the requested selector even if a future
client implementation skips label filtering on Watch.

Adds rest_test.go covering label-selector merging in List and a Watch
test that creates Secrets after the watch starts (since fake.Client.Watch
does not emit initial ADDED events) to assert only matching events are
observed.

Signed-off-by: IvanHunters <[email protected]>
@coderabbitai

coderabbitai Bot commented Apr 29, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Centralizes tenant label-selector construction with buildTenantSelector, returns immediate empty results for non-selectable selectors in List, reuses emptyList() for namespace/field mismatches, and updates Watch to use the merged selector, close immediately for non-selectable selectors, and drop non-matching non-DELETE events.

Changes

Cohort / File(s) Summary
REST Handler Logic
pkg/registry/core/tenantsecret/rest.go
Adds buildTenantSelector and emptyList() helpers. List uses the merged selector and returns emptyList() for non-selectable selectors or namespace/field mismatches. Watch uses the merged selector, returns a watcher that closes immediately for non-selectable selectors, and defensively filters out non-DELETED events whose objects don't match the selector.
Test Suite
pkg/registry/core/tenantsecret/rest_test.go
New tests covering List with/without selectors (tenant-labeled vs non-tenant Secrets, labels.Everything() and labels.Nothing()), and Watch behavior (immediate close for labels.Nothing(), verifying only matching tenant Secrets produce Added events). Includes helpers for fake client/scheme setup and deterministic event draining.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 Soft paws sift through labels’ maze,
I build selectors, tidy the ways,
Empty lists close where none belong,
Watchful hops drop events that’re wrong,
A rabbit cheers — tidy registry, hooray!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: applying labelSelector filtering to TenantSecret Watch operations, which addresses a bug where the selector was previously ignored.
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.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tenantsecret-watch-labelselector

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
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

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

@github-actions github-actions Bot added area/api Issues or PRs related to the cozystack-api aggregated API server kind/bug Categorizes issue or PR as related to a bug labels Apr 29, 2026
@IvanHunters
IvanHunters marked this pull request as ready for review April 29, 2026 17:49
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses an inconsistency where the TenantSecret.Watch() endpoint ignored user-provided label selectors, causing consumers to receive all tenant Secrets in a namespace. The changes ensure that the watch stream correctly honors these selectors, mirroring the existing behavior of the List() endpoint, and adds defensive measures to prevent data leakage.

Highlights

  • Label Selector Integration: Merged opts.LabelSelector into the TenantSecret.Watch() selector to ensure consistency with List() behavior.
  • Defensive Filtering: Implemented a post-filter in the streaming goroutine to guarantee that events strictly adhere to the requested selector.
  • Edge Case Handling: Added logic to handle non-selectable selectors by returning a closed watcher channel.
  • Testing: Introduced rest_test.go to validate both List and Watch filtering behaviors.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@dosubot dosubot Bot added the size/M This PR changes 30-99 lines, ignoring generated files label Apr 29, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements proper label selector support for the TenantSecret REST implementation in both List and Watch operations. It ensures that user-provided label selectors are correctly merged with the internal tenant resource filter. Additionally, it adds a defensive post-filtering step in the Watch event loop and includes a comprehensive suite of unit tests to verify these behaviors and prevent regressions. I have no feedback to provide.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

REQUEST_CHANGES — the new defensive post-filter on the Watch event stream silently drops legitimate transition-out events, which breaks cache consistency for the very dashboard use case this PR is meant to fix.

Business context: TenantSecret.Watch ignored opts.LabelSelector and emitted every tenant Secret in the namespace; the goal is to merge the user's selector into the underlying watch and stream only matching events.

Blockers

B1: defensive post-filter drops transition-out DELETED events

File: pkg/registry/core/tenantsecret/rest.go:517-522
Issue: The defensive ls.Matches(labels.Set(sec.Labels)) post-filter at line 520 drops every event whose object no longer matches the selector. That includes the legitimate DELETED event the apiserver emits when a Secret's labels mutate out of the watched selector — for example when apps.cozystack.io/application.name is changed from harbor-prod to harbor-staging.
Evidence: Standard apiserver watch semantics (https://kubernetes.io/docs/reference/using-api/api-concepts/#filtering-during-watch) require that, when an UPDATE results in an object no longer matching the filter, the apiserver synthesizes a DELETED event with the post-update labels. The post-filter at rest.go:520 evaluates the object's current labels — which by definition do not match the selector — so the DELETED event is dropped. Cached client subscriptions (e.g. dashboards that rely on watch-driven local stores) never learn the object left their selected set and retain stale TenantSecrets indefinitely.
Impact: Selector-aware watch consumers — the exact use case the PR description names — accumulate stale entries whenever a tenant Secret's labels change. The bug only manifests against a real apiserver, not the fake client used in TestWatch_WithLabelSelector_FiltersEvents, so the existing test won't catch the regression.
Fix: Either drop the post-filter entirely (trust the underlying watch's selector handling) or gate it on event type so DELETED events pass through untouched: if ev.Type != watch.Deleted && !ls.Matches(labels.Set(sec.Labels)) { continue }. The apiserver will not emit ADDED/MODIFIED for non-matching objects, and emits DELETED specifically for transitions out — those must be forwarded so clients can evict stale entries.

Non-blocking follow-ups

  1. No test exercises labels.Nothing() (or any non-selectable selector) for either List (rest.go:259-262) or Watch (rest.go:456-460). The labels.Nothing() branch in Watch in particular is the only path that returns a closed-channel ProxyWatcher, and it is currently untested.

  2. Selector-merge logic (labels.NewSelector(), internal-tenant requirement, Requirements() with selectable check) is duplicated between List (rest.go:244-266) and Watch (rest.go:450-465). A helper like buildTenantSelector(opts) (labels.Selector, bool /*selectable*/) would centralize the rule. Not urgent.

Comment thread pkg/registry/core/tenantsecret/rest.go Outdated
// Defensive: post-filter against the merged selector. The underlying
// watch already filters by label, but this guards against any client
// implementation that doesn't honor LabelSelector on Watch.
if !ls.Matches(labels.Set(sec.Labels)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This post-filter drops legitimate transition-out DELETED events. When a Secret's labels mutate out of the selector (e.g. application.name changes from harbor-prod to harbor-staging), the apiserver synthesizes a DELETED event with the post-update labels — which by construction don't match ls. The current ls.Matches(...) check drops that event, so cached client subscriptions never learn the object left their watched set.

Suggested fix: gate the filter on event type so DELETED passes through:

if ev.Type != watch.Deleted && !ls.Matches(labels.Set(sec.Labels)) {
    continue
}

The apiserver only emits ADDED/MODIFIED for objects in the selected set, so dropping non-matching ones in those cases is still safe defense-in-depth.

…te selector logic

- Gate defensive label post-filter on event type: DELETED events now always
  pass through so cached clients can evict stale entries when a Secret's
  labels mutate out of the watched selector
- Extract buildTenantSelector helper to eliminate duplicated selector-merge
  code between List and Watch
- Add TestList_WithNothingSelector_ReturnsEmpty and
  TestWatch_WithNothingSelector_ClosesImmediately to cover the previously
  untested labels.Nothing() path

Signed-off-by: IvanHunters <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
pkg/registry/core/tenantsecret/rest_test.go (1)

302-337: Avoid fixed timeout waiting when expected events are already observed.

collectAddedNames currently drains until timeout/channel close, so this test usually waits the full timeout even after getting the needed event. Consider early-return once expected ADDED events are collected to reduce latency/flakiness.

Suggested refactor
-	got := collectAddedNames(t, w, 500*time.Millisecond)
+	got := collectAddedNames(t, w, 1, 2*time.Second)

-func collectAddedNames(t *testing.T, w watch.Interface, timeout time.Duration) []string {
+func collectAddedNames(t *testing.T, w watch.Interface, expected int, timeout time.Duration) []string {
 	t.Helper()
 	names := make([]string, 0)
 	deadline := time.NewTimer(timeout)
 	defer deadline.Stop()

 	for {
 		select {
 		case ev, ok := <-w.ResultChan():
 			if !ok {
 				sort.Strings(names)
 				return names
 			}
 			if ev.Type != watch.Added {
 				continue
 			}
 			ts, ok := ev.Object.(*corev1alpha1.TenantSecret)
 			if !ok {
 				t.Fatalf("expected *TenantSecret in event, got %T", ev.Object)
 			}
 			names = append(names, ts.Name)
+			if expected > 0 && len(names) >= expected {
+				sort.Strings(names)
+				return names
+			}
 		case <-deadline.C:
 			sort.Strings(names)
 			return names
 		}
 	}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/registry/core/tenantsecret/rest_test.go` around lines 302 - 337,
collectAddedNames currently always waits the full timeout or channel close even
when the test already observed the expected ADDED events; change
collectAddedNames to accept an expectedCount (or a boolean to early-return on
first match) and in the loop after appending ts.Name check if len(names) >=
expectedCount then sort and return immediately. Update the test call that does
got := collectAddedNames(t, w, 500*time.Millisecond) to pass expectedCount 1
(since the test expects "harbor-test-credentials"), keeping the existing
behavior of ignoring non-Added events and type-checking TenantSecret.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/registry/core/tenantsecret/rest_test.go`:
- Around line 194-196: The test silently ignores the error from labels.Parse
when building sel; change the call to capture the error (e.g., sel, err :=
labels.Parse(...)) and assert/fail if err != nil (for example use
t.Fatalf("failed to parse selector: %v", err) or the test helper you use such as
require.NoError) so parsing failures surface during test runs; update the sel
variable usage accordingly in rest_test.go.

---

Nitpick comments:
In `@pkg/registry/core/tenantsecret/rest_test.go`:
- Around line 302-337: collectAddedNames currently always waits the full timeout
or channel close even when the test already observed the expected ADDED events;
change collectAddedNames to accept an expectedCount (or a boolean to
early-return on first match) and in the loop after appending ts.Name check if
len(names) >= expectedCount then sort and return immediately. Update the test
call that does got := collectAddedNames(t, w, 500*time.Millisecond) to pass
expectedCount 1 (since the test expects "harbor-test-credentials"), keeping the
existing behavior of ignoring non-Added events and type-checking TenantSecret.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 51bbc893-bc33-4363-b38a-87a66468970c

📥 Commits

Reviewing files that changed from the base of the PR and between c913c9d and 4e97cb3.

📒 Files selected for processing (2)
  • pkg/registry/core/tenantsecret/rest.go
  • pkg/registry/core/tenantsecret/rest_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/registry/core/tenantsecret/rest.go

Comment on lines +194 to +196
sel, _ := labels.Parse(
"apps.cozystack.io/application.kind=Bucket,apps.cozystack.io/application.name=test",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Handle selector parse errors instead of ignoring them.

At Line 194, labels.Parse error is discarded. Even in tests, silently ignoring parse failures can mask regressions.

Proposed fix
-	sel, _ := labels.Parse(
+	sel, err := labels.Parse(
 		"apps.cozystack.io/application.kind=Bucket,apps.cozystack.io/application.name=test",
 	)
+	if err != nil {
+		t.Fatalf("parse selector: %v", err)
+	}
📝 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
sel, _ := labels.Parse(
"apps.cozystack.io/application.kind=Bucket,apps.cozystack.io/application.name=test",
)
sel, err := labels.Parse(
"apps.cozystack.io/application.kind=Bucket,apps.cozystack.io/application.name=test",
)
if err != nil {
t.Fatalf("parse selector: %v", err)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/registry/core/tenantsecret/rest_test.go` around lines 194 - 196, The test
silently ignores the error from labels.Parse when building sel; change the call
to capture the error (e.g., sel, err := labels.Parse(...)) and assert/fail if
err != nil (for example use t.Fatalf("failed to parse selector: %v", err) or the
test helper you use such as require.NoError) so parsing failures surface during
test runs; update the sel variable usage accordingly in rest_test.go.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — the post-filter fix lands exactly as suggested (DELETED events pass through, ADDED/MODIFIED still gated by the selector), the selector-merge logic is now factored into buildTenantSelector, and both labels.Nothing() paths (List + Watch) are covered by tests.

Business context: TenantSecret.Watch ignored opts.LabelSelector and emitted every tenant Secret in the namespace; the goal is to merge the user's selector into the underlying watch and stream only matching events.

Resolved since previous review

  • B1 (defensive post-filter dropping transition-out DELETED) — fixed in 4e97cb3 with if ev.Type != watch.Deleted && !ls.Matches(...) { continue } plus an inline comment explaining that the apiserver synthesizes DELETED with the post-mutation labels.
  • Non-blocking #1 (no test for labels.Nothing()) — addressed by TestList_WithNothingSelector_ReturnsEmpty and TestWatch_WithNothingSelector_ClosesImmediately.
  • Non-blocking #2 (selector-merge duplication between List and Watch) — addressed by extracting buildTenantSelector(opts).

@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Apr 29, 2026
@lexfrei
Aleksei Sviridkin (lexfrei) merged commit 5278a63 into main Apr 30, 2026
11 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the fix/tenantsecret-watch-labelselector branch April 30, 2026 09:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api Issues or PRs related to the cozystack-api aggregated API server kind/bug Categorizes issue or PR as related to a bug lgtm This PR has been approved by a maintainer size/M This PR changes 30-99 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants