From cfa4bd18ea50872b1c73631adc1ebe2fe2e2caad Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 30 Aug 2026 10:29:02 -0400 Subject: [PATCH 1/2] fix(admin): handle resources the controller has not reconciled yet A Client or Exporter exists the moment it is created, but its credentials and endpoint arrive later, when the controller reconciles it - or never, if nothing is watching that namespace. Reading one in that state failed with a traceback rather than an explanation: jmp admin import client fresh AttributeError: 'NoneType' object has no attribute 'credential' jmp admin get exporter KeyError: 'status' Make the exporter's status optional the way the client's already is, so a status-less exporter lists and renders (as Unknown, with no endpoint and no devices), and raise CredentialNotReadyError from get_client_config and get_exporter_config, which jmp admin import reports as: Error: The client 'fresh' has no credentials yet. The Jumpstarter controller issues them shortly after the resource is created; check that it is running and watching this namespace, then try again. Assisted-by: Claude Signed-off-by: Kirk Brauer --- .../jumpstarter_cli_admin/import_res.py | 5 ++ .../jumpstarter_kubernetes/clients.py | 3 + .../jumpstarter_kubernetes/clients_test.py | 28 +++++++++ .../jumpstarter_kubernetes/exceptions.py | 18 ++++++ .../jumpstarter_kubernetes/exporters.py | 24 ++++--- .../jumpstarter_kubernetes/exporters_test.py | 62 +++++++++++++++++++ 6 files changed, 132 insertions(+), 8 deletions(-) diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py index 2faf0b684..d73769150 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py @@ -13,6 +13,7 @@ opt_output_path_only, ) from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api +from jumpstarter_kubernetes.exceptions import JumpstarterKubernetesError from kubernetes_asyncio.client.exceptions import ApiException from kubernetes_asyncio.config.config_exception import ConfigException @@ -92,6 +93,8 @@ async def import_client( click.echo(f"Client configuration successfully saved to {config_path}") else: click.echo(config_path) + except JumpstarterKubernetesError as e: + raise click.ClickException(str(e)) from e except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: @@ -141,6 +144,8 @@ async def import_exporter( click.echo(f"Exporter configuration successfully saved to {config_path}") else: click.echo(config_path) + except JumpstarterKubernetesError as e: + raise click.ClickException(str(e)) from e except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py index a92ca60aa..bce4ecf9a 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py @@ -7,6 +7,7 @@ from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference from pydantic import Field +from .exceptions import CredentialNotReadyError from .json import JsonBaseModel from .list import V1Alpha1List from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference @@ -148,6 +149,8 @@ async def get_client(self, name: str) -> V1Alpha1Client: async def get_client_config(self, name: str, allow: list[str], unsafe=False) -> ClientConfigV1Alpha1: """Get a client config for a specified client name""" client = await self.get_client(name) + if client.status is None or client.status.credential is None: + raise CredentialNotReadyError("client", name) secret = await self.core_api.read_namespaced_secret(client.status.credential.name, self.namespace) endpoint = client.status.endpoint token = base64.b64decode(secret.data["token"]).decode("utf8") diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py index 3a3d0263c..969e94918 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py @@ -7,6 +7,7 @@ from jumpstarter_kubernetes import V1Alpha1Client, V1Alpha1ClientStatus from jumpstarter_kubernetes.clients import ClientsV1Alpha1Api +from jumpstarter_kubernetes.exceptions import CredentialNotReadyError TEST_CLIENT = V1Alpha1Client( api_version="jumpstarter.dev/v1alpha1", @@ -424,3 +425,30 @@ def test_client_from_dict_keeps_labels(): ) assert client.metadata.labels == {"team": "platform"} assert '"team": "platform"' in client.dump_json() + + +@pytest.mark.asyncio +async def test_get_client_config_without_credentials(): + """A client whose credentials the controller has not issued yet is reported, not crashed on""" + api = ClientsV1Alpha1Api(namespace="test-namespace") + api.api = AsyncMock() + api.core_api = AsyncMock() + api.api.get_namespaced_custom_object = AsyncMock( + return_value={ + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Client", + "metadata": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "name": "fresh-client", + "namespace": "test-namespace", + "resourceVersion": "1", + "uid": "test-uid", + }, + } + ) + + with pytest.raises(CredentialNotReadyError, match="fresh-client"): + await api.get_client_config("fresh-client", allow=[], unsafe=False) + + api.core_api.read_namespaced_secret.assert_not_awaited() diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py index fbc2b4cbb..af7d1c3f6 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py @@ -11,6 +11,24 @@ class JumpstarterKubernetesError(Exception): pass +class CredentialNotReadyError(JumpstarterKubernetesError): + """Raised when a client or exporter exists but has no credentials yet. + + The controller issues credentials asynchronously, so a resource created a + moment ago - or one in a namespace the controller does not watch - has a + name but nothing to authenticate with. + """ + + def __init__(self, kind: str, name: str): + self.kind = kind + self.name = name + super().__init__( + f"The {kind} '{name}' has no credentials yet. " + "The Jumpstarter controller issues them shortly after the resource is created; " + "check that it is running and watching this namespace, then try again." + ) + + class ToolNotInstalledError(JumpstarterKubernetesError): """Raised when a required tool (kind, minikube, kubectl) is not installed.""" diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py index 3ad891f9d..af8abd902 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py @@ -1,11 +1,12 @@ import asyncio import base64 -from typing import Literal +from typing import Literal, Optional from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference from pydantic import Field from .datetime import time_since +from .exceptions import CredentialNotReadyError from .json import JsonBaseModel from .list import V1Alpha1List from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference @@ -24,9 +25,11 @@ class V1Alpha1ExporterDevice(JsonBaseModel): class V1Alpha1ExporterStatus(JsonBaseModel): - credential: SerializeV1ObjectReference - devices: list[V1Alpha1ExporterDevice] - endpoint: str + # The controller fills these in after it reconciles the exporter, so a + # freshly created one has a status with nothing in it yet. + credential: Optional[SerializeV1ObjectReference] = None + devices: list[V1Alpha1ExporterDevice] = [] + endpoint: str = "" exporter_status: str | None = Field(alias="exporterStatus", default=None) status_message: str | None = Field(alias="statusMessage", default=None) @@ -35,7 +38,7 @@ class V1Alpha1Exporter(JsonBaseModel): api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") kind: Literal["Exporter"] = Field(default="Exporter") metadata: SerializeV1ObjectMeta - status: V1Alpha1ExporterStatus + status: Optional[V1Alpha1ExporterStatus] = None @staticmethod def from_dict(dict: dict): @@ -57,13 +60,16 @@ def from_dict(dict: dict): credential=V1ObjectReference(name=dict["status"]["credential"]["name"]) if "credential" in dict["status"] else None, - endpoint=dict["status"]["endpoint"], + endpoint=dict["status"].get("endpoint", ""), devices=[V1Alpha1ExporterDevice(labels=d["labels"], uuid=d["uuid"]) for d in dict["status"]["devices"]] if "devices" in dict["status"] else [], exporter_status=dict["status"].get("exporterStatus"), status_message=dict["status"].get("statusMessage"), - ), + ) + # An exporter the controller has not reconciled yet has no status. + if "status" in dict + else None, ) @classmethod @@ -104,7 +110,7 @@ def rich_add_rows(self, table, devices: bool = False): table.add_row( self.metadata.name, status or "Unknown", - self.status.endpoint, + self.status.endpoint if self.status else "", str(len(self.status.devices) if self.status and self.status.devices else 0), time_since(self.metadata.creation_timestamp), ) @@ -188,6 +194,8 @@ async def create_exporter( async def get_exporter_config(self, name: str) -> ExporterConfigV1Alpha1: """Get an exporter config for a specified exporter name""" exporter = await self.get_exporter(name) + if exporter.status is None or exporter.status.credential is None: + raise CredentialNotReadyError("exporter", name) secret = await self.core_api.read_namespaced_secret(exporter.status.credential.name, self.namespace) endpoint = exporter.status.endpoint token = base64.b64decode(secret.data["token"]).decode("utf8") diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py index aed3147b1..8205adf20 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py @@ -5,6 +5,7 @@ from kubernetes_asyncio.client.exceptions import ApiException from kubernetes_asyncio.client.models import V1ConfigMap, V1ObjectMeta, V1ObjectReference, V1Secret +from jumpstarter_kubernetes.exceptions import CredentialNotReadyError from jumpstarter_kubernetes.exporters import ( ExportersV1Alpha1Api, V1Alpha1Exporter, @@ -384,3 +385,64 @@ def test_exporter_from_dict_keeps_labels(): ) assert exporter.metadata.labels == {"board": "rpi4"} assert '"board": "rpi4"' in exporter.dump_json() + + +@pytest.mark.asyncio +async def test_get_exporter_config_without_credentials(): + """An exporter whose credentials the controller has not issued yet is reported, not crashed on""" + api = ExportersV1Alpha1Api(namespace="test-namespace") + api.api = AsyncMock() + api.core_api = AsyncMock() + api.api.get_namespaced_custom_object = AsyncMock( + return_value={ + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Exporter", + "metadata": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "name": "fresh-exporter", + "namespace": "test-namespace", + "resourceVersion": "1", + "uid": "test-uid", + }, + } + ) + + with pytest.raises(CredentialNotReadyError, match="fresh-exporter"): + await api.get_exporter_config("fresh-exporter") + + api.core_api.read_namespaced_secret.assert_not_awaited() + + +def test_exporter_from_dict_without_status(): + """An exporter the controller has not reconciled yet has no status at all""" + exporter = V1Alpha1Exporter.from_dict( + { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Exporter", + "metadata": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "name": "fresh-exporter", + "namespace": "default", + "resourceVersion": "1", + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8", + }, + } + ) + assert exporter.metadata.name == "fresh-exporter" + assert exporter.status is None + + +def test_exporter_rich_add_rows_without_status(): + """A status-less exporter still renders as a row""" + exporter = V1Alpha1Exporter( + api_version="jumpstarter.dev/v1alpha1", + kind="Exporter", + metadata=V1ObjectMeta(name="fresh-exporter", namespace="default", creation_timestamp="2021-10-01T00:00:00Z"), + status=None, + ) + mock_table = MagicMock() + exporter.rich_add_rows(mock_table) + name, status, endpoint, devices, _age = mock_table.add_row.call_args.args + assert (name, status, endpoint, devices) == ("fresh-exporter", "Unknown", "", "0") From ad5be3154294abfedca76106ef5337c6743efc21 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Mon, 31 Aug 2026 10:15:18 -0400 Subject: [PATCH 2/2] fix(admin): keep exporters with no devices in the devices listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `jmp admin get exporter -d` enumerates one row per device, so an exporter with none to enumerate produced no rows and vanished from the listing — "No resources found" for a resource that plainly exists. That covers an exporter the controller has not reconciled yet, and also one that has simply never run, which has a status but an empty device list. Emit a single row with empty label and UUID columns in that case. Assisted-by: Claude Signed-off-by: Kirk Brauer --- .../jumpstarter_kubernetes/exporters.py | 40 ++++++++++++------- .../jumpstarter_kubernetes/exporters_test.py | 29 ++++++++++++++ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py index af8abd902..3d59d749d 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py @@ -91,20 +91,32 @@ def rich_add_columns(cls, table, devices: bool = False): def rich_add_rows(self, table, devices: bool = False): status = self.status.exporter_status if self.status else "Unknown" if devices: - if self.status is not None: - for d in self.status.devices: - labels = [] - if d.labels is not None: - for label in d.labels: - labels.append(f"{label}:{str(d.labels[label])}") - table.add_row( - self.metadata.name, - status or "Unknown", - self.status.endpoint, - time_since(self.metadata.creation_timestamp), - ",".join(labels), - d.uuid, - ) + if not (self.status and self.status.devices): + # An exporter with no devices to enumerate — never run, or not + # reconciled yet — still exists, so it still gets a row. Without + # this it disappears from the listing entirely. + table.add_row( + self.metadata.name, + status or "Unknown", + self.status.endpoint if self.status else "", + time_since(self.metadata.creation_timestamp), + "", + "", + ) + return + for d in self.status.devices: + labels = [] + if d.labels is not None: + for label in d.labels: + labels.append(f"{label}:{str(d.labels[label])}") + table.add_row( + self.metadata.name, + status or "Unknown", + self.status.endpoint, + time_since(self.metadata.creation_timestamp), + ",".join(labels), + d.uuid, + ) else: table.add_row( diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py index 8205adf20..3df1d03dc 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py @@ -446,3 +446,32 @@ def test_exporter_rich_add_rows_without_status(): exporter.rich_add_rows(mock_table) name, status, endpoint, devices, _age = mock_table.add_row.call_args.args assert (name, status, endpoint, devices) == ("fresh-exporter", "Unknown", "", "0") + + +def test_exporter_rich_add_rows_devices_without_status(): + """A status-less exporter is still listed when devices are requested""" + exporter = V1Alpha1Exporter( + api_version="jumpstarter.dev/v1alpha1", + kind="Exporter", + metadata=V1ObjectMeta(name="fresh-exporter", namespace="default", creation_timestamp="2021-10-01T00:00:00Z"), + status=None, + ) + mock_table = MagicMock() + exporter.rich_add_rows(mock_table, devices=True) + name, status, endpoint, _age, labels, uuid = mock_table.add_row.call_args.args + assert (name, status, endpoint, labels, uuid) == ("fresh-exporter", "Unknown", "", "", "") + + +def test_exporter_rich_add_rows_devices_when_it_has_none(): + """An exporter that has never run has no devices, but has not disappeared""" + exporter = V1Alpha1Exporter( + api_version="jumpstarter.dev/v1alpha1", + kind="Exporter", + metadata=V1ObjectMeta(name="never-run", namespace="default", creation_timestamp="2021-10-01T00:00:00Z"), + status=V1Alpha1ExporterStatus(endpoint="https://e", devices=[]), + ) + mock_table = MagicMock() + exporter.rich_add_rows(mock_table, devices=True) + assert mock_table.add_row.call_count == 1 + name, status, endpoint, _age, labels, uuid = mock_table.add_row.call_args.args + assert (name, status, endpoint, labels, uuid) == ("never-run", "Unknown", "https://e", "", "")