Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion e2e/test/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."))

Expand Down
17 changes: 16 additions & 1 deletion python/packages/jumpstarter/jumpstarter/client/grpc.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,6 +17,8 @@
from jumpstarter.common import ExporterStatus
from jumpstarter.common.grpc import translate_grpc_exceptions

logger = logging.getLogger(__name__)


@dataclass
class WithOptions:
Expand Down Expand Up @@ -416,7 +419,19 @@ 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 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:
Expand Down
42 changes: 42 additions & 0 deletions python/packages/jumpstarter/jumpstarter/client/grpc_test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import logging
from datetime import datetime, timedelta
from io import StringIO
from unittest.mock import AsyncMock, Mock, patch
Expand All @@ -12,6 +13,7 @@
Exporter,
ExporterList,
Lease,
LeaseList,
WithOptions,
add_display_columns,
add_exporter_row,
Expand Down Expand Up @@ -654,6 +656,46 @@ 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_keeps_matching_and_excludes_erroring_leases(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"]

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():
from jumpstarter_protocol import client_pb2
Expand Down
33 changes: 24 additions & 9 deletions python/packages/jumpstarter/jumpstarter/client/selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,27 @@ 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 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 operator == "in":
return key in sel_labels and sel_labels[key] in values
if operator == "exists":
return key in sel_labels
raise ValueError(f"unknown label selector operator: {operator!r}")
Comment thread
raballew marked this conversation as resolved.


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).

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
Expand All @@ -84,13 +100,12 @@ 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)
Comment thread
raballew marked this conversation as resolved.
if not found:
return False

Expand Down
117 changes: 111 additions & 6 deletions python/packages/jumpstarter/jumpstarter/client/selectors_test.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,94 @@
"""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 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 TestSelectorContains:
"""Tests for checking if a lease's selector contains a filter's criteria."""

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"])

def test_raises_when_key_absent(self):
with pytest.raises(ValueError, match="unknown label selector operator"):
_label_satisfies_expression({}, "key", "bogus", ["val"])

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

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

Expand All @@ -27,15 +104,43 @@ 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
assert selector_contains("board=rpi", "!experimental") is False

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

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_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
Expand Down
Loading