From 5c5f4848e70ef74d793c5b3784b292fde5ec2430 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 30 Aug 2026 10:31:24 -0400 Subject: [PATCH] feat(admin): keep labels in client and exporter output Labels are what an exporter is selected by and how clients are grouped, but from_dict rebuilt the metadata without them, so they were absent from `jmp admin get client|exporter -o json|yaml` even though the cluster had them. Anything reading that output - a UI listing a namespace, a script grouping exporters - had to fall back to kubectl for a field the command was already fetching. Assisted-by: Claude Signed-off-by: Kirk Brauer --- .../jumpstarter_kubernetes/clients.py | 3 ++ .../jumpstarter_kubernetes/clients_test.py | 38 +++++++++++++------ .../jumpstarter_kubernetes/exporters.py | 3 ++ .../jumpstarter_kubernetes/exporters_test.py | 26 +++++++++++-- 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py index a1acc96f7..a92ca60aa 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py @@ -40,6 +40,9 @@ def from_dict(dict: dict): metadata=V1ObjectMeta( creation_timestamp=dict["metadata"]["creationTimestamp"], generation=dict["metadata"]["generation"], + # Labels are how a client is grouped and found, so they belong + # in the output rather than only in the stored object. + labels=dict["metadata"].get("labels"), name=dict["metadata"]["name"], namespace=dict["metadata"]["namespace"], resource_version=dict["metadata"]["resourceVersion"], diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py index 3a3983c43..3a3d0263c 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py @@ -255,9 +255,7 @@ async def test_get_ca_bundle_with_ca_cert(): # Verify it's base64-encoded expected = base64.b64encode(ca_cert_pem.encode("utf-8")).decode("utf-8") assert result == expected - api.core_api.read_namespaced_config_map.assert_called_once_with( - "jumpstarter-service-ca-cert", "test-namespace" - ) + api.core_api.read_namespaced_config_map.assert_called_once_with("jumpstarter-service-ca-cert", "test-namespace") @pytest.mark.asyncio @@ -297,9 +295,7 @@ async def test_get_ca_bundle_configmap_not_found(): api.core_api = AsyncMock() # Mock 404 error - api.core_api.read_namespaced_config_map = AsyncMock( - side_effect=ApiException(status=404, reason="Not Found") - ) + api.core_api.read_namespaced_config_map = AsyncMock(side_effect=ApiException(status=404, reason="Not Found")) result = await api.get_ca_bundle() @@ -313,9 +309,7 @@ async def test_get_ca_bundle_other_api_error(): api.core_api = AsyncMock() # Mock 403 error - api.core_api.read_namespaced_config_map = AsyncMock( - side_effect=ApiException(status=403, reason="Forbidden") - ) + api.core_api.read_namespaced_config_map = AsyncMock(side_effect=ApiException(status=403, reason="Forbidden")) with pytest.raises(ApiException) as exc_info: await api.get_ca_bundle() @@ -400,9 +394,7 @@ async def test_get_client_config_without_ca_bundle(): api.core_api.read_namespaced_secret = AsyncMock(return_value=mock_secret) # Mock ConfigMap not found - api.core_api.read_namespaced_config_map = AsyncMock( - side_effect=ApiException(status=404, reason="Not Found") - ) + api.core_api.read_namespaced_config_map = AsyncMock(side_effect=ApiException(status=404, reason="Not Found")) config = await api.get_client_config("test-client", allow=[], unsafe=False) @@ -410,3 +402,25 @@ async def test_get_client_config_without_ca_bundle(): assert config.tls.ca == "" assert config.endpoint == "https://test-endpoint:8082" assert config.token == token + + +def test_client_from_dict_keeps_labels(): + """Labels are how clients are grouped, so from_dict must not drop them""" + client = V1Alpha1Client.from_dict( + { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Client", + "metadata": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "labels": {"team": "platform"}, + "name": "test-client", + "namespace": "default", + "resourceVersion": "1", + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8", + }, + "status": {"endpoint": "https://test-client"}, + } + ) + assert client.metadata.labels == {"team": "platform"} + assert '"team": "platform"' in client.dump_json() diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py index d5b10e278..3ad891f9d 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py @@ -45,6 +45,9 @@ def from_dict(dict: dict): metadata=V1ObjectMeta( creation_timestamp=dict["metadata"]["creationTimestamp"], generation=dict["metadata"]["generation"], + # Labels are what a client selects an exporter by, so they are + # part of what an admin needs to see about one. + labels=dict["metadata"].get("labels"), name=dict["metadata"]["name"], namespace=dict["metadata"]["namespace"], resource_version=dict["metadata"]["resourceVersion"], diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py index 7bb34c173..aed3147b1 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py @@ -354,9 +354,7 @@ async def test_get_exporter_config_without_ca_bundle(): api.core_api.read_namespaced_secret = AsyncMock(return_value=mock_secret) # Mock ConfigMap not found - api.core_api.read_namespaced_config_map = AsyncMock( - side_effect=ApiException(status=404, reason="Not Found") - ) + api.core_api.read_namespaced_config_map = AsyncMock(side_effect=ApiException(status=404, reason="Not Found")) config = await api.get_exporter_config("test-exporter") @@ -364,3 +362,25 @@ async def test_get_exporter_config_without_ca_bundle(): assert config.tls.ca == "" assert config.endpoint == "https://test-endpoint:8082" assert config.token == token + + +def test_exporter_from_dict_keeps_labels(): + """Labels are what an exporter is selected by, so from_dict must keep them""" + exporter = V1Alpha1Exporter.from_dict( + { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Exporter", + "metadata": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "labels": {"board": "rpi4"}, + "name": "test-exporter", + "namespace": "default", + "resourceVersion": "1", + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8", + }, + "status": {"credential": {"name": "c"}, "devices": [], "endpoint": "https://e"}, + } + ) + assert exporter.metadata.labels == {"board": "rpi4"} + assert '"board": "rpi4"' in exporter.dump_json()