Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Comment on lines +96 to +97

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The except is broader than necessary. It covers the entire JumpstarterKubernetesError hierarchy rather than just CredentialNotReadyError. Not an error today but maybe something we could design more defensively.

except ApiException as e:
handle_k8s_api_exception(e)
except ConfigException as e:
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)

Expand All @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@staticmethod
def from_dict(dict: dict):
Expand All @@ -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
Expand All @@ -85,26 +91,38 @@ 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(
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),
)
Expand Down Expand Up @@ -188,6 +206,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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -384,3 +385,93 @@ 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")


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", "", "")
Loading