diff --git a/e2e/tests.bats b/e2e/tests.bats index a52f9b62b..0dbad893d 100644 --- a/e2e/tests.bats +++ b/e2e/tests.bats @@ -185,6 +185,33 @@ EOF assert_success assert_output "No resources found." + # Test complex selectors with matchExpressions (regression test for server-side over-filtering) + # Use 'sa' exporter since 'oidc' is already leased above. The '!nonexistent' is a matchExpression + # that will always be true (label doesn't exist on exporters), allowing the lease to match. + jmp create lease --selector 'example.com/board=sa,!nonexistent' --duration 1d + + # Partial match: filter with just matchLabels (subset) → expecting a match + run jmp get leases --selector 'example.com/board=sa' -o yaml + assert_success + assert_output --partial "example.com/board=sa" + + # Partial match: filter with just matchExpressions (subset) → expecting a match + # This specifically tests client-side filtering of matchExpressions + run jmp get leases --selector '!nonexistent' -o yaml + assert_success + assert_output --partial "!nonexistent" + + # Non-matching matchExpressions → expecting no match with current implementation + # where we're filtering against the original lease request + run jmp get leases --selector 'example.com/board=sa,!production' + assert_success + assert_output "No resources found." + + # Filter asks for more than lease has → expecting no match + run jmp get leases --selector 'example.com/board=sa,!nonexistent,region=us' + assert_success + assert_output "No resources found." + jmp delete leases --all } diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/delete.py b/python/packages/jumpstarter-cli/jumpstarter_cli/delete.py index 617acf8ce..f607c9bc8 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/delete.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/delete.py @@ -32,6 +32,8 @@ def delete_leases(config, name: str, selector: str | None, all: bool, output: Ou names.append(name) elif selector: leases = config.list_leases(filter=selector) + # Client-side filtering for matchExpressions (server only filters matchLabels) + leases = leases.filter_by_selector(selector) for lease in leases.leases: if lease.client == config.metadata.name: names.append(lease.name) diff --git a/python/packages/jumpstarter/jumpstarter/client/grpc.py b/python/packages/jumpstarter/jumpstarter/client/grpc.py index 8e30d1433..b535c7702 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc.py @@ -13,7 +13,7 @@ from jumpstarter_protocol import client_pb2, client_pb2_grpc, jumpstarter_pb2_grpc, kubernetes_pb2, router_pb2_grpc from pydantic import BaseModel, ConfigDict, Field, field_serializer -from jumpstarter.client.selectors import selector_contains +from jumpstarter.client.selectors import extract_match_labels_filter, selector_contains from jumpstarter.common.grpc import translate_grpc_exceptions @@ -383,7 +383,7 @@ async def ListLeases( parent="namespaces/{}".format(self.namespace), page_size=page_size, page_token=page_token, - filter=filter, + filter=extract_match_labels_filter(filter), only_active=only_active, ) ) diff --git a/python/packages/jumpstarter/jumpstarter/client/selectors.py b/python/packages/jumpstarter/jumpstarter/client/selectors.py index 6e3a84beb..0b453fb1e 100644 --- a/python/packages/jumpstarter/jumpstarter/client/selectors.py +++ b/python/packages/jumpstarter/jumpstarter/client/selectors.py @@ -51,6 +51,22 @@ def parse_label_selector(selector: str) -> tuple[dict[str, str], list[tuple[str, return match_labels, match_expressions +def extract_match_labels_filter(selector: str | None) -> str | None: + """Extract only the matchLabels portion from a selector string. + + This is used to send only the server-filterable portion to the server, + since matchExpressions can't be matched against metadata.labels. + """ + if not selector: + return None + match_labels, _ = parse_label_selector(selector) + if not match_labels: + return None + # Format matchLabels dict back to a selector string. + # Example: {"board": "rpi", "env": "test"} -> "board=rpi,env=test" + return ",".join(f"{k}={v}" for k, v in match_labels.items()) + + def selector_contains(selector: str, requirements: str) -> bool: """Check if selector contains all criteria from requirements.