Skip to content

Conversation

@evakhoni
Copy link
Contributor

@evakhoni evakhoni commented Jan 27, 2026

Mirrors the labels from spec.selector.matchLabels to metadata.labels
during lease creation. This allows the controller to filter leases
server-side when using label selectors (e.g., jmp get leases -l key=val).

Fixes: #36

Summary by CodeRabbit

  • New Features

    • Lease objects now inherit labels from selector matchLabels.
    • Client tooling adds label-selector parsing and optional client-side selector filtering.
  • Bug Fixes

    • Lease listing applies an active-only filter by default when unspecified.
  • Tests

    • New unit and end-to-end tests covering label propagation, selector parsing, matchExpressions-only, negation, and selector-filtering behavior.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 27, 2026

📝 Walkthrough

Walkthrough

Copies selector matchLabels into Lease.ObjectMeta.Labels during protobuf conversion, adds an implicit "lease-ended DoesNotExist" label requirement to ListLeases when OnlyActive is nil/true, and introduces Python client-side label-selector parsing/filtering plus unit and e2e tests for selector behavior.

Changes

Cohort / File(s) Summary
Lease helper update
controller/api/v1alpha1/lease_helpers.go
Populate constructed Lease.ObjectMeta.Labels with parsed selector MatchLabels in LeaseFromProtobuf.
Go unit tests
controller/api/v1alpha1/lease_helpers_test.go
Add tests verifying MatchLabels are copied to lease labels and selectors with only matchExpressions yield empty labels.
Client service selector filtering
controller/internal/service/client/v1/client_service.go
When OnlyActive is nil/true, augment the label selector with a DoesNotExist requirement for the lease-ended label and use the combined selector for listing leases.
End-to-end tests
e2e/tests.bats
Add regression checks that jmp get leases -l <selector> respects selectors (matching and no-resources cases).
Python client selector parsing & filtering
python/packages/jumpstarter/jumpstarter/client/selectors.py, python/packages/jumpstarter/jumpstarter/client/selectors_test.py, python/packages/jumpstarter/jumpstarter/client/grpc.py, python/packages/jumpstarter-cli/jumpstarter_cli/get.py
Add parse_label_selector and selector_contains; add LeaseList.filter_by_selector and call it from CLI to apply client-side filtering; add unit tests.
Module changes
go.mod
Add dependency for k8s.io/apimachinery/pkg/selection used to construct label requirement.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant CLI as Client CLI
participant PyClient as Python client
participant CS as ClientService
participant API as K8s API Server
CLI->>PyClient: request ListLeases(selector, only_active)
PyClient->>CS: gRPC ListLeases(selector, only_active)
CS->>CS: if only_active nil/true, add lease-ended DoesNotExist to selector
CS->>API: GET /leases?labelSelector=combined-selector
API-->>CS: filtered lease list
CS-->>PyClient: return leases (may be paginated)
PyClient->>PyClient: optionally .filter_by_selector(selector) to enforce selector semantics
PyClient-->>CLI: present final filtered leases

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • jumpstarter-dev/jumpstarter#353 — Related selector/label representation changes and client-side selector handling.
  • jumpstarter-dev/jumpstarter#338 — Related adjustments to lease selector/label handling and tests.

Poem

🐰 I stitched matchLabels into each lease's tag,
Hopped through selectors so queries don't lag.
Tests nibble at edges, e2e gives a cheer,
Filters aligned — hop, code's in the clear. 🥕

🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
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.
Out of Scope Changes check ❓ Inconclusive The PR remains focused on lease label filtering implementation. However, the PR description notes that the changes do not fully resolve the issue, suggesting incomplete implementation of the stated objectives. Verify whether the current implementation fully addresses issue #36 or if additional changes are needed to complete the lease selector filtering feature.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: allowing lease filtering by exporter labels through server-side label selector support.
Linked Issues check ✅ Passed The PR implements label mirroring from spec.selector.matchLabels to metadata.labels to enable server-side filtering [#36], with both Go and Python changes supporting the filtering functionality.

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

✨ Finishing touches
  • 📝 Generate docstrings

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.

@evakhoni evakhoni requested a review from mangelajo January 27, 2026 18:16
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

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

⚠️ Outside diff range comments (1)
controller/api/v1alpha1/lease_helpers.go (1)

198-203: Make a defensive copy of selector labels to prevent unintended selector mutations.

ObjectMeta.Labels and Spec.Selector.MatchLabels currently point to the same map object. Label mutations elsewhere in the codebase (e.g., in lease_controller.go where system labels are added) will also mutate the selector and change its filtering semantics. Make a defensive copy for metadata labels.

Proposed fix
+	labelsCopy := make(map[string]string, len(selector.MatchLabels))
+	for k, v := range selector.MatchLabels {
+		labelsCopy[k] = v
+	}
 	return &Lease{
 		ObjectMeta: metav1.ObjectMeta{
 			Namespace: key.Namespace,
 			Name:      key.Name,
-			Labels:    selector.MatchLabels,
+			Labels:    labelsCopy,
 		},

@evakhoni evakhoni marked this pull request as draft January 27, 2026 21:40
@evakhoni
Copy link
Contributor Author

hmm.. seems this is not enough.. must be another issue.. set it draft for now investigating..

Context("when creating a lease with selector labels", func() {
It("should copy selector matchLabels to lease metadata labels", func() {
pbLease := &cpb.Lease{
Selector: "board-type=virtual,env=test",
Copy link
Member

Choose a reason for hiding this comment

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

Can you also test what happens when you have a selector like

board-type=virtual,!env
board-type=virtual,firmware in (v2, v3)
board-type=virtual,firmware!=v3

all those are valid selectors ... and I believe ... now after seing your patch (sorry, because this was my idea) that we cannot map it directly to labels :-/

Copy link
Contributor Author

@evakhoni evakhoni Jan 28, 2026

Choose a reason for hiding this comment

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

hmm.. yep seems to be a limitation of the current one. the question is whatever we see value in some filtering rather than no filtering, or we better do it now the proper way so we don't have to get back to it later on 🤔
also claude suggests two solutions for this one:

  • Store the full selector as a serialized annotation
    or
  • client-side filtering

wonder wdyt? also if you think we must have it support complex filters from day one, should we postpone this PR for if seems like a low priority at the moment, or it's something that looks useful for the 0.8 release? 🤔

side note, I added 4296b66 as filtering wasn't working. tested some basics ones with it and it works now.

Copy link
Contributor Author

@evakhoni evakhoni Jan 28, 2026

Choose a reason for hiding this comment

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

for client-side filtering, we can keep the existing code, to pre-filter by matchLabels, so the server still does the heavy lifting, and then post-process it on the client to remove what doesn't match the complex filter criteria. performance-wise it's better than pure client-side, with the only caveat of having the filtering implementation code scattered across both.

Copy link
Member

Choose a reason for hiding this comment

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

I think that's a good compromise.

Doing heavy filtering on the server without labels will be expensive, because you need to traverse all, but letting the server do the optimized part, and then the client (eventually, don't spend time on it now) ... do the rest...

👍 for this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

eventually, don't spend time on it now

haven't seen this part of the comment 😆 so ended up implementing something quickly c13d074

@evakhoni evakhoni marked this pull request as ready for review January 28, 2026 14:23
@evakhoni evakhoni requested review from bennyz and bkhizgiy January 28, 2026 15:10
Copy link
Member

@mangelajo mangelajo left a comment

Choose a reason for hiding this comment

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

I think this is much better than the issue we have now, and complex filters can be done later on client side as you said.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@python/packages/jumpstarter/jumpstarter/client/grpc.py`:
- Around line 315-323: The client-side filter_by_selector function returns a new
LeaseList but preserves the original next_page_token, which can break pagination
after items are removed; update filter_by_selector (in jumpstarter.client.grpc)
to clear the pagination token by returning LeaseList(leases=filtered,
next_page_token=None) when client-side filtering is applied (i.e., when
filter_selector is provided and filtering occurs), and ensure selector_contains
is still used for membership checks so callers know pagination is no longer
valid after filtering.
🧹 Nitpick comments (3)
python/packages/jumpstarter/jumpstarter/client/selectors.py (1)

47-49: Missing handling for unrecognized selector parts.

When a selector part doesn't match any of the defined patterns, it's silently ignored. This could lead to unexpected behavior if users provide malformed selectors.

Consider logging a warning or raising an error for unrecognized parts to help users identify typos or unsupported syntax.

💡 Optional improvement
         # key (Exists) - bare key without operator
         elif re.match(r"^[a-zA-Z0-9_./-]+$", part):
             match_expressions.append((part, "exists", []))
+        else:
+            # Optionally warn about unrecognized selector syntax
+            import logging
+            logging.warning(f"Unrecognized selector part ignored: {part}")

     return match_labels, match_expressions
python/packages/jumpstarter/jumpstarter/client/selectors_test.py (1)

6-35: Test coverage could be expanded for edge cases and parse_label_selector.

The tests cover core scenarios well, but consider adding:

  1. Direct tests for parse_label_selector - Currently only selector_contains is tested. Edge cases in parsing (malformed input, whitespace handling, empty values) would benefit from direct coverage.

  2. Edge cases missing:

    • key notin (...) operator
    • key!=value operator
    • Multiple expressions combined
    • Empty selector vs empty filter
    • Whitespace variations in values
💡 Additional test cases
def test_notin_expression(self):
    assert selector_contains("env notin (dev, staging)", "env notin (dev, staging)") is True

def test_not_equal_expression(self):
    assert selector_contains("board!=jetson", "board!=jetson") is True

def test_empty_selector_empty_filter(self):
    assert selector_contains("", "") is True

def test_whitespace_in_values(self):
    # Verify whitespace handling in comma-separated values
    assert selector_contains("env in (dev, prod)", "env in (dev,prod)") is True

As per coding guidelines: "Test coverage should be comprehensive, focusing on end-to-end testing with server and client interactions."

python/packages/jumpstarter-cli/jumpstarter_cli/get.py (1)

57-57: Redundant filtering: same selector applied both server-side and client-side.

The same selector is passed to both list_leases(filter=selector, ...) (server-side) and .filter_by_selector(selector) (client-side). This means:

  1. For matchLabels: Filtering happens twice unnecessarily
  2. For matchExpressions: Server-side filtering may not support them, so client-side is needed

The implementation works correctly but is inefficient for simple label selectors. Consider:

  • Only applying client-side filtering when needed (e.g., when matchExpressions are present)
  • Or documenting that this double-filtering is intentional as a workaround

Given the PR comment that this "is not fully resolving the problem," this may be an interim solution.

💡 Optional optimization
from jumpstarter.client.selectors import parse_label_selector

# Only apply client-side filtering if matchExpressions are present
_, match_exprs = parse_label_selector(selector) if selector else (None, [])
leases = config.list_leases(filter=selector, only_active=not show_all)
if match_exprs:
    leases = leases.filter_by_selector(selector)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

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)
python/packages/jumpstarter/jumpstarter/client/selectors_test.py (1)

9-35: Add test cases for whitespace/invalid segments and additional operators.

Consider adding coverage for !=, notin, bare-key exists, and whitespace variants like board = rpi to lock in the expected parsing behavior and prevent regressions.

Comment on lines 20 to 50
# Split by comma, but not inside parentheses
parts = re.split(r",(?![^()]*\))", selector)

for part in parts:
part = part.strip()
if not part:
continue

# key in (v1, v2, ...)
if m := re.match(r"^([a-zA-Z0-9_./-]+)\s+in\s+\(([^)]*)\)$", part):
key, values = m.groups()
match_expressions.append((key, "in", [v.strip() for v in values.split(",")]))
# key notin (v1, v2, ...)
elif m := re.match(r"^([a-zA-Z0-9_./-]+)\s+notin\s+\(([^)]*)\)$", part):
key, values = m.groups()
match_expressions.append((key, "notin", [v.strip() for v in values.split(",")]))
# !key (DoesNotExist)
elif m := re.match(r"^!([a-zA-Z0-9_./-]+)$", part):
match_expressions.append((m.group(1), "!exists", []))
# key!=value
elif m := re.match(r"^([a-zA-Z0-9_./-]+)!=(.+)$", part):
key, value = m.groups()
match_expressions.append((key, "!=", [value]))
# key=value or key==value
elif m := re.match(r"^([a-zA-Z0-9_./-]+)==?(.+)$", part):
key, value = m.groups()
match_labels[key] = value
# key (Exists) - bare key without operator
elif re.match(r"^[a-zA-Z0-9_./-]+$", part):
match_expressions.append((part, "exists", []))

Copy link
Contributor

@coderabbitai coderabbitai bot Jan 28, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Prevent invalid/unsupported selector segments from becoming a no‑op filter.

parse_label_selector silently ignores unparsed segments. Combined with selector_contains treating empty requirements as True, inputs like board = rpi (or any unsupported token) can incorrectly match all leases. Please validate segments and either fail fast or treat invalid requirements as non-matching; also tolerate optional whitespace and strip values to avoid accidental invalidation.

💡 Suggested fix (one option)
-    # Split by comma, but not inside parentheses
-    parts = re.split(r",(?![^()]*\))", selector)
+    # Split by comma, but not inside parentheses
+    parts = re.split(r",(?![^()]*\))", selector)
+    invalid_parts: list[str] = []

     for part in parts:
         part = part.strip()
         if not part:
             continue

         # key in (v1, v2, ...)
-        if m := re.match(r"^([a-zA-Z0-9_./-]+)\s+in\s+\(([^)]*)\)$", part):
+        if m := re.match(r"^([a-zA-Z0-9_./-]+)\s+in\s*\(([^)]*)\)$", part):
             key, values = m.groups()
-            match_expressions.append((key, "in", [v.strip() for v in values.split(",")]))
+            vals = [v.strip() for v in values.split(",") if v.strip()]
+            match_expressions.append((key, "in", vals))
         # key notin (v1, v2, ...)
-        elif m := re.match(r"^([a-zA-Z0-9_./-]+)\s+notin\s+\(([^)]*)\)$", part):
+        elif m := re.match(r"^([a-zA-Z0-9_./-]+)\s+notin\s*\(([^)]*)\)$", part):
             key, values = m.groups()
-            match_expressions.append((key, "notin", [v.strip() for v in values.split(",")]))
+            vals = [v.strip() for v in values.split(",") if v.strip()]
+            match_expressions.append((key, "notin", vals))
         # !key (DoesNotExist)
         elif m := re.match(r"^!([a-zA-Z0-9_./-]+)$", part):
             match_expressions.append((m.group(1), "!exists", []))
         # key!=value
-        elif m := re.match(r"^([a-zA-Z0-9_./-]+)!=(.+)$", part):
+        elif m := re.match(r"^([a-zA-Z0-9_./-]+)\s*!=\s*(.+)$", part):
             key, value = m.groups()
-            match_expressions.append((key, "!=", [value]))
+            match_expressions.append((key, "!=", [value.strip()]))
         # key=value or key==value
-        elif m := re.match(r"^([a-zA-Z0-9_./-]+)==?(.+)$", part):
+        elif m := re.match(r"^([a-zA-Z0-9_./-]+)\s*==?\s*(.+)$", part):
             key, value = m.groups()
-            match_labels[key] = value
+            match_labels[key] = value.strip()
         # key (Exists) - bare key without operator
         elif re.match(r"^[a-zA-Z0-9_./-]+$", part):
             match_expressions.append((part, "exists", []))
+        else:
+            invalid_parts.append(part)
+
+    if invalid_parts:
+        raise ValueError(f"invalid label selector segment(s): {', '.join(invalid_parts)}")

Copy link
Contributor Author

Choose a reason for hiding this comment

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

just checked, on get exporters this works

jmp get exporters -l 'board-type =   virtual'

so updated the code to support this as well

Copy link
Contributor

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the diff from the GitHub compare URL to see the changes made
gh api repos/jumpstarter-dev/jumpstarter/compare/102455192a620747e3aca27aad0186328a38069f...8d851b1e258b3edb9c5ab47ceb552b4c52900a06 \
  --jq '.files[] | select(.filename == "python/packages/jumpstarter/jumpstarter/client/selectors.py") | .patch'

Length of output: 3198


@evakhoni Great, the whitespace tolerance around operators looks good now! 👍

One concern from my original review remains: the parser still silently skips any segment it doesn't recognize. For example, board-type = virtual, unsupported syntax here would parse only the first part and ignore the second, potentially leading to unintended broad matches. Consider adding an else clause that either raises a ValueError or collects unmatched parts to help catch typos and unsupported syntax early.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@mangelajo wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

@mangelajo mangelajo merged commit ae2fef4 into jumpstarter-dev:main Jan 29, 2026
23 checks passed
@evakhoni evakhoni deleted the lease-labels branch January 29, 2026 13:34
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.

jmp get leases ignores the selector

2 participants