diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py index 7f21092f4..184439524 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py @@ -124,3 +124,111 @@ class OutputMode(str): opt_nointeractive = click.option( "--nointeractive", is_flag=True, default=False, help="Disable interactive prompts (for use in scripts)." ) + + +def _normalize_tokens(items: list[str], normalize_case: bool) -> list[str]: + """Extract and normalize tokens from comma-separated values.""" + tokens = ( + token.strip().lower() if normalize_case else token.strip() + for item in items + for token in item.split(',') + ) + return [token for token in tokens if token] + + +def _deduplicate_tokens(tokens: list[str]) -> list[str]: + """Remove duplicates while preserving order.""" + return list(dict.fromkeys(tokens)) + + +def _validate_tokens(tokens: list[str], allowed_values: set[str], ctx, param) -> None: + """Validate tokens against allowed values.""" + invalid = [t for t in tokens if t not in allowed_values] + if invalid: + allowed_list = ", ".join(sorted(allowed_values)) + raise click.BadParameter( + f"Invalid value(s) {invalid}. Allowed values are: {allowed_list}", + ctx=ctx, + param=param + ) + + +def parse_comma_separated( + ctx: click.Context, + param: click.Parameter, + value: str | tuple[str, ...] | None, + allowed_values: set[str] | None = None, + normalize_case: bool = True +) -> list[str]: + """Generic comma-separated value parser with validation and normalization. + + Supports both CSV format ("a,b") and repeated flags ("a" "b" from --flag a --flag b). + Normalizes by stripping whitespace, optionally lowercasing, deduplicating while preserving order. + Optionally validates against allowed values and raises click.BadParameter on invalid tokens. + + Args: + ctx: Click context + param: Click parameter + value: Input value(s) - string for CSV or tuple for repeated flags + allowed_values: Set of allowed values for validation (None = no validation) + normalize_case: Whether to convert values to lowercase + + Returns: + List of normalized, deduplicated values + + Raises: + click.BadParameter: If validation fails with invalid tokens + """ + if not value: + return [] + + # Handle both single string and tuple (from multiple flag usage) + items = [value] if isinstance(value, str) else list(value) + + # Process tokens through the pipeline + all_tokens = _normalize_tokens(items, normalize_case) + unique_tokens = _deduplicate_tokens(all_tokens) + + # Validate if allowed values are specified + if allowed_values is not None: + _validate_tokens(unique_tokens, allowed_values, ctx, param) + + return unique_tokens + + +def opt_comma_separated( + name: str, + allowed_values: set[str] | None = None, + normalize_case: bool = True, + help_text: str | None = None +): + """Create a click option for comma-separated values with optional validation. + + Args: + name: Option name (e.g. "with" creates --with option) + allowed_values: Set of allowed values for validation (None = no validation) + normalize_case: Whether to convert values to lowercase + help_text: Custom help text (auto-generated if None) + + Returns: + Click option decorator + """ + + def callback(ctx, param, value): + return parse_comma_separated(ctx, param, value, allowed_values, normalize_case) + + # Auto-generate help text if not provided + if help_text is None: + if allowed_values: + allowed_list = ", ".join(sorted(allowed_values)) + help_text = f"Comma-separated values. Allowed: {allowed_list} (comma-separated or repeated)" + else: + help_text = "Comma-separated values (comma-separated or repeated)" + + return click.option( + f"--{name}", + f"{name}_options", + callback=callback, + multiple=True, + help=help_text + ) diff --git a/packages/jumpstarter-cli/jumpstarter_cli/get.py b/packages/jumpstarter-cli/jumpstarter_cli/get.py index 0ab19af3f..d62e6dee1 100644 --- a/packages/jumpstarter-cli/jumpstarter_cli/get.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/get.py @@ -1,7 +1,7 @@ import click from jumpstarter_cli_common.config import opt_config from jumpstarter_cli_common.exceptions import handle_exceptions_with_reauthentication -from jumpstarter_cli_common.opt import OutputType, opt_output_all +from jumpstarter_cli_common.opt import OutputType, opt_comma_separated, opt_output_all from jumpstarter_cli_common.print import model_print from .common import opt_selector @@ -19,15 +19,20 @@ def get(): @opt_config(exporter=False) @opt_selector @opt_output_all -@click.option("--with", "with_options", multiple=True, help="Include additional information (e.g., 'leases')") +@opt_comma_separated( + "with", + {"leases", "online"}, + help_text="Include fields: leases, online (comma-separated or repeated)" +) @handle_exceptions_with_reauthentication(relogin_client) -def get_exporters(config, selector: str | None, output: OutputType, with_options: tuple[str, ...]): +def get_exporters(config, selector: str | None, output: OutputType, with_options: list[str]): """ Display one or many exporters """ include_leases = "leases" in with_options - exporters = config.list_exporters(filter=selector, include_leases=include_leases) + include_online = "online" in with_options + exporters = config.list_exporters(filter=selector, include_leases=include_leases, include_online=include_online) model_print(exporters, output) diff --git a/packages/jumpstarter-cli/jumpstarter_cli/get_test.py b/packages/jumpstarter-cli/jumpstarter_cli/get_test.py new file mode 100644 index 000000000..d06a8b837 --- /dev/null +++ b/packages/jumpstarter-cli/jumpstarter_cli/get_test.py @@ -0,0 +1,241 @@ +from unittest.mock import Mock + +import click +import pytest +from jumpstarter_cli_common.opt import parse_comma_separated + +from jumpstarter.client.grpc import Exporter, ExporterList, Lease +from jumpstarter.config.client import ClientConfigV1Alpha1 + + +class TestParseWith: + """Test the generic parse_comma_separated function with --with specific validation.""" + + @property + def allowed_values(self): + """Allowed values for --with option""" + return {"leases", "online"} + + def test_single_option(self): + """Test parsing a single option""" + result = parse_comma_separated(None, None, "leases", self.allowed_values) + assert result == ["leases"] + + def test_multiple_options(self): + """Test parsing multiple comma-separated options""" + result = parse_comma_separated(None, None, "leases,online", self.allowed_values) + assert result == ["leases", "online"] + + def test_options_with_spaces(self): + """Test parsing options with spaces around commas""" + result = parse_comma_separated(None, None, "leases, online", self.allowed_values) + assert result == ["leases", "online"] + + def test_empty_value(self): + """Test parsing empty or None value""" + assert parse_comma_separated(None, None, None, self.allowed_values) == [] + assert parse_comma_separated(None, None, "", self.allowed_values) == [] + + def test_invalid_options_raise_error(self): + """Test that invalid options raise click.BadParameter""" + with pytest.raises( + click.BadParameter, + match="Invalid value\\(s\\) \\['unknown', 'invalid'\\]. Allowed values are: leases, online" + ): + parse_comma_separated(None, None, "unknown,online,invalid", self.allowed_values) + + with pytest.raises( + click.BadParameter, + match="Invalid value\\(s\\) \\['invalid'\\]. Allowed values are: leases, online" + ): + parse_comma_separated(None, None, "online,invalid", self.allowed_values) + + def test_repeated_flags_tuple_input(self): + """Test parsing multiple flags as tuple (--with a --with b)""" + result = parse_comma_separated(None, None, ("leases", "online"), self.allowed_values) + assert result == ["leases", "online"] + + def test_mixed_csv_and_repeated_flags(self): + """Test mixing CSV and repeated flags""" + result = parse_comma_separated(None, None, ("leases,online", "leases"), self.allowed_values) + assert result == ["leases", "online"] # deduplicated + + def test_normalization_lowercase(self): + """Test that values are normalized to lowercase""" + result = parse_comma_separated(None, None, "LEASES,Online", self.allowed_values) + assert result == ["leases", "online"] + + def test_whitespace_stripping(self): + """Test that whitespace is stripped from values""" + result = parse_comma_separated(None, None, " leases , online ", self.allowed_values) + assert result == ["leases", "online"] + + def test_empty_tokens_dropped(self): + """Test that empty tokens are dropped""" + result = parse_comma_separated(None, None, "leases,,online,", self.allowed_values) + assert result == ["leases", "online"] + + def test_deduplication_preserves_order(self): + """Test that deduplication preserves first occurrence order""" + result = parse_comma_separated(None, None, "online,leases,online,leases", self.allowed_values) + assert result == ["online", "leases"] + + def test_empty_string_in_tuple(self): + """Test handling empty strings in tuple""" + result = parse_comma_separated(None, None, ("", "leases", ""), self.allowed_values) + assert result == ["leases"] + + def test_complex_mixed_input(self): + """Test complex input with CSV, repeated flags, whitespace, and case variation""" + result = parse_comma_separated(None, None, (" LEASES, online ", "Online", "leases,"), self.allowed_values) + assert result == ["leases", "online"] + + def test_no_validation_mode(self): + """Test that arbitrary values are accepted when allowed_values=None""" + result = parse_comma_separated(None, None, "arbitrary,values,anything", None) + assert result == ["arbitrary", "values", "anything"] + + def test_case_normalization_disabled(self): + """Test that case normalization can be disabled""" + result = parse_comma_separated(None, None, "LEASES,Online", {"LEASES", "Online"}, normalize_case=False) + assert result == ["LEASES", "Online"] + + +class TestGetExportersLogic: + def create_test_config(self): + """Create a mock config for testing""" + config = Mock(spec=ClientConfigV1Alpha1) + return config + + def create_test_exporters(self, include_leases=False, include_online_status=False): + """Create test exporters with optional lease data""" + exporters = [ + Exporter( + namespace="default", + name="exporter-1", + labels={"type": "device", "env": "test"}, + online=True + ), + Exporter( + namespace="default", + name="exporter-2", + labels={"type": "server", "env": "prod"}, + online=False + ) + ] + + if include_leases: + # Add lease to first exporter + lease = Mock(spec=Lease) + lease.client = "test-client" + lease.get_status.return_value = "Active" + lease.effective_begin_time = Mock() + lease.effective_begin_time.strftime.return_value = "2023-01-01 10:00:00" + exporters[0].lease = lease + + return ExporterList( + exporters=exporters, + next_page_token=None, + include_online=include_online_status, + include_leases=include_leases + ) + + def test_with_options_parsing_leases(self): + """Test that 'leases' in with_options is parsed correctly""" + with_options = ("leases",) + + include_leases = "leases" in with_options + include_online = "online" in with_options + + assert include_leases is True + assert include_online is False + + def test_with_options_parsing_online(self): + """Test that 'online' in with_options is parsed correctly""" + with_options = ("online",) + + include_leases = "leases" in with_options + include_online = "online" in with_options + + assert include_leases is False + assert include_online is True + + def test_with_options_parsing_both(self): + """Test that both 'leases' and 'online' in with_options are parsed correctly""" + with_options = ("leases", "online") + + include_leases = "leases" in with_options + include_online = "online" in with_options + + assert include_leases is True + assert include_online is True + + def test_with_options_parsing_empty(self): + """Test that empty with_options are parsed correctly""" + with_options = () + + include_leases = "leases" in with_options + include_online = "online" in with_options + + assert include_leases is False + assert include_online is False + + def test_with_options_parsing_unknown(self): + """Test that the parse_with function now validates and rejects unknown options""" + # This test verifies that the new parse_with function would reject unknown options + # The actual CLI behavior now validates input, so unknown options cause failures + # This test documents the expected behavior change + pass # Test is no longer relevant since parse_with now validates input + + def test_exporter_list_creation_basic(self): + """Test creating ExporterList with basic exporters""" + exporters = self.create_test_exporters() + + assert isinstance(exporters, ExporterList) + assert len(exporters.exporters) == 2 + assert exporters.include_online is False + assert exporters.include_leases is False + + def test_exporter_list_creation_with_options(self): + """Test creating ExporterList with various options""" + exporters = self.create_test_exporters(include_leases=True, include_online_status=True) + + assert isinstance(exporters, ExporterList) + assert len(exporters.exporters) == 2 + assert exporters.include_online is True + assert exporters.include_leases is True + + +class TestGetExportersIntegration: + """Integration tests for data flow""" + + def test_exporter_to_exporter_list_flow(self): + """Test the data flow from individual Exporter objects to ExporterList""" + # Create individual exporters + exporter1 = Exporter( + namespace="lab-1", + name="rpi-device-001", + labels={"device": "raspberry-pi", "location": "rack-1"}, + online=True + ) + exporter2 = Exporter( + namespace="lab-1", + name="server-001", + labels={"device": "server", "location": "rack-2"}, + online=False + ) + + # Create ExporterList + exporter_list = ExporterList( + exporters=[exporter1, exporter2], + next_page_token=None, + include_online=True, + include_leases=False + ) + + # Verify the list contains the exporters and has correct options + assert len(exporter_list.exporters) == 2 + assert exporter_list.exporters[0].name == "rpi-device-001" + assert exporter_list.exporters[1].name == "server-001" + assert exporter_list.include_online is True + assert exporter_list.include_leases is False diff --git a/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py b/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py index ad1e1c8b2..9682b4b6b 100644 --- a/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py +++ b/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py @@ -33,7 +33,7 @@ from ...v1 import kubernetes_pb2 as jumpstarter_dot_v1_dot_kubernetes__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"jumpstarter/client/v1/client.proto\x12\x15jumpstarter.client.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1fjumpstarter/v1/kubernetes.proto\"\x84\x02\n\x08\x45xporter\x12\x17\n\x04name\x18\x01 \x01(\tB\x03\xe0\x41\x08R\x04name\x12\x43\n\x06labels\x18\x02 \x03(\x0b\x32+.jumpstarter.client.v1.Exporter.LabelsEntryR\x06labels\x1a\x39\n\x0bLabelsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01:_\xea\x41\\\n\x18jumpstarter.dev/Exporter\x12+namespaces/{namespace}/exporters/{exporter}*\texporters2\x08\x65xporter\"\xed\x06\n\x05Lease\x12\x17\n\x04name\x18\x01 \x01(\tB\x03\xe0\x41\x08R\x04name\x12\"\n\x08selector\x18\x02 \x01(\tB\x06\xe0\x41\x02\xe0\x41\x05R\x08selector\x12:\n\x08\x64uration\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x03\xe0\x41\x02R\x08\x64uration\x12M\n\x12\x65\x66\x66\x65\x63tive_duration\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x03\xe0\x41\x03R\x11\x65\x66\x66\x65\x63tiveDuration\x12>\n\nbegin_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x00R\tbeginTime\x88\x01\x01\x12V\n\x14\x65\x66\x66\x65\x63tive_begin_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x03\xe0\x41\x03H\x01R\x12\x65\x66\x66\x65\x63tiveBeginTime\x88\x01\x01\x12:\n\x08\x65nd_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x02R\x07\x65ndTime\x88\x01\x01\x12R\n\x12\x65\x66\x66\x65\x63tive_end_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x03\xe0\x41\x03H\x03R\x10\x65\x66\x66\x65\x63tiveEndTime\x88\x01\x01\x12;\n\x06\x63lient\x18\t \x01(\tB\x1e\xe0\x41\x03\xfa\x41\x18\n\x16jumpstarter.dev/ClientH\x04R\x06\x63lient\x88\x01\x01\x12\x41\n\x08\x65xporter\x18\n \x01(\tB \xe0\x41\x03\xfa\x41\x1a\n\x18jumpstarter.dev/ExporterH\x05R\x08\x65xporter\x88\x01\x01\x12>\n\nconditions\x18\x0b \x03(\x0b\x32\x19.jumpstarter.v1.ConditionB\x03\xe0\x41\x03R\nconditions:P\xea\x41M\n\x15jumpstarter.dev/Lease\x12%namespaces/{namespace}/leases/{lease}*\x06leases2\x05leaseB\r\n\x0b_begin_timeB\x17\n\x15_effective_begin_timeB\x0b\n\t_end_timeB\x15\n\x13_effective_end_timeB\t\n\x07_clientB\x0b\n\t_exporter\"J\n\x12GetExporterRequest\x12\x34\n\x04name\x18\x01 \x01(\tB \xe0\x41\x02\xfa\x41\x1a\n\x18jumpstarter.dev/ExporterR\x04name\"\xb3\x01\n\x14ListExportersRequest\x12\x38\n\x06parent\x18\x01 \x01(\tB \xe0\x41\x02\xfa\x41\x1a\x12\x18jumpstarter.dev/ExporterR\x06parent\x12 \n\tpage_size\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x08pageSize\x12\"\n\npage_token\x18\x03 \x01(\tB\x03\xe0\x41\x01R\tpageToken\x12\x1b\n\x06\x66ilter\x18\x04 \x01(\tB\x03\xe0\x41\x01R\x06\x66ilter\"~\n\x15ListExportersResponse\x12=\n\texporters\x18\x01 \x03(\x0b\x32\x1f.jumpstarter.client.v1.ExporterR\texporters\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"D\n\x0fGetLeaseRequest\x12\x31\n\x04name\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\n\x15jumpstarter.dev/LeaseR\x04name\"\xad\x01\n\x11ListLeasesRequest\x12\x35\n\x06parent\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\x12\x15jumpstarter.dev/LeaseR\x06parent\x12 \n\tpage_size\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x08pageSize\x12\"\n\npage_token\x18\x03 \x01(\tB\x03\xe0\x41\x01R\tpageToken\x12\x1b\n\x06\x66ilter\x18\x04 \x01(\tB\x03\xe0\x41\x01R\x06\x66ilter\"r\n\x12ListLeasesResponse\x12\x34\n\x06leases\x18\x01 \x03(\x0b\x32\x1c.jumpstarter.client.v1.LeaseR\x06leases\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xa4\x01\n\x12\x43reateLeaseRequest\x12\x35\n\x06parent\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\x12\x15jumpstarter.dev/LeaseR\x06parent\x12\x1e\n\x08lease_id\x18\x02 \x01(\tB\x03\xe0\x41\x01R\x07leaseId\x12\x37\n\x05lease\x18\x03 \x01(\x0b\x32\x1c.jumpstarter.client.v1.LeaseB\x03\xe0\x41\x02R\x05lease\"\x8f\x01\n\x12UpdateLeaseRequest\x12\x37\n\x05lease\x18\x01 \x01(\x0b\x32\x1c.jumpstarter.client.v1.LeaseB\x03\xe0\x41\x02R\x05lease\x12@\n\x0bupdate_mask\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.FieldMaskB\x03\xe0\x41\x01R\nupdateMask\"G\n\x12\x44\x65leteLeaseRequest\x12\x31\n\x04name\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\n\x15jumpstarter.dev/LeaseR\x04name2\xa7\x08\n\rClientService\x12\x8d\x01\n\x0bGetExporter\x12).jumpstarter.client.v1.GetExporterRequest\x1a\x1f.jumpstarter.client.v1.Exporter\"2\xda\x41\x04name\x82\xd3\xe4\x93\x02%\x12#/v1/{name=namespaces/*/exporters/*}\x12\xa0\x01\n\rListExporters\x12+.jumpstarter.client.v1.ListExportersRequest\x1a,.jumpstarter.client.v1.ListExportersResponse\"4\xda\x41\x06parent\x82\xd3\xe4\x93\x02%\x12#/v1/{parent=namespaces/*}/exporters\x12\x81\x01\n\x08GetLease\x12&.jumpstarter.client.v1.GetLeaseRequest\x1a\x1c.jumpstarter.client.v1.Lease\"/\xda\x41\x04name\x82\xd3\xe4\x93\x02\"\x12 /v1/{name=namespaces/*/leases/*}\x12\x94\x01\n\nListLeases\x12(.jumpstarter.client.v1.ListLeasesRequest\x1a).jumpstarter.client.v1.ListLeasesResponse\"1\xda\x41\x06parent\x82\xd3\xe4\x93\x02\"\x12 /v1/{parent=namespaces/*}/leases\x12\x9f\x01\n\x0b\x43reateLease\x12).jumpstarter.client.v1.CreateLeaseRequest\x1a\x1c.jumpstarter.client.v1.Lease\"G\xda\x41\x15parent,lease,lease_id\x82\xd3\xe4\x93\x02)\" /v1/{parent=namespaces/*}/leases:\x05lease\x12\xa1\x01\n\x0bUpdateLease\x12).jumpstarter.client.v1.UpdateLeaseRequest\x1a\x1c.jumpstarter.client.v1.Lease\"I\xda\x41\x11lease,update_mask\x82\xd3\xe4\x93\x02/2&/v1/{lease.name=namespaces/*/leases/*}:\x05lease\x12\x81\x01\n\x0b\x44\x65leteLease\x12).jumpstarter.client.v1.DeleteLeaseRequest\x1a\x16.google.protobuf.Empty\"/\xda\x41\x04name\x82\xd3\xe4\x93\x02\"* /v1/{name=namespaces/*/leases/*}B\x9e\x01\n\x19\x63om.jumpstarter.client.v1B\x0b\x43lientProtoP\x01\xa2\x02\x03JCX\xaa\x02\x15Jumpstarter.Client.V1\xca\x02\x15Jumpstarter\\Client\\V1\xe2\x02!Jumpstarter\\Client\\V1\\GPBMetadata\xea\x02\x17Jumpstarter::Client::V1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"jumpstarter/client/v1/client.proto\x12\x15jumpstarter.client.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1fjumpstarter/v1/kubernetes.proto\"\xa1\x02\n\x08\x45xporter\x12\x17\n\x04name\x18\x01 \x01(\tB\x03\xe0\x41\x08R\x04name\x12\x43\n\x06labels\x18\x02 \x03(\x0b\x32+.jumpstarter.client.v1.Exporter.LabelsEntryR\x06labels\x12\x1b\n\x06online\x18\x03 \x01(\x08\x42\x03\xe0\x41\x03R\x06online\x1a\x39\n\x0bLabelsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01:_\xea\x41\\\n\x18jumpstarter.dev/Exporter\x12+namespaces/{namespace}/exporters/{exporter}*\texporters2\x08\x65xporter\"\xed\x06\n\x05Lease\x12\x17\n\x04name\x18\x01 \x01(\tB\x03\xe0\x41\x08R\x04name\x12\"\n\x08selector\x18\x02 \x01(\tB\x06\xe0\x41\x02\xe0\x41\x05R\x08selector\x12:\n\x08\x64uration\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x03\xe0\x41\x02R\x08\x64uration\x12M\n\x12\x65\x66\x66\x65\x63tive_duration\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x03\xe0\x41\x03R\x11\x65\x66\x66\x65\x63tiveDuration\x12>\n\nbegin_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x00R\tbeginTime\x88\x01\x01\x12V\n\x14\x65\x66\x66\x65\x63tive_begin_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x03\xe0\x41\x03H\x01R\x12\x65\x66\x66\x65\x63tiveBeginTime\x88\x01\x01\x12:\n\x08\x65nd_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x02R\x07\x65ndTime\x88\x01\x01\x12R\n\x12\x65\x66\x66\x65\x63tive_end_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x03\xe0\x41\x03H\x03R\x10\x65\x66\x66\x65\x63tiveEndTime\x88\x01\x01\x12;\n\x06\x63lient\x18\t \x01(\tB\x1e\xe0\x41\x03\xfa\x41\x18\n\x16jumpstarter.dev/ClientH\x04R\x06\x63lient\x88\x01\x01\x12\x41\n\x08\x65xporter\x18\n \x01(\tB \xe0\x41\x03\xfa\x41\x1a\n\x18jumpstarter.dev/ExporterH\x05R\x08\x65xporter\x88\x01\x01\x12>\n\nconditions\x18\x0b \x03(\x0b\x32\x19.jumpstarter.v1.ConditionB\x03\xe0\x41\x03R\nconditions:P\xea\x41M\n\x15jumpstarter.dev/Lease\x12%namespaces/{namespace}/leases/{lease}*\x06leases2\x05leaseB\r\n\x0b_begin_timeB\x17\n\x15_effective_begin_timeB\x0b\n\t_end_timeB\x15\n\x13_effective_end_timeB\t\n\x07_clientB\x0b\n\t_exporter\"J\n\x12GetExporterRequest\x12\x34\n\x04name\x18\x01 \x01(\tB \xe0\x41\x02\xfa\x41\x1a\n\x18jumpstarter.dev/ExporterR\x04name\"\xb3\x01\n\x14ListExportersRequest\x12\x38\n\x06parent\x18\x01 \x01(\tB \xe0\x41\x02\xfa\x41\x1a\x12\x18jumpstarter.dev/ExporterR\x06parent\x12 \n\tpage_size\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x08pageSize\x12\"\n\npage_token\x18\x03 \x01(\tB\x03\xe0\x41\x01R\tpageToken\x12\x1b\n\x06\x66ilter\x18\x04 \x01(\tB\x03\xe0\x41\x01R\x06\x66ilter\"~\n\x15ListExportersResponse\x12=\n\texporters\x18\x01 \x03(\x0b\x32\x1f.jumpstarter.client.v1.ExporterR\texporters\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"D\n\x0fGetLeaseRequest\x12\x31\n\x04name\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\n\x15jumpstarter.dev/LeaseR\x04name\"\xad\x01\n\x11ListLeasesRequest\x12\x35\n\x06parent\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\x12\x15jumpstarter.dev/LeaseR\x06parent\x12 \n\tpage_size\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x08pageSize\x12\"\n\npage_token\x18\x03 \x01(\tB\x03\xe0\x41\x01R\tpageToken\x12\x1b\n\x06\x66ilter\x18\x04 \x01(\tB\x03\xe0\x41\x01R\x06\x66ilter\"r\n\x12ListLeasesResponse\x12\x34\n\x06leases\x18\x01 \x03(\x0b\x32\x1c.jumpstarter.client.v1.LeaseR\x06leases\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xa4\x01\n\x12\x43reateLeaseRequest\x12\x35\n\x06parent\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\x12\x15jumpstarter.dev/LeaseR\x06parent\x12\x1e\n\x08lease_id\x18\x02 \x01(\tB\x03\xe0\x41\x01R\x07leaseId\x12\x37\n\x05lease\x18\x03 \x01(\x0b\x32\x1c.jumpstarter.client.v1.LeaseB\x03\xe0\x41\x02R\x05lease\"\x8f\x01\n\x12UpdateLeaseRequest\x12\x37\n\x05lease\x18\x01 \x01(\x0b\x32\x1c.jumpstarter.client.v1.LeaseB\x03\xe0\x41\x02R\x05lease\x12@\n\x0bupdate_mask\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.FieldMaskB\x03\xe0\x41\x01R\nupdateMask\"G\n\x12\x44\x65leteLeaseRequest\x12\x31\n\x04name\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\n\x15jumpstarter.dev/LeaseR\x04name2\xa7\x08\n\rClientService\x12\x8d\x01\n\x0bGetExporter\x12).jumpstarter.client.v1.GetExporterRequest\x1a\x1f.jumpstarter.client.v1.Exporter\"2\xda\x41\x04name\x82\xd3\xe4\x93\x02%\x12#/v1/{name=namespaces/*/exporters/*}\x12\xa0\x01\n\rListExporters\x12+.jumpstarter.client.v1.ListExportersRequest\x1a,.jumpstarter.client.v1.ListExportersResponse\"4\xda\x41\x06parent\x82\xd3\xe4\x93\x02%\x12#/v1/{parent=namespaces/*}/exporters\x12\x81\x01\n\x08GetLease\x12&.jumpstarter.client.v1.GetLeaseRequest\x1a\x1c.jumpstarter.client.v1.Lease\"/\xda\x41\x04name\x82\xd3\xe4\x93\x02\"\x12 /v1/{name=namespaces/*/leases/*}\x12\x94\x01\n\nListLeases\x12(.jumpstarter.client.v1.ListLeasesRequest\x1a).jumpstarter.client.v1.ListLeasesResponse\"1\xda\x41\x06parent\x82\xd3\xe4\x93\x02\"\x12 /v1/{parent=namespaces/*}/leases\x12\x9f\x01\n\x0b\x43reateLease\x12).jumpstarter.client.v1.CreateLeaseRequest\x1a\x1c.jumpstarter.client.v1.Lease\"G\xda\x41\x15parent,lease,lease_id\x82\xd3\xe4\x93\x02)\" /v1/{parent=namespaces/*}/leases:\x05lease\x12\xa1\x01\n\x0bUpdateLease\x12).jumpstarter.client.v1.UpdateLeaseRequest\x1a\x1c.jumpstarter.client.v1.Lease\"I\xda\x41\x11lease,update_mask\x82\xd3\xe4\x93\x02/2&/v1/{lease.name=namespaces/*/leases/*}:\x05lease\x12\x81\x01\n\x0b\x44\x65leteLease\x12).jumpstarter.client.v1.DeleteLeaseRequest\x1a\x16.google.protobuf.Empty\"/\xda\x41\x04name\x82\xd3\xe4\x93\x02\"* /v1/{name=namespaces/*/leases/*}B\x9e\x01\n\x19\x63om.jumpstarter.client.v1B\x0b\x43lientProtoP\x01\xa2\x02\x03JCX\xaa\x02\x15Jumpstarter.Client.V1\xca\x02\x15Jumpstarter\\Client\\V1\xe2\x02!Jumpstarter\\Client\\V1\\GPBMetadata\xea\x02\x17Jumpstarter::Client::V1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -45,6 +45,8 @@ _globals['_EXPORTER_LABELSENTRY']._serialized_options = b'8\001' _globals['_EXPORTER'].fields_by_name['name']._loaded_options = None _globals['_EXPORTER'].fields_by_name['name']._serialized_options = b'\340A\010' + _globals['_EXPORTER'].fields_by_name['online']._loaded_options = None + _globals['_EXPORTER'].fields_by_name['online']._serialized_options = b'\340A\003' _globals['_EXPORTER']._loaded_options = None _globals['_EXPORTER']._serialized_options = b'\352A\\\n\030jumpstarter.dev/Exporter\022+namespaces/{namespace}/exporters/{exporter}*\texporters2\010exporter' _globals['_LEASE'].fields_by_name['name']._loaded_options = None @@ -114,29 +116,29 @@ _globals['_CLIENTSERVICE'].methods_by_name['DeleteLease']._loaded_options = None _globals['_CLIENTSERVICE'].methods_by_name['DeleteLease']._serialized_options = b'\332A\004name\202\323\344\223\002\"* /v1/{name=namespaces/*/leases/*}' _globals['_EXPORTER']._serialized_start=338 - _globals['_EXPORTER']._serialized_end=598 - _globals['_EXPORTER_LABELSENTRY']._serialized_start=444 - _globals['_EXPORTER_LABELSENTRY']._serialized_end=501 - _globals['_LEASE']._serialized_start=601 - _globals['_LEASE']._serialized_end=1478 - _globals['_GETEXPORTERREQUEST']._serialized_start=1480 - _globals['_GETEXPORTERREQUEST']._serialized_end=1554 - _globals['_LISTEXPORTERSREQUEST']._serialized_start=1557 - _globals['_LISTEXPORTERSREQUEST']._serialized_end=1736 - _globals['_LISTEXPORTERSRESPONSE']._serialized_start=1738 - _globals['_LISTEXPORTERSRESPONSE']._serialized_end=1864 - _globals['_GETLEASEREQUEST']._serialized_start=1866 - _globals['_GETLEASEREQUEST']._serialized_end=1934 - _globals['_LISTLEASESREQUEST']._serialized_start=1937 - _globals['_LISTLEASESREQUEST']._serialized_end=2110 - _globals['_LISTLEASESRESPONSE']._serialized_start=2112 - _globals['_LISTLEASESRESPONSE']._serialized_end=2226 - _globals['_CREATELEASEREQUEST']._serialized_start=2229 - _globals['_CREATELEASEREQUEST']._serialized_end=2393 - _globals['_UPDATELEASEREQUEST']._serialized_start=2396 - _globals['_UPDATELEASEREQUEST']._serialized_end=2539 - _globals['_DELETELEASEREQUEST']._serialized_start=2541 - _globals['_DELETELEASEREQUEST']._serialized_end=2612 - _globals['_CLIENTSERVICE']._serialized_start=2615 - _globals['_CLIENTSERVICE']._serialized_end=3678 + _globals['_EXPORTER']._serialized_end=627 + _globals['_EXPORTER_LABELSENTRY']._serialized_start=473 + _globals['_EXPORTER_LABELSENTRY']._serialized_end=530 + _globals['_LEASE']._serialized_start=630 + _globals['_LEASE']._serialized_end=1507 + _globals['_GETEXPORTERREQUEST']._serialized_start=1509 + _globals['_GETEXPORTERREQUEST']._serialized_end=1583 + _globals['_LISTEXPORTERSREQUEST']._serialized_start=1586 + _globals['_LISTEXPORTERSREQUEST']._serialized_end=1765 + _globals['_LISTEXPORTERSRESPONSE']._serialized_start=1767 + _globals['_LISTEXPORTERSRESPONSE']._serialized_end=1893 + _globals['_GETLEASEREQUEST']._serialized_start=1895 + _globals['_GETLEASEREQUEST']._serialized_end=1963 + _globals['_LISTLEASESREQUEST']._serialized_start=1966 + _globals['_LISTLEASESREQUEST']._serialized_end=2139 + _globals['_LISTLEASESRESPONSE']._serialized_start=2141 + _globals['_LISTLEASESRESPONSE']._serialized_end=2255 + _globals['_CREATELEASEREQUEST']._serialized_start=2258 + _globals['_CREATELEASEREQUEST']._serialized_end=2422 + _globals['_UPDATELEASEREQUEST']._serialized_start=2425 + _globals['_UPDATELEASEREQUEST']._serialized_end=2568 + _globals['_DELETELEASEREQUEST']._serialized_start=2570 + _globals['_DELETELEASEREQUEST']._serialized_end=2641 + _globals['_CLIENTSERVICE']._serialized_start=2644 + _globals['_CLIENTSERVICE']._serialized_end=3707 # @@protoc_insertion_point(module_scope) diff --git a/packages/jumpstarter/jumpstarter/client/grpc.py b/packages/jumpstarter/jumpstarter/client/grpc.py index 49db4b4b8..f3615b43b 100644 --- a/packages/jumpstarter/jumpstarter/client/grpc.py +++ b/packages/jumpstarter/jumpstarter/client/grpc.py @@ -16,6 +16,43 @@ from jumpstarter.common.grpc import translate_grpc_exceptions +@dataclass +class WithOptions: + show_online: bool = False + show_leases: bool = False + + +def add_display_columns(table, options: WithOptions = None): + if options is None: + options = WithOptions() + table.add_column("NAME") + if options.show_online: + table.add_column("ONLINE") + table.add_column("LABELS") + if options.show_leases: + table.add_column("LEASED BY") + table.add_column("LEASE STATUS") + table.add_column("START TIME") + + +def add_exporter_row(table, exporter, options: WithOptions = None, lease_info: tuple[str, str, str] | None = None): + if options is None: + options = WithOptions() + row_data = [] + row_data.append(exporter.name) + if options.show_online: + row_data.append("yes" if exporter.online else "no") + row_data.append(",".join(("{}={}".format(k, v) for k, v in sorted(exporter.labels.items())))) + if options.show_leases: + if lease_info: + lease_client, lease_status, start_time = lease_info + else: + lease_client, lease_status, start_time = "", "Available", "" + row_data.extend([lease_client, lease_status, start_time]) + + table.add_row(*row_data) + + def parse_identifier(identifier: str, kind: str) -> tuple[str, str]: segments = identifier.split("/") if len(segments) != 4: @@ -43,22 +80,30 @@ class Exporter(BaseModel): namespace: str name: str labels: dict[str, str] + online: bool = False + lease: Lease | None = None @classmethod def from_protobuf(cls, data: client_pb2.Exporter) -> Exporter: namespace, name = parse_exporter_identifier(data.name) - return cls(namespace=namespace, name=name, labels=data.labels) + return cls(namespace=namespace, name=name, labels=data.labels, online=data.online) @classmethod - def rich_add_columns(cls, table): - table.add_column("NAME") - table.add_column("LABELS") + def rich_add_columns(cls, table, options: WithOptions = None): + add_display_columns(table, options) - def rich_add_rows(self, table): - table.add_row( - self.name, - ",".join(("{}={}".format(i[0], i[1]) for i in self.labels.items())), - ) + def rich_add_rows(self, table, options: WithOptions = None): + lease_info = None + if options and options.show_leases and self.lease: + lease_client = self.lease.client + lease_status = self.lease.get_status() + start_time = "" + if self.lease.effective_begin_time: + start_time = self.lease.effective_begin_time.strftime("%Y-%m-%d %H:%M:%S") + lease_info = (lease_client, lease_status, start_time) + elif options and options.show_leases: + lease_info = ("", "Available", "") + add_exporter_row(table, self, options, lease_info) def rich_add_names(self, names): names.append(self.name) @@ -147,58 +192,11 @@ def get_status(self) -> str: return latest_condition.reason if latest_condition.reason else "Unknown" -class WithLease(BaseModel): - exporter: Exporter - lease: Lease | None = None - - @classmethod - def rich_add_columns(cls, table): - table.add_column("NAME") - table.add_column("LABELS") - table.add_column("LEASED BY") - table.add_column("LEASE STATUS") - table.add_column("START TIME") - - def rich_add_rows(self, table): - lease_client = "" - lease_status = "Available" - start_time = "" - - if self.lease and self.lease.exporter == self.exporter.name: - lease_client = self.lease.client - lease_status = self.lease.get_status() - if self.lease.effective_begin_time: - start_time = self.lease.effective_begin_time.strftime("%Y-%m-%d %H:%M:%S") - - table.add_row( - self.exporter.name, - ",".join(("{}={}".format(i[0], i[1]) for i in self.exporter.labels.items())), - lease_client, - lease_status, - start_time, - ) - - def rich_add_names(self, names): - self.exporter.rich_add_names(names) - - def model_dump_json(self, **kwargs): - json_kwargs = {k: v for k, v in kwargs.items() if k in {"indent", "separators", "sort_keys", "ensure_ascii"}} - data = { - "exporter": self.exporter.model_dump(mode="json"), - "lease": self.lease.model_dump(mode="json") if self.lease else None, - } - return json.dumps(data, **json_kwargs) - - def model_dump(self, **kwargs): - return { - "exporter": self.exporter.model_dump(mode="json"), - "lease": self.lease.model_dump(mode="json") if self.lease else None, - } - - class ExporterList(BaseModel): exporters: list[Exporter] next_page_token: str | None = Field(exclude=True) + include_online: bool = Field(default=False, exclude=True) + include_leases: bool = Field(default=False, exclude=True) @classmethod def from_protobuf(cls, data: client_pb2.ListExportersResponse) -> ExporterList: @@ -207,62 +205,49 @@ def from_protobuf(cls, data: client_pb2.ListExportersResponse) -> ExporterList: next_page_token=data.next_page_token, ) - @classmethod - def rich_add_columns(cls, table): - Exporter.rich_add_columns(table) + def rich_add_columns(self, table): + options = WithOptions(show_online=self.include_online, show_leases=self.include_leases) + Exporter.rich_add_columns(table, options) def rich_add_rows(self, table): + options = WithOptions(show_online=self.include_online, show_leases=self.include_leases) for exporter in self.exporters: - exporter.rich_add_rows(table) + exporter.rich_add_rows(table, options) def rich_add_names(self, names): for exporter in self.exporters: exporter.rich_add_names(names) - -class WithLeaseList(BaseModel): - """List of exporters with lease information""" - - exporters_with_leases: list[WithLease] - next_page_token: str | None = Field(exclude=True) - - @classmethod - def rich_add_columns(cls, table): - WithLease.rich_add_columns(table) - - def rich_add_rows(self, table): - for exporter_with_lease in self.exporters_with_leases: - exporter_with_lease.rich_add_rows(table) - - def rich_add_names(self, names): - for exporter_with_lease in self.exporters_with_leases: - exporter_with_lease.rich_add_names(names) - def model_dump_json(self, **kwargs): json_kwargs = {k: v for k, v in kwargs.items() if k in {"indent", "separators", "sort_keys", "ensure_ascii"}} + + # Determine which fields to exclude + exclude_fields = set() + if not self.include_leases: + exclude_fields.add("lease") + if not self.include_online: + exclude_fields.add("online") + data = { "exporters": [ - { - "exporter": ewl.exporter.model_dump(mode="json"), - "lease": ewl.lease.model_dump(mode="json") if ewl.lease else None, - } - for ewl in self.exporters_with_leases + exporter.model_dump(mode="json", exclude=exclude_fields) for exporter in self.exporters ] } return json.dumps(data, **json_kwargs) def model_dump(self, **kwargs): + exclude_fields = set() + if not self.include_leases: + exclude_fields.add("lease") + if not self.include_online: + exclude_fields.add("online") + return { "exporters": [ - { - "exporter": ewl.exporter.model_dump(mode="json"), - "lease": ewl.lease.model_dump(mode="json") if ewl.lease else None, - } - for ewl in self.exporters_with_leases + exporter.model_dump(mode="json", exclude=exclude_fields) for exporter in self.exporters ] } - class LeaseList(BaseModel): leases: list[Lease] next_page_token: str | None = Field(exclude=True) diff --git a/packages/jumpstarter/jumpstarter/client/grpc_test.py b/packages/jumpstarter/jumpstarter/client/grpc_test.py new file mode 100644 index 000000000..f5f069ad9 --- /dev/null +++ b/packages/jumpstarter/jumpstarter/client/grpc_test.py @@ -0,0 +1,354 @@ +from datetime import datetime +from io import StringIO +from unittest.mock import Mock + +from rich.console import Console +from rich.table import Table + +from jumpstarter.client.grpc import ( + Exporter, + Lease, + WithOptions, + add_display_columns, + add_exporter_row, +) + + +class TestWithOptions: + def test_default_options(self): + options = WithOptions() + assert options.show_online is False + assert options.show_leases is False + + def test_custom_options(self): + options = WithOptions(show_online=True, show_leases=True) + assert options.show_online is True + assert options.show_leases is True + + +class TestAddDisplayColumns: + def test_basic_columns(self): + table = Table() + add_display_columns(table) + + columns = [col.header for col in table.columns] + assert columns == ["NAME", "LABELS"] + + def test_with_online_column(self): + table = Table() + options = WithOptions(show_online=True) + add_display_columns(table, options) + + columns = [col.header for col in table.columns] + assert columns == ["NAME", "ONLINE", "LABELS"] + + def test_with_leases_columns(self): + table = Table() + options = WithOptions(show_leases=True) + add_display_columns(table, options) + + columns = [col.header for col in table.columns] + assert columns == ["NAME", "LABELS", "LEASED BY", "LEASE STATUS", "START TIME"] + + def test_with_all_columns(self): + table = Table() + options = WithOptions(show_online=True, show_leases=True) + add_display_columns(table, options) + + columns = [col.header for col in table.columns] + assert columns == ["NAME", "ONLINE", "LABELS", "LEASED BY", "LEASE STATUS", "START TIME"] + + +class TestAddExporterRow: + def create_test_exporter(self, online=True, labels=None): + if labels is None: + labels = {"env": "test", "type": "device"} + return Exporter( + namespace="default", + name="test-exporter", + labels=labels, + online=online + ) + + def test_basic_row(self): + table = Table() + add_display_columns(table) + + exporter = self.create_test_exporter() + add_exporter_row(table, exporter) + + # Just verify a row was added and correct number of columns + assert len(table.rows) == 1 + assert len(table.columns) == 2 # NAME, LABELS + + def test_row_with_lease_info(self): + table = Table() + options = WithOptions(show_leases=True) + add_display_columns(table, options) + + exporter = self.create_test_exporter() + lease_info = ("client-123", "Active", "2023-01-01 10:00:00") + add_exporter_row(table, exporter, options, lease_info) + + assert len(table.rows) == 1 + assert len(table.columns) == 5 # NAME, LABELS, LEASED BY, LEASE STATUS, START TIME + + def test_row_with_lease_info_available(self): + table = Table() + options = WithOptions(show_leases=True) + add_display_columns(table, options) + + exporter = self.create_test_exporter() + lease_info = ("", "Available", "") + add_exporter_row(table, exporter, options, lease_info) + + assert len(table.rows) == 1 + assert len(table.columns) == 5 + + def test_row_with_all_options(self): + table = Table() + options = WithOptions(show_online=True, show_leases=True) + add_display_columns(table, options) + + exporter = self.create_test_exporter(online=False) + lease_info = ("client-456", "Expired", "2023-01-01 08:00:00") + add_exporter_row(table, exporter, options, lease_info) + + assert len(table.rows) == 1 + assert len(table.columns) == 6 # NAME, ONLINE, LABELS, LEASED BY, LEASE STATUS, START TIME + + +class TestExporterList: + def create_test_lease(self, client="test-client", status="Active"): + lease = Mock(spec=Lease) + lease.client = client + lease.get_status.return_value = status + lease.effective_begin_time = datetime(2023, 1, 1, 10, 0, 0) + return lease + + def test_exporter_without_lease(self): + exporter = Exporter( + namespace="default", + name="test-exporter", + labels={"type": "device"}, + online=True + ) + + table = Table() + Exporter.rich_add_columns(table) + exporter.rich_add_rows(table) + + assert len(table.rows) == 1 + assert len(table.columns) == 2 # NAME, LABELS + + def test_exporter_with_lease_no_display(self): + lease = self.create_test_lease() + exporter = Exporter( + namespace="default", + name="test-exporter", + labels={"type": "device"}, + online=True, + lease=lease + ) + + table = Table() + Exporter.rich_add_columns(table) + exporter.rich_add_rows(table) + + # Should not show lease info when show_leases=False + assert len(table.rows) == 1 + assert len(table.columns) == 2 # NAME, LABELS + + def test_exporter_with_lease_display(self): + lease = self.create_test_lease() + exporter = Exporter( + namespace="default", + name="test-exporter", + labels={"type": "device"}, + online=True, + lease=lease + ) + + table = Table() + options = WithOptions(show_leases=True) + Exporter.rich_add_columns(table, options) + exporter.rich_add_rows(table, options) + + assert len(table.rows) == 1 + assert len(table.columns) == 5 # NAME, LABELS, LEASED BY, LEASE STATUS, START TIME + + # Test actual table content by rendering it + console = Console(file=StringIO(), width=120) + console.print(table) + output = console.file.getvalue() + + # Check that the actual content is present in the rendered output + assert "test-exporter" in output + assert "type=device" in output + assert "test-client" in output + assert "Active" in output + assert "2023-01-01 10:00:00" in output + + def test_exporter_without_lease_but_show_leases(self): + exporter = Exporter( + namespace="default", + name="test-exporter", + labels={"type": "device"}, + online=True + ) + + table = Table() + options = WithOptions(show_leases=True) + Exporter.rich_add_columns(table, options) + exporter.rich_add_rows(table, options) + + assert len(table.rows) == 1 + assert len(table.columns) == 5 # NAME, LABELS, LEASED BY, LEASE STATUS, START TIME + + # Test actual table content by rendering it + console = Console(file=StringIO(), width=120) + console.print(table) + output = console.file.getvalue() + + # Check that the actual content shows "Available" status + assert "test-exporter" in output + assert "type=device" in output + assert "Available" in output + # Should NOT contain lease client or start time for available exporters + assert "test-client" not in output + + def test_exporter_online_status_display(self): + """Test that online status icons are correctly displayed""" + # Test online exporter + exporter_online = Exporter( + namespace="default", + name="online-exporter", + labels={"type": "device"}, + online=True + ) + + # Test offline exporter + exporter_offline = Exporter( + namespace="default", + name="offline-exporter", + labels={"type": "device"}, + online=False + ) + + # Test with online status display enabled + table = Table() + options = WithOptions(show_online=True) + Exporter.rich_add_columns(table, options) + exporter_online.rich_add_rows(table, options) + exporter_offline.rich_add_rows(table, options) + + assert len(table.rows) == 2 + assert len(table.columns) == 3 # NAME, ONLINE, LABELS + + # Test actual table content by rendering it + console = Console(file=StringIO(), width=120) + console.print(table) + output = console.file.getvalue() + + # Check that the actual content shows correct online status indicators + assert "online-exporter" in output + assert "offline-exporter" in output + assert "yes" in output # Should show "yes" for online + assert "no" in output # Should show "no" for offline + + def test_exporter_all_features_display(self): + """Test all display features together: online status + lease info""" + lease = self.create_test_lease(client="full-test-client", status="Active") + + # Create exporters with different combinations of online/lease status + exporter_online_with_lease = Exporter( + namespace="default", + name="online-with-lease", + labels={"env": "prod"}, + online=True, + lease=lease + ) + + exporter_offline_no_lease = Exporter( + namespace="default", + name="offline-no-lease", + labels={"env": "dev"}, + online=False + # No lease + ) + + # Test with all options enabled + table = Table() + options = WithOptions(show_online=True, show_leases=True) + Exporter.rich_add_columns(table, options) + exporter_online_with_lease.rich_add_rows(table, options) + exporter_offline_no_lease.rich_add_rows(table, options) + + assert len(table.rows) == 2 + assert len(table.columns) == 6 # NAME, ONLINE, LABELS, LEASED BY, LEASE STATUS, START TIME + + # Test actual table content by rendering it + console = Console(file=StringIO(), width=150) + console.print(table) + output = console.file.getvalue() + + # Verify all content is present + assert "online-with-lease" in output + assert "offline-no-lease" in output + assert "env=prod" in output + assert "env=dev" in output + assert "yes" in output # Online indicator + assert "no" in output # Offline indicator + assert "full-test-client" in output # Lease client + assert "Active" in output # Lease status + assert "Available" in output # Available status for no lease + assert "2023-01-01 10:00:00" in output # Lease start time + + def test_exporter_lease_info_extraction(self): + """Test that lease information is correctly extracted from lease objects""" + lease = self.create_test_lease(client="my-client", status="Expired") + exporter = Exporter( + namespace="default", + name="test-exporter", + labels={"type": "device"}, + online=True, + lease=lease + ) + + # Manually verify the lease data that would be extracted + assert exporter.lease.client == "my-client" + assert exporter.lease.get_status() == "Expired" + assert exporter.lease.effective_begin_time.strftime("%Y-%m-%d %H:%M:%S") == "2023-01-01 10:00:00" + + # Test the logic that builds lease_info tuple in rich_add_rows + options = WithOptions(show_leases=True) + if options.show_leases and exporter.lease: + lease_client = exporter.lease.client + lease_status = exporter.lease.get_status() + start_time = exporter.lease.effective_begin_time.strftime("%Y-%m-%d %H:%M:%S") + lease_info = (lease_client, lease_status, start_time) + + assert lease_info == ("my-client", "Expired", "2023-01-01 10:00:00") + + def test_exporter_no_lease_info_extraction(self): + """Test that default lease information is used when no lease exists""" + exporter = Exporter( + namespace="default", + name="test-exporter", + labels={"type": "device"}, + online=True + # No lease attached + ) + + # Test the logic that builds lease_info tuple when no lease exists + options = WithOptions(show_leases=True) + if options.show_leases: + if exporter.lease: + # This path should not be taken + raise AssertionError("Should not have lease data") + else: + # This path should be taken - default "Available" status + lease_info = ("", "Available", "") + assert lease_info == ("", "Available", "") + + diff --git a/packages/jumpstarter/jumpstarter/config/client.py b/packages/jumpstarter/jumpstarter/config/client.py index 332587ff1..9872c3c7e 100644 --- a/packages/jumpstarter/jumpstarter/config/client.py +++ b/packages/jumpstarter/jumpstarter/config/client.py @@ -26,7 +26,7 @@ from .grpc import call_credentials from .shell import ShellConfigV1Alpha1 from .tls import TLSConfigV1Alpha1 -from jumpstarter.client.grpc import ClientService, WithLease, WithLeaseList +from jumpstarter.client.grpc import ClientService, Exporter from jumpstarter.common.exceptions import ( ConfigurationError, ConnectionError, @@ -60,8 +60,10 @@ async def wrapper(*args, **kwargs): raise e except Exception: raise + return wrapper + class ClientConfigV1Alpha1Drivers(BaseSettings): model_config = SettingsConfigDict(env_prefix="JMP_DRIVERS_") @@ -137,7 +139,6 @@ async def get_exporter( svc = ClientService(channel=await self.channel(), namespace=self.metadata.namespace) return await svc.GetExporter(name=name) - @_blocking_compat @_handle_connection_error async def list_exporters( @@ -146,10 +147,14 @@ async def list_exporters( page_token: str | None = None, filter: str | None = None, include_leases: bool = False, + include_online: bool = False, ): svc = ClientService(channel=await self.channel(), namespace=self.metadata.namespace) exporters_response = await svc.ListExporters(page_size=page_size, page_token=page_token, filter=filter) + # Set the include_online flag for display purposes + exporters_response.include_online = include_online + if not include_leases: return exporters_response @@ -165,12 +170,17 @@ async def list_exporters( exporters_with_leases = [] for exporter in exporters_response.exporters: lease = lease_map.get(exporter.name) - exporters_with_leases.append(WithLease(exporter=exporter, lease=lease)) - return WithLeaseList( - exporters_with_leases=exporters_with_leases, next_page_token=exporters_response.next_page_token - ) - - + exporter_with_lease = Exporter( + namespace=exporter.namespace, + name=exporter.name, + labels=exporter.labels, + online=exporter.online, + lease=lease, + ) + exporters_with_leases.append(exporter_with_lease) + exporters_response.include_leases = True + exporters_response.exporters = exporters_with_leases + return exporters_response @_blocking_compat @_handle_connection_error @@ -221,7 +231,6 @@ async def update_lease( svc = ClientService(channel=await self.channel(), namespace=self.metadata.namespace) return await svc.UpdateLease(name=name, duration=duration) - @asynccontextmanager async def lease_async( self, @@ -238,17 +247,17 @@ async def lease_async( release_lease = lease_name == "" try: async with Lease( - channel=await self.channel(), - namespace=self.metadata.namespace, - name=lease_name, - selector=selector, - duration=duration, - portal=portal, - allow=self.drivers.allow, - unsafe=self.drivers.unsafe, - release=release_lease, - tls_config=self.tls, - grpc_options=self.grpcOptions, + channel=await self.channel(), + namespace=self.metadata.namespace, + name=lease_name, + selector=selector, + duration=duration, + portal=portal, + allow=self.drivers.allow, + unsafe=self.drivers.unsafe, + release=release_lease, + tls_config=self.tls, + grpc_options=self.grpcOptions, ) as lease: yield lease