From 57447c06508b34a8df33c5b8c9b583add61c6bde Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Mon, 1 Jun 2026 09:50:05 +0200 Subject: [PATCH 01/13] 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 cd1246f6cd75a024b1f2cd668e3f801ab2dfbd06 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Mon, 1 Jun 2026 09:54:07 +0200 Subject: [PATCH 02/13] 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 a2131198ff778427d5cb4726b1ebe2607b02eb3f Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Mon, 1 Jun 2026 10:02:30 +0200 Subject: [PATCH 03/13] 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 7c5e583242ce6a67ce6a11c7c688d3a01415864a Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Mon, 1 Jun 2026 12:29:14 +0200 Subject: [PATCH 04/13] 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 39dafc9718ce70c417f3fa956a576f0c1243c540 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 5 Jun 2026 21:12:35 +0200 Subject: [PATCH 05/13] 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 8eaf9f3dd2c3b775d14935a15905e0af74190b3a Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 5 Jun 2026 21:12:54 +0200 Subject: [PATCH 06/13] 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 90bdcf2c9775c9a2a4904f55ee834e7edd8a0659 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 5 Jun 2026 21:13:15 +0200 Subject: [PATCH 07/13] 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 62be057f3b337ae5fea162c3ccb90631b3a8508c Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 5 Jun 2026 21:13:28 +0200 Subject: [PATCH 08/13] 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 48a977799c786a1ac36da73a3a17ed1e63e6bfd1 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 5 Jun 2026 21:34:33 +0200 Subject: [PATCH 09/13] 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 6e2b575cf..7d1f6aab0 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: @@ -370,7 +373,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 20f4e904f..62b6d3012 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py @@ -10,6 +10,7 @@ ClientService, Exporter, Lease, + LeaseList, WithOptions, add_display_columns, add_exporter_row, @@ -532,6 +533,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 06d95a125431f28d4447c70e9460056be05b9fa5 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 5 Jun 2026 21:41:36 +0200 Subject: [PATCH 10/13] 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 62b6d3012..d3703e389 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py @@ -560,6 +560,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 1d2c92e1f79b3938538ec38b7f20397912aa92a3 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 5 Jun 2026 21:42:35 +0200 Subject: [PATCH 11/13] 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 d3703e389..84195d109 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py @@ -545,7 +545,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"), @@ -560,20 +560,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 3fc5cc9ddcac3c4bfa1c93283e2eab34fae9b2ea Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 19 Jun 2026 11:22:11 +0200 Subject: [PATCH 12/13] 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 2798cb6eca610ece6d0bd2bd02e74b259bd01a12 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 19 Jun 2026 19:23:38 +0200 Subject: [PATCH 13/13] 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 cbc31485e..bd6785909 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."))