From d6a4f0aa6be0795a2903c888af8ea96dac85bef0 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Mon, 1 Jun 2026 09:50:05 +0200 Subject: [PATCH 01/15] fix: selector_contains now matches labels against expression requirements Cherry-picked from 14f02205e to provide the _label_satisfies_expression function that this branch will fix. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jumpstarter/client/selectors.py | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors.py b/python/packages/jumpstarter/jumpstarter/client/selectors.py index d7225fec9..db9cc09b9 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors.py @@ -67,6 +67,23 @@ def extract_match_labels_filter(selector: str | None) -> str | None: return ",".join(f"{k}={v}" for k, v in match_labels.items()) +def _label_satisfies_expression( + sel_labels: dict[str, str], key: str, operator: str, values: list[str] +) -> bool: + if key not in sel_labels: + return False + label_value = sel_labels[key] + if operator == "in": + return label_value in values + if operator == "exists": + return True + if operator == "!=": + return label_value not in values + if operator == "notin": + return label_value not in values + return False + + def selector_contains(selector: str, requirements: str) -> bool: """Check if selector contains all criteria from requirements. @@ -84,13 +101,15 @@ def selector_contains(selector: str, requirements: str) -> bool: if sel_labels.get(key) != value: return False - # All required matchExpressions must be in selector's matchExpressions + # All required matchExpressions must be satisfied by selector's + # matchExpressions or matchLabels for r_key, r_op, r_vals in req_exprs: - found = False - for s_key, s_op, s_vals in sel_exprs: - if s_key == r_key and s_op == r_op and set(s_vals) == set(r_vals): - found = True - break + found = any( + s_key == r_key and s_op == r_op and set(s_vals) == set(r_vals) + for s_key, s_op, s_vals in sel_exprs + ) + if not found: + found = _label_satisfies_expression(sel_labels, r_key, r_op, r_vals) if not found: return False From be5130a9e084fce4006e7bbdd04b248ef6150067 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Mon, 1 Jun 2026 09:54:07 +0200 Subject: [PATCH 02/15] fix: raise ValueError for unknown label selector operators _label_satisfies_expression silently returned False for unrecognized operator strings, masking bugs in callers. Now raises ValueError with a message including the invalid operator. Also adds explicit handling for the !exists operator when the key is present. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jumpstarter/client/selectors.py | 4 +- .../jumpstarter/client/selectors_test.py | 45 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors.py b/python/packages/jumpstarter/jumpstarter/client/selectors.py index db9cc09b9..44b817ec4 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors.py @@ -81,7 +81,9 @@ def _label_satisfies_expression( return label_value not in values if operator == "notin": return label_value not in values - return False + if operator == "!exists": + return False + raise ValueError(f"unknown label selector operator: {operator!r}") def selector_contains(selector: str, requirements: str) -> bool: diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors_test.py b/python/packages/jumpstarter/jumpstarter/client/selectors_test.py index 23f629aae..587be2b7b 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors_test.py @@ -1,6 +1,8 @@ """Tests for label selector matching.""" -from jumpstarter.client.selectors import selector_contains +import pytest + +from jumpstarter.client.selectors import _label_satisfies_expression, selector_contains class TestSelectorContains: @@ -40,3 +42,44 @@ def test_whitespace_tolerance(self): assert selector_contains("board=rpi", "board =rpi") is True assert selector_contains("board=rpi", "board= rpi") is True assert selector_contains("firmware!=v3", "firmware != v3") is True + + +class TestLabelSatisfiesExpressionUnknownOperator: + """Tests for _label_satisfies_expression raising ValueError on unknown operators.""" + + def test_empty_string_operator_raises_value_error(self): + with pytest.raises(ValueError, match="unknown label selector operator"): + _label_satisfies_expression({"key": "value"}, "key", "", ["value"]) + + def test_invalid_operator_raises_value_error(self): + with pytest.raises(ValueError, match="unknown label selector operator"): + _label_satisfies_expression({"key": "value"}, "key", "invalid", ["value"]) + + def test_equals_operator_raises_value_error(self): + with pytest.raises(ValueError, match="unknown label selector operator"): + _label_satisfies_expression({"key": "value"}, "key", "=", ["value"]) + + def test_not_exists_operator_returns_false_when_key_present(self): + assert _label_satisfies_expression({"key": "value"}, "key", "!exists", []) is False + + def test_error_message_includes_operator(self): + with pytest.raises(ValueError, match="'bogus'"): + _label_satisfies_expression({"key": "value"}, "key", "bogus", ["value"]) + + def test_in_operator_still_works(self): + assert _label_satisfies_expression({"key": "value"}, "key", "in", ["value"]) is True + assert _label_satisfies_expression({"key": "value"}, "key", "in", ["other"]) is False + + def test_notin_operator_still_works(self): + assert _label_satisfies_expression({"key": "value"}, "key", "notin", ["other"]) is True + assert _label_satisfies_expression({"key": "value"}, "key", "notin", ["value"]) is False + + def test_exists_operator_still_works(self): + assert _label_satisfies_expression({"key": "value"}, "key", "exists", []) is True + + def test_not_equal_operator_still_works(self): + assert _label_satisfies_expression({"key": "value"}, "key", "!=", ["other"]) is True + assert _label_satisfies_expression({"key": "value"}, "key", "!=", ["value"]) is False + + def test_key_not_in_labels_returns_false(self): + assert _label_satisfies_expression({}, "missing", "in", ["value"]) is False From 61dfa23b10c120166428d6abda9ebbc18991711b Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Mon, 1 Jun 2026 10:02:30 +0200 Subject: [PATCH 03/15] test: add integration tests for selector_contains label-to-expression fallback Cover the fallback path where selector_contains uses _label_satisfies_expression when no matching expression is found in sel_exprs. This verifies that matchLabels correctly satisfy "in" and "notin" matchExpression requirements. Generated-By: Forge/20260601_094535_362027_58545917 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jumpstarter/jumpstarter/client/selectors_test.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors_test.py b/python/packages/jumpstarter/jumpstarter/client/selectors_test.py index 587be2b7b..d7510482d 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors_test.py @@ -36,6 +36,18 @@ def test_filter_not_exists(self): def test_empty_filter_matches_all(self): assert selector_contains("board=rpi,firmware in (v2, v3)", "") is True + def test_match_label_satisfies_in_expression(self): + assert selector_contains("board=rpi", "board in (rpi, jetson)") is True + + def test_match_label_does_not_satisfy_in_expression(self): + assert selector_contains("board=rpi", "board in (jetson, nano)") is False + + def test_match_label_satisfies_notin_expression(self): + assert selector_contains("board=rpi", "board notin (jetson, nano)") is True + + def test_match_label_does_not_satisfy_notin_expression(self): + assert selector_contains("board=rpi", "board notin (rpi, nano)") is False + def test_whitespace_tolerance(self): """Whitespace around operators should be tolerated (matching Go behavior).""" assert selector_contains("board=rpi", "board = rpi") is True From c5d13b912c6c88547abedce52f4b2186c4930bf5 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Mon, 1 Jun 2026 12:29:14 +0200 Subject: [PATCH 04/15] fix: resolve ruff formatting issues in selectors module Co-Authored-By: Claude Opus 4.6 (1M context) --- .../packages/jumpstarter/jumpstarter/client/selectors.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors.py b/python/packages/jumpstarter/jumpstarter/client/selectors.py index 44b817ec4..223d045a1 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors.py @@ -67,9 +67,7 @@ def extract_match_labels_filter(selector: str | None) -> str | None: return ",".join(f"{k}={v}" for k, v in match_labels.items()) -def _label_satisfies_expression( - sel_labels: dict[str, str], key: str, operator: str, values: list[str] -) -> bool: +def _label_satisfies_expression(sel_labels: dict[str, str], key: str, operator: str, values: list[str]) -> bool: if key not in sel_labels: return False label_value = sel_labels[key] @@ -106,10 +104,7 @@ def selector_contains(selector: str, requirements: str) -> bool: # All required matchExpressions must be satisfied by selector's # matchExpressions or matchLabels for r_key, r_op, r_vals in req_exprs: - found = any( - s_key == r_key and s_op == r_op and set(s_vals) == set(r_vals) - for s_key, s_op, s_vals in sel_exprs - ) + found = any(s_key == r_key and s_op == r_op and set(s_vals) == set(r_vals) for s_key, s_op, s_vals in sel_exprs) if not found: found = _label_satisfies_expression(sel_labels, r_key, r_op, r_vals) if not found: From 5db8aa7c6670ab5a2e41a3a68910dd987fe9ec3b Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 5 Jun 2026 21:12:35 +0200 Subject: [PATCH 05/15] fix: correct !exists operator to return True when key is absent Move the !exists check before the key-presence guard so that DoesNotExist correctly returns True when the key is missing from labels, matching Kubernetes semantics. Update the selector_contains test to reflect the corrected behavior. Generated-By: Forge/20260605_210046_334143_d93db272 Co-Authored-By: Claude Opus 4.6 (1M context) --- python/packages/jumpstarter/jumpstarter/client/selectors.py | 4 ++-- .../jumpstarter/jumpstarter/client/selectors_test.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors.py b/python/packages/jumpstarter/jumpstarter/client/selectors.py index 223d045a1..77c32c2ec 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors.py @@ -68,6 +68,8 @@ def extract_match_labels_filter(selector: str | None) -> str | None: def _label_satisfies_expression(sel_labels: dict[str, str], key: str, operator: str, values: list[str]) -> bool: + if operator == "!exists": + return key not in sel_labels if key not in sel_labels: return False label_value = sel_labels[key] @@ -79,8 +81,6 @@ def _label_satisfies_expression(sel_labels: dict[str, str], key: str, operator: return label_value not in values if operator == "notin": return label_value not in values - if operator == "!exists": - return False raise ValueError(f"unknown label selector operator: {operator!r}") diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors_test.py b/python/packages/jumpstarter/jumpstarter/client/selectors_test.py index d7510482d..024996516 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors_test.py @@ -31,7 +31,7 @@ def test_no_match_expression(self): def test_filter_not_exists(self): assert selector_contains("board=rpi,!experimental", "!experimental") is True - assert selector_contains("board=rpi", "!experimental") is False + assert selector_contains("board=rpi", "!experimental") is True def test_empty_filter_matches_all(self): assert selector_contains("board=rpi,firmware in (v2, v3)", "") is True @@ -93,5 +93,8 @@ def test_not_equal_operator_still_works(self): assert _label_satisfies_expression({"key": "value"}, "key", "!=", ["other"]) is True assert _label_satisfies_expression({"key": "value"}, "key", "!=", ["value"]) is False + def test_not_exists_operator_returns_true_when_key_absent(self): + assert _label_satisfies_expression({}, "missing", "!exists", []) is True + def test_key_not_in_labels_returns_false(self): assert _label_satisfies_expression({}, "missing", "in", ["value"]) is False From c1f8732a40b67132b87b4af31b3ad5b095f60b6e Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 5 Jun 2026 21:12:54 +0200 Subject: [PATCH 06/15] fix: update selector_contains docstring to reflect expression evaluation The docstring previously stated that matchExpressions must be "present" in the selector, but after the label-to-expression fallback change they are now "satisfied" by evaluation against matchLabels. Generated-By: Forge/20260605_210046_334143_d93db272 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../packages/jumpstarter/jumpstarter/client/selectors.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors.py b/python/packages/jumpstarter/jumpstarter/client/selectors.py index 77c32c2ec..5401f8ae3 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors.py @@ -85,10 +85,11 @@ def _label_satisfies_expression(sel_labels: dict[str, str], key: str, operator: def selector_contains(selector: str, requirements: str) -> bool: - """Check if selector contains all criteria from requirements. + """Check if selector satisfies all criteria from requirements. - Returns True if all matchLabels and matchExpressions in `requirements` - are present in `selector`. + Returns True if all matchLabels in `requirements` are present in `selector` + and all matchExpressions in `requirements` are satisfied by `selector` + (either by exact match in matchExpressions or by evaluation against matchLabels). """ if not requirements or not requirements.strip(): return True From eeb9b1d789aa730c222bc59c65056e7ed9f7247d Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 5 Jun 2026 21:13:15 +0200 Subject: [PATCH 07/15] fix: document ValueError raised by _label_satisfies_expression Add docstring with Raises section so callers are aware that an unrecognized operator will raise ValueError. Generated-By: Forge/20260605_210046_334143_d93db272 Co-Authored-By: Claude Opus 4.6 (1M context) --- python/packages/jumpstarter/jumpstarter/client/selectors.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors.py b/python/packages/jumpstarter/jumpstarter/client/selectors.py index 5401f8ae3..430d960fa 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors.py @@ -68,6 +68,12 @@ def extract_match_labels_filter(selector: str | None) -> str | None: def _label_satisfies_expression(sel_labels: dict[str, str], key: str, operator: str, values: list[str]) -> bool: + """Check if a single label expression is satisfied by the given labels. + + Raises: + ValueError: If `operator` is not one of the recognized operators + ("in", "notin", "exists", "!exists", "!="). + """ if operator == "!exists": return key not in sel_labels if key not in sel_labels: From 168c2a32a6d221483c927c9382877b2c999748ac Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 5 Jun 2026 21:13:28 +0200 Subject: [PATCH 08/15] test: add coverage for exists operator when key is absent Verify that _label_satisfies_expression returns False for the exists operator when the key is not present in the labels, preventing regressions in the key-presence guard logic. Generated-By: Forge/20260605_210046_334143_d93db272 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../packages/jumpstarter/jumpstarter/client/selectors_test.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors_test.py b/python/packages/jumpstarter/jumpstarter/client/selectors_test.py index 024996516..925f345ef 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors_test.py @@ -89,6 +89,9 @@ def test_notin_operator_still_works(self): def test_exists_operator_still_works(self): assert _label_satisfies_expression({"key": "value"}, "key", "exists", []) is True + def test_exists_operator_returns_false_when_key_absent(self): + assert _label_satisfies_expression({}, "missing", "exists", []) is False + def test_not_equal_operator_still_works(self): assert _label_satisfies_expression({"key": "value"}, "key", "!=", ["other"]) is True assert _label_satisfies_expression({"key": "value"}, "key", "!=", ["value"]) is False From daa599b7240d6c54564d64d0f28cd5b71801277d Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 5 Jun 2026 21:34:33 +0200 Subject: [PATCH 09/15] fix: handle ValueError from selector_contains in filter_by_selector Catch ValueError raised by _label_satisfies_expression so that a single lease with an unrecognised operator does not break the entire filter operation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jumpstarter/jumpstarter/client/grpc.py | 11 ++++++- .../jumpstarter/client/grpc_test.py | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/python/packages/jumpstarter/jumpstarter/client/grpc.py b/python/packages/jumpstarter/jumpstarter/client/grpc.py index 055c0255b..1aedca01e 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging from collections import OrderedDict from dataclasses import InitVar, dataclass, field from datetime import datetime, timedelta @@ -16,6 +17,8 @@ from jumpstarter.common import ExporterStatus from jumpstarter.common.grpc import translate_grpc_exceptions +logger = logging.getLogger(__name__) + @dataclass class WithOptions: @@ -416,7 +419,13 @@ def filter_by_selector(self, filter_selector: str | None) -> LeaseList: """ if not filter_selector: return self - filtered = [lease for lease in self.leases if selector_contains(lease.selector, filter_selector)] + filtered = [] + for lease in self.leases: + try: + if selector_contains(lease.selector, filter_selector): + filtered.append(lease) + except ValueError: + logger.warning("skipping lease %s: unable to evaluate selector %r", lease.name, lease.selector) return LeaseList(leases=filtered, next_page_token=None) def filter_by_client(self, client_name: str) -> LeaseList: diff --git a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py index 7c72392ee..50c877b0a 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py @@ -12,6 +12,7 @@ Exporter, ExporterList, Lease, + LeaseList, WithOptions, add_display_columns, add_exporter_row, @@ -654,6 +655,34 @@ def test_rich_display_empty_tags(self): assert "TAGS" in columns +class TestLeaseListFilterBySelector: + def create_lease(self, name="test-lease", selector="board=rpi"): + return Lease( + namespace="default", + name=name, + selector=selector, + duration=timedelta(hours=1), + client="test-client", + exporter="test-exporter", + conditions=[], + ) + + def test_filter_skips_lease_when_selector_contains_raises(self): + leases = LeaseList( + leases=[ + self.create_lease(name="good", selector="board=rpi"), + self.create_lease(name="bad", selector="board=jetson"), + ], + next_page_token=None, + ) + with patch( + "jumpstarter.client.grpc.selector_contains", + side_effect=[True, ValueError("unknown label selector operator: 'bogus'")], + ): + result = leases.filter_by_selector("board=rpi") + assert [lease.name for lease in result.leases] == ["good"] + + @pytest.mark.asyncio async def test_create_lease_sets_tags_on_protobuf(): from jumpstarter_protocol import client_pb2 From 9f10ac854dfda705d5009cae54bc8216bc455193 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 5 Jun 2026 21:41:36 +0200 Subject: [PATCH 10/15] test: assert ValueError lease is excluded from filter results Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jumpstarter/jumpstarter/client/grpc_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py index 50c877b0a..6f55ab891 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py @@ -682,6 +682,20 @@ def test_filter_skips_lease_when_selector_contains_raises(self): result = leases.filter_by_selector("board=rpi") assert [lease.name for lease in result.leases] == ["good"] + def test_filter_excludes_lease_when_selector_contains_raises(self): + leases = LeaseList( + leases=[ + self.create_lease(name="only", selector="board=rpi"), + ], + next_page_token=None, + ) + with patch( + "jumpstarter.client.grpc.selector_contains", + side_effect=ValueError("unknown label selector operator: 'bogus'"), + ): + result = leases.filter_by_selector("board=rpi") + assert result.leases == [] + @pytest.mark.asyncio async def test_create_lease_sets_tags_on_protobuf(): From 525d68b49061818a2ff54434b06f1c1ff5b17573 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 5 Jun 2026 21:42:35 +0200 Subject: [PATCH 11/15] test: merge redundant filter_by_selector ValueError tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jumpstarter/jumpstarter/client/grpc_test.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py index 6f55ab891..36b0cbfe5 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py @@ -667,7 +667,7 @@ def create_lease(self, name="test-lease", selector="board=rpi"): conditions=[], ) - def test_filter_skips_lease_when_selector_contains_raises(self): + def test_filter_keeps_matching_and_excludes_erroring_leases(self): leases = LeaseList( leases=[ self.create_lease(name="good", selector="board=rpi"), @@ -682,20 +682,6 @@ def test_filter_skips_lease_when_selector_contains_raises(self): result = leases.filter_by_selector("board=rpi") assert [lease.name for lease in result.leases] == ["good"] - def test_filter_excludes_lease_when_selector_contains_raises(self): - leases = LeaseList( - leases=[ - self.create_lease(name="only", selector="board=rpi"), - ], - next_page_token=None, - ) - with patch( - "jumpstarter.client.grpc.selector_contains", - side_effect=ValueError("unknown label selector operator: 'bogus'"), - ): - result = leases.filter_by_selector("board=rpi") - assert result.leases == [] - @pytest.mark.asyncio async def test_create_lease_sets_tags_on_protobuf(): From bf55b5101d7629cd2aa3294d4edaa0f241c7a663 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 19 Jun 2026 11:22:11 +0200 Subject: [PATCH 12/15] fix: match k8s semantics for notin and != with absent keys In Kubernetes, NotIn and NotEquals return true when the key is absent from the label set. The previous implementation returned false for all operators when the key was absent (except !exists), which diverged from k8s behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jumpstarter/client/selectors.py | 15 +- .../jumpstarter/client/selectors_test.py | 137 +++++++++++------- 2 files changed, 90 insertions(+), 62 deletions(-) diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors.py b/python/packages/jumpstarter/jumpstarter/client/selectors.py index 430d960fa..9490b7db0 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors.py @@ -68,25 +68,16 @@ def extract_match_labels_filter(selector: str | None) -> str | None: def _label_satisfies_expression(sel_labels: dict[str, str], key: str, operator: str, values: list[str]) -> bool: - """Check if a single label expression is satisfied by the given labels. - - Raises: - ValueError: If `operator` is not one of the recognized operators - ("in", "notin", "exists", "!exists", "!="). - """ if operator == "!exists": return key not in sel_labels + if operator in ("notin", "!="): + return key not in sel_labels or sel_labels[key] not in values if key not in sel_labels: return False - label_value = sel_labels[key] if operator == "in": - return label_value in values + return sel_labels[key] in values if operator == "exists": return True - if operator == "!=": - return label_value not in values - if operator == "notin": - return label_value not in values raise ValueError(f"unknown label selector operator: {operator!r}") diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors_test.py b/python/packages/jumpstarter/jumpstarter/client/selectors_test.py index 925f345ef..3839ddd84 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors_test.py @@ -5,8 +5,70 @@ from jumpstarter.client.selectors import _label_satisfies_expression, selector_contains +class TestLabelSatisfiesExpressionIn: + def test_key_present_value_matches(self): + assert _label_satisfies_expression({"board": "rpi"}, "board", "in", ["rpi", "jetson"]) is True + + def test_key_present_value_does_not_match(self): + assert _label_satisfies_expression({"board": "rpi"}, "board", "in", ["jetson", "nano"]) is False + + def test_key_absent(self): + assert _label_satisfies_expression({}, "board", "in", ["rpi"]) is False + + +class TestLabelSatisfiesExpressionNotIn: + def test_key_present_value_not_in_set(self): + assert _label_satisfies_expression({"board": "rpi"}, "board", "notin", ["jetson", "nano"]) is True + + def test_key_present_value_in_set(self): + assert _label_satisfies_expression({"board": "rpi"}, "board", "notin", ["rpi", "nano"]) is False + + def test_key_absent(self): + assert _label_satisfies_expression({}, "board", "notin", ["rpi"]) is True + + +class TestLabelSatisfiesExpressionExists: + def test_key_present(self): + assert _label_satisfies_expression({"board": "rpi"}, "board", "exists", []) is True + + def test_key_absent(self): + assert _label_satisfies_expression({}, "board", "exists", []) is False + + +class TestLabelSatisfiesExpressionDoesNotExist: + def test_key_present(self): + assert _label_satisfies_expression({"board": "rpi"}, "board", "!exists", []) is False + + def test_key_absent(self): + assert _label_satisfies_expression({}, "board", "!exists", []) is True + + +class TestLabelSatisfiesExpressionNotEqual: + def test_key_present_value_differs(self): + assert _label_satisfies_expression({"board": "rpi"}, "board", "!=", ["jetson"]) is True + + def test_key_present_value_same(self): + assert _label_satisfies_expression({"board": "rpi"}, "board", "!=", ["rpi"]) is False + + def test_key_absent(self): + assert _label_satisfies_expression({}, "board", "!=", ["rpi"]) is True + + +class TestLabelSatisfiesExpressionUnknownOperator: + def test_raises_value_error(self): + with pytest.raises(ValueError, match="unknown label selector operator"): + _label_satisfies_expression({"key": "val"}, "key", "bogus", ["val"]) + + def test_error_message_includes_operator(self): + with pytest.raises(ValueError, match="'bogus'"): + _label_satisfies_expression({"key": "val"}, "key", "bogus", ["val"]) + + def test_empty_string_operator_raises(self): + with pytest.raises(ValueError, match="unknown label selector operator"): + _label_satisfies_expression({"key": "val"}, "key", "", ["val"]) + + class TestSelectorContains: - """Tests for checking if a lease's selector contains a filter's criteria.""" def test_exact_match_labels(self): assert selector_contains("board=rpi", "board=rpi") is True @@ -14,6 +76,12 @@ def test_exact_match_labels(self): def test_subset_match_labels(self): assert selector_contains("board=rpi,env=prod", "board=rpi") is True + def test_double_equals_match(self): + assert selector_contains("board=rpi", "board==rpi") is True + + def test_double_equals_in_selector(self): + assert selector_contains("board==rpi", "board=rpi") is True + def test_no_match_labels(self): assert selector_contains("board=jetson", "board=rpi") is False @@ -29,10 +97,15 @@ def test_match_mixed(self): def test_no_match_expression(self): assert selector_contains("board=rpi", "firmware in (v2, v3)") is False - def test_filter_not_exists(self): + def test_filter_not_exists_present_in_selector(self): assert selector_contains("board=rpi,!experimental", "!experimental") is True + + def test_filter_not_exists_absent_from_selector(self): assert selector_contains("board=rpi", "!experimental") is True + def test_filter_not_exists_key_present_in_labels(self): + assert selector_contains("experimental=true", "!experimental") is False + def test_empty_filter_matches_all(self): assert selector_contains("board=rpi,firmware in (v2, v3)", "") is True @@ -48,56 +121,20 @@ def test_match_label_satisfies_notin_expression(self): def test_match_label_does_not_satisfy_notin_expression(self): assert selector_contains("board=rpi", "board notin (rpi, nano)") is False + def test_notin_key_absent_from_selector(self): + assert selector_contains("board=rpi", "env notin (prod)") is True + + def test_not_equal_key_absent_from_selector(self): + assert selector_contains("board=rpi", "env!=prod") is True + + def test_exists_key_present_in_selector(self): + assert selector_contains("board=rpi", "board") is True + + def test_exists_key_absent_from_selector(self): + assert selector_contains("board=rpi", "env") is False + def test_whitespace_tolerance(self): - """Whitespace around operators should be tolerated (matching Go behavior).""" assert selector_contains("board=rpi", "board = rpi") is True assert selector_contains("board=rpi", "board =rpi") is True assert selector_contains("board=rpi", "board= rpi") is True assert selector_contains("firmware!=v3", "firmware != v3") is True - - -class TestLabelSatisfiesExpressionUnknownOperator: - """Tests for _label_satisfies_expression raising ValueError on unknown operators.""" - - def test_empty_string_operator_raises_value_error(self): - with pytest.raises(ValueError, match="unknown label selector operator"): - _label_satisfies_expression({"key": "value"}, "key", "", ["value"]) - - def test_invalid_operator_raises_value_error(self): - with pytest.raises(ValueError, match="unknown label selector operator"): - _label_satisfies_expression({"key": "value"}, "key", "invalid", ["value"]) - - def test_equals_operator_raises_value_error(self): - with pytest.raises(ValueError, match="unknown label selector operator"): - _label_satisfies_expression({"key": "value"}, "key", "=", ["value"]) - - def test_not_exists_operator_returns_false_when_key_present(self): - assert _label_satisfies_expression({"key": "value"}, "key", "!exists", []) is False - - def test_error_message_includes_operator(self): - with pytest.raises(ValueError, match="'bogus'"): - _label_satisfies_expression({"key": "value"}, "key", "bogus", ["value"]) - - def test_in_operator_still_works(self): - assert _label_satisfies_expression({"key": "value"}, "key", "in", ["value"]) is True - assert _label_satisfies_expression({"key": "value"}, "key", "in", ["other"]) is False - - def test_notin_operator_still_works(self): - assert _label_satisfies_expression({"key": "value"}, "key", "notin", ["other"]) is True - assert _label_satisfies_expression({"key": "value"}, "key", "notin", ["value"]) is False - - def test_exists_operator_still_works(self): - assert _label_satisfies_expression({"key": "value"}, "key", "exists", []) is True - - def test_exists_operator_returns_false_when_key_absent(self): - assert _label_satisfies_expression({}, "missing", "exists", []) is False - - def test_not_equal_operator_still_works(self): - assert _label_satisfies_expression({"key": "value"}, "key", "!=", ["other"]) is True - assert _label_satisfies_expression({"key": "value"}, "key", "!=", ["value"]) is False - - def test_not_exists_operator_returns_true_when_key_absent(self): - assert _label_satisfies_expression({}, "missing", "!exists", []) is True - - def test_key_not_in_labels_returns_false(self): - assert _label_satisfies_expression({}, "missing", "in", ["value"]) is False From cc1d7a8a5cdf6eb893a19428441b07692cc88bc3 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 19 Jun 2026 19:23:38 +0200 Subject: [PATCH 13/15] test: align e2e lease selector tests with k8s !exists semantics The !production filter now correctly matches leases where the key is absent. Add a !example.com/board negative test to cover the case where the key is present and !exists should fail. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/test/e2e_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/e2e/test/e2e_test.go b/e2e/test/e2e_test.go index 6678df218..ddb4a8f38 100644 --- a/e2e/test/e2e_test.go +++ b/e2e/test/e2e_test.go @@ -387,7 +387,11 @@ var _ = Describe("Core E2E Tests", Label("core"), Ordered, func() { Expect(err).NotTo(HaveOccurred(), out) Expect(out).To(ContainSubstring("!nonexistent")) - out, err = Jmp("get", "leases", "--selector", "example.com/board=sa,!production") + out, err = Jmp("get", "leases", "--selector", "example.com/board=sa,!production", "-o", "yaml") + Expect(err).NotTo(HaveOccurred(), out) + Expect(out).To(ContainSubstring("example.com/board=sa")) + + out, err = Jmp("get", "leases", "--selector", "example.com/board=sa,!example.com/board") Expect(err).NotTo(HaveOccurred(), out) Expect(out).To(Equal("No resources found.")) From 142e36e48a50b70ba7390ec4849662964082a346 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 4 Aug 2026 08:19:25 +0200 Subject: [PATCH 14/15] fix: raise for unknown selector operators regardless of key presence The absence guard ran before the unknown-operator check, so an unrecognized operator paired with a key missing from matchLabels still returned False silently - the exact defect described in #718. Restructure so every recognized operator handles key absence itself and anything else falls through to the ValueError. Also document the raise on selector_contains as requested in review. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GkN7BAYTyjaohfCCCqSVcn --- .../packages/jumpstarter/jumpstarter/client/selectors.py | 9 +++++---- .../jumpstarter/jumpstarter/client/selectors_test.py | 9 ++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors.py b/python/packages/jumpstarter/jumpstarter/client/selectors.py index 9490b7db0..233cedbff 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors.py @@ -72,12 +72,10 @@ def _label_satisfies_expression(sel_labels: dict[str, str], key: str, operator: return key not in sel_labels if operator in ("notin", "!="): return key not in sel_labels or sel_labels[key] not in values - if key not in sel_labels: - return False if operator == "in": - return sel_labels[key] in values + return key in sel_labels and sel_labels[key] in values if operator == "exists": - return True + return key in sel_labels raise ValueError(f"unknown label selector operator: {operator!r}") @@ -87,6 +85,9 @@ def selector_contains(selector: str, requirements: str) -> bool: Returns True if all matchLabels in `requirements` are present in `selector` and all matchExpressions in `requirements` are satisfied by `selector` (either by exact match in matchExpressions or by evaluation against matchLabels). + + Raises ValueError if `requirements` contains an expression with an unknown + operator that is not exactly matched by the selector's matchExpressions. """ if not requirements or not requirements.strip(): return True diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors_test.py b/python/packages/jumpstarter/jumpstarter/client/selectors_test.py index 3839ddd84..1e6c519dc 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors_test.py @@ -67,9 +67,16 @@ def test_empty_string_operator_raises(self): with pytest.raises(ValueError, match="unknown label selector operator"): _label_satisfies_expression({"key": "val"}, "key", "", ["val"]) + def test_raises_when_key_absent(self): + with pytest.raises(ValueError, match="unknown label selector operator"): + _label_satisfies_expression({}, "key", "bogus", ["val"]) -class TestSelectorContains: + def test_empty_string_operator_raises_when_key_absent(self): + with pytest.raises(ValueError, match="unknown label selector operator"): + _label_satisfies_expression({"other": "val"}, "key", "", ["val"]) + +class TestSelectorContains: def test_exact_match_labels(self): assert selector_contains("board=rpi", "board=rpi") is True From fb28f5fa6979bd109723aa5e09b9213b227f07ba Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Tue, 4 Aug 2026 08:19:25 +0200 Subject: [PATCH 15/15] fix: name filter and error in filter_by_selector warning The ValueError originates from an unevaluable operator in the user-supplied filter, not from the lease's stored selector, so the warning blamed the wrong side. Include the filter and the error text so the log points at the actual cause. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GkN7BAYTyjaohfCCCqSVcn --- .../packages/jumpstarter/jumpstarter/client/grpc.py | 10 ++++++++-- .../jumpstarter/jumpstarter/client/grpc_test.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter/jumpstarter/client/grpc.py b/python/packages/jumpstarter/jumpstarter/client/grpc.py index 1aedca01e..856a482a4 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc.py @@ -424,8 +424,14 @@ def filter_by_selector(self, filter_selector: str | None) -> LeaseList: try: if selector_contains(lease.selector, filter_selector): filtered.append(lease) - except ValueError: - logger.warning("skipping lease %s: unable to evaluate selector %r", lease.name, lease.selector) + except ValueError as error: + logger.warning( + "skipping lease %s: cannot evaluate filter %r against selector %r: %s", + lease.name, + filter_selector, + lease.selector, + error, + ) return LeaseList(leases=filtered, next_page_token=None) def filter_by_client(self, client_name: str) -> LeaseList: diff --git a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py index 36b0cbfe5..a1592ec38 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py @@ -1,4 +1,5 @@ import json +import logging from datetime import datetime, timedelta from io import StringIO from unittest.mock import AsyncMock, Mock, patch @@ -682,6 +683,18 @@ def test_filter_keeps_matching_and_excludes_erroring_leases(self): result = leases.filter_by_selector("board=rpi") assert [lease.name for lease in result.leases] == ["good"] + def test_filter_warning_names_lease_filter_and_error(self, caplog): + leases = LeaseList(leases=[self.create_lease(name="bad", selector="board=jetson")], next_page_token=None) + with patch( + "jumpstarter.client.grpc.selector_contains", + side_effect=ValueError("unknown label selector operator: 'bogus'"), + ): + with caplog.at_level(logging.WARNING, logger="jumpstarter.client.grpc"): + leases.filter_by_selector("board in rpi") + assert "bad" in caplog.text + assert "board in rpi" in caplog.text + assert "unknown label selector operator: 'bogus'" in caplog.text + @pytest.mark.asyncio async def test_create_lease_sets_tags_on_protobuf():