From 6c3108fc8565f7aea0e2a3ac94c980571256f9a9 Mon Sep 17 00:00:00 2001 From: Kirk Date: Fri, 7 Mar 2025 23:31:52 -0500 Subject: [PATCH 01/23] Use pydantic to validate and serialize k8s objects for Jumpstarter --- .../jumpstarter_kubernetes/__init__.py | 2 + .../jumpstarter_kubernetes/clients.py | 29 ++-- .../jumpstarter_kubernetes/clients_test.py | 71 +++++++- .../jumpstarter_kubernetes/exporters.py | 33 ++-- .../jumpstarter_kubernetes/exporters_test.py | 107 ++++++++++++ .../jumpstarter_kubernetes/leases.py | 39 +++-- .../jumpstarter_kubernetes/list.py | 18 ++ .../jumpstarter_kubernetes/serialize.py | 13 ++ .../jumpstarter_kubernetes/test_leases.py | 156 ++++++++++++++++++ .../jumpstarter-kubernetes/pyproject.toml | 21 ++- uv.lock | 2 + 11 files changed, 442 insertions(+), 49 deletions(-) create mode 100644 packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py create mode 100644 packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py create mode 100644 packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py create mode 100644 packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py index f1612ea21..13c610042 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py @@ -2,6 +2,7 @@ from .exporters import ExportersV1Alpha1Api, V1Alpha1Exporter, V1Alpha1ExporterDevice, V1Alpha1ExporterStatus from .install import get_ip_address, helm_installed, install_helm_chart from .leases import LeasesV1Alpha1Api, V1Alpha1Lease, V1Alpha1LeaseSpec, V1Alpha1LeaseStatus +from .list import V1Alpha1List __all__ = [ "ClientsV1Alpha1Api", @@ -15,6 +16,7 @@ "V1Alpha1Lease", "V1Alpha1LeaseStatus", "V1Alpha1LeaseSpec", + "V1Alpha1List", "get_ip_address", "helm_installed", "install_helm_chart", diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py index 152289bfb..2b0a1829b 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py @@ -1,11 +1,13 @@ import asyncio import base64 import logging -from dataclasses import dataclass from typing import Literal, Optional +import yaml from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference +from pydantic import BaseModel, ConfigDict, Field +from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi from jumpstarter.config import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers, ObjectMeta @@ -15,18 +17,25 @@ CREATE_CLIENT_COUNT = 10 -@dataclass(kw_only=True) -class V1Alpha1ClientStatus: - credential: Optional[V1ObjectReference] = None +class V1Alpha1ClientStatus(BaseModel): + credential: Optional[SerializeV1ObjectReference] = None endpoint: str + model_config = ConfigDict(arbitrary_types_allowed=True) -@dataclass(kw_only=True) -class V1Alpha1Client: - api_version: Literal["jumpstarter.dev/v1alpha1"] - kind: Literal["Client"] - metadata: V1ObjectMeta - status: V1Alpha1ClientStatus + +class V1Alpha1Client(BaseModel): + api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") + kind: Literal["Client"] = Field(default="Client") + metadata: SerializeV1ObjectMeta + + model_config = ConfigDict(arbitrary_types_allowed=True) + + def dump_json(self): + return self.model_dump_json(indent=4, by_alias=True) + + def dump_yaml(self): + return yaml.safe_dump(self.model_dump(by_alias=True), indent=2) class ClientsV1Alpha1Api(AbstractAsyncCustomObjectApi): diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py index 7641b8e6e..4b4948bf3 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py @@ -1,2 +1,69 @@ -def test_client(): - pass +from kubernetes_asyncio.client.models import V1ObjectMeta + +from jumpstarter_kubernetes import V1Alpha1Client, V1Alpha1ClientStatus + +TEST_CLIENT = V1Alpha1Client( + api_version="jumpstarter.dev/v1alpha1", + kind="Client", + metadata=V1ObjectMeta( + creation_timestamp="2021-10-01T00:00:00Z", + generation=1, + name="test-client", + namespace="default", + resource_version="1", + uid="7a25eb81-6443-47ec-a62f-50165bffede8", + ), + status=V1Alpha1ClientStatus(credential=None, endpoint="https://test-client"), +) + + +def test_client_dump_json(): + assert ( + TEST_CLIENT.dump_json() + == """{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Client", + "metadata": { + "annotations": null, + "creationTimestamp": "2021-10-01T00:00:00Z", + "deletionGracePeriodSeconds": null, + "deletionTimestamp": null, + "finalizers": null, + "generateName": null, + "generation": 1, + "labels": null, + "managedFields": null, + "name": "test-client", + "namespace": "default", + "ownerReferences": null, + "resourceVersion": "1", + "selfLink": null, + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8" + } +}""" + ) + + +def test_client_dump_yaml(): + assert ( + TEST_CLIENT.dump_yaml() + == """apiVersion: jumpstarter.dev/v1alpha1 +kind: Client +metadata: + annotations: null + creationTimestamp: '2021-10-01T00:00:00Z' + deletionGracePeriodSeconds: null + deletionTimestamp: null + finalizers: null + generateName: null + generation: 1 + labels: null + managedFields: null + name: test-client + namespace: default + ownerReferences: null + resourceVersion: '1' + selfLink: null + uid: 7a25eb81-6443-47ec-a62f-50165bffede8 +""" + ) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py index 03a473c01..8e50bb597 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py @@ -1,10 +1,12 @@ import asyncio import base64 -from dataclasses import dataclass from typing import Literal +import yaml from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference +from pydantic import BaseModel, ConfigDict, Field +from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi from jumpstarter.config import ExporterConfigV1Alpha1, ObjectMeta @@ -12,26 +14,33 @@ CREATE_EXPORTER_COUNT = 10 -@dataclass(kw_only=True) -class V1Alpha1ExporterDevice: +class V1Alpha1ExporterDevice(BaseModel): labels: dict[str, str] uuid: str -@dataclass(kw_only=True) -class V1Alpha1ExporterStatus: - credential: V1ObjectReference - endpoint: str +class V1Alpha1ExporterStatus(BaseModel): + credential: SerializeV1ObjectReference devices: list[V1Alpha1ExporterDevice] + endpoint: str + + model_config = ConfigDict(arbitrary_types_allowed=True) -@dataclass(kw_only=True) -class V1Alpha1Exporter: - api_version: Literal["jumpstarter.dev/v1alpha1"] - kind: Literal["Exporter"] - metadata: V1ObjectMeta +class V1Alpha1Exporter(BaseModel): + api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") + kind: Literal["Exporter"] = Field(default="Exporter") + metadata: SerializeV1ObjectMeta status: V1Alpha1ExporterStatus + model_config = ConfigDict(arbitrary_types_allowed=True) + + def dump_json(self): + return self.model_dump_json(indent=4, by_alias=True) + + def dump_yaml(self): + return yaml.safe_dump(self.model_dump(by_alias=True), indent=2) + class ExportersV1Alpha1Api(AbstractAsyncCustomObjectApi): """Interact with the exporters custom resource API""" diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py new file mode 100644 index 000000000..f167c6540 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py @@ -0,0 +1,107 @@ +from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference + +from jumpstarter_kubernetes.exporters import V1Alpha1Exporter, V1Alpha1ExporterDevice, V1Alpha1ExporterStatus + +TEST_EXPORTER = V1Alpha1Exporter( + api_version="jumpstarter.dev/v1alpha1", + kind="Exporter", + metadata=V1ObjectMeta( + creation_timestamp="2021-10-01T00:00:00Z", + generation=1, + name="test-exporter", + namespace="default", + resource_version="1", + uid="7a25eb81-6443-47ec-a62f-50165bffede8", + ), + status=V1Alpha1ExporterStatus( + credential=V1ObjectReference(name="test-credential"), + devices=[V1Alpha1ExporterDevice(labels={"test": "label"}, uuid="f4cf49ab-fc64-46c6-94e7-a40502eb77b1")], + endpoint="https://test-exporter", + ), +) + + +def test_exporter_dump_json(): + assert ( + TEST_EXPORTER.dump_json() + == """{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Exporter", + "metadata": { + "annotations": null, + "creationTimestamp": "2021-10-01T00:00:00Z", + "deletionGracePeriodSeconds": null, + "deletionTimestamp": null, + "finalizers": null, + "generateName": null, + "generation": 1, + "labels": null, + "managedFields": null, + "name": "test-exporter", + "namespace": "default", + "ownerReferences": null, + "resourceVersion": "1", + "selfLink": null, + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8" + }, + "status": { + "credential": { + "apiVersion": null, + "fieldPath": null, + "kind": null, + "name": "test-credential", + "namespace": null, + "resourceVersion": null, + "uid": null + }, + "devices": [ + { + "labels": { + "test": "label" + }, + "uuid": "f4cf49ab-fc64-46c6-94e7-a40502eb77b1" + } + ], + "endpoint": "https://test-exporter" + } +}""" + ) + + +def test_exporter_dump_yaml(): + assert ( + TEST_EXPORTER.dump_yaml() + == """apiVersion: jumpstarter.dev/v1alpha1 +kind: Exporter +metadata: + annotations: null + creationTimestamp: '2021-10-01T00:00:00Z' + deletionGracePeriodSeconds: null + deletionTimestamp: null + finalizers: null + generateName: null + generation: 1 + labels: null + managedFields: null + name: test-exporter + namespace: default + ownerReferences: null + resourceVersion: '1' + selfLink: null + uid: 7a25eb81-6443-47ec-a62f-50165bffede8 +status: + credential: + apiVersion: null + fieldPath: null + kind: null + name: test-credential + namespace: null + resourceVersion: null + uid: null + devices: + - labels: + test: label + uuid: f4cf49ab-fc64-46c6-94e7-a40502eb77b1 + endpoint: https://test-exporter +""" + ) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py index 38f0a1396..2f9c781de 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py @@ -1,35 +1,46 @@ import pprint -from dataclasses import dataclass from typing import Literal, Optional +import yaml from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference +from pydantic import BaseModel, ConfigDict, Field +from .serialize import SerializeV1Condition, SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi -@dataclass(kw_only=True) -class V1Alpha1LeaseStatus: +class V1Alpha1LeaseStatus(BaseModel): begin_time: str + conditions: list[SerializeV1Condition] end_time: Optional[str] ended: bool - exporter: Optional[V1ObjectReference] - conditions: list[V1Condition] + exporter: Optional[SerializeV1ObjectReference] + model_config = ConfigDict(arbitrary_types_allowed=True) -@dataclass(kw_only=True) -class V1Alpha1LeaseSpec: - client: V1ObjectReference + +class V1Alpha1LeaseSpec(BaseModel): + client: SerializeV1ObjectReference duration: Optional[str] selector: dict[str, str] + model_config = ConfigDict(arbitrary_types_allowed=True) -@dataclass(kw_only=True) -class V1Alpha1Lease: - api_version: Literal["jumpstarter.dev/v1alpha1"] - kind: Literal["Lease"] - metadata: V1ObjectMeta - status: V1Alpha1LeaseStatus + +class V1Alpha1Lease(BaseModel): + api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") + kind: Literal["Lease"] = Field(default="Lease") + metadata: SerializeV1ObjectMeta spec: V1Alpha1LeaseSpec + status: V1Alpha1LeaseStatus + + model_config = ConfigDict(arbitrary_types_allowed=True) + + def dump_json(self): + return self.model_dump_json(indent=4, by_alias=True) + + def dump_yaml(self): + return yaml.safe_dump(self.model_dump(by_alias=True), indent=2) class LeasesV1Alpha1Api(AbstractAsyncCustomObjectApi): diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py new file mode 100644 index 000000000..87bb620cd --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py @@ -0,0 +1,18 @@ +from typing import Any, Literal + +import yaml +from pydantic import BaseModel, Field + + +class V1Alpha1List(BaseModel): + """A generic list result type.""" + + api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") + items: list[Any] + kind: Literal["List"] = Field(default="List") + + def dump_json(self): + return self.model_dump_json(indent=4, by_alias=True) + + def dump_yaml(self): + return yaml.safe_dump(self.model_dump(by_alias=True), indent=2) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py new file mode 100644 index 000000000..842e64dda --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py @@ -0,0 +1,13 @@ +from typing import Annotated, Any, Dict + +from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference +from pydantic import WrapSerializer + + +def k8s_obj_to_dict(value: Any, handler, info) -> Dict[str, Any]: + return value.to_dict(serialize=True) + + +SerializeV1Condition = Annotated[V1Condition, WrapSerializer(k8s_obj_to_dict)] +SerializeV1ObjectMeta = Annotated[V1ObjectMeta, WrapSerializer(k8s_obj_to_dict)] +SerializeV1ObjectReference = Annotated[V1ObjectReference, WrapSerializer(k8s_obj_to_dict)] diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py new file mode 100644 index 000000000..39e8030bc --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py @@ -0,0 +1,156 @@ +from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference + +from jumpstarter_kubernetes import V1Alpha1Lease, V1Alpha1LeaseSpec, V1Alpha1LeaseStatus + +TEST_LEASE = V1Alpha1Lease( + api_version="jumpstarter.dev/v1alpha1", + kind="Lease", + metadata=V1ObjectMeta( + creation_timestamp="2021-10-01T00:00:00Z", + generation=1, + name="test-lease", + namespace="default", + resource_version="1", + uid="7a25eb81-6443-47ec-a62f-50165bffede8", + ), + spec=V1Alpha1LeaseSpec( + client=V1ObjectReference(name="test-client"), + duration="1h", + selector={"test": "label", "another": "something"}, + ), + status=V1Alpha1LeaseStatus( + begin_time="2021-10-01T00:00:00Z", + conditions=[ + V1Condition( + last_transition_time="2021-10-01T00:00:00Z", status="True", type="Active", message="", reason="" + ) + ], + end_time="2021-10-01T01:00:00Z", + ended=False, + exporter=V1ObjectReference(name="test-exporter"), + ), +) + + +def test_lease_dump_json(): + assert ( + TEST_LEASE.dump_json() + == """{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Lease", + "metadata": { + "annotations": null, + "creationTimestamp": "2021-10-01T00:00:00Z", + "deletionGracePeriodSeconds": null, + "deletionTimestamp": null, + "finalizers": null, + "generateName": null, + "generation": 1, + "labels": null, + "managedFields": null, + "name": "test-lease", + "namespace": "default", + "ownerReferences": null, + "resourceVersion": "1", + "selfLink": null, + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8" + }, + "spec": { + "client": { + "apiVersion": null, + "fieldPath": null, + "kind": null, + "name": "test-client", + "namespace": null, + "resourceVersion": null, + "uid": null + }, + "duration": "1h", + "selector": { + "test": "label", + "another": "something" + } + }, + "status": { + "begin_time": "2021-10-01T00:00:00Z", + "conditions": [ + { + "lastTransitionTime": "2021-10-01T00:00:00Z", + "message": "", + "observedGeneration": null, + "reason": "", + "status": "True", + "type": "Active" + } + ], + "end_time": "2021-10-01T01:00:00Z", + "ended": false, + "exporter": { + "apiVersion": null, + "fieldPath": null, + "kind": null, + "name": "test-exporter", + "namespace": null, + "resourceVersion": null, + "uid": null + } + } +}""" + ) + + +def test_lease_dump_yaml(): + assert ( + TEST_LEASE.dump_yaml() + == """apiVersion: jumpstarter.dev/v1alpha1 +kind: Lease +metadata: + annotations: null + creationTimestamp: '2021-10-01T00:00:00Z' + deletionGracePeriodSeconds: null + deletionTimestamp: null + finalizers: null + generateName: null + generation: 1 + labels: null + managedFields: null + name: test-lease + namespace: default + ownerReferences: null + resourceVersion: '1' + selfLink: null + uid: 7a25eb81-6443-47ec-a62f-50165bffede8 +spec: + client: + apiVersion: null + fieldPath: null + kind: null + name: test-client + namespace: null + resourceVersion: null + uid: null + duration: 1h + selector: + another: something + test: label +status: + begin_time: '2021-10-01T00:00:00Z' + conditions: + - lastTransitionTime: '2021-10-01T00:00:00Z' + message: '' + observedGeneration: null + reason: '' + status: 'True' + type: Active + end_time: '2021-10-01T01:00:00Z' + ended: false + exporter: + apiVersion: null + fieldPath: null + kind: null + name: test-exporter + namespace: null + resourceVersion: null + uid: null +""" + ) diff --git a/packages/jumpstarter-kubernetes/pyproject.toml b/packages/jumpstarter-kubernetes/pyproject.toml index fd1d4045e..2f85a436f 100644 --- a/packages/jumpstarter-kubernetes/pyproject.toml +++ b/packages/jumpstarter-kubernetes/pyproject.toml @@ -2,24 +2,23 @@ name = "jumpstarter-kubernetes" dynamic = ["version", "urls"] description = "" -authors = [ - { name = "Kirk Brauer", email = "kbrauer@hatci.com" }, -] +authors = [{ name = "Kirk Brauer", email = "kbrauer@hatci.com" }] readme = "README.md" license = { text = "Apache-2.0" } requires-python = ">=3.11" dependencies = [ - "jumpstarter", - "kubernetes>=31.0.0", - "kubernetes-asyncio>=31.1.0", + "jumpstarter", + "pydantic>=1.9.0", + "kubernetes>=31.0.0", + "kubernetes-asyncio>=31.1.0", ] [dependency-groups] dev = [ - "pytest>=8.3.2", - "pytest-anyio>=0.0.0", - "pytest-asyncio>=0.0.0", - "pytest-cov>=5.0.0", + "pytest>=8.3.2", + "pytest-anyio>=0.0.0", + "pytest-asyncio>=0.0.0", + "pytest-cov>=5.0.0", ] [tool.hatch.metadata.hooks.vcs.urls] @@ -28,7 +27,7 @@ source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}. [tool.hatch.version] source = "vcs" -raw-options = { 'root' = '../../'} +raw-options = { 'root' = '../../' } [build-system] requires = ["hatchling", "hatch-vcs"] diff --git a/uv.lock b/uv.lock index 5487bee7b..d33751348 100644 --- a/uv.lock +++ b/uv.lock @@ -1763,6 +1763,7 @@ dependencies = [ { name = "jumpstarter" }, { name = "kubernetes" }, { name = "kubernetes-asyncio" }, + { name = "pydantic" }, ] [package.dev-dependencies] @@ -1778,6 +1779,7 @@ requires-dist = [ { name = "jumpstarter", editable = "packages/jumpstarter" }, { name = "kubernetes", specifier = ">=31.0.0" }, { name = "kubernetes-asyncio", specifier = ">=31.1.0" }, + { name = "pydantic", specifier = ">=1.9.0" }, ] [package.metadata.requires-dev] From 4679023bd6e827a41d8d9abf23765f8ca1e8d672 Mon Sep 17 00:00:00 2001 From: Kirk Date: Sun, 9 Mar 2025 06:43:07 -0400 Subject: [PATCH 02/23] Add JSON output CLI option for admin CLI --- .../jumpstarter_cli_admin/get.py | 120 +++++++++++++----- .../jumpstarter_cli_common/__init__.py | 3 +- .../jumpstarter_cli_common/opt.py | 4 + .../jumpstarter_kubernetes/clients.py | 8 +- .../jumpstarter_kubernetes/exporters.py | 5 +- .../jumpstarter_kubernetes/leases.py | 5 +- .../jumpstarter_kubernetes/list.py | 6 +- .../jumpstarter_kubernetes/serialize.py | 3 +- 8 files changed, 108 insertions(+), 46 deletions(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py index 70cbfa510..90225ae4c 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py @@ -9,6 +9,7 @@ opt_kubeconfig, opt_log_level, opt_namespace, + opt_output, time_since, ) from jumpstarter_kubernetes import ( @@ -18,6 +19,7 @@ V1Alpha1Client, V1Alpha1Exporter, V1Alpha1Lease, + V1Alpha1List, ) from kubernetes_asyncio.client.exceptions import ApiException from kubernetes_asyncio.config.config_exception import ConfigException @@ -44,32 +46,51 @@ def get(log_level: Optional[str]): def make_client_row(client: V1Alpha1Client): return { "NAME": client.metadata.name, - "ENDPOINT": client.status.endpoint, + "ENDPOINT": client.status.endpoint if client.status is not None else "", "AGE": time_since(client.metadata.creation_timestamp), } +def print_client(client: V1Alpha1Client, output: str): + if output == "json": + click.echo(client.dump_json()) + elif output == "yaml": + click.echo(client.dump_yaml()) + else: + click.echo(make_table(CLIENT_COLUMNS, [make_client_row(client)])) + + +def print_clients(clients: V1Alpha1List[V1Alpha1Client], namespace: str, output: str): + if output == "json": + click.echo(clients.dump_json()) + elif output == "yaml": + click.echo(clients.dump_yaml()) + elif len(clients.items) == 0: + raise click.ClickException(f'No resources found in "{namespace}" namespace') + else: + click.echo(make_table(CLIENT_COLUMNS, list(map(make_client_row, clients.items)))) + + @get.command("client") @click.argument("name", type=str, required=False, default=None) @opt_namespace @opt_kubeconfig @opt_context -async def get_client(name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str): +@opt_output +async def get_client( + name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: str +): """Get the client objects in a Kubernetes cluster""" try: async with ClientsV1Alpha1Api(namespace, kubeconfig, context) as api: if name is not None: # Get a single client in a namespace client = await api.get_client(name) - click.echo(make_table(CLIENT_COLUMNS, [make_client_row(client)])) + print_client(client, output) else: # List clients in a namespace clients = await api.list_clients() - if len(clients) == 0: - raise click.ClickException(f'No resources found in "{namespace}" namespace') - else: - rows = list(map(make_client_row, clients)) - click.echo(make_table(CLIENT_COLUMNS, rows)) + print_clients(clients) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: @@ -110,14 +131,42 @@ def get_device_rows(exporters: list[V1Alpha1Exporter]): return devices +def print_exporter(exporter: V1Alpha1Exporter, devices: bool, output: str): + if output == "json": + click.echo(exporter.dump_json()) + elif output == "yaml": + click.echo(exporter.dump_yaml()) + elif devices: + # Print the devices for the exporter + click.echo(make_table(DEVICE_COLUMNS, get_device_rows([exporter]))) + else: + click.echo(make_table(EXPORTER_COLUMNS, [make_exporter_row(exporter)])) + + +def print_exporters(exporters: V1Alpha1List[V1Alpha1Exporter], namespace: str, devices: bool, output: str): + if output == "json": + click.echo(exporters.dump_json()) + elif output == "yaml": + click.echo(exporters.dump_yaml()) + elif len(exporters.items) == 0: + raise click.ClickException(f'No resources found in "{namespace}" namespace') + elif devices: + # Print the devices for each exporter + rows = get_device_rows(exporters) + click.echo(make_table(DEVICE_COLUMNS, rows)) + else: + click.echo(make_table(EXPORTER_COLUMNS, list(map(make_exporter_row, exporters.items)))) + + @get.command("exporter") @click.argument("name", type=str, required=False, default=None) @opt_namespace @opt_kubeconfig @opt_context +@opt_output @click.option("-d", "--devices", is_flag=True, help="Display the devices hosted by the exporter(s)") async def get_exporter( - name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, devices: bool + name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, devices: bool, output: str ): """Get the exporter objects in a Kubernetes cluster""" try: @@ -125,25 +174,11 @@ async def get_exporter( if name is not None: # Get a single client in a namespace exporter = await api.get_exporter(name) - if devices: - # Print the devices for the exporter - click.echo(make_table(DEVICE_COLUMNS, get_device_rows([exporter]))) - else: - # Print the exporter - click.echo(make_table(EXPORTER_COLUMNS, [make_exporter_row(exporter)])) + print_exporter(exporter, devices, output) else: # List clients in a namespace exporters = await api.list_exporters() - if len(exporters) == 0: - raise click.ClickException(f'No resources found in "{namespace}" namespace') - elif devices: - # Print the devices for each exporter - rows = get_device_rows(exporters) - click.echo(make_table(DEVICE_COLUMNS, rows)) - else: - # Print the exporters - rows = list(map(make_exporter_row, exporters)) - click.echo(make_table(EXPORTER_COLUMNS, rows)) + print_exporters(exporters, namespace, devices, output) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: @@ -187,29 +222,46 @@ def make_lease_row(lease: V1Alpha1Lease): } +def print_lease(lease: V1Alpha1Lease, output: str): + if output == "json": + click.echo(lease.dump_json()) + elif output == "yaml": + click.echo(lease.dump_yaml()) + else: + click.echo(make_table(LEASE_COLUMNS, [make_lease_row(lease)])) + + +def print_leases(leases: V1Alpha1List[V1Alpha1Lease], namespace: str, output: str): + if output == "json": + click.echo(leases.dump_json()) + elif output == "yaml": + click.echo(leases.dump_yaml()) + elif len(leases.items) == 0: + raise click.ClickException(f'No resources found in "{namespace}" namespace') + else: + click.echo(make_table(LEASE_COLUMNS, list(map(make_lease_row, leases.items)))) + + @get.command("lease") @click.argument("name", type=str, required=False, default=None) @opt_namespace @opt_kubeconfig @opt_context -async def get_lease(name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str): +@opt_output +async def get_lease( + name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: str +): """Get the lease objects in a Kubernetes cluster""" try: async with LeasesV1Alpha1Api(namespace, kubeconfig, context) as api: if name is not None: # Get a single lease in a namespace lease = await api.get_lease(name) - # Print the lease - click.echo(make_table(LEASE_COLUMNS, [make_lease_row(lease)])) + print_lease(lease, output) else: # List leases in a namespace leases = await api.list_leases() - if len(leases) == 0: - raise click.ClickException(f'No resources found in "{namespace}" namespace') - else: - # Print the leases - rows = list(map(make_lease_row, leases)) - click.echo(make_table(LEASE_COLUMNS, rows)) + print_leases(leases, namespace, output) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py index aa400508f..e557b702b 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py @@ -1,5 +1,5 @@ from .alias import AliasedGroup -from .opt import opt_context, opt_kubeconfig, opt_labels, opt_log_level, opt_namespace +from .opt import opt_context, opt_kubeconfig, opt_labels, opt_log_level, opt_namespace, opt_output from .table import make_table from .time import time_since from .version import get_client_version, version @@ -12,6 +12,7 @@ "opt_kubeconfig", "opt_namespace", "opt_labels", + "opt_output", "time_since", "version", "get_client_version", diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py index 63dc07ffd..514513142 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py @@ -17,3 +17,7 @@ opt_namespace = click.option("-n", "--namespace", type=str, help="Kubernetes namespace to use", default="default") opt_labels = click.option("-l", "--label", "labels", type=(str, str), multiple=True, help="Labels") + +opt_output = click.option( + "-o", "--output", type=click.Choice(["json", "yaml"]), default=None, help="Set the CLI output format" +) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py index 2b0a1829b..ec5c93329 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py @@ -7,6 +7,7 @@ from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference from pydantic import BaseModel, ConfigDict, Field +from .list import V1Alpha1List from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi from jumpstarter.config import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers, ObjectMeta @@ -28,6 +29,7 @@ class V1Alpha1Client(BaseModel): api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") kind: Literal["Client"] = Field(default="Client") metadata: SerializeV1ObjectMeta + status: Optional[V1Alpha1ClientStatus] model_config = ConfigDict(arbitrary_types_allowed=True) @@ -61,7 +63,7 @@ def _deserialize(result: dict) -> V1Alpha1Client: endpoint=result["status"]["endpoint"], ) if "status" in result - else V1Alpha1ClientStatus(credential=None, endpoint=""), + else None, ) async def create_client( @@ -99,12 +101,12 @@ async def create_client( await asyncio.sleep(CREATE_CLIENT_DELAY) raise Exception("Timeout waiting for client credentials") - async def list_clients(self) -> list[V1Alpha1Client]: + async def list_clients(self) -> V1Alpha1List[V1Alpha1Client]: """List the client objects in the cluster async""" res = await self.api.list_namespaced_custom_object( namespace=self.namespace, group="jumpstarter.dev", plural="clients", version="v1alpha1" ) - return [ClientsV1Alpha1Api._deserialize(c) for c in res["items"]] + return V1Alpha1List(items=[ClientsV1Alpha1Api._deserialize(c) for c in res["items"]]) async def get_client(self, name: str) -> V1Alpha1Client: """Get a single client object from the cluster async""" diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py index 8e50bb597..254bd0e5e 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py @@ -6,6 +6,7 @@ from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference from pydantic import BaseModel, ConfigDict, Field +from .list import V1Alpha1List from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi from jumpstarter.config import ExporterConfigV1Alpha1, ObjectMeta @@ -71,12 +72,12 @@ def _deserialize(result: dict) -> V1Alpha1Exporter: ), ) - async def list_exporters(self) -> list[V1Alpha1Exporter]: + async def list_exporters(self) -> V1Alpha1List[V1Alpha1Exporter]: """List the exporter objects in the cluster""" res = await self.api.list_namespaced_custom_object( namespace=self.namespace, group="jumpstarter.dev", plural="exporters", version="v1alpha1" ) - return [ExportersV1Alpha1Api._deserialize(c) for c in res["items"]] + return V1Alpha1List(items=[ExportersV1Alpha1Api._deserialize(c) for c in res["items"]]) async def get_exporter(self, name: str) -> V1Alpha1Exporter: """Get a single exporter object from the cluster""" diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py index 2f9c781de..74e24481c 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py @@ -5,6 +5,7 @@ from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference from pydantic import BaseModel, ConfigDict, Field +from .list import V1Alpha1List from .serialize import SerializeV1Condition, SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi @@ -87,12 +88,12 @@ def _deserialize(result: dict) -> V1Alpha1Lease: ), ) - async def list_leases(self) -> list[V1Alpha1Lease]: + async def list_leases(self) -> V1Alpha1List[V1Alpha1Lease]: """List the lease objects in the cluster async""" res = await self.api.list_namespaced_custom_object( namespace=self.namespace, group="jumpstarter.dev", plural="leases", version="v1alpha1" ) - return [LeasesV1Alpha1Api._deserialize(c) for c in res["items"]] + return V1Alpha1Lease(items=[LeasesV1Alpha1Api._deserialize(c) for c in res["items"]]) async def get_lease(self, name: str) -> V1Alpha1Lease: """Get a single lease object from the cluster async""" diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py index 87bb620cd..a042ec0fa 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py @@ -1,14 +1,14 @@ -from typing import Any, Literal +from typing import Literal import yaml from pydantic import BaseModel, Field -class V1Alpha1List(BaseModel): +class V1Alpha1List[T](BaseModel): """A generic list result type.""" api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") - items: list[Any] + items: list[T] kind: Literal["List"] = Field(default="List") def dump_json(self): diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py index 842e64dda..35173600d 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py @@ -5,7 +5,8 @@ def k8s_obj_to_dict(value: Any, handler, info) -> Dict[str, Any]: - return value.to_dict(serialize=True) + result = value.to_dict(serialize=True) + return {k: v for k, v in result.items() if v is not None} SerializeV1Condition = Annotated[V1Condition, WrapSerializer(k8s_obj_to_dict)] From 8dfef75c6e6ab8bc5d3edfd580ce61be40e87cb4 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 06:58:29 -0400 Subject: [PATCH 03/23] Fix get clients --- packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py index 90225ae4c..43cabfab4 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py @@ -90,7 +90,7 @@ async def get_client( else: # List clients in a namespace clients = await api.list_clients() - print_clients(clients) + print_clients(clients, namespace, output) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: From fb26b40ffd45056a248cd21452ab65975a7abfc4 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 08:05:34 -0400 Subject: [PATCH 04/23] Fix Pydantic validation and model issues --- .../jumpstarter_kubernetes/clients.py | 66 +++++++------- .../jumpstarter_kubernetes/exporters.py | 72 +++++++-------- .../jumpstarter_kubernetes/json.py | 14 +++ .../jumpstarter_kubernetes/leases.py | 90 +++++++++---------- .../jumpstarter_kubernetes/list.py | 13 +-- 5 files changed, 127 insertions(+), 128 deletions(-) create mode 100644 packages/jumpstarter-kubernetes/jumpstarter_kubernetes/json.py diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py index ec5c93329..a7f3d729a 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py @@ -3,10 +3,10 @@ import logging from typing import Literal, Optional -import yaml from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference -from pydantic import BaseModel, ConfigDict, Field +from pydantic import Field +from .json import JsonBaseModel from .list import V1Alpha1List from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi @@ -18,54 +18,52 @@ CREATE_CLIENT_COUNT = 10 -class V1Alpha1ClientStatus(BaseModel): +class V1Alpha1ClientStatus(JsonBaseModel): credential: Optional[SerializeV1ObjectReference] = None endpoint: str - model_config = ConfigDict(arbitrary_types_allowed=True) - -class V1Alpha1Client(BaseModel): +class V1Alpha1Client(JsonBaseModel): api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") kind: Literal["Client"] = Field(default="Client") metadata: SerializeV1ObjectMeta status: Optional[V1Alpha1ClientStatus] - model_config = ConfigDict(arbitrary_types_allowed=True) - - def dump_json(self): - return self.model_dump_json(indent=4, by_alias=True) - - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(by_alias=True), indent=2) - - -class ClientsV1Alpha1Api(AbstractAsyncCustomObjectApi): - """Interact with the clients custom resource API""" - @staticmethod - def _deserialize(result: dict) -> V1Alpha1Client: + def from_dict(dict: dict): return V1Alpha1Client( - api_version=result["apiVersion"], - kind=result["kind"], + api_version=dict["apiVersion"], + kind=dict["kind"], metadata=V1ObjectMeta( - creation_timestamp=result["metadata"]["creationTimestamp"], - generation=result["metadata"]["generation"], - name=result["metadata"]["name"], - namespace=result["metadata"]["namespace"], - resource_version=result["metadata"]["resourceVersion"], - uid=result["metadata"]["uid"], + creation_timestamp=dict["metadata"]["creationTimestamp"], + generation=dict["metadata"]["generation"], + name=dict["metadata"]["name"], + namespace=dict["metadata"]["namespace"], + resource_version=dict["metadata"]["resourceVersion"], + uid=dict["metadata"]["uid"], ), status=V1Alpha1ClientStatus( - credential=V1ObjectReference(name=result["status"]["credential"]["name"]) - if "credential" in result["status"] + credential=V1ObjectReference(name=dict["status"]["credential"]["name"]) + if "credential" in dict["status"] else None, - endpoint=result["status"]["endpoint"], + endpoint=dict["status"]["endpoint"], ) - if "status" in result + if "status" in dict else None, ) + +class V1Alpha1ClientList(V1Alpha1List[V1Alpha1Client]): + kind: Literal["ClientList"] = Field(default="ClientList") + + @staticmethod + def from_dict(dict: dict): + return V1Alpha1ClientList(items=[V1Alpha1Client.from_dict(c) for c in dict["items"]]) + + +class ClientsV1Alpha1Api(AbstractAsyncCustomObjectApi): + """Interact with the clients custom resource API""" + async def create_client( self, name: str, labels: dict[str, str] | None = None, oidc_username: str | None = None ) -> V1Alpha1Client: @@ -96,7 +94,7 @@ async def create_client( # check if the client status is updated with the credentials if "status" in updated_client: if "credential" in updated_client["status"]: - return ClientsV1Alpha1Api._deserialize(updated_client) + return V1Alpha1Client.from_dict(updated_client) count += 1 await asyncio.sleep(CREATE_CLIENT_DELAY) raise Exception("Timeout waiting for client credentials") @@ -106,14 +104,14 @@ async def list_clients(self) -> V1Alpha1List[V1Alpha1Client]: res = await self.api.list_namespaced_custom_object( namespace=self.namespace, group="jumpstarter.dev", plural="clients", version="v1alpha1" ) - return V1Alpha1List(items=[ClientsV1Alpha1Api._deserialize(c) for c in res["items"]]) + return V1Alpha1ClientList.from_dict(res) async def get_client(self, name: str) -> V1Alpha1Client: """Get a single client object from the cluster async""" result = await self.api.get_namespaced_custom_object( namespace=self.namespace, group="jumpstarter.dev", plural="clients", version="v1alpha1", name=name ) - return ClientsV1Alpha1Api._deserialize(result) + return V1Alpha1Client.from_dict(result) async def get_client_config(self, name: str, allow: list[str], unsafe=False) -> ClientConfigV1Alpha1: """Get a client config for a specified client name""" diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py index 254bd0e5e..56cb3af84 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py @@ -2,10 +2,10 @@ import base64 from typing import Literal -import yaml from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference -from pydantic import BaseModel, ConfigDict, Field +from pydantic import Field +from .json import JsonBaseModel from .list import V1Alpha1List from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi @@ -15,76 +15,72 @@ CREATE_EXPORTER_COUNT = 10 -class V1Alpha1ExporterDevice(BaseModel): +class V1Alpha1ExporterDevice(JsonBaseModel): labels: dict[str, str] uuid: str -class V1Alpha1ExporterStatus(BaseModel): +class V1Alpha1ExporterStatus(JsonBaseModel): credential: SerializeV1ObjectReference devices: list[V1Alpha1ExporterDevice] endpoint: str - model_config = ConfigDict(arbitrary_types_allowed=True) - -class V1Alpha1Exporter(BaseModel): +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 - model_config = ConfigDict(arbitrary_types_allowed=True) - - def dump_json(self): - return self.model_dump_json(indent=4, by_alias=True) - - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(by_alias=True), indent=2) - - -class ExportersV1Alpha1Api(AbstractAsyncCustomObjectApi): - """Interact with the exporters custom resource API""" - @staticmethod - def _deserialize(result: dict) -> V1Alpha1Exporter: + def from_dict(dict: dict): return V1Alpha1Exporter( - api_version=result["apiVersion"], - kind=result["kind"], + api_version=dict["apiVersion"], + kind=dict["kind"], metadata=V1ObjectMeta( - creation_timestamp=result["metadata"]["creationTimestamp"], - generation=result["metadata"]["generation"], - name=result["metadata"]["name"], - namespace=result["metadata"]["namespace"], - resource_version=result["metadata"]["resourceVersion"], - uid=result["metadata"]["uid"], + creation_timestamp=dict["metadata"]["creationTimestamp"], + generation=dict["metadata"]["generation"], + name=dict["metadata"]["name"], + namespace=dict["metadata"]["namespace"], + resource_version=dict["metadata"]["resourceVersion"], + uid=dict["metadata"]["uid"], ), status=V1Alpha1ExporterStatus( - credential=V1ObjectReference(name=result["status"]["credential"]["name"]) - if "credential" in result["status"] + credential=V1ObjectReference(name=dict["status"]["credential"]["name"]) + if "credential" in dict["status"] else None, - endpoint=result["status"]["endpoint"], - devices=[ - V1Alpha1ExporterDevice(labels=d["labels"], uuid=d["uuid"]) for d in result["status"]["devices"] - ] - if "devices" in result["status"] + endpoint=dict["status"]["endpoint"], + devices=[V1Alpha1ExporterDevice(labels=d["labels"], uuid=d["uuid"]) for d in dict["status"]["devices"]] + if "devices" in dict["status"] else [], ), ) + +class V1Alpha1ExporterList(V1Alpha1List[V1Alpha1Exporter]): + kind: Literal["ExporterList"] = Field(default="ExporterList") + + @staticmethod + def from_dict(dict: dict): + return V1Alpha1ExporterList(items=[V1Alpha1Exporter.from_dict(c) for c in dict["items"]]) + + +class ExportersV1Alpha1Api(AbstractAsyncCustomObjectApi): + """Interact with the exporters custom resource API""" + async def list_exporters(self) -> V1Alpha1List[V1Alpha1Exporter]: """List the exporter objects in the cluster""" res = await self.api.list_namespaced_custom_object( namespace=self.namespace, group="jumpstarter.dev", plural="exporters", version="v1alpha1" ) - return V1Alpha1List(items=[ExportersV1Alpha1Api._deserialize(c) for c in res["items"]]) + return V1Alpha1ExporterList.from_dict(res) async def get_exporter(self, name: str) -> V1Alpha1Exporter: """Get a single exporter object from the cluster""" result = await self.api.get_namespaced_custom_object( namespace=self.namespace, group="jumpstarter.dev", plural="exporters", version="v1alpha1", name=name ) - return ExportersV1Alpha1Api._deserialize(result) + return V1Alpha1Exporter.from_dict(result) async def create_exporter( self, name: str, labels: dict[str, str] | None = None, oidc_username: str | None = None @@ -116,7 +112,7 @@ async def create_exporter( # check if the client status is updated with the credentials if "status" in updated_exporter: if "credential" in updated_exporter["status"]: - return ExportersV1Alpha1Api._deserialize(updated_exporter) + return V1Alpha1Exporter.from_dict(updated_exporter) count += 1 await asyncio.sleep(CREATE_EXPORTER_DELAY) raise Exception("Timeout waiting for exporter credentials") diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/json.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/json.py new file mode 100644 index 000000000..bfd444dd0 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/json.py @@ -0,0 +1,14 @@ +import yaml +from pydantic import BaseModel, ConfigDict + + +class JsonBaseModel(BaseModel): + """A Pydantic BaseModel with additional Jumpstarter JSON options applied.""" + + def dump_json(self): + return self.model_dump_json(indent=4, by_alias=True) + + def dump_yaml(self): + return yaml.safe_dump(self.model_dump(by_alias=True), indent=2) + + model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py index 74e24481c..f57b9fe40 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py @@ -1,71 +1,56 @@ -import pprint from typing import Literal, Optional -import yaml from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference -from pydantic import BaseModel, ConfigDict, Field +from pydantic import Field +from .json import JsonBaseModel from .list import V1Alpha1List from .serialize import SerializeV1Condition, SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi -class V1Alpha1LeaseStatus(BaseModel): - begin_time: str +class V1Alpha1LeaseStatus(JsonBaseModel): + begin_time: Optional[str] = Field(alias="beginTime") conditions: list[SerializeV1Condition] - end_time: Optional[str] + end_time: Optional[str] = Field(alias="endTime") ended: bool exporter: Optional[SerializeV1ObjectReference] - model_config = ConfigDict(arbitrary_types_allowed=True) - -class V1Alpha1LeaseSpec(BaseModel): +class V1Alpha1LeaseSpec(JsonBaseModel): client: SerializeV1ObjectReference duration: Optional[str] selector: dict[str, str] - model_config = ConfigDict(arbitrary_types_allowed=True) - -class V1Alpha1Lease(BaseModel): +class V1Alpha1Lease(JsonBaseModel): api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") kind: Literal["Lease"] = Field(default="Lease") metadata: SerializeV1ObjectMeta spec: V1Alpha1LeaseSpec status: V1Alpha1LeaseStatus - model_config = ConfigDict(arbitrary_types_allowed=True) - - def dump_json(self): - return self.model_dump_json(indent=4, by_alias=True) - - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(by_alias=True), indent=2) - - -class LeasesV1Alpha1Api(AbstractAsyncCustomObjectApi): - """Interact with the leases custom resource API""" - @staticmethod - def _deserialize(result: dict) -> V1Alpha1Lease: + def from_dict(dict: dict): return V1Alpha1Lease( - api_version=result["apiVersion"], - kind=result["kind"], + api_version=dict["apiVersion"], + kind=dict["kind"], metadata=V1ObjectMeta( - creation_timestamp=result["metadata"]["creationTimestamp"], - generation=result["metadata"]["generation"], - name=result["metadata"]["name"], - namespace=result["metadata"]["namespace"], - resource_version=result["metadata"]["resourceVersion"], - uid=result["metadata"]["uid"], + creation_timestamp=dict["metadata"]["creationTimestamp"], + generation=dict["metadata"]["generation"], + labels=dict["metadata"]["labels"], + managed_fields=dict["metadata"]["managedFields"], + name=dict["metadata"]["name"], + namespace=dict["metadata"]["namespace"], + resource_version=dict["metadata"]["resourceVersion"], + uid=dict["metadata"]["uid"], ), status=V1Alpha1LeaseStatus( - begin_time=result["status"]["beginTime"] if "beginTime" in result["status"] else None, - end_time=result["status"]["endTime"] if "endTime" in result["status"] else None, - ended=result["status"]["ended"], - exporter=V1ObjectReference(name=result["status"]["exporterRef"]["name"]) - if "exporterRef" in result["status"] + begin_time=dict["status"]["beginTime"] if "beginTime" in dict["status"] else None, + end_time=dict["status"]["endTime"] if "endTime" in dict["status"] else None, + ended=dict["status"]["ended"], + exporter=V1ObjectReference(name=dict["status"]["exporterRef"]["name"]) + if "exporterRef" in dict["status"] else None, conditions=[ V1Condition( @@ -76,29 +61,40 @@ def _deserialize(result: dict) -> V1Alpha1Lease: status=cond["status"], type=cond["type"], ) - for cond in result["status"]["conditions"] + for cond in dict["status"]["conditions"] ], ), spec=V1Alpha1LeaseSpec( - client=V1ObjectReference(name=result["spec"]["clientRef"]["name"]) - if "clientRef" in result["spec"] + client=V1ObjectReference(name=dict["spec"]["clientRef"]["name"]) + if "clientRef" in dict["spec"] else None, - duration=result["spec"]["duration"] if "duration" in result["spec"] else None, - selector=result["spec"]["selector"], + duration=dict["spec"]["duration"] if "duration" in dict["spec"] else None, + selector=dict["spec"]["selector"], ), ) + +class V1Alpha1LeaseList(V1Alpha1List[V1Alpha1Lease]): + kind: Literal["LeaseList"] = Field(default="LeaseList") + + @staticmethod + def from_dict(dict: dict): + return V1Alpha1LeaseList(items=[V1Alpha1Lease.from_dict(c) for c in dict["items"]]) + + +class LeasesV1Alpha1Api(AbstractAsyncCustomObjectApi): + """Interact with the leases custom resource API""" + async def list_leases(self) -> V1Alpha1List[V1Alpha1Lease]: """List the lease objects in the cluster async""" - res = await self.api.list_namespaced_custom_object( + result = await self.api.list_namespaced_custom_object( namespace=self.namespace, group="jumpstarter.dev", plural="leases", version="v1alpha1" ) - return V1Alpha1Lease(items=[LeasesV1Alpha1Api._deserialize(c) for c in res["items"]]) + return V1Alpha1LeaseList.from_dict(result) async def get_lease(self, name: str) -> V1Alpha1Lease: """Get a single lease object from the cluster async""" result = await self.api.get_namespaced_custom_object( namespace=self.namespace, group="jumpstarter.dev", plural="leases", version="v1alpha1", name=name ) - pprint.pp(result) - return LeasesV1Alpha1Api._deserialize(result) + return V1Alpha1Lease.from_dict(result) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py index a042ec0fa..ffeb532c9 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py @@ -1,18 +1,13 @@ from typing import Literal -import yaml -from pydantic import BaseModel, Field +from pydantic import Field +from .json import JsonBaseModel -class V1Alpha1List[T](BaseModel): + +class V1Alpha1List[T](JsonBaseModel): """A generic list result type.""" api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") items: list[T] kind: Literal["List"] = Field(default="List") - - def dump_json(self): - return self.model_dump_json(indent=4, by_alias=True) - - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(by_alias=True), indent=2) From 58ce7b19a63ae8a6deea402ad4a7a67761ba6bc2 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 08:19:44 -0400 Subject: [PATCH 05/23] Refactor to make printing objects more type-safe --- .../jumpstarter_cli_admin/get.py | 174 +----------------- .../jumpstarter_cli_admin/print.py | 164 +++++++++++++++++ .../jumpstarter_cli_common/__init__.py | 3 +- .../jumpstarter_cli_common/opt.py | 12 +- 4 files changed, 187 insertions(+), 166 deletions(-) create mode 100644 packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py index 43cabfab4..d2c132510 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py @@ -4,22 +4,17 @@ import asyncclick as click from jumpstarter_cli_common import ( AliasedGroup, - make_table, + OutputType, opt_context, opt_kubeconfig, opt_log_level, opt_namespace, opt_output, - time_since, ) from jumpstarter_kubernetes import ( ClientsV1Alpha1Api, ExportersV1Alpha1Api, LeasesV1Alpha1Api, - V1Alpha1Client, - V1Alpha1Exporter, - V1Alpha1Lease, - V1Alpha1List, ) from kubernetes_asyncio.client.exceptions import ApiException from kubernetes_asyncio.config.config_exception import ConfigException @@ -28,6 +23,7 @@ handle_k8s_api_exception, handle_k8s_config_exception, ) +from .print import print_client, print_clients, print_exporter, print_exporters, print_lease, print_leases @click.group(cls=AliasedGroup) @@ -40,37 +36,6 @@ def get(log_level: Optional[str]): logging.basicConfig(level=logging.INFO) -CLIENT_COLUMNS = ["NAME", "ENDPOINT", "AGE"] - - -def make_client_row(client: V1Alpha1Client): - return { - "NAME": client.metadata.name, - "ENDPOINT": client.status.endpoint if client.status is not None else "", - "AGE": time_since(client.metadata.creation_timestamp), - } - - -def print_client(client: V1Alpha1Client, output: str): - if output == "json": - click.echo(client.dump_json()) - elif output == "yaml": - click.echo(client.dump_yaml()) - else: - click.echo(make_table(CLIENT_COLUMNS, [make_client_row(client)])) - - -def print_clients(clients: V1Alpha1List[V1Alpha1Client], namespace: str, output: str): - if output == "json": - click.echo(clients.dump_json()) - elif output == "yaml": - click.echo(clients.dump_yaml()) - elif len(clients.items) == 0: - raise click.ClickException(f'No resources found in "{namespace}" namespace') - else: - click.echo(make_table(CLIENT_COLUMNS, list(map(make_client_row, clients.items)))) - - @get.command("client") @click.argument("name", type=str, required=False, default=None) @opt_namespace @@ -78,17 +43,15 @@ def print_clients(clients: V1Alpha1List[V1Alpha1Client], namespace: str, output: @opt_context @opt_output async def get_client( - name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: str + name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: Optional[OutputType] ): """Get the client objects in a Kubernetes cluster""" try: async with ClientsV1Alpha1Api(namespace, kubeconfig, context) as api: if name is not None: - # Get a single client in a namespace client = await api.get_client(name) print_client(client, output) else: - # List clients in a namespace clients = await api.list_clients() print_clients(clients, namespace, output) except ApiException as e: @@ -97,67 +60,6 @@ async def get_client( handle_k8s_config_exception(e) -EXPORTER_COLUMNS = ["NAME", "ENDPOINT", "DEVICES", "AGE"] -DEVICE_COLUMNS = ["NAME", "ENDPOINT", "AGE", "LABELS", "UUID"] - - -def make_exporter_row(exporter: V1Alpha1Exporter): - """Make an exporter row to print""" - return { - "NAME": exporter.metadata.name, - "ENDPOINT": exporter.status.endpoint, - "DEVICES": str(len(exporter.status.devices)), - "AGE": time_since(exporter.metadata.creation_timestamp), - } - - -def get_device_rows(exporters: list[V1Alpha1Exporter]): - """Get the device rows to print from the exporters""" - devices = [] - for e in exporters: - for d in e.status.devices: - labels = [] - for label in d.labels: - labels.append(f"{label}:{str(d.labels[label])}") - devices.append( - { - "NAME": e.metadata.name, - "ENDPOINT": e.status.endpoint, - "AGE": time_since(e.metadata.creation_timestamp), - "LABELS": ",".join(labels), - "UUID": d.uuid, - } - ) - return devices - - -def print_exporter(exporter: V1Alpha1Exporter, devices: bool, output: str): - if output == "json": - click.echo(exporter.dump_json()) - elif output == "yaml": - click.echo(exporter.dump_yaml()) - elif devices: - # Print the devices for the exporter - click.echo(make_table(DEVICE_COLUMNS, get_device_rows([exporter]))) - else: - click.echo(make_table(EXPORTER_COLUMNS, [make_exporter_row(exporter)])) - - -def print_exporters(exporters: V1Alpha1List[V1Alpha1Exporter], namespace: str, devices: bool, output: str): - if output == "json": - click.echo(exporters.dump_json()) - elif output == "yaml": - click.echo(exporters.dump_yaml()) - elif len(exporters.items) == 0: - raise click.ClickException(f'No resources found in "{namespace}" namespace') - elif devices: - # Print the devices for each exporter - rows = get_device_rows(exporters) - click.echo(make_table(DEVICE_COLUMNS, rows)) - else: - click.echo(make_table(EXPORTER_COLUMNS, list(map(make_exporter_row, exporters.items)))) - - @get.command("exporter") @click.argument("name", type=str, required=False, default=None) @opt_namespace @@ -166,17 +68,20 @@ def print_exporters(exporters: V1Alpha1List[V1Alpha1Exporter], namespace: str, d @opt_output @click.option("-d", "--devices", is_flag=True, help="Display the devices hosted by the exporter(s)") async def get_exporter( - name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, devices: bool, output: str + name: Optional[str], + kubeconfig: Optional[str], + context: Optional[str], + namespace: str, + devices: bool, + output: Optional[OutputType], ): """Get the exporter objects in a Kubernetes cluster""" try: async with ExportersV1Alpha1Api(namespace, kubeconfig, context) as api: if name is not None: - # Get a single client in a namespace exporter = await api.get_exporter(name) print_exporter(exporter, devices, output) else: - # List clients in a namespace exporters = await api.list_exporters() print_exporters(exporters, namespace, devices, output) except ApiException as e: @@ -185,63 +90,6 @@ async def get_exporter( handle_k8s_config_exception(e) -LEASE_COLUMNS = ["NAME", "CLIENT", "SELECTOR", "EXPORTER", "STATUS", "REASON", "BEGIN", "END", "DURATION", "AGE"] - - -def get_reason(lease: V1Alpha1Lease): - condition = lease.status.conditions[-1] if len(lease.status.conditions) > 0 else None - reason = condition.reason if condition is not None else "Unknown" - status = condition.status if condition is not None else "False" - if reason == "Ready": - if status == "True": - return "Ready" - else: - return "Waiting" - elif reason == "Expired": - if status == "True": - return "Expired" - else: - return "Complete" - - -def make_lease_row(lease: V1Alpha1Lease): - selectors = [] - for label in lease.spec.selector: - selectors.append(f"{label}:{str(lease.spec.selector[label])}") - return { - "NAME": lease.metadata.name, - "CLIENT": lease.spec.client.name if lease.spec.client is not None else "", - "SELECTOR": ",".join(selectors) if len(selectors) > 0 else "*", - "EXPORTER": lease.status.exporter.name if lease.status.exporter is not None else "", - "DURATION": lease.spec.duration, - "STATUS": "Ended" if lease.status.ended else "InProgress", - "REASON": get_reason(lease), - "BEGIN": lease.status.begin_time if lease.status.begin_time is not None else "", - "END": lease.status.end_time if lease.status.end_time is not None else "", - "AGE": time_since(lease.metadata.creation_timestamp), - } - - -def print_lease(lease: V1Alpha1Lease, output: str): - if output == "json": - click.echo(lease.dump_json()) - elif output == "yaml": - click.echo(lease.dump_yaml()) - else: - click.echo(make_table(LEASE_COLUMNS, [make_lease_row(lease)])) - - -def print_leases(leases: V1Alpha1List[V1Alpha1Lease], namespace: str, output: str): - if output == "json": - click.echo(leases.dump_json()) - elif output == "yaml": - click.echo(leases.dump_yaml()) - elif len(leases.items) == 0: - raise click.ClickException(f'No resources found in "{namespace}" namespace') - else: - click.echo(make_table(LEASE_COLUMNS, list(map(make_lease_row, leases.items)))) - - @get.command("lease") @click.argument("name", type=str, required=False, default=None) @opt_namespace @@ -249,17 +97,15 @@ def print_leases(leases: V1Alpha1List[V1Alpha1Lease], namespace: str, output: st @opt_context @opt_output async def get_lease( - name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: str + name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: Optional[OutputType] ): """Get the lease objects in a Kubernetes cluster""" try: async with LeasesV1Alpha1Api(namespace, kubeconfig, context) as api: if name is not None: - # Get a single lease in a namespace lease = await api.get_lease(name) print_lease(lease, output) else: - # List leases in a namespace leases = await api.list_leases() print_leases(leases, namespace, output) except ApiException as e: diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py new file mode 100644 index 000000000..5330546df --- /dev/null +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py @@ -0,0 +1,164 @@ +from typing import Optional + +import asyncclick as click +from jumpstarter_cli_common import ( + OutputType, + make_table, + time_since, +) +from jumpstarter_kubernetes import ( + V1Alpha1Client, + V1Alpha1Exporter, + V1Alpha1Lease, + V1Alpha1List, +) + +CLIENT_COLUMNS = ["NAME", "ENDPOINT", "AGE"] + + +def make_client_row(client: V1Alpha1Client): + return { + "NAME": client.metadata.name, + "ENDPOINT": client.status.endpoint if client.status is not None else "", + "AGE": time_since(client.metadata.creation_timestamp), + } + + +def print_client(client: V1Alpha1Client, output: Optional[OutputType]): + if output == OutputType.JSON: + click.echo(client.dump_json()) + elif output == OutputType.YAML: + click.echo(client.dump_yaml()) + else: + click.echo(make_table(CLIENT_COLUMNS, [make_client_row(client)])) + + +def print_clients(clients: V1Alpha1List[V1Alpha1Client], namespace: str, output: Optional[OutputType]): + if output == OutputType.JSON: + click.echo(clients.dump_json()) + elif output == OutputType.YAML: + click.echo(clients.dump_yaml()) + elif len(clients.items) == 0: + raise click.ClickException(f'No resources found in "{namespace}" namespace') + else: + click.echo(make_table(CLIENT_COLUMNS, list(map(make_client_row, clients.items)))) + + +EXPORTER_COLUMNS = ["NAME", "ENDPOINT", "DEVICES", "AGE"] +DEVICE_COLUMNS = ["NAME", "ENDPOINT", "AGE", "LABELS", "UUID"] + + +def make_exporter_row(exporter: V1Alpha1Exporter): + """Make an exporter row to print""" + return { + "NAME": exporter.metadata.name, + "ENDPOINT": exporter.status.endpoint, + "DEVICES": str(len(exporter.status.devices)), + "AGE": time_since(exporter.metadata.creation_timestamp), + } + + +def get_device_rows(exporters: list[V1Alpha1Exporter]): + """Get the device rows to print from the exporters""" + devices = [] + for e in exporters: + for d in e.status.devices: + labels = [] + for label in d.labels: + labels.append(f"{label}:{str(d.labels[label])}") + devices.append( + { + "NAME": e.metadata.name, + "ENDPOINT": e.status.endpoint, + "AGE": time_since(e.metadata.creation_timestamp), + "LABELS": ",".join(labels), + "UUID": d.uuid, + } + ) + return devices + + +def print_exporter(exporter: V1Alpha1Exporter, devices: bool, output: Optional[OutputType]): + if output == OutputType.JSON: + click.echo(exporter.dump_json()) + elif output == OutputType.YAML: + click.echo(exporter.dump_yaml()) + elif devices: + # Print the devices for the exporter + click.echo(make_table(DEVICE_COLUMNS, get_device_rows([exporter]))) + else: + click.echo(make_table(EXPORTER_COLUMNS, [make_exporter_row(exporter)])) + + +def print_exporters( + exporters: V1Alpha1List[V1Alpha1Exporter], namespace: str, devices: bool, output: Optional[OutputType] +): + if output == OutputType.JSON: + click.echo(exporters.dump_json()) + elif output == OutputType.YAML: + click.echo(exporters.dump_yaml()) + elif len(exporters.items) == 0: + raise click.ClickException(f'No resources found in "{namespace}" namespace') + elif devices: + # Print the devices for each exporter + rows = get_device_rows(exporters) + click.echo(make_table(DEVICE_COLUMNS, rows)) + else: + click.echo(make_table(EXPORTER_COLUMNS, list(map(make_exporter_row, exporters.items)))) + + +LEASE_COLUMNS = ["NAME", "CLIENT", "SELECTOR", "EXPORTER", "STATUS", "REASON", "BEGIN", "END", "DURATION", "AGE"] + + +def get_reason(lease: V1Alpha1Lease): + condition = lease.status.conditions[-1] if len(lease.status.conditions) > 0 else None + reason = condition.reason if condition is not None else "Unknown" + status = condition.status if condition is not None else "False" + if reason == "Ready": + if status == "True": + return "Ready" + else: + return "Waiting" + elif reason == "Expired": + if status == "True": + return "Expired" + else: + return "Complete" + + +def make_lease_row(lease: V1Alpha1Lease): + selectors = [] + for label in lease.spec.selector: + selectors.append(f"{label}:{str(lease.spec.selector[label])}") + return { + "NAME": lease.metadata.name, + "CLIENT": lease.spec.client.name if lease.spec.client is not None else "", + "SELECTOR": ",".join(selectors) if len(selectors) > 0 else "*", + "EXPORTER": lease.status.exporter.name if lease.status.exporter is not None else "", + "DURATION": lease.spec.duration, + "STATUS": "Ended" if lease.status.ended else "InProgress", + "REASON": get_reason(lease), + "BEGIN": lease.status.begin_time if lease.status.begin_time is not None else "", + "END": lease.status.end_time if lease.status.end_time is not None else "", + "AGE": time_since(lease.metadata.creation_timestamp), + } + + +def print_lease(lease: V1Alpha1Lease, output: Optional[OutputType]): + if output == OutputType.JSON: + click.echo(lease.dump_json()) + elif output == OutputType.YAML: + click.echo(lease.dump_yaml()) + else: + click.echo(make_table(LEASE_COLUMNS, [make_lease_row(lease)])) + + +def print_leases(leases: V1Alpha1List[V1Alpha1Lease], namespace: str, output: Optional[OutputType]): + if output == OutputType.JSON: + click.echo(leases.dump_json()) + elif output == OutputType.YAML: + click.echo(leases.dump_yaml()) + elif len(leases.items) == 0: + raise click.ClickException(f'No resources found in "{namespace}" namespace') + else: + click.echo(make_table(LEASE_COLUMNS, list(map(make_lease_row, leases.items)))) diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py index e557b702b..f0bc3e630 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py @@ -1,5 +1,5 @@ from .alias import AliasedGroup -from .opt import opt_context, opt_kubeconfig, opt_labels, opt_log_level, opt_namespace, opt_output +from .opt import OutputType, opt_context, opt_kubeconfig, opt_labels, opt_log_level, opt_namespace, opt_output from .table import make_table from .time import time_since from .version import get_client_version, version @@ -13,6 +13,7 @@ "opt_namespace", "opt_labels", "opt_output", + "OutputType", "time_since", "version", "get_client_version", diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py index 514513142..4ec424ee1 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py @@ -18,6 +18,16 @@ opt_labels = click.option("-l", "--label", "labels", type=(str, str), multiple=True, help="Labels") + +class OutputType(str): + JSON = "json" + YAML = "yaml" + + opt_output = click.option( - "-o", "--output", type=click.Choice(["json", "yaml"]), default=None, help="Set the CLI output format" + "-o", + "--output", + type=click.Choice([OutputType.JSON, OutputType.YAML]), + default=None, + help="Set the CLI output format", ) From 45b33c85c7d728424aef71f408b965eed21b4a0c Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 08:35:16 -0400 Subject: [PATCH 06/23] Add output to admin create commands --- .../jumpstarter_cli_admin/create.py | 50 +++++++++++++++---- .../jumpstarter_cli_admin/get.py | 8 +-- .../jumpstarter_cli_admin/print.py | 38 +++++++------- .../jumpstarter_cli_common/__init__.py | 4 +- .../jumpstarter_cli_common/opt.py | 15 ++++-- 5 files changed, 76 insertions(+), 39 deletions(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py index 57cc82e09..bc3958f8c 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py @@ -1,16 +1,19 @@ import logging +from pathlib import Path from typing import Optional import asyncclick as click from jumpstarter_cli_common import ( AliasedGroup, + OutputMode, opt_context, opt_kubeconfig, opt_labels, opt_log_level, opt_namespace, + opt_output, ) -from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api +from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api, V1Alpha1Client, V1Alpha1Exporter from kubernetes_asyncio.client.exceptions import ApiException from kubernetes_asyncio.config.config_exception import ConfigException @@ -33,6 +36,15 @@ def create(log_level: Optional[str]): logging.basicConfig(level=logging.INFO) +def print_created_client(client: V1Alpha1Client, output: OutputMode, client_config_path: Path): + if output == OutputMode.JSON: + click.echo(client.dump_json()) + elif output == OutputMode.YAML: + click.echo(client.dump_yaml()) + else: + click.echo(f"Client configuration successfully saved to {client_config_path}") + + @create.command("client") @click.argument("name", type=str, required=False, default=None) @click.option( @@ -51,7 +63,6 @@ def create(log_level: Optional[str]): ) @click.option("--unsafe", is_flag=True, help="Should all driver client packages be allowed to load (UNSAFE!).") @click.option( - "-o", "--out", type=click.Path(dir_okay=False, resolve_path=True, writable=True), help="Specify an output file for the client config.", @@ -62,6 +73,7 @@ def create(log_level: Optional[str]): @opt_kubeconfig @opt_context @opt_oidc_username +@opt_output async def create_client( name: Optional[str], kubeconfig: Optional[str], @@ -73,15 +85,19 @@ async def create_client( unsafe: bool, out: Optional[str], oidc_username: str | None, + output: Optional[OutputMode], ): """Create a client object in the Kubernetes cluster""" try: async with ClientsV1Alpha1Api(namespace, kubeconfig, context) as api: - click.echo(f"Creating client '{name}' in namespace '{namespace}'") - await api.create_client(name, dict(labels), oidc_username) + if output is None: + # Only print status if is not JSON/YAML + click.echo(f"Creating client '{name}' in namespace '{namespace}'") + created_client = await api.create_client(name, dict(labels), oidc_username) # Save the client config if save or out is not None or click.confirm("Save client configuration?"): - click.echo("Fetching client credentials from cluster") + if output is None: + click.echo("Fetching client credentials from cluster") client_config = await api.get_client_config(name, allow=[], unsafe=unsafe) if unsafe is False and allow is None: unsafe = click.confirm("Allow unsafe driver client imports?") @@ -98,13 +114,22 @@ async def create_client( user_config = UserConfigV1Alpha1.load_or_create() user_config.config.current_client = client_config UserConfigV1Alpha1.save(user_config) - click.echo(f"Client configuration successfully saved to {client_config.path}") + print_created_client(created_client, output, client_config.path) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: handle_k8s_config_exception(e) +def print_created_exporter(exporter: V1Alpha1Exporter, output: OutputMode, exporter_config_path: Path): + if output == OutputMode.JSON: + click.echo(exporter.dump_json()) + elif output == OutputMode.YAML: + click.echo(exporter.dump_yaml()) + else: + click.echo(f"Exporter configuration successfully saved to {exporter_config_path}") + + @create.command("exporter") @click.argument("name", type=str, required=False, default=None) @click.option( @@ -115,7 +140,6 @@ async def create_client( default=False, ) @click.option( - "-o", "--out", type=click.Path(dir_okay=False, resolve_path=True, writable=True), help="Specify an output file for the exporter config.", @@ -126,6 +150,7 @@ async def create_client( @opt_kubeconfig @opt_context @opt_oidc_username +@opt_output async def create_exporter( name: Optional[str], kubeconfig: Optional[str], @@ -135,18 +160,21 @@ async def create_exporter( save: bool, out: Optional[str], oidc_username: str | None, + output: Optional[OutputMode], ): """Create an exporter object in the Kubernetes cluster""" try: async with ExportersV1Alpha1Api(namespace, kubeconfig, context) as api: - click.echo(f"Creating exporter '{name}' in namespace '{namespace}'") - await api.create_exporter(name, dict(labels), oidc_username) + if output is None: + click.echo(f"Creating exporter '{name}' in namespace '{namespace}'") + created_exporter = await api.create_exporter(name, dict(labels), oidc_username) # Save the client config if save or out is not None or click.confirm("Save exporter configuration?"): - click.echo("Fetching exporter credentials from cluster") + if output is None: + click.echo("Fetching exporter credentials from cluster") exporter_config = await api.get_exporter_config(name) ExporterConfigV1Alpha1.save(exporter_config, out) - click.echo(f"Exporter configuration successfully saved to {exporter_config.path}") + print_created_exporter(created_exporter, output, exporter_config.path) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py index d2c132510..975119284 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py @@ -4,7 +4,7 @@ import asyncclick as click from jumpstarter_cli_common import ( AliasedGroup, - OutputType, + OutputMode, opt_context, opt_kubeconfig, opt_log_level, @@ -43,7 +43,7 @@ def get(log_level: Optional[str]): @opt_context @opt_output async def get_client( - name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: Optional[OutputType] + name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: Optional[OutputMode] ): """Get the client objects in a Kubernetes cluster""" try: @@ -73,7 +73,7 @@ async def get_exporter( context: Optional[str], namespace: str, devices: bool, - output: Optional[OutputType], + output: Optional[OutputMode], ): """Get the exporter objects in a Kubernetes cluster""" try: @@ -97,7 +97,7 @@ async def get_exporter( @opt_context @opt_output async def get_lease( - name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: Optional[OutputType] + name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: Optional[OutputMode] ): """Get the lease objects in a Kubernetes cluster""" try: diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py index 5330546df..022cde22a 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py @@ -2,7 +2,7 @@ import asyncclick as click from jumpstarter_cli_common import ( - OutputType, + OutputMode, make_table, time_since, ) @@ -24,19 +24,19 @@ def make_client_row(client: V1Alpha1Client): } -def print_client(client: V1Alpha1Client, output: Optional[OutputType]): - if output == OutputType.JSON: +def print_client(client: V1Alpha1Client, output: Optional[OutputMode]): + if output == OutputMode.JSON: click.echo(client.dump_json()) - elif output == OutputType.YAML: + elif output == OutputMode.YAML: click.echo(client.dump_yaml()) else: click.echo(make_table(CLIENT_COLUMNS, [make_client_row(client)])) -def print_clients(clients: V1Alpha1List[V1Alpha1Client], namespace: str, output: Optional[OutputType]): - if output == OutputType.JSON: +def print_clients(clients: V1Alpha1List[V1Alpha1Client], namespace: str, output: Optional[OutputMode]): + if output == OutputMode.JSON: click.echo(clients.dump_json()) - elif output == OutputType.YAML: + elif output == OutputMode.YAML: click.echo(clients.dump_yaml()) elif len(clients.items) == 0: raise click.ClickException(f'No resources found in "{namespace}" namespace') @@ -78,10 +78,10 @@ def get_device_rows(exporters: list[V1Alpha1Exporter]): return devices -def print_exporter(exporter: V1Alpha1Exporter, devices: bool, output: Optional[OutputType]): - if output == OutputType.JSON: +def print_exporter(exporter: V1Alpha1Exporter, devices: bool, output: Optional[OutputMode]): + if output == OutputMode.JSON: click.echo(exporter.dump_json()) - elif output == OutputType.YAML: + elif output == OutputMode.YAML: click.echo(exporter.dump_yaml()) elif devices: # Print the devices for the exporter @@ -91,11 +91,11 @@ def print_exporter(exporter: V1Alpha1Exporter, devices: bool, output: Optional[O def print_exporters( - exporters: V1Alpha1List[V1Alpha1Exporter], namespace: str, devices: bool, output: Optional[OutputType] + exporters: V1Alpha1List[V1Alpha1Exporter], namespace: str, devices: bool, output: Optional[OutputMode] ): - if output == OutputType.JSON: + if output == OutputMode.JSON: click.echo(exporters.dump_json()) - elif output == OutputType.YAML: + elif output == OutputMode.YAML: click.echo(exporters.dump_yaml()) elif len(exporters.items) == 0: raise click.ClickException(f'No resources found in "{namespace}" namespace') @@ -144,19 +144,19 @@ def make_lease_row(lease: V1Alpha1Lease): } -def print_lease(lease: V1Alpha1Lease, output: Optional[OutputType]): - if output == OutputType.JSON: +def print_lease(lease: V1Alpha1Lease, output: Optional[OutputMode]): + if output == OutputMode.JSON: click.echo(lease.dump_json()) - elif output == OutputType.YAML: + elif output == OutputMode.YAML: click.echo(lease.dump_yaml()) else: click.echo(make_table(LEASE_COLUMNS, [make_lease_row(lease)])) -def print_leases(leases: V1Alpha1List[V1Alpha1Lease], namespace: str, output: Optional[OutputType]): - if output == OutputType.JSON: +def print_leases(leases: V1Alpha1List[V1Alpha1Lease], namespace: str, output: Optional[OutputMode]): + if output == OutputMode.JSON: click.echo(leases.dump_json()) - elif output == OutputType.YAML: + elif output == OutputMode.YAML: click.echo(leases.dump_yaml()) elif len(leases.items) == 0: raise click.ClickException(f'No resources found in "{namespace}" namespace') diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py index f0bc3e630..dbc25c35f 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py @@ -1,5 +1,5 @@ from .alias import AliasedGroup -from .opt import OutputType, opt_context, opt_kubeconfig, opt_labels, opt_log_level, opt_namespace, opt_output +from .opt import OutputMode, opt_context, opt_kubeconfig, opt_labels, opt_log_level, opt_namespace, opt_output from .table import make_table from .time import time_since from .version import get_client_version, version @@ -13,7 +13,7 @@ "opt_namespace", "opt_labels", "opt_output", - "OutputType", + "OutputMode", "time_since", "version", "get_client_version", diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py index 4ec424ee1..fc5f697cb 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py @@ -19,15 +19,24 @@ opt_labels = click.option("-l", "--label", "labels", type=(str, str), multiple=True, help="Labels") -class OutputType(str): +class OutputMode(str): JSON = "json" YAML = "yaml" + NAME = "name" opt_output = click.option( "-o", "--output", - type=click.Choice([OutputType.JSON, OutputType.YAML]), + type=click.Choice([OutputMode.JSON, OutputMode.YAML]), default=None, - help="Set the CLI output format", + help="Output mode.", +) + +opt_output_name = click.option( + "-o", + "--output", + type=click.Choice([OutputMode.NAME]), + default=None, + help='Output mode. Use "-o name" for shorter output (resource/name).', ) From a928a2888ec7533350eeab97617d462b93472374 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 08:42:47 -0400 Subject: [PATCH 07/23] Add name output mode for simplified output --- .../jumpstarter_cli_admin/create.py | 10 +++++++--- .../jumpstarter_cli_admin/get.py | 8 ++++---- .../jumpstarter_cli_admin/print.py | 15 +++++++++++++++ .../jumpstarter_cli_common/__init__.py | 4 ++-- .../jumpstarter_cli_common/opt.py | 8 ++++---- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py index bc3958f8c..e5bc65ee4 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py @@ -11,7 +11,7 @@ opt_labels, opt_log_level, opt_namespace, - opt_output, + opt_output_all, ) from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api, V1Alpha1Client, V1Alpha1Exporter from kubernetes_asyncio.client.exceptions import ApiException @@ -41,6 +41,8 @@ def print_created_client(client: V1Alpha1Client, output: OutputMode, client_conf click.echo(client.dump_json()) elif output == OutputMode.YAML: click.echo(client.dump_yaml()) + elif output == OutputMode.NAME: + click.echo(f"client.jumpstarter.dev/{client.metadata.name}") else: click.echo(f"Client configuration successfully saved to {client_config_path}") @@ -73,7 +75,7 @@ def print_created_client(client: V1Alpha1Client, output: OutputMode, client_conf @opt_kubeconfig @opt_context @opt_oidc_username -@opt_output +@opt_output_all async def create_client( name: Optional[str], kubeconfig: Optional[str], @@ -126,6 +128,8 @@ def print_created_exporter(exporter: V1Alpha1Exporter, output: OutputMode, expor click.echo(exporter.dump_json()) elif output == OutputMode.YAML: click.echo(exporter.dump_yaml()) + elif output == OutputMode.NAME: + click.echo(f"exporter.jumpstarter.dev/{exporter.metadata.name}") else: click.echo(f"Exporter configuration successfully saved to {exporter_config_path}") @@ -150,7 +154,7 @@ def print_created_exporter(exporter: V1Alpha1Exporter, output: OutputMode, expor @opt_kubeconfig @opt_context @opt_oidc_username -@opt_output +@opt_output_all async def create_exporter( name: Optional[str], kubeconfig: Optional[str], diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py index 975119284..bd0b06c48 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py @@ -9,7 +9,7 @@ opt_kubeconfig, opt_log_level, opt_namespace, - opt_output, + opt_output_all, ) from jumpstarter_kubernetes import ( ClientsV1Alpha1Api, @@ -41,7 +41,7 @@ def get(log_level: Optional[str]): @opt_namespace @opt_kubeconfig @opt_context -@opt_output +@opt_output_all async def get_client( name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: Optional[OutputMode] ): @@ -65,7 +65,7 @@ async def get_client( @opt_namespace @opt_kubeconfig @opt_context -@opt_output +@opt_output_all @click.option("-d", "--devices", is_flag=True, help="Display the devices hosted by the exporter(s)") async def get_exporter( name: Optional[str], @@ -95,7 +95,7 @@ async def get_exporter( @opt_namespace @opt_kubeconfig @opt_context -@opt_output +@opt_output_all async def get_lease( name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: Optional[OutputMode] ): diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py index 022cde22a..4e66756d6 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py @@ -29,6 +29,8 @@ def print_client(client: V1Alpha1Client, output: Optional[OutputMode]): click.echo(client.dump_json()) elif output == OutputMode.YAML: click.echo(client.dump_yaml()) + elif output == OutputMode.NAME: + click.echo(f"client.jumpstarter.dev/{client.metadata.name}") else: click.echo(make_table(CLIENT_COLUMNS, [make_client_row(client)])) @@ -38,6 +40,9 @@ def print_clients(clients: V1Alpha1List[V1Alpha1Client], namespace: str, output: click.echo(clients.dump_json()) elif output == OutputMode.YAML: click.echo(clients.dump_yaml()) + elif output == OutputMode.NAME: + if len(clients.items) > 0: + click.echo(f"client.jumpstarter.dev/{clients.items[0].metadata.name}") elif len(clients.items) == 0: raise click.ClickException(f'No resources found in "{namespace}" namespace') else: @@ -83,6 +88,8 @@ def print_exporter(exporter: V1Alpha1Exporter, devices: bool, output: Optional[O click.echo(exporter.dump_json()) elif output == OutputMode.YAML: click.echo(exporter.dump_yaml()) + elif output == OutputMode.NAME: + click.echo(f"exporter.jumpstarter.dev/{exporter.metadata.name}") elif devices: # Print the devices for the exporter click.echo(make_table(DEVICE_COLUMNS, get_device_rows([exporter]))) @@ -97,6 +104,9 @@ def print_exporters( click.echo(exporters.dump_json()) elif output == OutputMode.YAML: click.echo(exporters.dump_yaml()) + elif output == OutputMode.NAME: + if len(exporters.items) > 0: + click.echo(f"exporters.jumpstarter.dev/{exporters.items[0].metadata.name}") elif len(exporters.items) == 0: raise click.ClickException(f'No resources found in "{namespace}" namespace') elif devices: @@ -149,6 +159,8 @@ def print_lease(lease: V1Alpha1Lease, output: Optional[OutputMode]): click.echo(lease.dump_json()) elif output == OutputMode.YAML: click.echo(lease.dump_yaml()) + elif output == OutputMode.NAME: + click.echo(f"lease.jumpstarter.dev/{lease.metadata.name}") else: click.echo(make_table(LEASE_COLUMNS, [make_lease_row(lease)])) @@ -158,6 +170,9 @@ def print_leases(leases: V1Alpha1List[V1Alpha1Lease], namespace: str, output: Op click.echo(leases.dump_json()) elif output == OutputMode.YAML: click.echo(leases.dump_yaml()) + elif output == OutputMode.NAME: + if len(leases.items) > 0: + click.echo(f"lease.jumpstarter.dev/{leases.items[0].metadata.name}") elif len(leases.items) == 0: raise click.ClickException(f'No resources found in "{namespace}" namespace') else: diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py index dbc25c35f..41eec338e 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py @@ -1,5 +1,5 @@ from .alias import AliasedGroup -from .opt import OutputMode, opt_context, opt_kubeconfig, opt_labels, opt_log_level, opt_namespace, opt_output +from .opt import OutputMode, opt_context, opt_kubeconfig, opt_labels, opt_log_level, opt_namespace, opt_output_all from .table import make_table from .time import time_since from .version import get_client_version, version @@ -12,7 +12,7 @@ "opt_kubeconfig", "opt_namespace", "opt_labels", - "opt_output", + "opt_output_all", "OutputMode", "time_since", "version", diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py index fc5f697cb..def949306 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py @@ -25,15 +25,15 @@ class OutputMode(str): NAME = "name" -opt_output = click.option( +opt_output_all = click.option( "-o", "--output", - type=click.Choice([OutputMode.JSON, OutputMode.YAML]), + type=click.Choice([OutputMode.JSON, OutputMode.YAML, OutputMode.NAME]), default=None, - help="Output mode.", + help='Output mode. Use "-o name" for shorter output (resource/name).', ) -opt_output_name = click.option( +opt_output_name_only = click.option( "-o", "--output", type=click.Choice([OutputMode.NAME]), From 4cce53e40bc2b10f1ae93d6afb733702a7374e32 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 08:50:38 -0400 Subject: [PATCH 08/23] Add OutputType meta type and name-only outputs for admin delete --- .../jumpstarter_cli_admin/create.py | 9 ++--- .../jumpstarter_cli_admin/delete.py | 33 +++++++++++++++---- .../jumpstarter_cli_admin/get.py | 8 ++--- .../jumpstarter_cli_admin/print.py | 17 ++++------ .../jumpstarter_cli_common/__init__.py | 16 ++++++++- .../jumpstarter_cli_common/opt.py | 6 ++++ 6 files changed, 64 insertions(+), 25 deletions(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py index e5bc65ee4..012370c95 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py @@ -6,6 +6,7 @@ from jumpstarter_cli_common import ( AliasedGroup, OutputMode, + OutputType, opt_context, opt_kubeconfig, opt_labels, @@ -36,7 +37,7 @@ def create(log_level: Optional[str]): logging.basicConfig(level=logging.INFO) -def print_created_client(client: V1Alpha1Client, output: OutputMode, client_config_path: Path): +def print_created_client(client: V1Alpha1Client, output: OutputType, client_config_path: Path): if output == OutputMode.JSON: click.echo(client.dump_json()) elif output == OutputMode.YAML: @@ -87,7 +88,7 @@ async def create_client( unsafe: bool, out: Optional[str], oidc_username: str | None, - output: Optional[OutputMode], + output: OutputType, ): """Create a client object in the Kubernetes cluster""" try: @@ -123,7 +124,7 @@ async def create_client( handle_k8s_config_exception(e) -def print_created_exporter(exporter: V1Alpha1Exporter, output: OutputMode, exporter_config_path: Path): +def print_created_exporter(exporter: V1Alpha1Exporter, output: OutputType, exporter_config_path: Path): if output == OutputMode.JSON: click.echo(exporter.dump_json()) elif output == OutputMode.YAML: @@ -164,7 +165,7 @@ async def create_exporter( save: bool, out: Optional[str], oidc_username: str | None, - output: Optional[OutputMode], + output: OutputType, ): """Create an exporter object in the Kubernetes cluster""" try: diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py index 4cf68e463..e1d3d7b0f 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py @@ -4,10 +4,12 @@ import asyncclick as click from jumpstarter_cli_common import ( AliasedGroup, + NameOutputType, opt_context, opt_kubeconfig, opt_log_level, opt_namespace, + opt_output_name_only, ) from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api from kubernetes_asyncio.client.exceptions import ApiException @@ -42,14 +44,23 @@ def delete(log_level: Optional[str]): @opt_namespace @opt_kubeconfig @opt_context +@opt_output_name_only async def delete_client( - name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, delete: bool + name: Optional[str], + kubeconfig: Optional[str], + context: Optional[str], + namespace: str, + delete: bool, + output: NameOutputType, ): """Delete a client object in the Kubernetes cluster""" try: async with ClientsV1Alpha1Api(namespace, kubeconfig, context) as api: await api.delete_client(name) - click.echo(f"Deleted client '{name}' in namespace '{namespace}'") + if output is None: + click.echo(f"Deleted client '{name}' in namespace '{namespace}'") + else: + click.echo(f"client.jumpstarter.dev/{name}") # Save the client config if ClientConfigV1Alpha1.exists(name) and (delete or click.confirm("Delete client configuration?")): # If this is the default, clear default @@ -59,7 +70,8 @@ async def delete_client( UserConfigV1Alpha1.save(user_config) # Delete the client config ClientConfigV1Alpha1.delete(name) - click.echo("Client configuration successfully deleted") + if output is None: + click.echo("Client configuration successfully deleted") except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: @@ -79,18 +91,27 @@ async def delete_client( @opt_kubeconfig @opt_context async def delete_exporter( - name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, delete: bool + name: Optional[str], + kubeconfig: Optional[str], + context: Optional[str], + namespace: str, + delete: bool, + output: NameOutputType, ): """Delete an exporter object in the Kubernetes cluster""" try: async with ExportersV1Alpha1Api(namespace, kubeconfig, context) as api: await api.delete_exporter(name) - click.echo(f"Deleted exporter '{name}' in namespace '{namespace}'") + if output is None: + click.echo(f"Deleted exporter '{name}' in namespace '{namespace}'") + else: + click.echo(f"exporter.jumpstarter.dev/{name}") # Save the exporter config if ExporterConfigV1Alpha1.exists(name) and (delete or click.confirm("Delete exporter configuration?")): # Delete the exporter config ExporterConfigV1Alpha1.delete(name) - click.echo("Exporter configuration successfully deleted") + if output is None: + click.echo("Exporter configuration successfully deleted") except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py index bd0b06c48..a5358b1e5 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py @@ -4,7 +4,7 @@ import asyncclick as click from jumpstarter_cli_common import ( AliasedGroup, - OutputMode, + OutputType, opt_context, opt_kubeconfig, opt_log_level, @@ -43,7 +43,7 @@ def get(log_level: Optional[str]): @opt_context @opt_output_all async def get_client( - name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: Optional[OutputMode] + name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: OutputType ): """Get the client objects in a Kubernetes cluster""" try: @@ -73,7 +73,7 @@ async def get_exporter( context: Optional[str], namespace: str, devices: bool, - output: Optional[OutputMode], + output: OutputType, ): """Get the exporter objects in a Kubernetes cluster""" try: @@ -97,7 +97,7 @@ async def get_exporter( @opt_context @opt_output_all async def get_lease( - name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: Optional[OutputMode] + name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: OutputType ): """Get the lease objects in a Kubernetes cluster""" try: diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py index 4e66756d6..aae8b9a53 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py @@ -1,8 +1,7 @@ -from typing import Optional - import asyncclick as click from jumpstarter_cli_common import ( OutputMode, + OutputType, make_table, time_since, ) @@ -24,7 +23,7 @@ def make_client_row(client: V1Alpha1Client): } -def print_client(client: V1Alpha1Client, output: Optional[OutputMode]): +def print_client(client: V1Alpha1Client, output: OutputType): if output == OutputMode.JSON: click.echo(client.dump_json()) elif output == OutputMode.YAML: @@ -35,7 +34,7 @@ def print_client(client: V1Alpha1Client, output: Optional[OutputMode]): click.echo(make_table(CLIENT_COLUMNS, [make_client_row(client)])) -def print_clients(clients: V1Alpha1List[V1Alpha1Client], namespace: str, output: Optional[OutputMode]): +def print_clients(clients: V1Alpha1List[V1Alpha1Client], namespace: str, output: OutputType): if output == OutputMode.JSON: click.echo(clients.dump_json()) elif output == OutputMode.YAML: @@ -83,7 +82,7 @@ def get_device_rows(exporters: list[V1Alpha1Exporter]): return devices -def print_exporter(exporter: V1Alpha1Exporter, devices: bool, output: Optional[OutputMode]): +def print_exporter(exporter: V1Alpha1Exporter, devices: bool, output: OutputType): if output == OutputMode.JSON: click.echo(exporter.dump_json()) elif output == OutputMode.YAML: @@ -97,9 +96,7 @@ def print_exporter(exporter: V1Alpha1Exporter, devices: bool, output: Optional[O click.echo(make_table(EXPORTER_COLUMNS, [make_exporter_row(exporter)])) -def print_exporters( - exporters: V1Alpha1List[V1Alpha1Exporter], namespace: str, devices: bool, output: Optional[OutputMode] -): +def print_exporters(exporters: V1Alpha1List[V1Alpha1Exporter], namespace: str, devices: bool, output: OutputType): if output == OutputMode.JSON: click.echo(exporters.dump_json()) elif output == OutputMode.YAML: @@ -154,7 +151,7 @@ def make_lease_row(lease: V1Alpha1Lease): } -def print_lease(lease: V1Alpha1Lease, output: Optional[OutputMode]): +def print_lease(lease: V1Alpha1Lease, output: OutputType): if output == OutputMode.JSON: click.echo(lease.dump_json()) elif output == OutputMode.YAML: @@ -165,7 +162,7 @@ def print_lease(lease: V1Alpha1Lease, output: Optional[OutputMode]): click.echo(make_table(LEASE_COLUMNS, [make_lease_row(lease)])) -def print_leases(leases: V1Alpha1List[V1Alpha1Lease], namespace: str, output: Optional[OutputMode]): +def print_leases(leases: V1Alpha1List[V1Alpha1Lease], namespace: str, output: OutputType): if output == OutputMode.JSON: click.echo(leases.dump_json()) elif output == OutputMode.YAML: diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py index 41eec338e..4be514015 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py @@ -1,5 +1,16 @@ from .alias import AliasedGroup -from .opt import OutputMode, opt_context, opt_kubeconfig, opt_labels, opt_log_level, opt_namespace, opt_output_all +from .opt import ( + NameOutputType, + OutputMode, + OutputType, + opt_context, + opt_kubeconfig, + opt_labels, + opt_log_level, + opt_namespace, + opt_output_all, + opt_output_name_only, +) from .table import make_table from .time import time_since from .version import get_client_version, version @@ -13,7 +24,10 @@ "opt_namespace", "opt_labels", "opt_output_all", + "opt_output_name_only", "OutputMode", + "OutputType", + "NameOutputType", "time_since", "version", "get_client_version", diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py index def949306..86b6ad4af 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py @@ -1,3 +1,5 @@ +from typing import Literal, Optional + import asyncclick as click opt_log_level = click.option( @@ -25,6 +27,8 @@ class OutputMode(str): NAME = "name" +OutputType = Optional[OutputMode] + opt_output_all = click.option( "-o", "--output", @@ -33,6 +37,8 @@ class OutputMode(str): help='Output mode. Use "-o name" for shorter output (resource/name).', ) +NameOutputType = Optional[Literal["name"]] + opt_output_name_only = click.option( "-o", "--output", From a46430c0df7541074867bb76250c51b906caee21 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 09:24:52 -0400 Subject: [PATCH 09/23] Add nointeractive option for automated script usage --- .../jumpstarter_cli_admin/create.py | 26 ++++++----- .../jumpstarter_cli_admin/delete.py | 14 +++++- .../jumpstarter_cli_admin/import_res.py | 43 ++++++++++++++----- .../jumpstarter_cli_common/__init__.py | 6 +++ .../jumpstarter_cli_common/opt.py | 15 +++++++ 5 files changed, 80 insertions(+), 24 deletions(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py index 012370c95..cbd95667b 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py @@ -1,5 +1,4 @@ import logging -from pathlib import Path from typing import Optional import asyncclick as click @@ -12,6 +11,7 @@ opt_labels, opt_log_level, opt_namespace, + opt_nointeractive, opt_output_all, ) from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api, V1Alpha1Client, V1Alpha1Exporter @@ -37,15 +37,13 @@ def create(log_level: Optional[str]): logging.basicConfig(level=logging.INFO) -def print_created_client(client: V1Alpha1Client, output: OutputType, client_config_path: Path): +def print_created_client(client: V1Alpha1Client, output: OutputType): if output == OutputMode.JSON: click.echo(client.dump_json()) elif output == OutputMode.YAML: click.echo(client.dump_yaml()) elif output == OutputMode.NAME: click.echo(f"client.jumpstarter.dev/{client.metadata.name}") - else: - click.echo(f"Client configuration successfully saved to {client_config_path}") @create.command("client") @@ -76,6 +74,7 @@ def print_created_client(client: V1Alpha1Client, output: OutputType, client_conf @opt_kubeconfig @opt_context @opt_oidc_username +@opt_nointeractive @opt_output_all async def create_client( name: Optional[str], @@ -88,6 +87,7 @@ async def create_client( unsafe: bool, out: Optional[str], oidc_username: str | None, + nointeractive: bool, output: OutputType, ): """Create a client object in the Kubernetes cluster""" @@ -98,7 +98,7 @@ async def create_client( click.echo(f"Creating client '{name}' in namespace '{namespace}'") created_client = await api.create_client(name, dict(labels), oidc_username) # Save the client config - if save or out is not None or click.confirm("Save client configuration?"): + if save or out is not None or nointeractive is False and click.confirm("Save client configuration?"): if output is None: click.echo("Fetching client credentials from cluster") client_config = await api.get_client_config(name, allow=[], unsafe=unsafe) @@ -117,22 +117,22 @@ async def create_client( user_config = UserConfigV1Alpha1.load_or_create() user_config.config.current_client = client_config UserConfigV1Alpha1.save(user_config) - print_created_client(created_client, output, client_config.path) + if output is None: + click.echo(f"Client configuration successfully saved to {client_config.path}") + print_created_client(created_client, output) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: handle_k8s_config_exception(e) -def print_created_exporter(exporter: V1Alpha1Exporter, output: OutputType, exporter_config_path: Path): +def print_created_exporter(exporter: V1Alpha1Exporter, output: OutputType): if output == OutputMode.JSON: click.echo(exporter.dump_json()) elif output == OutputMode.YAML: click.echo(exporter.dump_yaml()) elif output == OutputMode.NAME: click.echo(f"exporter.jumpstarter.dev/{exporter.metadata.name}") - else: - click.echo(f"Exporter configuration successfully saved to {exporter_config_path}") @create.command("exporter") @@ -155,6 +155,7 @@ def print_created_exporter(exporter: V1Alpha1Exporter, output: OutputType, expor @opt_kubeconfig @opt_context @opt_oidc_username +@opt_nointeractive @opt_output_all async def create_exporter( name: Optional[str], @@ -165,6 +166,7 @@ async def create_exporter( save: bool, out: Optional[str], oidc_username: str | None, + nointeractive: bool, output: OutputType, ): """Create an exporter object in the Kubernetes cluster""" @@ -174,12 +176,14 @@ async def create_exporter( click.echo(f"Creating exporter '{name}' in namespace '{namespace}'") created_exporter = await api.create_exporter(name, dict(labels), oidc_username) # Save the client config - if save or out is not None or click.confirm("Save exporter configuration?"): + if save or out is not None or nointeractive is False and click.confirm("Save exporter configuration?"): if output is None: click.echo("Fetching exporter credentials from cluster") exporter_config = await api.get_exporter_config(name) ExporterConfigV1Alpha1.save(exporter_config, out) - print_created_exporter(created_exporter, output, exporter_config.path) + if output is None: + click.echo(f"Exporter configuration successfully saved to {exporter_config.path}") + print_created_exporter(created_exporter, output) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py index e1d3d7b0f..9b86d8f9f 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py @@ -9,6 +9,7 @@ opt_kubeconfig, opt_log_level, opt_namespace, + opt_nointeractive, opt_output_name_only, ) from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api @@ -45,6 +46,7 @@ def delete(log_level: Optional[str]): @opt_kubeconfig @opt_context @opt_output_name_only +@opt_nointeractive async def delete_client( name: Optional[str], kubeconfig: Optional[str], @@ -52,6 +54,7 @@ async def delete_client( namespace: str, delete: bool, output: NameOutputType, + nointeractive: bool, ): """Delete a client object in the Kubernetes cluster""" try: @@ -62,7 +65,9 @@ async def delete_client( else: click.echo(f"client.jumpstarter.dev/{name}") # Save the client config - if ClientConfigV1Alpha1.exists(name) and (delete or click.confirm("Delete client configuration?")): + if ClientConfigV1Alpha1.exists(name) and ( + delete or nointeractive is False and click.confirm("Delete client configuration?") + ): # If this is the default, clear default user_config = UserConfigV1Alpha1.load_or_create() if user_config.config.current_client is not None and user_config.config.current_client.alias == name: @@ -90,6 +95,8 @@ async def delete_client( @opt_namespace @opt_kubeconfig @opt_context +@opt_output_name_only +@opt_nointeractive async def delete_exporter( name: Optional[str], kubeconfig: Optional[str], @@ -97,6 +104,7 @@ async def delete_exporter( namespace: str, delete: bool, output: NameOutputType, + nointeractive: bool, ): """Delete an exporter object in the Kubernetes cluster""" try: @@ -107,7 +115,9 @@ async def delete_exporter( else: click.echo(f"exporter.jumpstarter.dev/{name}") # Save the exporter config - if ExporterConfigV1Alpha1.exists(name) and (delete or click.confirm("Delete exporter configuration?")): + if ExporterConfigV1Alpha1.exists(name) and ( + delete or nointeractive is False and click.confirm("Delete exporter configuration?") + ): # Delete the exporter config ExporterConfigV1Alpha1.delete(name) if output is None: diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py index 387c2092c..5a48d0698 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py @@ -2,9 +2,12 @@ import asyncclick as click from jumpstarter_cli_common import ( + PathOutputType, opt_context, opt_kubeconfig, opt_namespace, + opt_nointeractive, + opt_output_path_only, ) from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api from kubernetes_asyncio.client.exceptions import ApiException @@ -28,11 +31,7 @@ def import_res(): @import_res.command("client") @click.argument("name", type=str) -@opt_namespace -@opt_kubeconfig -@opt_context @click.option( - "-o", "--out", type=click.Path(dir_okay=False, resolve_path=True, writable=True), help="Specify an output file for the client config.", @@ -45,6 +44,11 @@ def import_res(): default=None, ) @click.option("--unsafe", is_flag=True, help="Should all driver client packages be allowed to load (UNSAFE!).") +@opt_namespace +@opt_kubeconfig +@opt_context +@opt_output_path_only +@opt_nointeractive async def import_client( name: str, namespace: str, @@ -53,6 +57,8 @@ async def import_client( allow: Optional[str], unsafe: bool, out: Optional[str], + output: PathOutputType, + nointeractive: bool, ): """Import a client config from a Kubernetes cluster""" # Check that a client config with the same name does not exist @@ -60,13 +66,14 @@ async def import_client( raise click.ClickException(f"A client with the name '{name}' already exists") try: async with ClientsV1Alpha1Api(namespace, kubeconfig, context) as api: - if unsafe is False and allow is None: + if unsafe is False and allow is None and nointeractive is False: unsafe = click.confirm("Allow unsafe driver client imports?") if unsafe is False: allow = click.prompt( "Enter a comma-separated list of allowed driver packages (optional)", default="", type=str ) - click.echo("Fetching client credentials from cluster") + if output is None: + click.echo("Fetching client credentials from cluster") allow_drivers = allow.split(",") if allow is not None and len(allow) > 0 else [] client_config = await api.get_client_config(name, allow=allow_drivers, unsafe=unsafe) ClientConfigV1Alpha1.save(client_config, out) @@ -75,7 +82,10 @@ async def import_client( user_config = UserConfigV1Alpha1.load_or_create() user_config.config.current_client = client_config UserConfigV1Alpha1.save(user_config) - click.echo(f"Client configuration successfully saved to {client_config.path}") + if output is None: + click.echo(f"Client configuration successfully saved to {client_config.path}") + else: + click.echo(client_config.path) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: @@ -85,7 +95,6 @@ async def import_client( @import_res.command("exporter") @click.argument("name", default="default") @click.option( - "-o", "--out", type=click.Path(dir_okay=False, resolve_path=True, writable=True), help="Specify an output file for the exporter config.", @@ -93,8 +102,16 @@ async def import_client( @opt_namespace @opt_kubeconfig @opt_context +@opt_output_path_only +@opt_nointeractive async def import_exporter( - name: str, namespace: str, out: Optional[str], kubeconfig: Optional[str], context: Optional[str] + name: str, + namespace: str, + out: Optional[str], + kubeconfig: Optional[str], + context: Optional[str], + output: PathOutputType, + nointeractive: bool, ): """Import an exporter config from a Kubernetes cluster""" try: @@ -105,10 +122,14 @@ async def import_exporter( raise click.ClickException(f'An exporter with the name "{name}" already exists') try: async with ExportersV1Alpha1Api(namespace, kubeconfig, context) as api: - click.echo("Fetching exporter credentials from cluster") + if output is None: + click.echo("Fetching exporter credentials from cluster") exporter_config = await api.get_exporter_config(name) ExporterConfigV1Alpha1.save(exporter_config, out) - click.echo(f"Exporter configuration successfully saved to {exporter_config.path}") + if output is None: + click.echo(f"Exporter configuration successfully saved to {exporter_config.path}") + else: + click.echo(exporter_config.path) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py index 4be514015..2b16c92c2 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py @@ -3,13 +3,16 @@ NameOutputType, OutputMode, OutputType, + PathOutputType, opt_context, opt_kubeconfig, opt_labels, opt_log_level, opt_namespace, + opt_nointeractive, opt_output_all, opt_output_name_only, + opt_output_path_only, ) from .table import make_table from .time import time_since @@ -22,12 +25,15 @@ "opt_log_level", "opt_kubeconfig", "opt_namespace", + "opt_nointeractive", "opt_labels", "opt_output_all", "opt_output_name_only", + "opt_output_path_only", "OutputMode", "OutputType", "NameOutputType", + "PathOutputType", "time_since", "version", "get_client_version", diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py index 86b6ad4af..815b6c318 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py @@ -25,6 +25,7 @@ class OutputMode(str): JSON = "json" YAML = "yaml" NAME = "name" + PATH = "path" OutputType = Optional[OutputMode] @@ -46,3 +47,17 @@ class OutputMode(str): default=None, help='Output mode. Use "-o name" for shorter output (resource/name).', ) + +PathOutputType = Optional[Literal["path"]] + +opt_output_path_only = click.option( + "-o", + "--output", + type=click.Choice([OutputMode.PATH]), + default=None, + help='Output mode. Use "-o path" for shorter output (file/path).', +) + +opt_nointeractive = click.option( + "--nointeractive", is_flag=True, default=False, help="Disable interactive prompts (for use in scripts)." +) From 9c6d95ade0812e3e04b5a7c99d7a36287ecc2c45 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 09:35:04 -0400 Subject: [PATCH 10/23] Fixed all admin CLI tests --- .../jumpstarter_cli_admin/create_test.py | 6 +- .../jumpstarter_cli_admin/delete_test.py | 6 +- .../jumpstarter_cli_admin/get_test.py | 145 ++++++++++-------- .../jumpstarter_cli_admin/print.py | 4 +- .../jumpstarter_kubernetes/__init__.py | 15 +- 5 files changed, 102 insertions(+), 74 deletions(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py index bed0d9e70..5590a29d1 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py @@ -11,7 +11,7 @@ V1Alpha1Exporter, V1Alpha1ExporterStatus, ) -from kubernetes_asyncio.client.models import V1ObjectMeta +from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference from .create import create from jumpstarter.config import ( @@ -120,7 +120,9 @@ async def test_create_client( api_version="jumpstarter.dev/v1alpha1", kind="Exporter", metadata=V1ObjectMeta(namespace="default", name=EXPORTER_NAME, creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ExporterStatus(endpoint=EXPORTER_ENDPOINT, credential=None, devices=[]), + status=V1Alpha1ExporterStatus( + endpoint=EXPORTER_ENDPOINT, credential=V1ObjectReference(name=f"{EXPORTER_NAME}-credential"), devices=[] + ), ) EXPORTER_CONFIG = ExporterConfigV1Alpha1( alias=EXPORTER_NAME, diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py index 52cd506f8..32b3cf74b 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py @@ -8,7 +8,7 @@ V1Alpha1Exporter, V1Alpha1ExporterStatus, ) -from kubernetes_asyncio.client.models import V1ObjectMeta +from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference from .delete import delete from jumpstarter.config import ( @@ -136,7 +136,9 @@ async def test_delete_client( api_version="jumpstarter.dev/v1alpha1", kind="Exporter", metadata=V1ObjectMeta(namespace="default", name=EXPORTER_NAME, creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ExporterStatus(endpoint=EXPORTER_ENDPOINT, credential=None, devices=[]), + status=V1Alpha1ExporterStatus( + endpoint=EXPORTER_ENDPOINT, credential=V1ObjectReference(name=f"{EXPORTER_NAME}-credential"), devices=[] + ), ) EXPORTER_CONFIG = ExporterConfigV1Alpha1( alias=EXPORTER_NAME, diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py index f4ce22bdb..bb5714740 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py @@ -7,11 +7,14 @@ ExportersV1Alpha1Api, LeasesV1Alpha1Api, V1Alpha1Client, + V1Alpha1ClientList, V1Alpha1ClientStatus, V1Alpha1Exporter, V1Alpha1ExporterDevice, + V1Alpha1ExporterList, V1Alpha1ExporterStatus, V1Alpha1Lease, + V1Alpha1LeaseList, V1Alpha1LeaseSpec, V1Alpha1LeaseStatus, ) @@ -46,7 +49,9 @@ async def test_get_client(_load_kube_config_mock, get_client_mock: AsyncMock): api_version="jumpstarter.dev/v1alpha1", kind="Client", metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ClientStatus(endpoint="grpc://example.com:443", credential="asdfb123423"), + status=V1Alpha1ClientStatus( + endpoint="grpc://example.com:443", credential=V1ObjectReference(name="test-credential") + ), ) result = await runner.invoke(get, ["client", "test"]) assert result.exit_code == 0 @@ -74,20 +79,26 @@ async def test_get_clients(_load_kube_config_mock, list_clients_mock: AsyncMock) runner = CliRunner() # List clients - list_clients_mock.return_value = [ - V1Alpha1Client( - api_version="jumpstarter.dev/v1alpha1", - kind="Client", - metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ClientStatus(endpoint="grpc://example.com:443", credential="asdfb123423"), - ), - V1Alpha1Client( - api_version="jumpstarter.dev/v1alpha1", - kind="Client", - metadata=V1ObjectMeta(name="another", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ClientStatus(endpoint="grpc://example.com:443", credential="asdfb123423"), - ), - ] + list_clients_mock.return_value = V1Alpha1ClientList( + items=[ + V1Alpha1Client( + api_version="jumpstarter.dev/v1alpha1", + kind="Client", + metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ClientStatus( + endpoint="grpc://example.com:443", credential=V1ObjectReference(name="test-credential") + ), + ), + V1Alpha1Client( + api_version="jumpstarter.dev/v1alpha1", + kind="Client", + metadata=V1ObjectMeta(name="another", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ClientStatus( + endpoint="grpc://example.com:443", credential=V1ObjectReference(name="another-credential") + ), + ), + ] + ) result = await runner.invoke(get, ["clients"]) assert result.exit_code == 0 assert "test" in result.output @@ -96,7 +107,7 @@ async def test_get_clients(_load_kube_config_mock, list_clients_mock: AsyncMock) list_clients_mock.reset_mock() # No clients found - list_clients_mock.return_value = [] + list_clients_mock.return_value = V1Alpha1ClientList(items=[]) result = await runner.invoke(get, ["clients"]) assert result.exit_code == 1 assert "No resources found" in result.output @@ -184,28 +195,30 @@ async def test_get_exporters(_load_kube_config_mock, list_exporters_mock: AsyncM runner = CliRunner() # List exporters - list_exporters_mock.return_value = [ - V1Alpha1Exporter( - api_version="jumpstarter.dev/v1alpha1", - kind="Exporter", - metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ExporterStatus( - endpoint="grpc://example.com:443", - credential=V1ObjectReference(name="test-credential"), - devices=[], + list_exporters_mock.return_value = V1Alpha1ExporterList( + items=[ + V1Alpha1Exporter( + api_version="jumpstarter.dev/v1alpha1", + kind="Exporter", + metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ExporterStatus( + endpoint="grpc://example.com:443", + credential=V1ObjectReference(name="test-credential"), + devices=[], + ), ), - ), - V1Alpha1Exporter( - api_version="jumpstarter.dev/v1alpha1", - kind="Exporter", - metadata=V1ObjectMeta(name="another", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ExporterStatus( - endpoint="grpc://example.com:443", - credential=V1ObjectReference(name="another-credential"), - devices=[], + V1Alpha1Exporter( + api_version="jumpstarter.dev/v1alpha1", + kind="Exporter", + metadata=V1ObjectMeta(name="another", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ExporterStatus( + endpoint="grpc://example.com:443", + credential=V1ObjectReference(name="another-credential"), + devices=[], + ), ), - ), - ] + ] + ) result = await runner.invoke(get, ["exporters"]) assert result.exit_code == 0 assert "test" in result.output @@ -216,7 +229,7 @@ async def test_get_exporters(_load_kube_config_mock, list_exporters_mock: AsyncM with patch.object( ExportersV1Alpha1Api, "list_exporters", - return_value=[], + return_value=V1Alpha1ExporterList(items=[]), ): result = await runner.invoke(get, ["exporters"]) assert result.exit_code == 1 @@ -230,32 +243,36 @@ async def test_get_exporters_devices(_load_kube_config_mock, list_exporters_mock runner = CliRunner() # List exporters - list_exporters_mock.return_value = [ - V1Alpha1Exporter( - api_version="jumpstarter.dev/v1alpha1", - kind="Exporter", - metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ExporterStatus( - endpoint="grpc://example.com:443", - credential=V1ObjectReference(name="test-credential"), - devices=[ - V1Alpha1ExporterDevice(labels={"hardware": "rpi4"}, uuid="82a8ac0d-d7ff-4009-8948-18a3c5c607b1") - ], + list_exporters_mock.return_value = V1Alpha1ExporterList( + items=[ + V1Alpha1Exporter( + api_version="jumpstarter.dev/v1alpha1", + kind="Exporter", + metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ExporterStatus( + endpoint="grpc://example.com:443", + credential=V1ObjectReference(name="test-credential"), + devices=[ + V1Alpha1ExporterDevice(labels={"hardware": "rpi4"}, uuid="82a8ac0d-d7ff-4009-8948-18a3c5c607b1") + ], + ), ), - ), - V1Alpha1Exporter( - api_version="jumpstarter.dev/v1alpha1", - kind="Exporter", - metadata=V1ObjectMeta(name="another", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ExporterStatus( - endpoint="grpc://example.com:443", - credential=V1ObjectReference(name="another-credential"), - devices=[ - V1Alpha1ExporterDevice(labels={"hardware": "rpi4"}, uuid="f7cd30ac-64a3-42c6-ba31-b25f033b97c1"), - ], + V1Alpha1Exporter( + api_version="jumpstarter.dev/v1alpha1", + kind="Exporter", + metadata=V1ObjectMeta(name="another", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ExporterStatus( + endpoint="grpc://example.com:443", + credential=V1ObjectReference(name="another-credential"), + devices=[ + V1Alpha1ExporterDevice( + labels={"hardware": "rpi4"}, uuid="f7cd30ac-64a3-42c6-ba31-b25f033b97c1" + ), + ], + ), ), - ), - ] + ] + ) result = await runner.invoke(get, ["exporters", "--devices"]) assert result.exit_code == 0 assert "test" in result.output @@ -266,7 +283,7 @@ async def test_get_exporters_devices(_load_kube_config_mock, list_exporters_mock list_exporters_mock.reset_mock() # No exporters found - list_exporters_mock.return_value = [] + list_exporters_mock.return_value = V1Alpha1ExporterList(items=[]) result = await runner.invoke(get, ["exporters", "--devices"]) assert result.exit_code == 1 assert "No resources found" in result.output @@ -390,7 +407,7 @@ async def test_get_leases(_load_kube_config_mock, list_leases_mock: AsyncMock): runner = CliRunner() # Found leases - list_leases_mock.return_value = [IN_PROGRESS_LEASE, FINISHED_LEASE] + list_leases_mock.return_value = V1Alpha1LeaseList(items=[IN_PROGRESS_LEASE, FINISHED_LEASE]) result = await runner.invoke(get, ["leases"]) assert result.exit_code == 0 assert "82a8ac0d-d7ff-4009-8948-18a3c5c607b1" in result.output @@ -409,7 +426,7 @@ async def test_get_leases(_load_kube_config_mock, list_leases_mock: AsyncMock): list_leases_mock.reset_mock() # No leases found - list_leases_mock.return_value = [] + list_leases_mock.return_value = V1Alpha1LeaseList(items=[]) result = await runner.invoke(get, ["leases"]) assert result.exit_code == 1 assert "No resources found" in result.output diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py index aae8b9a53..6f1de61a6 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py @@ -107,9 +107,7 @@ def print_exporters(exporters: V1Alpha1List[V1Alpha1Exporter], namespace: str, d elif len(exporters.items) == 0: raise click.ClickException(f'No resources found in "{namespace}" namespace') elif devices: - # Print the devices for each exporter - rows = get_device_rows(exporters) - click.echo(make_table(DEVICE_COLUMNS, rows)) + click.echo(make_table(DEVICE_COLUMNS, get_device_rows(exporters.items))) else: click.echo(make_table(EXPORTER_COLUMNS, list(map(make_exporter_row, exporters.items)))) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py index 13c610042..2febbd246 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py @@ -1,20 +1,29 @@ -from .clients import ClientsV1Alpha1Api, V1Alpha1Client, V1Alpha1ClientStatus -from .exporters import ExportersV1Alpha1Api, V1Alpha1Exporter, V1Alpha1ExporterDevice, V1Alpha1ExporterStatus +from .clients import ClientsV1Alpha1Api, V1Alpha1Client, V1Alpha1ClientList, V1Alpha1ClientStatus +from .exporters import ( + ExportersV1Alpha1Api, + V1Alpha1Exporter, + V1Alpha1ExporterDevice, + V1Alpha1ExporterList, + V1Alpha1ExporterStatus, +) from .install import get_ip_address, helm_installed, install_helm_chart -from .leases import LeasesV1Alpha1Api, V1Alpha1Lease, V1Alpha1LeaseSpec, V1Alpha1LeaseStatus +from .leases import LeasesV1Alpha1Api, V1Alpha1Lease, V1Alpha1LeaseList, V1Alpha1LeaseSpec, V1Alpha1LeaseStatus from .list import V1Alpha1List __all__ = [ "ClientsV1Alpha1Api", "V1Alpha1Client", + "V1Alpha1ClientList", "V1Alpha1ClientStatus", "ExportersV1Alpha1Api", "V1Alpha1Exporter", + "V1Alpha1ExporterList", "V1Alpha1ExporterStatus", "V1Alpha1ExporterDevice", "LeasesV1Alpha1Api", "V1Alpha1Lease", "V1Alpha1LeaseStatus", + "V1Alpha1LeaseList", "V1Alpha1LeaseSpec", "V1Alpha1List", "get_ip_address", From b0e261448e89ba5603934b25bd88b892f4520a5e Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 09:58:56 -0400 Subject: [PATCH 11/23] Add admin create tests for JSON/YAML/name output --- .../jumpstarter_cli_admin/create_test.py | 123 +++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py index 5590a29d1..5781321d2 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py @@ -34,9 +34,41 @@ api_version="jumpstarter.dev/v1alpha1", kind="Client", metadata=V1ObjectMeta(namespace="default", name=CLIENT_NAME, creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ClientStatus(endpoint=CLIENT_ENDPOINT, credential=None), + status=V1Alpha1ClientStatus( + endpoint=CLIENT_ENDPOINT, credential=V1ObjectReference(name=f"{CLIENT_NAME}-credential") + ), ) +CLIENT_JSON = """{{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Client", + "metadata": {{ + "creationTimestamp": "2024-01-01T21:00:00Z", + "name": "{name}", + "namespace": "default" + }}, + "status": {{ + "credential": {{ + "name": "{name}-credential" + }}, + "endpoint": "{endpoint}" + }} +}} +""".format(name=CLIENT_NAME, endpoint=CLIENT_ENDPOINT) + +CLIENT_YAML = """apiVersion: jumpstarter.dev/v1alpha1 +kind: Client +metadata: + creationTimestamp: '2024-01-01T21:00:00Z' + name: {name} + namespace: default +status: + credential: + name: {name}-credential + endpoint: {endpoint} + +""".format(name=CLIENT_NAME, endpoint=CLIENT_ENDPOINT) + UNSAFE_CLIENT_CONFIG = ClientConfigV1Alpha1( alias=CLIENT_NAME, metadata=ObjectMeta(namespace="default", name=CLIENT_NAME), @@ -108,6 +140,34 @@ async def test_create_client( mock_save_client.assert_called_once_with(CLIENT_CONFIG, None) mock_save_client.reset_mock() + # Save with nointeractive + result = await runner.invoke(create, ["client", CLIENT_NAME, "--nointeractive"]) + assert result.exit_code == 0 + assert "Creating client" in result.output + mock_save_client.assert_not_called() + mock_save_client.reset_mock() + + # With JSON output + result = await runner.invoke(create, ["client", CLIENT_NAME, "--nointeractive", "--output", "json"]) + assert result.exit_code == 0 + assert result.output == CLIENT_JSON + mock_save_client.assert_not_called() + mock_save_client.reset_mock() + + # With YAML output + result = await runner.invoke(create, ["client", CLIENT_NAME, "--nointeractive", "--output", "yaml"]) + assert result.exit_code == 0 + assert result.output == CLIENT_YAML + mock_save_client.assert_not_called() + mock_save_client.reset_mock() + + # With name output + result = await runner.invoke(create, ["client", CLIENT_NAME, "--nointeractive", "--output", "name"]) + assert result.exit_code == 0 + assert result.output == f"client.jumpstarter.dev/{CLIENT_NAME}\n" + mock_save_client.assert_not_called() + mock_save_client.reset_mock() + # Generate a random exporter name EXPORTER_NAME = uuid.uuid4().hex @@ -124,6 +184,39 @@ async def test_create_client( endpoint=EXPORTER_ENDPOINT, credential=V1ObjectReference(name=f"{EXPORTER_NAME}-credential"), devices=[] ), ) + +EXPORTER_JSON = """{{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Exporter", + "metadata": {{ + "creationTimestamp": "2024-01-01T21:00:00Z", + "name": "{name}", + "namespace": "default" + }}, + "status": {{ + "credential": {{ + "name": "{name}-credential" + }}, + "devices": [], + "endpoint": "{endpoint}" + }} +}} +""".format(name=EXPORTER_NAME, endpoint=EXPORTER_ENDPOINT) + +EXPORTER_YAML = """apiVersion: jumpstarter.dev/v1alpha1 +kind: Exporter +metadata: + creationTimestamp: '2024-01-01T21:00:00Z' + name: {name} + namespace: default +status: + credential: + name: {name}-credential + devices: [] + endpoint: {endpoint} + +""".format(name=EXPORTER_NAME, endpoint=EXPORTER_ENDPOINT) + EXPORTER_CONFIG = ExporterConfigV1Alpha1( alias=EXPORTER_NAME, metadata=ObjectMeta(namespace="default", name=EXPORTER_NAME), @@ -173,6 +266,34 @@ async def test_create_exporter( save_exporter_mock.assert_called_once_with(EXPORTER_CONFIG, out) save_exporter_mock.reset_mock() + # Save with nointeractive + result = await runner.invoke(create, ["exporter", EXPORTER_NAME, "--nointeractive"]) + assert result.exit_code == 0 + assert "Creating exporter" in result.output + save_exporter_mock.assert_not_called() + save_exporter_mock.reset_mock() + + # Save with JSON output + result = await runner.invoke(create, ["exporter", EXPORTER_NAME, "--nointeractive", "--output", "json"]) + assert result.exit_code == 0 + assert result.output == EXPORTER_JSON + save_exporter_mock.assert_not_called() + save_exporter_mock.reset_mock() + + # Save with YAML output + result = await runner.invoke(create, ["exporter", EXPORTER_NAME, "--nointeractive", "--output", "yaml"]) + assert result.exit_code == 0 + assert result.output == EXPORTER_YAML + save_exporter_mock.assert_not_called() + save_exporter_mock.reset_mock() + + # Save with name output + result = await runner.invoke(create, ["exporter", EXPORTER_NAME, "--nointeractive", "--output", "name"]) + assert result.exit_code == 0 + assert result.output == f"exporter.jumpstarter.dev/{EXPORTER_NAME}\n" + save_exporter_mock.assert_not_called() + save_exporter_mock.reset_mock() + @pytest.fixture def anyio_backend(): From 0a336cd1dae17a0c933b39c352fa0790537a1829 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 10:05:46 -0400 Subject: [PATCH 12/23] Add nointeractive and name output tests for admin delete --- .../jumpstarter_cli_admin/delete_test.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py index 32b3cf74b..daf4a4820 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py @@ -125,6 +125,37 @@ async def test_delete_client( mock_config_delete.reset_mock() mock_save_user_config.reset_mock() + # Delete client object nointeractive + mock_config_exists.return_value = True + result = await runner.invoke(delete, ["client", CLIENT_NAME, "--nointeractive"]) + assert result.exit_code == 0 + assert f"Deleted client '{CLIENT_NAME}' in namespace 'default'" in result.output + assert "Client configuration successfully deleted" not in result.output + mock_delete_client.assert_called_once_with(CLIENT_NAME) + mock_load_or_create_user_config.assert_not_called() + mock_config_delete.assert_not_called() + + mock_load_or_create_user_config.reset_mock() + mock_config_exists.reset_mock() + mock_delete_client.reset_mock() + mock_config_delete.reset_mock() + mock_save_user_config.reset_mock() + + # Delete client object output name + mock_config_exists.return_value = True + result = await runner.invoke(delete, ["client", CLIENT_NAME, "--nointeractive", "--output", "name"]) + assert result.exit_code == 0 + assert result.output == f"client.jumpstarter.dev/{CLIENT_NAME}\n" + mock_delete_client.assert_called_once_with(CLIENT_NAME) + mock_load_or_create_user_config.assert_not_called() + mock_config_delete.assert_not_called() + + mock_load_or_create_user_config.reset_mock() + mock_config_exists.reset_mock() + mock_delete_client.reset_mock() + mock_config_delete.reset_mock() + mock_save_user_config.reset_mock() + EXPORTER_NAME = "test" EXPORTER_ENDPOINT = "grpc://example.com:443" @@ -200,6 +231,31 @@ async def test_delete_exporter( mock_delete_exporter.reset_mock() mock_config_delete.reset_mock() + # Delete exporter object nointeractive + mock_config_exists.return_value = True + result = await runner.invoke(delete, ["exporter", EXPORTER_NAME, "--nointeractive"]) + assert result.exit_code == 0 + assert "Deleted exporter 'test' in namespace 'default'" in result.output + assert "Exporter configuration successfully deleted" not in result.output + mock_delete_exporter.assert_called_once_with(EXPORTER_NAME) + mock_config_delete.assert_not_called() + + mock_config_exists.reset_mock() + mock_delete_exporter.reset_mock() + mock_config_delete.reset_mock() + + # Delete exporter object output name + mock_config_exists.return_value = True + result = await runner.invoke(delete, ["exporter", EXPORTER_NAME, "--nointeractive", "--output", "name"]) + assert result.exit_code == 0 + assert result.output == f"exporter.jumpstarter.dev/{EXPORTER_NAME}\n" + mock_delete_exporter.assert_called_once_with(EXPORTER_NAME) + mock_config_delete.assert_not_called() + + mock_config_exists.reset_mock() + mock_delete_exporter.reset_mock() + mock_config_delete.reset_mock() + @pytest.fixture def anyio_backend(): From 17b63e709b8f74424a99f439dc13763d97f3ffe9 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 11:05:50 -0400 Subject: [PATCH 13/23] Add JSON, YAML, and name output tests for admin get --- .../jumpstarter_cli_admin/get_test.py | 956 ++++++++++++++++-- .../jumpstarter_cli_admin/print.py | 2 +- 2 files changed, 854 insertions(+), 104 deletions(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py index bb5714740..65df1051c 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py @@ -38,6 +38,46 @@ def getheaders(self): return {} +TEST_CLIENT = V1Alpha1Client( + api_version="jumpstarter.dev/v1alpha1", + kind="Client", + metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ClientStatus( + endpoint="grpc://example.com:443", credential=V1ObjectReference(name="test-credential") + ), +) + +TEST_CLIENT_JSON = """{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Client", + "metadata": { + "creationTimestamp": "2024-01-01T21:00:00Z", + "name": "test", + "namespace": "testing" + }, + "status": { + "credential": { + "name": "test-credential" + }, + "endpoint": "grpc://example.com:443" + } +} +""" + +TEST_CLIENT_YAML = """apiVersion: jumpstarter.dev/v1alpha1 +kind: Client +metadata: + creationTimestamp: '2024-01-01T21:00:00Z' + name: test + namespace: testing +status: + credential: + name: test-credential + endpoint: grpc://example.com:443 + +""" + + @pytest.mark.anyio @patch.object(ClientsV1Alpha1Api, "get_client") @patch.object(ClientsV1Alpha1Api, "_load_kube_config") @@ -45,20 +85,34 @@ async def test_get_client(_load_kube_config_mock, get_client_mock: AsyncMock): runner = CliRunner() # Get a single client - get_client_mock.return_value = V1Alpha1Client( - api_version="jumpstarter.dev/v1alpha1", - kind="Client", - metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ClientStatus( - endpoint="grpc://example.com:443", credential=V1ObjectReference(name="test-credential") - ), - ) + get_client_mock.return_value = TEST_CLIENT result = await runner.invoke(get, ["client", "test"]) assert result.exit_code == 0 assert "test" in result.output assert "grpc://example.com:443" in result.output get_client_mock.reset_mock() + # Get a single client JSON output + get_client_mock.return_value = TEST_CLIENT + result = await runner.invoke(get, ["client", "test", "--output", "json"]) + assert result.exit_code == 0 + assert result.output == TEST_CLIENT_JSON + get_client_mock.reset_mock() + + # Get a single client YAML output + get_client_mock.return_value = TEST_CLIENT + result = await runner.invoke(get, ["client", "test", "--output", "yaml"]) + assert result.exit_code == 0 + assert result.output == TEST_CLIENT_YAML + get_client_mock.reset_mock() + + # Get a single client name output + get_client_mock.return_value = TEST_CLIENT + result = await runner.invoke(get, ["client", "test", "--output", "name"]) + assert result.exit_code == 0 + assert result.output == "client.jumpstarter.dev/test\n" + get_client_mock.reset_mock() + # No client found get_client_mock.side_effect = ApiException( http_resp=MockResponse( @@ -70,6 +124,106 @@ async def test_get_client(_load_kube_config_mock, get_client_mock: AsyncMock): result = await runner.invoke(get, ["client", "hello"]) assert result.exit_code == 1 assert "NotFound" in result.output + get_client_mock.reset_mock() + + +CLIENTS_LIST = V1Alpha1ClientList( + items=[ + V1Alpha1Client( + api_version="jumpstarter.dev/v1alpha1", + kind="Client", + metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ClientStatus( + endpoint="grpc://example.com:443", credential=V1ObjectReference(name="test-credential") + ), + ), + V1Alpha1Client( + api_version="jumpstarter.dev/v1alpha1", + kind="Client", + metadata=V1ObjectMeta(name="another", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ClientStatus( + endpoint="grpc://example.com:443", credential=V1ObjectReference(name="another-credential") + ), + ), + ] +) + +CLIENTS_LIST_JSON = """{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "items": [ + { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Client", + "metadata": { + "creationTimestamp": "2024-01-01T21:00:00Z", + "name": "test", + "namespace": "testing" + }, + "status": { + "credential": { + "name": "test-credential" + }, + "endpoint": "grpc://example.com:443" + } + }, + { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Client", + "metadata": { + "creationTimestamp": "2024-01-01T21:00:00Z", + "name": "another", + "namespace": "testing" + }, + "status": { + "credential": { + "name": "another-credential" + }, + "endpoint": "grpc://example.com:443" + } + } + ], + "kind": "ClientList" +} +""" + +CLIENTS_LIST_EMPTY_JSON = """{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "items": [], + "kind": "ClientList" +} +""" + +CLIENTS_LIST_YAML = """apiVersion: jumpstarter.dev/v1alpha1 +items: +- apiVersion: jumpstarter.dev/v1alpha1 + kind: Client + metadata: + creationTimestamp: '2024-01-01T21:00:00Z' + name: test + namespace: testing + status: + credential: + name: test-credential + endpoint: grpc://example.com:443 +- apiVersion: jumpstarter.dev/v1alpha1 + kind: Client + metadata: + creationTimestamp: '2024-01-01T21:00:00Z' + name: another + namespace: testing + status: + credential: + name: another-credential + endpoint: grpc://example.com:443 +kind: ClientList + +""" + +CLIENTS_LIST_EMPTY_YAML = """apiVersion: jumpstarter.dev/v1alpha1 +items: [] +kind: ClientList + +""" @pytest.mark.anyio @@ -79,26 +233,7 @@ async def test_get_clients(_load_kube_config_mock, list_clients_mock: AsyncMock) runner = CliRunner() # List clients - list_clients_mock.return_value = V1Alpha1ClientList( - items=[ - V1Alpha1Client( - api_version="jumpstarter.dev/v1alpha1", - kind="Client", - metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ClientStatus( - endpoint="grpc://example.com:443", credential=V1ObjectReference(name="test-credential") - ), - ), - V1Alpha1Client( - api_version="jumpstarter.dev/v1alpha1", - kind="Client", - metadata=V1ObjectMeta(name="another", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ClientStatus( - endpoint="grpc://example.com:443", credential=V1ObjectReference(name="another-credential") - ), - ), - ] - ) + list_clients_mock.return_value = CLIENTS_LIST result = await runner.invoke(get, ["clients"]) assert result.exit_code == 0 assert "test" in result.output @@ -106,11 +241,96 @@ async def test_get_clients(_load_kube_config_mock, list_clients_mock: AsyncMock) assert "grpc://example.com:443" in result.output list_clients_mock.reset_mock() + # List clients JSON output + list_clients_mock.return_value = CLIENTS_LIST + result = await runner.invoke(get, ["clients", "--output", "json"]) + assert result.exit_code == 0 + assert result.output == CLIENTS_LIST_JSON + list_clients_mock.reset_mock() + + # List clients YAML output + list_clients_mock.return_value = CLIENTS_LIST + result = await runner.invoke(get, ["clients", "--output", "yaml"]) + assert result.exit_code == 0 + assert result.output == CLIENTS_LIST_YAML + list_clients_mock.reset_mock() + + # List clients name output + list_clients_mock.return_value = CLIENTS_LIST + result = await runner.invoke(get, ["clients", "--output", "name"]) + assert result.exit_code == 0 + assert result.output == "client.jumpstarter.dev/test\n" + list_clients_mock.reset_mock() + # No clients found list_clients_mock.return_value = V1Alpha1ClientList(items=[]) result = await runner.invoke(get, ["clients"]) assert result.exit_code == 1 assert "No resources found" in result.output + list_clients_mock.reset_mock() + + # No clients found JSON output + list_clients_mock.return_value = V1Alpha1ClientList(items=[]) + result = await runner.invoke(get, ["clients", "--output", "json"]) + assert result.exit_code == 0 + assert result.output == CLIENTS_LIST_EMPTY_JSON + list_clients_mock.reset_mock() + + # No clients found YAML output + list_clients_mock.return_value = V1Alpha1ClientList(items=[]) + result = await runner.invoke(get, ["clients", "--output", "yaml"]) + assert result.exit_code == 0 + assert result.output == CLIENTS_LIST_EMPTY_YAML + list_clients_mock.reset_mock() + + # No clients found name output + list_clients_mock.return_value = V1Alpha1ClientList(items=[]) + result = await runner.invoke(get, ["clients", "--output", "name"]) + assert result.exit_code == 0 + assert result.output == "" + list_clients_mock.reset_mock() + + +TEST_EXPORTER = V1Alpha1Exporter( + api_version="jumpstarter.dev/v1alpha1", + kind="Exporter", + metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ExporterStatus( + endpoint="grpc://example.com:443", credential=V1ObjectReference(name="test-credential"), devices=[] + ), +) + +TEST_EXPORTER_JSON = """{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Exporter", + "metadata": { + "creationTimestamp": "2024-01-01T21:00:00Z", + "name": "test", + "namespace": "testing" + }, + "status": { + "credential": { + "name": "test-credential" + }, + "devices": [], + "endpoint": "grpc://example.com:443" + } +} +""" + +TEST_EXPORTER_YAML = """apiVersion: jumpstarter.dev/v1alpha1 +kind: Exporter +metadata: + creationTimestamp: '2024-01-01T21:00:00Z' + name: test + namespace: testing +status: + credential: + name: test-credential + devices: [] + endpoint: grpc://example.com:443 + +""" @pytest.mark.anyio @@ -120,20 +340,34 @@ async def test_get_exporter(_load_kube_config_mock, get_exporter_mock: AsyncMock runner = CliRunner() # Get a single exporter - get_exporter_mock.return_value = V1Alpha1Exporter( - api_version="jumpstarter.dev/v1alpha1", - kind="Exporter", - metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ExporterStatus( - endpoint="grpc://example.com:443", credential=V1ObjectReference(name="test-credential"), devices=[] - ), - ) + get_exporter_mock.return_value = TEST_EXPORTER result = await runner.invoke(get, ["exporter", "test"]) assert result.exit_code == 0 assert "test" in result.output assert "grpc://example.com:443" in result.output get_exporter_mock.reset_mock() + # Get a single exporter JSON output + get_exporter_mock.return_value = TEST_EXPORTER + result = await runner.invoke(get, ["exporter", "test", "--output", "json"]) + assert result.exit_code == 0 + assert result.output == TEST_EXPORTER_JSON + get_exporter_mock.reset_mock() + + # Get a single exporter YAML output + get_exporter_mock.return_value = TEST_EXPORTER + result = await runner.invoke(get, ["exporter", "test", "--output", "yaml"]) + assert result.exit_code == 0 + assert result.output == TEST_EXPORTER_YAML + get_exporter_mock.reset_mock() + + # Get a single exporter name output + get_exporter_mock.return_value = TEST_EXPORTER + result = await runner.invoke(get, ["exporter", "test", "--output", "name"]) + assert result.exit_code == 0 + assert result.output == "exporter.jumpstarter.dev/test\n" + get_exporter_mock.reset_mock() + # No exporter found get_exporter_mock.side_effect = ApiException( http_resp=MockResponse( @@ -147,25 +381,79 @@ async def test_get_exporter(_load_kube_config_mock, get_exporter_mock: AsyncMock assert "NotFound" in result.output +TEST_EXPORTER_DEVICES = V1Alpha1Exporter( + api_version="jumpstarter.dev/v1alpha1", + kind="Exporter", + metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ExporterStatus( + endpoint="grpc://example.com:443", + credential=V1ObjectReference(name="test-credential"), + devices=[ + V1Alpha1ExporterDevice(labels={"hardware": "rpi4"}, uuid="82a8ac0d-d7ff-4009-8948-18a3c5c607b1"), + V1Alpha1ExporterDevice(labels={"hardware": "rpi4"}, uuid="f7cd30ac-64a3-42c6-ba31-b25f033b97c1"), + ], + ), +) + +TEST_EXPORTER_DEVICES_JSON = """{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Exporter", + "metadata": { + "creationTimestamp": "2024-01-01T21:00:00Z", + "name": "test", + "namespace": "testing" + }, + "status": { + "credential": { + "name": "test-credential" + }, + "devices": [ + { + "labels": { + "hardware": "rpi4" + }, + "uuid": "82a8ac0d-d7ff-4009-8948-18a3c5c607b1" + }, + { + "labels": { + "hardware": "rpi4" + }, + "uuid": "f7cd30ac-64a3-42c6-ba31-b25f033b97c1" + } + ], + "endpoint": "grpc://example.com:443" + } +} +""" + +TEST_EXPORTER_DEVICES_YAML = """apiVersion: jumpstarter.dev/v1alpha1 +kind: Exporter +metadata: + creationTimestamp: '2024-01-01T21:00:00Z' + name: test + namespace: testing +status: + credential: + name: test-credential + devices: + - labels: + hardware: rpi4 + uuid: 82a8ac0d-d7ff-4009-8948-18a3c5c607b1 + - labels: + hardware: rpi4 + uuid: f7cd30ac-64a3-42c6-ba31-b25f033b97c1 + endpoint: grpc://example.com:443 + +""" + + @pytest.mark.anyio @patch.object(ExportersV1Alpha1Api, "get_exporter") @patch.object(ExportersV1Alpha1Api, "_load_kube_config") async def test_get_exporter_devices(_load_kube_config_mock, get_exporter_mock: AsyncMock): runner = CliRunner() # Returns exporter - get_exporter_mock.return_value = V1Alpha1Exporter( - api_version="jumpstarter.dev/v1alpha1", - kind="Exporter", - metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ExporterStatus( - endpoint="grpc://example.com:443", - credential=V1ObjectReference(name="test-credential"), - devices=[ - V1Alpha1ExporterDevice(labels={"hardware": "rpi4"}, uuid="82a8ac0d-d7ff-4009-8948-18a3c5c607b1"), - V1Alpha1ExporterDevice(labels={"hardware": "rpi4"}, uuid="f7cd30ac-64a3-42c6-ba31-b25f033b97c1"), - ], - ), - ) + get_exporter_mock.return_value = TEST_EXPORTER_DEVICES result = await runner.invoke(get, ["exporter", "test", "--devices"]) assert result.exit_code == 0 assert "test" in result.output @@ -175,6 +463,27 @@ async def test_get_exporter_devices(_load_kube_config_mock, get_exporter_mock: A assert "f7cd30ac-64a3-42c6-ba31-b25f033b97c1" in result.output get_exporter_mock.reset_mock() + # Returns exporter JSON output + get_exporter_mock.return_value = TEST_EXPORTER_DEVICES + result = await runner.invoke(get, ["exporter", "test", "--devices", "--output", "json"]) + assert result.exit_code == 0 + assert result.output == TEST_EXPORTER_DEVICES_JSON + get_exporter_mock.reset_mock() + + # Returns exporter YAML output + get_exporter_mock.return_value = TEST_EXPORTER_DEVICES + result = await runner.invoke(get, ["exporter", "test", "--devices", "--output", "yaml"]) + assert result.exit_code == 0 + assert result.output == TEST_EXPORTER_DEVICES_YAML + get_exporter_mock.reset_mock() + + # Returns exporter name output + get_exporter_mock.return_value = TEST_EXPORTER_DEVICES + result = await runner.invoke(get, ["exporter", "test", "--devices", "--output", "name"]) + assert result.exit_code == 0 + assert result.output == "exporter.jumpstarter.dev/test\n" + get_exporter_mock.reset_mock() + # No exporter found get_exporter_mock.side_effect = ApiException( http_resp=MockResponse( @@ -188,6 +497,100 @@ async def test_get_exporter_devices(_load_kube_config_mock, get_exporter_mock: A assert "NotFound" in result.output +EXPORTERS_LIST = V1Alpha1ExporterList( + items=[ + V1Alpha1Exporter( + api_version="jumpstarter.dev/v1alpha1", + kind="Exporter", + metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ExporterStatus( + endpoint="grpc://example.com:443", + credential=V1ObjectReference(name="test-credential"), + devices=[], + ), + ), + V1Alpha1Exporter( + api_version="jumpstarter.dev/v1alpha1", + kind="Exporter", + metadata=V1ObjectMeta(name="another", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ExporterStatus( + endpoint="grpc://example.com:443", + credential=V1ObjectReference(name="another-credential"), + devices=[], + ), + ), + ] +) + +EXPORTERS_LIST_JSON = """{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "items": [ + { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Exporter", + "metadata": { + "creationTimestamp": "2024-01-01T21:00:00Z", + "name": "test", + "namespace": "testing" + }, + "status": { + "credential": { + "name": "test-credential" + }, + "devices": [], + "endpoint": "grpc://example.com:443" + } + }, + { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Exporter", + "metadata": { + "creationTimestamp": "2024-01-01T21:00:00Z", + "name": "another", + "namespace": "testing" + }, + "status": { + "credential": { + "name": "another-credential" + }, + "devices": [], + "endpoint": "grpc://example.com:443" + } + } + ], + "kind": "ExporterList" +} +""" + +EXPORTERS_LIST_YAML = """apiVersion: jumpstarter.dev/v1alpha1 +items: +- apiVersion: jumpstarter.dev/v1alpha1 + kind: Exporter + metadata: + creationTimestamp: '2024-01-01T21:00:00Z' + name: test + namespace: testing + status: + credential: + name: test-credential + devices: [] + endpoint: grpc://example.com:443 +- apiVersion: jumpstarter.dev/v1alpha1 + kind: Exporter + metadata: + creationTimestamp: '2024-01-01T21:00:00Z' + name: another + namespace: testing + status: + credential: + name: another-credential + devices: [] + endpoint: grpc://example.com:443 +kind: ExporterList + +""" + + @pytest.mark.anyio @patch.object(ExportersV1Alpha1Api, "list_exporters") @patch.object(ExportersV1Alpha1Api, "_load_kube_config") @@ -195,36 +598,34 @@ async def test_get_exporters(_load_kube_config_mock, list_exporters_mock: AsyncM runner = CliRunner() # List exporters - list_exporters_mock.return_value = V1Alpha1ExporterList( - items=[ - V1Alpha1Exporter( - api_version="jumpstarter.dev/v1alpha1", - kind="Exporter", - metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ExporterStatus( - endpoint="grpc://example.com:443", - credential=V1ObjectReference(name="test-credential"), - devices=[], - ), - ), - V1Alpha1Exporter( - api_version="jumpstarter.dev/v1alpha1", - kind="Exporter", - metadata=V1ObjectMeta(name="another", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ExporterStatus( - endpoint="grpc://example.com:443", - credential=V1ObjectReference(name="another-credential"), - devices=[], - ), - ), - ] - ) + list_exporters_mock.return_value = EXPORTERS_LIST result = await runner.invoke(get, ["exporters"]) assert result.exit_code == 0 assert "test" in result.output assert "another" in result.output list_exporters_mock.reset_mock() + # List exporters JSON output + list_exporters_mock.return_value = EXPORTERS_LIST + result = await runner.invoke(get, ["exporters", "--output", "json"]) + assert result.exit_code == 0 + assert result.output == EXPORTERS_LIST_JSON + list_exporters_mock.reset_mock() + + # List exporters YAML output + list_exporters_mock.return_value = EXPORTERS_LIST + result = await runner.invoke(get, ["exporters", "--output", "yaml"]) + assert result.exit_code == 0 + assert result.output == EXPORTERS_LIST_YAML + list_exporters_mock.reset_mock() + + # List exporters name output + list_exporters_mock.return_value = EXPORTERS_LIST + result = await runner.invoke(get, ["exporters", "--output", "name"]) + assert result.exit_code == 0 + assert result.output == "exporter.jumpstarter.dev/test\n" + list_exporters_mock.reset_mock() + # No exporters found with patch.object( ExportersV1Alpha1Api, @@ -236,6 +637,124 @@ async def test_get_exporters(_load_kube_config_mock, list_exporters_mock: AsyncM assert "No resources found" in result.output +EXPORTER_DEVICES_LIST = V1Alpha1ExporterList( + items=[ + V1Alpha1Exporter( + api_version="jumpstarter.dev/v1alpha1", + kind="Exporter", + metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ExporterStatus( + endpoint="grpc://example.com:443", + credential=V1ObjectReference(name="test-credential"), + devices=[ + V1Alpha1ExporterDevice(labels={"hardware": "rpi4"}, uuid="82a8ac0d-d7ff-4009-8948-18a3c5c607b1") + ], + ), + ), + V1Alpha1Exporter( + api_version="jumpstarter.dev/v1alpha1", + kind="Exporter", + metadata=V1ObjectMeta(name="another", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), + status=V1Alpha1ExporterStatus( + endpoint="grpc://example.com:443", + credential=V1ObjectReference(name="another-credential"), + devices=[ + V1Alpha1ExporterDevice(labels={"hardware": "rpi4"}, uuid="f7cd30ac-64a3-42c6-ba31-b25f033b97c1"), + ], + ), + ), + ] +) + +EXPORTERS_DEVICES_LIST_JSON = """{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "items": [ + { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Exporter", + "metadata": { + "creationTimestamp": "2024-01-01T21:00:00Z", + "name": "test", + "namespace": "testing" + }, + "status": { + "credential": { + "name": "test-credential" + }, + "devices": [ + { + "labels": { + "hardware": "rpi4" + }, + "uuid": "82a8ac0d-d7ff-4009-8948-18a3c5c607b1" + } + ], + "endpoint": "grpc://example.com:443" + } + }, + { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Exporter", + "metadata": { + "creationTimestamp": "2024-01-01T21:00:00Z", + "name": "another", + "namespace": "testing" + }, + "status": { + "credential": { + "name": "another-credential" + }, + "devices": [ + { + "labels": { + "hardware": "rpi4" + }, + "uuid": "f7cd30ac-64a3-42c6-ba31-b25f033b97c1" + } + ], + "endpoint": "grpc://example.com:443" + } + } + ], + "kind": "ExporterList" +} +""" + +EXPORTERS_DEVICES_LIST_YAML = """apiVersion: jumpstarter.dev/v1alpha1 +items: +- apiVersion: jumpstarter.dev/v1alpha1 + kind: Exporter + metadata: + creationTimestamp: '2024-01-01T21:00:00Z' + name: test + namespace: testing + status: + credential: + name: test-credential + devices: + - labels: + hardware: rpi4 + uuid: 82a8ac0d-d7ff-4009-8948-18a3c5c607b1 + endpoint: grpc://example.com:443 +- apiVersion: jumpstarter.dev/v1alpha1 + kind: Exporter + metadata: + creationTimestamp: '2024-01-01T21:00:00Z' + name: another + namespace: testing + status: + credential: + name: another-credential + devices: + - labels: + hardware: rpi4 + uuid: f7cd30ac-64a3-42c6-ba31-b25f033b97c1 + endpoint: grpc://example.com:443 +kind: ExporterList + +""" + + @pytest.mark.anyio @patch.object(ExportersV1Alpha1Api, "list_exporters") @patch.object(ExportersV1Alpha1Api, "_load_kube_config") @@ -243,36 +762,7 @@ async def test_get_exporters_devices(_load_kube_config_mock, list_exporters_mock runner = CliRunner() # List exporters - list_exporters_mock.return_value = V1Alpha1ExporterList( - items=[ - V1Alpha1Exporter( - api_version="jumpstarter.dev/v1alpha1", - kind="Exporter", - metadata=V1ObjectMeta(name="test", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ExporterStatus( - endpoint="grpc://example.com:443", - credential=V1ObjectReference(name="test-credential"), - devices=[ - V1Alpha1ExporterDevice(labels={"hardware": "rpi4"}, uuid="82a8ac0d-d7ff-4009-8948-18a3c5c607b1") - ], - ), - ), - V1Alpha1Exporter( - api_version="jumpstarter.dev/v1alpha1", - kind="Exporter", - metadata=V1ObjectMeta(name="another", namespace="testing", creation_timestamp="2024-01-01T21:00:00Z"), - status=V1Alpha1ExporterStatus( - endpoint="grpc://example.com:443", - credential=V1ObjectReference(name="another-credential"), - devices=[ - V1Alpha1ExporterDevice( - labels={"hardware": "rpi4"}, uuid="f7cd30ac-64a3-42c6-ba31-b25f033b97c1" - ), - ], - ), - ), - ] - ) + list_exporters_mock.return_value = EXPORTER_DEVICES_LIST result = await runner.invoke(get, ["exporters", "--devices"]) assert result.exit_code == 0 assert "test" in result.output @@ -282,6 +772,27 @@ async def test_get_exporters_devices(_load_kube_config_mock, list_exporters_mock assert "f7cd30ac-64a3-42c6-ba31-b25f033b97c1" in result.output list_exporters_mock.reset_mock() + # List exporters JSON output + list_exporters_mock.return_value = EXPORTER_DEVICES_LIST + result = await runner.invoke(get, ["exporters", "--devices", "--output", "json"]) + assert result.exit_code == 0 + assert result.output == EXPORTERS_DEVICES_LIST_JSON + list_exporters_mock.reset_mock() + + # List exporters YAML output + list_exporters_mock.return_value = EXPORTER_DEVICES_LIST + result = await runner.invoke(get, ["exporters", "--devices", "--output", "yaml"]) + assert result.exit_code == 0 + assert result.output == EXPORTERS_DEVICES_LIST_YAML + list_exporters_mock.reset_mock() + + # List exporters name output + list_exporters_mock.return_value = EXPORTER_DEVICES_LIST + result = await runner.invoke(get, ["exporters", "--devices", "--output", "name"]) + assert result.exit_code == 0 + assert result.output == "exporter.jumpstarter.dev/test\n" + list_exporters_mock.reset_mock() + # No exporters found list_exporters_mock.return_value = V1Alpha1ExporterList(items=[]) result = await runner.invoke(get, ["exporters", "--devices"]) @@ -351,6 +862,69 @@ async def test_get_exporters_devices(_load_kube_config_mock, list_exporters_mock ), ) +FINISHED_LEASE_JSON = """{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Lease", + "metadata": { + "creationTimestamp": "2024-01-01T21:00:00Z", + "name": "82a8ac0d-d7ff-4009-8948-18a3c5c607b2", + "namespace": "testing" + }, + "spec": { + "client": { + "name": "test_client" + }, + "duration": "1h", + "selector": {} + }, + "status": { + "beginTime": "2024-01-01T21:00:00Z", + "conditions": [ + { + "lastTransitionTime": "2024-01-01T22:00:00Z", + "message": "", + "observedGeneration": 1, + "reason": "Expired", + "status": "False", + "type": "Ready" + } + ], + "endTime": "2024-01-01T22:00:00Z", + "ended": true, + "exporter": { + "name": "test_exporter" + } + } +} +""" + +FINISHED_LEASE_YAML = """apiVersion: jumpstarter.dev/v1alpha1 +kind: Lease +metadata: + creationTimestamp: '2024-01-01T21:00:00Z' + name: 82a8ac0d-d7ff-4009-8948-18a3c5c607b2 + namespace: testing +spec: + client: + name: test_client + duration: 1h + selector: {} +status: + beginTime: '2024-01-01T21:00:00Z' + conditions: + - lastTransitionTime: '2024-01-01T22:00:00Z' + message: '' + observedGeneration: 1 + reason: Expired + status: 'False' + type: Ready + endTime: '2024-01-01T22:00:00Z' + ended: true + exporter: + name: test_exporter + +""" + @pytest.mark.anyio @patch.object(LeasesV1Alpha1Api, "get_lease") @@ -387,6 +961,27 @@ async def test_get_lease(_load_kube_config_mock, get_lease_mock: AsyncMock): assert "1h" in result.output get_lease_mock.reset_mock() + # Get a finished lease JSON output + get_lease_mock.return_value = FINISHED_LEASE + result = await runner.invoke(get, ["lease", "82a8ac0d-d7ff-4009-8948-18a3c5c607b2", "--output", "json"]) + assert result.exit_code == 0 + assert result.output == FINISHED_LEASE_JSON + get_lease_mock.reset_mock() + + # Get a finished lease YAML output + get_lease_mock.return_value = FINISHED_LEASE + result = await runner.invoke(get, ["lease", "82a8ac0d-d7ff-4009-8948-18a3c5c607b2", "--output", "yaml"]) + assert result.exit_code == 0 + assert result.output == FINISHED_LEASE_YAML + get_lease_mock.reset_mock() + + # Get a finished lease name output + get_lease_mock.return_value = FINISHED_LEASE + result = await runner.invoke(get, ["lease", "82a8ac0d-d7ff-4009-8948-18a3c5c607b2", "--output", "name"]) + assert result.exit_code == 0 + assert result.output == "lease.jumpstarter.dev/82a8ac0d-d7ff-4009-8948-18a3c5c607b2\n" + get_lease_mock.reset_mock() + # No lease found get_lease_mock.side_effect = ApiException( http_resp=MockResponse( @@ -400,6 +995,140 @@ async def test_get_lease(_load_kube_config_mock, get_lease_mock: AsyncMock): assert "NotFound" in result.output +LEASES_LIST_JSON = """{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "items": [ + { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Lease", + "metadata": { + "creationTimestamp": "2024-01-01T21:00:00Z", + "name": "82a8ac0d-d7ff-4009-8948-18a3c5c607b1", + "namespace": "testing" + }, + "spec": { + "client": { + "name": "test_client" + }, + "duration": "5m", + "selector": { + "hardware": "rpi4" + } + }, + "status": { + "beginTime": "2024-01-01T21:00:00Z", + "conditions": [ + { + "lastTransitionTime": "2024-01-01T21:00:00Z", + "message": "", + "observedGeneration": 1, + "reason": "Ready", + "status": "True", + "type": "Ready" + } + ], + "endTime": null, + "ended": false, + "exporter": { + "name": "test_exporter" + } + } + }, + { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Lease", + "metadata": { + "creationTimestamp": "2024-01-01T21:00:00Z", + "name": "82a8ac0d-d7ff-4009-8948-18a3c5c607b2", + "namespace": "testing" + }, + "spec": { + "client": { + "name": "test_client" + }, + "duration": "1h", + "selector": {} + }, + "status": { + "beginTime": "2024-01-01T21:00:00Z", + "conditions": [ + { + "lastTransitionTime": "2024-01-01T22:00:00Z", + "message": "", + "observedGeneration": 1, + "reason": "Expired", + "status": "False", + "type": "Ready" + } + ], + "endTime": "2024-01-01T22:00:00Z", + "ended": true, + "exporter": { + "name": "test_exporter" + } + } + } + ], + "kind": "LeaseList" +} +""" + +LEASES_LIST_YAML = """apiVersion: jumpstarter.dev/v1alpha1 +items: +- apiVersion: jumpstarter.dev/v1alpha1 + kind: Lease + metadata: + creationTimestamp: '2024-01-01T21:00:00Z' + name: 82a8ac0d-d7ff-4009-8948-18a3c5c607b1 + namespace: testing + spec: + client: + name: test_client + duration: 5m + selector: + hardware: rpi4 + status: + beginTime: '2024-01-01T21:00:00Z' + conditions: + - lastTransitionTime: '2024-01-01T21:00:00Z' + message: '' + observedGeneration: 1 + reason: Ready + status: 'True' + type: Ready + endTime: null + ended: false + exporter: + name: test_exporter +- apiVersion: jumpstarter.dev/v1alpha1 + kind: Lease + metadata: + creationTimestamp: '2024-01-01T21:00:00Z' + name: 82a8ac0d-d7ff-4009-8948-18a3c5c607b2 + namespace: testing + spec: + client: + name: test_client + duration: 1h + selector: {} + status: + beginTime: '2024-01-01T21:00:00Z' + conditions: + - lastTransitionTime: '2024-01-01T22:00:00Z' + message: '' + observedGeneration: 1 + reason: Expired + status: 'False' + type: Ready + endTime: '2024-01-01T22:00:00Z' + ended: true + exporter: + name: test_exporter +kind: LeaseList + +""" + + @pytest.mark.anyio @patch.object(LeasesV1Alpha1Api, "list_leases") @patch.object(LeasesV1Alpha1Api, "_load_kube_config") @@ -425,6 +1154,27 @@ async def test_get_leases(_load_kube_config_mock, list_leases_mock: AsyncMock): assert "1h" in result.output list_leases_mock.reset_mock() + # Found leases JSON output + list_leases_mock.return_value = V1Alpha1LeaseList(items=[IN_PROGRESS_LEASE, FINISHED_LEASE]) + result = await runner.invoke(get, ["leases", "--output", "json"]) + assert result.exit_code == 0 + assert result.output == LEASES_LIST_JSON + list_leases_mock.reset_mock() + + # Found leases YAML output + list_leases_mock.return_value = V1Alpha1LeaseList(items=[IN_PROGRESS_LEASE, FINISHED_LEASE]) + result = await runner.invoke(get, ["leases", "--output", "yaml"]) + assert result.exit_code == 0 + assert result.output == LEASES_LIST_YAML + list_leases_mock.reset_mock() + + # Found leases name output + list_leases_mock.return_value = V1Alpha1LeaseList(items=[IN_PROGRESS_LEASE, FINISHED_LEASE]) + result = await runner.invoke(get, ["leases", "--output", "name"]) + assert result.exit_code == 0 + assert result.output == "lease.jumpstarter.dev/82a8ac0d-d7ff-4009-8948-18a3c5c607b1\n" + list_leases_mock.reset_mock() + # No leases found list_leases_mock.return_value = V1Alpha1LeaseList(items=[]) result = await runner.invoke(get, ["leases"]) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py index 6f1de61a6..a5abfad34 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py @@ -103,7 +103,7 @@ def print_exporters(exporters: V1Alpha1List[V1Alpha1Exporter], namespace: str, d click.echo(exporters.dump_yaml()) elif output == OutputMode.NAME: if len(exporters.items) > 0: - click.echo(f"exporters.jumpstarter.dev/{exporters.items[0].metadata.name}") + click.echo(f"exporter.jumpstarter.dev/{exporters.items[0].metadata.name}") elif len(exporters.items) == 0: raise click.ClickException(f'No resources found in "{namespace}" namespace') elif devices: From bb977c3465664a9acc5245d1c7b44958c21e5981 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 11:26:11 -0400 Subject: [PATCH 14/23] Add nointeractive test to import client --- .../jumpstarter_cli_admin/import_res_test.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res_test.py index 4adff16da..5707a967c 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res_test.py @@ -55,7 +55,14 @@ async def test_import_client(_load_kube_config_mock, get_client_config_mock: Asy save_client_config_mock.assert_called_once_with(UNSAFE_CLIENT_CONFIG, None) save_client_config_mock.reset_mock() - # Save with custom output + # Save with nointeractive + result = await runner.invoke(import_res, ["client", CLIENT_NAME, "--nointeractive"]) + assert result.exit_code == 0 + assert "Client configuration successfully saved" in result.output + save_client_config_mock.assert_called_once_with(UNSAFE_CLIENT_CONFIG, None) + save_client_config_mock.reset_mock() + + # Save with custom output file out = f"/tmp/{CLIENT_NAME}.yaml" result = await runner.invoke(import_res, ["client", CLIENT_NAME, "--unsafe", "--out", out]) assert result.exit_code == 0 From bef5b0633479d2e758215152fa4a90b392b1d74c Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 11:38:29 -0400 Subject: [PATCH 15/23] Add tests for import path output --- .../jumpstarter_cli_admin/import_res.py | 12 +++++----- .../jumpstarter_cli_admin/import_res_test.py | 24 +++++++++++++++++-- .../jumpstarter/jumpstarter/config/client.py | 3 ++- .../jumpstarter/config/exporter.py | 3 ++- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py index 5a48d0698..08f5bfb36 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py @@ -76,16 +76,16 @@ async def import_client( click.echo("Fetching client credentials from cluster") allow_drivers = allow.split(",") if allow is not None and len(allow) > 0 else [] client_config = await api.get_client_config(name, allow=allow_drivers, unsafe=unsafe) - ClientConfigV1Alpha1.save(client_config, out) + config_path = ClientConfigV1Alpha1.save(client_config, out) # If this is the only client config, set it as default if out is None and len(ClientConfigV1Alpha1.list()) == 1: user_config = UserConfigV1Alpha1.load_or_create() user_config.config.current_client = client_config UserConfigV1Alpha1.save(user_config) if output is None: - click.echo(f"Client configuration successfully saved to {client_config.path}") + click.echo(f"Client configuration successfully saved to {config_path}") else: - click.echo(client_config.path) + click.echo(config_path) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: @@ -125,11 +125,11 @@ async def import_exporter( if output is None: click.echo("Fetching exporter credentials from cluster") exporter_config = await api.get_exporter_config(name) - ExporterConfigV1Alpha1.save(exporter_config, out) + config_path = ExporterConfigV1Alpha1.save(exporter_config, out) if output is None: - click.echo(f"Exporter configuration successfully saved to {exporter_config.path}") + click.echo(f"Exporter configuration successfully saved to {config_path}") else: - click.echo(exporter_config.path) + click.echo(config_path) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res_test.py index 5707a967c..4c6fd1a69 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res_test.py @@ -1,4 +1,5 @@ import uuid +from pathlib import Path from unittest.mock import AsyncMock, Mock, patch import pytest @@ -62,7 +63,7 @@ async def test_import_client(_load_kube_config_mock, get_client_config_mock: Asy save_client_config_mock.assert_called_once_with(UNSAFE_CLIENT_CONFIG, None) save_client_config_mock.reset_mock() - # Save with custom output file + # Save with custom out file out = f"/tmp/{CLIENT_NAME}.yaml" result = await runner.invoke(import_res, ["client", CLIENT_NAME, "--unsafe", "--out", out]) assert result.exit_code == 0 @@ -70,6 +71,17 @@ async def test_import_client(_load_kube_config_mock, get_client_config_mock: Asy save_client_config_mock.assert_called_once_with(UNSAFE_CLIENT_CONFIG, out) save_client_config_mock.reset_mock() + # Save with path output + out = f"/tmp/{CLIENT_NAME}.yaml" + save_client_config_mock.return_value = Path(out) + result = await runner.invoke( + import_res, ["client", CLIENT_NAME, "--nointeractive", "--unsafe", "--out", out, "--output", "path"] + ) + assert result.exit_code == 0 + assert result.output == f"{out}\n" + save_client_config_mock.assert_called_once_with(UNSAFE_CLIENT_CONFIG, out) + save_client_config_mock.reset_mock() + # Create and save safe client config get_client_config_mock.reset_mock() get_client_config_mock.return_value = CLIENT_CONFIG @@ -115,13 +127,21 @@ async def test_import_exporter(_load_kube_config_mock, _get_exporter_config_mock save_exporter_config_mock.assert_called_with(EXPORTER_CONFIG, None) save_exporter_config_mock.reset_mock() - # Save with custom path + # Save with custom out file out = f"/tmp/{EXPORTER_NAME}.yaml" result = await runner.invoke(import_res, ["exporter", EXPORTER_NAME, "--out", out]) assert result.exit_code == 0 assert "Exporter configuration successfully saved" in result.output save_exporter_config_mock.assert_called_with(EXPORTER_CONFIG, out) + # Save with path output + out = f"/tmp/{EXPORTER_NAME}.yaml" + save_exporter_config_mock.return_value = Path(out) + result = await runner.invoke(import_res, ["exporter", EXPORTER_NAME, "--out", out, "--output", "path"]) + assert result.exit_code == 0 + assert result.output == f"{out}\n" + save_exporter_config_mock.assert_called_with(EXPORTER_CONFIG, out) + @pytest.fixture def anyio_backend(): diff --git a/packages/jumpstarter/jumpstarter/config/client.py b/packages/jumpstarter/jumpstarter/config/client.py index 2a793e398..6677e8c93 100644 --- a/packages/jumpstarter/jumpstarter/config/client.py +++ b/packages/jumpstarter/jumpstarter/config/client.py @@ -183,7 +183,7 @@ def load(cls, alias: str) -> Self: return cls.from_file(path) @classmethod - def save(cls, config: Self, path: Optional[os.PathLike] = None): + def save(cls, config: Self, path: Optional[os.PathLike] = None) -> Path: """Saves a client config as YAML.""" # Ensure the clients dir exists if path is None: @@ -194,6 +194,7 @@ def save(cls, config: Self, path: Optional[os.PathLike] = None): config.path = Path(path) with config.path.open(mode="w") as f: yaml.safe_dump(config.model_dump(mode="json"), f, sort_keys=False) + return config.path @classmethod def dump_yaml(cls, config: Self) -> str: diff --git a/packages/jumpstarter/jumpstarter/config/exporter.py b/packages/jumpstarter/jumpstarter/config/exporter.py index eeaee3674..e0d9856c5 100644 --- a/packages/jumpstarter/jumpstarter/config/exporter.py +++ b/packages/jumpstarter/jumpstarter/config/exporter.py @@ -117,7 +117,7 @@ def dump_yaml(self, config: Self) -> str: return yaml.safe_dump(config.model_dump(mode="json"), sort_keys=False) @classmethod - def save(cls, config: Self, path: Optional[str] = None): + def save(cls, config: Self, path: Optional[str] = None) -> Path: # Set the config path before saving if path is None: config.path = cls._get_path(config.alias) @@ -126,6 +126,7 @@ def save(cls, config: Self, path: Optional[str] = None): config.path = Path(path) with config.path.open(mode="w") as f: yaml.safe_dump(config.model_dump(mode="json"), f, sort_keys=False) + return config.path @classmethod def delete(cls, alias: str): From a316e6d15b354b457d97292e6511b4506771b912 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 12:07:11 -0400 Subject: [PATCH 16/23] Add JSON output for client list-configs --- .../jumpstarter_cli_client/client_config.py | 43 ++++++++++++------- .../jumpstarter_kubernetes/json.py | 2 +- .../jumpstarter/config/__init__.py | 2 + .../jumpstarter/jumpstarter/config/client.py | 25 ++++++++--- 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py index 2cf48df20..c8f59829e 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py @@ -1,16 +1,21 @@ from typing import Optional import asyncclick as click -from jumpstarter_cli_common import make_table +from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_output_all from jumpstarter_cli_common.exceptions import handle_exceptions -from jumpstarter.config import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers, ObjectMeta, UserConfigV1Alpha1 +from jumpstarter.config import ( + ClientConfigListV1Alpha1, + ClientConfigV1Alpha1, + ClientConfigV1Alpha1Drivers, + ObjectMeta, + UserConfigV1Alpha1, +) @click.command("create-config", short_help="Create a client config.") @click.argument("alias") @click.option( - "-o", "--out", type=click.Path(dir_okay=False, resolve_path=True, writable=True), help="Specify an output file for the client config.", @@ -108,8 +113,9 @@ def delete_client_config(name: str): @click.command("list-configs", short_help="List available client configurations.") +@opt_output_all @handle_exceptions -def list_client_configs(): +def list_client_configs(output: OutputType): # Allow listing if there is no user config defined current_name = None if UserConfigV1Alpha1.exists(): @@ -118,18 +124,23 @@ def list_client_configs(): configs = ClientConfigV1Alpha1.list() - columns = ["CURRENT", "NAME", "ENDPOINT", "PATH"] - - def make_row(c: ClientConfigV1Alpha1): - return { - "CURRENT": "*" if current_name == c.alias else "", - "NAME": c.alias, - "ENDPOINT": c.endpoint, - "PATH": str(c.path), - } - - rows = list(map(make_row, configs)) - click.echo(make_table(columns, rows)) + if output == OutputMode.JSON: + click.echo(ClientConfigListV1Alpha1(current_config=current_name, items=configs).dump_json()) + elif output == OutputMode.YAML: + click.echo(ClientConfigListV1Alpha1(current_config=current_name, items=configs).dump_yaml()) + else: + columns = ["CURRENT", "NAME", "ENDPOINT", "PATH"] + + def make_row(c: ClientConfigV1Alpha1): + return { + "CURRENT": "*" if current_name == c.alias else "", + "NAME": c.alias, + "ENDPOINT": c.endpoint, + "PATH": str(c.path), + } + + rows = list(map(make_row, configs)) + click.echo(make_table(columns, rows)) @click.command("use-config", short_help="Select the current client config.") diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/json.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/json.py index bfd444dd0..872c6696e 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/json.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/json.py @@ -9,6 +9,6 @@ def dump_json(self): return self.model_dump_json(indent=4, by_alias=True) def dump_yaml(self): - return yaml.safe_dump(self.model_dump(by_alias=True), indent=2) + return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) diff --git a/packages/jumpstarter/jumpstarter/config/__init__.py b/packages/jumpstarter/jumpstarter/config/__init__.py index fbdb65b86..070044161 100644 --- a/packages/jumpstarter/jumpstarter/config/__init__.py +++ b/packages/jumpstarter/jumpstarter/config/__init__.py @@ -1,4 +1,5 @@ from .client import ( + ClientConfigListV1Alpha1, ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers, ) @@ -18,6 +19,7 @@ "ObjectMeta", "UserConfigV1Alpha1", "UserConfigV1Alpha1Config", + "ClientConfigListV1Alpha1", "ClientConfigV1Alpha1", "ClientConfigV1Alpha1Drivers", "ExporterConfigV1Alpha1", diff --git a/packages/jumpstarter/jumpstarter/config/client.py b/packages/jumpstarter/jumpstarter/config/client.py index 6677e8c93..d64300a7c 100644 --- a/packages/jumpstarter/jumpstarter/config/client.py +++ b/packages/jumpstarter/jumpstarter/config/client.py @@ -7,7 +7,7 @@ import yaml from anyio.from_thread import BlockingPortal, start_blocking_portal from jumpstarter_protocol import jumpstarter_pb2, jumpstarter_pb2_grpc -from pydantic import BaseModel, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field, ValidationError from .common import CONFIG_PATH, ObjectMeta from .env import JMP_DRIVERS_ALLOW, JMP_ENDPOINT, JMP_LEASE, JMP_NAME, JMP_NAMESPACE, JMP_TOKEN @@ -37,8 +37,8 @@ class ClientConfigV1Alpha1Drivers(BaseModel): class ClientConfigV1Alpha1(BaseModel): CLIENT_CONFIGS_PATH: ClassVar[Path] = CONFIG_PATH / "clients" - alias: str = Field(default="default", exclude=True) - path: Path | None = Field(default=None, exclude=True) + alias: str = Field(default="default") + path: Path | None = Field(default=None) apiVersion: Literal["jumpstarter.dev/v1alpha1"] = Field(default="jumpstarter.dev/v1alpha1") kind: Literal["ClientConfig"] = Field(default="ClientConfig") @@ -193,12 +193,12 @@ def save(cls, config: Self, path: Optional[os.PathLike] = None) -> Path: else: config.path = Path(path) with config.path.open(mode="w") as f: - yaml.safe_dump(config.model_dump(mode="json"), f, sort_keys=False) + yaml.safe_dump(config.model_dump(mode="json", exclude={"path", "alias"}), f, sort_keys=False) return config.path @classmethod def dump_yaml(cls, config: Self) -> str: - return yaml.safe_dump(config.model_dump(mode="json"), sort_keys=False) + return yaml.safe_dump(config.model_dump(mode="json", exclude={"path", "alias"}), sort_keys=False) @classmethod def exists(cls, alias: str) -> bool: @@ -229,3 +229,18 @@ def delete(cls, alias: str): if path.exists() is False: raise FileNotFoundError(f"Client config '{path}' does not exist.") path.unlink() + + +class ClientConfigListV1Alpha1(BaseModel): + api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") + current_config: Optional[str] = Field(alias="currentConfig") + items: list[ClientConfigV1Alpha1] + kind: Literal["ClientConfigList"] = Field(default="ClientConfigList") + + def dump_json(self): + return self.model_dump_json(indent=4, by_alias=True) + + def dump_yaml(self): + return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) + + model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) From d2178836421ada354c870fa00cf01b6ec48124b7 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 15:59:46 -0400 Subject: [PATCH 17/23] Add JSON output for exporter configs list --- .../jumpstarter_cli_client/client_config.py | 10 +++++- .../exporter_config.py | 34 ++++++++++++------- .../jumpstarter/config/__init__.py | 3 +- .../jumpstarter/config/exporter.py | 32 ++++++++++++----- 4 files changed, 56 insertions(+), 23 deletions(-) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py index c8f59829e..4a60235cd 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py @@ -1,7 +1,12 @@ from typing import Optional import asyncclick as click -from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_output_all +from jumpstarter_cli_common import ( + OutputMode, + OutputType, + make_table, + opt_output_all, +) from jumpstarter_cli_common.exceptions import handle_exceptions from jumpstarter.config import ( @@ -128,6 +133,9 @@ def list_client_configs(output: OutputType): click.echo(ClientConfigListV1Alpha1(current_config=current_name, items=configs).dump_json()) elif output == OutputMode.YAML: click.echo(ClientConfigListV1Alpha1(current_config=current_name, items=configs).dump_yaml()) + elif output == OutputMode.NAME: + if len(configs) > 0: + click.echo(configs[0].alias) else: columns = ["CURRENT", "NAME", "ENDPOINT", "PATH"] diff --git a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_config.py b/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_config.py index 673119f3e..d4f2e9091 100644 --- a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_config.py +++ b/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_config.py @@ -1,7 +1,7 @@ import asyncclick as click -from jumpstarter_cli_common import make_table +from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_output_all -from jumpstarter.config.exporter import ExporterConfigV1Alpha1, ObjectMeta +from jumpstarter.config.exporter import ExporterConfigListV1Alpha1, ExporterConfigV1Alpha1, ObjectMeta arg_alias = click.argument("alias", default="default") @@ -53,15 +53,25 @@ def edit_exporter_config(alias): @click.command("list-configs") -def list_exporter_configs(): +@opt_output_all +def list_exporter_configs(output: OutputType): """List exporter configs.""" exporters = ExporterConfigV1Alpha1.list() - columns = ["ALIAS", "PATH"] - rows = [ - { - "ALIAS": exporter.alias, - "PATH": str(exporter.path), - } - for exporter in exporters - ] - click.echo(make_table(columns, rows)) + + if output == OutputMode.JSON: + click.echo(ExporterConfigListV1Alpha1(items=exporters).dump_json()) + elif output == OutputMode.YAML: + click.echo(ExporterConfigListV1Alpha1(items=exporters).dump_yaml()) + elif output == OutputMode.NAME: + if len(exporters) > 0: + click.echo(exporters[0].alias) + else: + columns = ["ALIAS", "PATH"] + rows = [ + { + "ALIAS": exporter.alias, + "PATH": str(exporter.path), + } + for exporter in exporters + ] + click.echo(make_table(columns, rows)) diff --git a/packages/jumpstarter/jumpstarter/config/__init__.py b/packages/jumpstarter/jumpstarter/config/__init__.py index 070044161..b61613a8d 100644 --- a/packages/jumpstarter/jumpstarter/config/__init__.py +++ b/packages/jumpstarter/jumpstarter/config/__init__.py @@ -5,7 +5,7 @@ ) from .common import CONFIG_API_VERSION, CONFIG_PATH, ObjectMeta from .env import JMP_CLIENT_CONFIG, JMP_DRIVERS_ALLOW, JMP_ENDPOINT, JMP_TOKEN -from .exporter import ExporterConfigV1Alpha1, ExporterConfigV1Alpha1DriverInstance +from .exporter import ExporterConfigListV1Alpha1, ExporterConfigV1Alpha1, ExporterConfigV1Alpha1DriverInstance from .user import UserConfigV1Alpha1, UserConfigV1Alpha1Config __all__ = [ @@ -22,6 +22,7 @@ "ClientConfigListV1Alpha1", "ClientConfigV1Alpha1", "ClientConfigV1Alpha1Drivers", + "ExporterConfigListV1Alpha1", "ExporterConfigV1Alpha1", "ExporterConfigV1Alpha1DriverInstance", ] diff --git a/packages/jumpstarter/jumpstarter/config/exporter.py b/packages/jumpstarter/jumpstarter/config/exporter.py index e0d9856c5..7a6096501 100644 --- a/packages/jumpstarter/jumpstarter/config/exporter.py +++ b/packages/jumpstarter/jumpstarter/config/exporter.py @@ -7,7 +7,7 @@ import grpc import yaml from anyio.from_thread import start_blocking_portal -from pydantic import BaseModel, Field, RootModel +from pydantic import BaseModel, ConfigDict, Field, RootModel from .common import ObjectMeta from .grpc import call_credentials @@ -69,10 +69,10 @@ def from_str(cls, config: str) -> ExporterConfigV1Alpha1DriverInstance: class ExporterConfigV1Alpha1(BaseModel): BASE_PATH: ClassVar[Path] = Path("/etc/jumpstarter/exporters") - alias: str = Field(default="default", exclude=True) + alias: str = Field(default="default") - apiVersion: Literal["jumpstarter.dev/v1alpha1"] = "jumpstarter.dev/v1alpha1" - kind: Literal["ExporterConfig"] = "ExporterConfig" + apiVersion: Literal["jumpstarter.dev/v1alpha1"] = Field(default="jumpstarter.dev/v1alpha1") + kind: Literal["ExporterConfig"] = Field(default="ExporterConfig") metadata: ObjectMeta endpoint: str @@ -81,7 +81,7 @@ class ExporterConfigV1Alpha1(BaseModel): export: dict[str, ExporterConfigV1Alpha1DriverInstance] = Field(default_factory=dict) - path: Path | None = Field(default=None, exclude=True) + path: Path | None = Field(default=None) @classmethod def _get_path(cls, alias: str): @@ -99,13 +99,13 @@ def load_path(cls, path: Path): return config @classmethod - def load(cls, alias: str): + def load(cls, alias: str) -> Self: config = cls.load_path(cls._get_path(alias)) config.alias = alias return config @classmethod - def list(cls): + def list(cls) -> list[Self]: exporters = [] with suppress(FileNotFoundError): for entry in cls.BASE_PATH.iterdir(): @@ -114,7 +114,7 @@ def list(cls): @classmethod def dump_yaml(self, config: Self) -> str: - return yaml.safe_dump(config.model_dump(mode="json"), sort_keys=False) + return yaml.safe_dump(config.model_dump(mode="json", exclude={"alias", "path"}), sort_keys=False) @classmethod def save(cls, config: Self, path: Optional[str] = None) -> Path: @@ -125,7 +125,7 @@ def save(cls, config: Self, path: Optional[str] = None) -> Path: else: config.path = Path(path) with config.path.open(mode="w") as f: - yaml.safe_dump(config.model_dump(mode="json"), f, sort_keys=False) + yaml.safe_dump(config.model_dump(mode="json", exclude={"alias", "path"}), f, sort_keys=False) return config.path @classmethod @@ -166,3 +166,17 @@ def channel_factory(): tls=self.tls, ) as exporter: await exporter.serve() + + +class ExporterConfigListV1Alpha1(BaseModel): + api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") + items: list[ExporterConfigV1Alpha1] + kind: Literal["ExporterConfigList"] = Field(default="ExporterConfigList") + + def dump_json(self): + return self.model_dump_json(indent=4, by_alias=True) + + def dump_yaml(self): + return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) + + model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) From 52761583a4ef8cb7a662797347a308dc0c531c80 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 16:27:36 -0400 Subject: [PATCH 18/23] Fix jumpstarter-kubernetes tests --- .../jumpstarter_kubernetes/clients_test.py | 25 +++----- .../jumpstarter_kubernetes/exporters_test.py | 32 +--------- .../jumpstarter_kubernetes/test_leases.py | 58 +++---------------- 3 files changed, 16 insertions(+), 99 deletions(-) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py index 4b4948bf3..d42383294 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py @@ -24,21 +24,16 @@ def test_client_dump_json(): "apiVersion": "jumpstarter.dev/v1alpha1", "kind": "Client", "metadata": { - "annotations": null, "creationTimestamp": "2021-10-01T00:00:00Z", - "deletionGracePeriodSeconds": null, - "deletionTimestamp": null, - "finalizers": null, - "generateName": null, "generation": 1, - "labels": null, - "managedFields": null, "name": "test-client", "namespace": "default", - "ownerReferences": null, "resourceVersion": "1", - "selfLink": null, "uid": "7a25eb81-6443-47ec-a62f-50165bffede8" + }, + "status": { + "credential": null, + "endpoint": "https://test-client" } }""" ) @@ -50,20 +45,14 @@ def test_client_dump_yaml(): == """apiVersion: jumpstarter.dev/v1alpha1 kind: Client metadata: - annotations: null creationTimestamp: '2021-10-01T00:00:00Z' - deletionGracePeriodSeconds: null - deletionTimestamp: null - finalizers: null - generateName: null generation: 1 - labels: null - managedFields: null name: test-client namespace: default - ownerReferences: null resourceVersion: '1' - selfLink: null uid: 7a25eb81-6443-47ec-a62f-50165bffede8 +status: + credential: null + endpoint: https://test-client """ ) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py index f167c6540..78104c0ad 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py @@ -28,31 +28,16 @@ def test_exporter_dump_json(): "apiVersion": "jumpstarter.dev/v1alpha1", "kind": "Exporter", "metadata": { - "annotations": null, "creationTimestamp": "2021-10-01T00:00:00Z", - "deletionGracePeriodSeconds": null, - "deletionTimestamp": null, - "finalizers": null, - "generateName": null, "generation": 1, - "labels": null, - "managedFields": null, "name": "test-exporter", "namespace": "default", - "ownerReferences": null, "resourceVersion": "1", - "selfLink": null, "uid": "7a25eb81-6443-47ec-a62f-50165bffede8" }, "status": { "credential": { - "apiVersion": null, - "fieldPath": null, - "kind": null, - "name": "test-credential", - "namespace": null, - "resourceVersion": null, - "uid": null + "name": "test-credential" }, "devices": [ { @@ -74,30 +59,15 @@ def test_exporter_dump_yaml(): == """apiVersion: jumpstarter.dev/v1alpha1 kind: Exporter metadata: - annotations: null creationTimestamp: '2021-10-01T00:00:00Z' - deletionGracePeriodSeconds: null - deletionTimestamp: null - finalizers: null - generateName: null generation: 1 - labels: null - managedFields: null name: test-exporter namespace: default - ownerReferences: null resourceVersion: '1' - selfLink: null uid: 7a25eb81-6443-47ec-a62f-50165bffede8 status: credential: - apiVersion: null - fieldPath: null - kind: null name: test-credential - namespace: null - resourceVersion: null - uid: null devices: - labels: test: label diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py index 39e8030bc..456359e54 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py @@ -33,37 +33,23 @@ def test_lease_dump_json(): + print(TEST_LEASE.dump_json()) assert ( TEST_LEASE.dump_json() == """{ "apiVersion": "jumpstarter.dev/v1alpha1", "kind": "Lease", "metadata": { - "annotations": null, "creationTimestamp": "2021-10-01T00:00:00Z", - "deletionGracePeriodSeconds": null, - "deletionTimestamp": null, - "finalizers": null, - "generateName": null, "generation": 1, - "labels": null, - "managedFields": null, "name": "test-lease", "namespace": "default", - "ownerReferences": null, "resourceVersion": "1", - "selfLink": null, "uid": "7a25eb81-6443-47ec-a62f-50165bffede8" }, "spec": { "client": { - "apiVersion": null, - "fieldPath": null, - "kind": null, - "name": "test-client", - "namespace": null, - "resourceVersion": null, - "uid": null + "name": "test-client" }, "duration": "1h", "selector": { @@ -72,27 +58,20 @@ def test_lease_dump_json(): } }, "status": { - "begin_time": "2021-10-01T00:00:00Z", + "beginTime": "2021-10-01T00:00:00Z", "conditions": [ { "lastTransitionTime": "2021-10-01T00:00:00Z", "message": "", - "observedGeneration": null, "reason": "", "status": "True", "type": "Active" } ], - "end_time": "2021-10-01T01:00:00Z", + "endTime": "2021-10-01T01:00:00Z", "ended": false, "exporter": { - "apiVersion": null, - "fieldPath": null, - "kind": null, - "name": "test-exporter", - "namespace": null, - "resourceVersion": null, - "uid": null + "name": "test-exporter" } } }""" @@ -100,57 +79,36 @@ def test_lease_dump_json(): def test_lease_dump_yaml(): + print(TEST_LEASE.dump_yaml()) assert ( TEST_LEASE.dump_yaml() == """apiVersion: jumpstarter.dev/v1alpha1 kind: Lease metadata: - annotations: null creationTimestamp: '2021-10-01T00:00:00Z' - deletionGracePeriodSeconds: null - deletionTimestamp: null - finalizers: null - generateName: null generation: 1 - labels: null - managedFields: null name: test-lease namespace: default - ownerReferences: null resourceVersion: '1' - selfLink: null uid: 7a25eb81-6443-47ec-a62f-50165bffede8 spec: client: - apiVersion: null - fieldPath: null - kind: null name: test-client - namespace: null - resourceVersion: null - uid: null duration: 1h selector: another: something test: label status: - begin_time: '2021-10-01T00:00:00Z' + beginTime: '2021-10-01T00:00:00Z' conditions: - lastTransitionTime: '2021-10-01T00:00:00Z' message: '' - observedGeneration: null reason: '' status: 'True' type: Active - end_time: '2021-10-01T01:00:00Z' + endTime: '2021-10-01T01:00:00Z' ended: false exporter: - apiVersion: null - fieldPath: null - kind: null name: test-exporter - namespace: null - resourceVersion: null - uid: null """ ) From 08958d83bdd79b44fea3ab2dbd1c24a23b2da756 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 16:38:55 -0400 Subject: [PATCH 19/23] Add path output options for client/exporter CLIs --- .../jumpstarter_cli_client/client_config.py | 23 ++++++++++++++---- .../exporter_config.py | 24 +++++++++++++++---- .../jumpstarter/jumpstarter/config/client.py | 3 ++- .../jumpstarter/config/exporter.py | 6 +++-- .../jumpstarter/jumpstarter/config/user.py | 6 +++-- 5 files changed, 47 insertions(+), 15 deletions(-) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py index 4a60235cd..8488c9c3e 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py @@ -4,8 +4,10 @@ from jumpstarter_cli_common import ( OutputMode, OutputType, + PathOutputType, make_table, opt_output_all, + opt_output_path_only, ) from jumpstarter_cli_common.exceptions import handle_exceptions @@ -61,6 +63,7 @@ default="", ) @click.option("--unsafe", is_flag=True, help="Should all driver client packages be allowed to load (UNSAFE!).") +@opt_output_path_only @handle_exceptions def create_client_config( alias: str, @@ -71,6 +74,7 @@ def create_client_config( allow: str, unsafe: bool, out: Optional[str], + output: PathOutputType, ): """Create a Jumpstarter client configuration.""" if out is None and ClientConfigV1Alpha1.exists(alias): @@ -83,7 +87,7 @@ def create_client_config( token=token, drivers=ClientConfigV1Alpha1Drivers(allow=allow.split(","), unsafe=unsafe), ) - ClientConfigV1Alpha1.save(config, out) + path = ClientConfigV1Alpha1.save(config, out) # If this is the only client config, set it as default if out is None and len(ClientConfigV1Alpha1.list()) == 1: @@ -91,6 +95,9 @@ def create_client_config( user_config.config.current_client = config UserConfigV1Alpha1.save(user_config) + if output == OutputMode.PATH: + click.echo(path) + def set_next_client(name: str): user_config = UserConfigV1Alpha1.load() if UserConfigV1Alpha1.exists() else None @@ -110,11 +117,14 @@ def set_next_client(name: str): @click.command("delete-config", short_help="Delete a client config.") @click.argument("name", type=str) +@opt_output_path_only @handle_exceptions -def delete_client_config(name: str): +def delete_client_config(name: str, output: PathOutputType): """Delete a Jumpstarter client configuration.""" set_next_client(name) - ClientConfigV1Alpha1.delete(name) + path = ClientConfigV1Alpha1.delete(name) + if output == OutputMode.PATH: + click.echo(path) @click.command("list-configs", short_help="List available client configurations.") @@ -153,8 +163,11 @@ def make_row(c: ClientConfigV1Alpha1): @click.command("use-config", short_help="Select the current client config.") @click.argument("name", type=str) +@opt_output_path_only @handle_exceptions -def use_client_config(name: str): +def use_client_config(name: str, output: PathOutputType): """Select the current Jumpstarter client configuration to use.""" user_config = UserConfigV1Alpha1.load_or_create() - user_config.use_client(name) + path = user_config.use_client(name) + if output == OutputMode.PATH: + click.echo(path) diff --git a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_config.py b/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_config.py index d4f2e9091..496fa7d17 100644 --- a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_config.py +++ b/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_config.py @@ -1,5 +1,12 @@ import asyncclick as click -from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_output_all +from jumpstarter_cli_common import ( + OutputMode, + OutputType, + PathOutputType, + make_table, + opt_output_all, + opt_output_path_only, +) from jumpstarter.config.exporter import ExporterConfigListV1Alpha1, ExporterConfigV1Alpha1, ObjectMeta @@ -11,8 +18,9 @@ @click.option("--name", prompt=True) @click.option("--endpoint", prompt=True) @click.option("--token", prompt=True) +@opt_output_path_only @arg_alias -def create_exporter_config(alias, namespace, name, endpoint, token): +def create_exporter_config(alias, namespace, name, endpoint, token, output: PathOutputType): """Create an exporter config.""" try: ExporterConfigV1Alpha1.load(alias) @@ -27,18 +35,24 @@ def create_exporter_config(alias, namespace, name, endpoint, token): endpoint=endpoint, token=token, ) - ExporterConfigV1Alpha1.save(config) + path = ExporterConfigV1Alpha1.save(config) + + if output == OutputMode.PATH: + click.echo(path) @click.command("delete-config") @arg_alias -def delete_exporter_config(alias): +@opt_output_path_only +def delete_exporter_config(alias, output: PathOutputType): """Delete an exporter config.""" try: ExporterConfigV1Alpha1.load(alias) except FileNotFoundError as err: raise click.ClickException(f'exporter "{alias}" does not exist') from err - ExporterConfigV1Alpha1.delete(alias) + path = ExporterConfigV1Alpha1.delete(alias) + if output == OutputMode.PATH: + click.echo(path) @click.command("edit-config") diff --git a/packages/jumpstarter/jumpstarter/config/client.py b/packages/jumpstarter/jumpstarter/config/client.py index d64300a7c..e1bd5dc92 100644 --- a/packages/jumpstarter/jumpstarter/config/client.py +++ b/packages/jumpstarter/jumpstarter/config/client.py @@ -223,12 +223,13 @@ def make_config(file: str): return list(map(make_config, files)) @classmethod - def delete(cls, alias: str): + def delete(cls, alias: str) -> Path: """Delete a client config by alias.""" path = cls._get_path(alias) if path.exists() is False: raise FileNotFoundError(f"Client config '{path}' does not exist.") path.unlink() + return path class ClientConfigListV1Alpha1(BaseModel): diff --git a/packages/jumpstarter/jumpstarter/config/exporter.py b/packages/jumpstarter/jumpstarter/config/exporter.py index 7a6096501..f9f7ca091 100644 --- a/packages/jumpstarter/jumpstarter/config/exporter.py +++ b/packages/jumpstarter/jumpstarter/config/exporter.py @@ -129,8 +129,10 @@ def save(cls, config: Self, path: Optional[str] = None) -> Path: return config.path @classmethod - def delete(cls, alias: str): - cls._get_path(alias).unlink(missing_ok=True) + def delete(cls, alias: str) -> Path: + path = cls._get_path(alias) + path.unlink(missing_ok=True) + return path @asynccontextmanager async def serve_unix_async(self): diff --git a/packages/jumpstarter/jumpstarter/config/user.py b/packages/jumpstarter/jumpstarter/config/user.py index dec027783..467a12231 100644 --- a/packages/jumpstarter/jumpstarter/config/user.py +++ b/packages/jumpstarter/jumpstarter/config/user.py @@ -82,16 +82,18 @@ def load_or_create(cls) -> Self: return cls.load() @classmethod - def save(cls, config: Self, path: Optional[str] = None): + def save(cls, config: Self, path: Optional[str] = None) -> Path: """Save a user config as YAML.""" with open(path or cls.USER_CONFIG_PATH, "w") as f: yaml.safe_dump(config.model_dump(mode="json", by_alias=True), f, sort_keys=False) + return path - def use_client(self, name: Optional[str]): + def use_client(self, name: Optional[str]) -> Path: """Updates the current client and saves the user config.""" if name is not None: self.config.current_client = ClientConfigV1Alpha1.load(name) else: self.config.current_client = None self.save(self) + return self.config.current_client.path From 99f09db74a7e7f47fd32d370c3fd9872467ec371 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 16:42:03 -0400 Subject: [PATCH 20/23] Fix issue when setting current client to None --- packages/jumpstarter/jumpstarter/config/user.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/jumpstarter/jumpstarter/config/user.py b/packages/jumpstarter/jumpstarter/config/user.py index 467a12231..ec5146291 100644 --- a/packages/jumpstarter/jumpstarter/config/user.py +++ b/packages/jumpstarter/jumpstarter/config/user.py @@ -89,11 +89,14 @@ def save(cls, config: Self, path: Optional[str] = None) -> Path: yaml.safe_dump(config.model_dump(mode="json", by_alias=True), f, sort_keys=False) return path - def use_client(self, name: Optional[str]) -> Path: + def use_client(self, name: Optional[str]) -> Path | None: """Updates the current client and saves the user config.""" if name is not None: self.config.current_client = ClientConfigV1Alpha1.load(name) else: self.config.current_client = None self.save(self) - return self.config.current_client.path + if self.config.current_client is not None: + return self.config.current_client.path + else: + return None From 59dcf46266e4f1f985d9d4ca64a3e7b63a23df4c Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 20:36:38 -0400 Subject: [PATCH 21/23] Fix coderabbit issues --- .../jumpstarter_cli_admin/print.py | 30 ++++++++++--------- .../jumpstarter_kubernetes/clients.py | 4 +-- .../jumpstarter/jumpstarter/config/user.py | 2 +- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py index a5abfad34..484d0695f 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py @@ -57,7 +57,7 @@ def make_exporter_row(exporter: V1Alpha1Exporter): return { "NAME": exporter.metadata.name, "ENDPOINT": exporter.status.endpoint, - "DEVICES": str(len(exporter.status.devices)), + "DEVICES": str(len(exporter.status.devices) if exporter.status and exporter.status.devices else 0), "AGE": time_since(exporter.metadata.creation_timestamp), } @@ -66,19 +66,21 @@ def get_device_rows(exporters: list[V1Alpha1Exporter]): """Get the device rows to print from the exporters""" devices = [] for e in exporters: - for d in e.status.devices: - labels = [] - for label in d.labels: - labels.append(f"{label}:{str(d.labels[label])}") - devices.append( - { - "NAME": e.metadata.name, - "ENDPOINT": e.status.endpoint, - "AGE": time_since(e.metadata.creation_timestamp), - "LABELS": ",".join(labels), - "UUID": d.uuid, - } - ) + if e.status is not None: + for d in e.status.devices: + labels = [] + if d.labels is not None: + for label in d.labels: + labels.append(f"{label}:{str(d.labels[label])}") + devices.append( + { + "NAME": e.metadata.name, + "ENDPOINT": e.status.endpoint, + "AGE": time_since(e.metadata.creation_timestamp), + "LABELS": ",".join(labels), + "UUID": d.uuid, + } + ) return devices diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py index a7f3d729a..836e44b6d 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py @@ -46,7 +46,7 @@ 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", ""), ) if "status" in dict else None, @@ -58,7 +58,7 @@ class V1Alpha1ClientList(V1Alpha1List[V1Alpha1Client]): @staticmethod def from_dict(dict: dict): - return V1Alpha1ClientList(items=[V1Alpha1Client.from_dict(c) for c in dict["items"]]) + return V1Alpha1ClientList(items=[V1Alpha1Client.from_dict(c) for c in dict.get("items", [])]) class ClientsV1Alpha1Api(AbstractAsyncCustomObjectApi): diff --git a/packages/jumpstarter/jumpstarter/config/user.py b/packages/jumpstarter/jumpstarter/config/user.py index ec5146291..343e691f7 100644 --- a/packages/jumpstarter/jumpstarter/config/user.py +++ b/packages/jumpstarter/jumpstarter/config/user.py @@ -87,7 +87,7 @@ def save(cls, config: Self, path: Optional[str] = None) -> Path: with open(path or cls.USER_CONFIG_PATH, "w") as f: yaml.safe_dump(config.model_dump(mode="json", by_alias=True), f, sort_keys=False) - return path + return path or cls.USER_CONFIG_PATH def use_client(self, name: Optional[str]) -> Path | None: """Updates the current client and saves the user config.""" From 696a82cb160d0cb972742ec4a1c8ccf4f5cca417 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 20:51:35 -0400 Subject: [PATCH 22/23] Add JSON output for version subcommand --- .../jumpstarter_cli_common/version.py | 31 +++++++++++++++++-- .../jumpstarter-cli-common/pyproject.toml | 27 ++++++++-------- .../jumpstarter-kubernetes/pyproject.toml | 2 +- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/version.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/version.py index 0d78ef945..a2d6bdf0b 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/version.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/version.py @@ -3,6 +3,10 @@ import sys import asyncclick as click +import yaml +from pydantic import BaseModel, ConfigDict, Field + +from .opt import OutputMode, OutputType, opt_output_all def get_client_version(): @@ -23,7 +27,30 @@ def version_msg(): return f"Jumpstarter v{jumpstarter_version} from {location} (Python {python_version})" +class JumpstarterVersion(BaseModel): + git_version: str = Field(alias="gitVersion") + python_version: str = Field(alias="pythonVersion") + + model_config = ConfigDict(populate_by_name=True) + + def dump_json(self): + return self.model_dump_json(indent=4, by_alias=True) + + def dump_yaml(self): + return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) + + +def version_obj(): + return JumpstarterVersion(git_version=importlib.metadata.version("jumpstarter"), python_version=sys.version) + + @click.command() -def version(): +@opt_output_all +def version(output: OutputType): """Get the current Jumpstarter version""" - click.echo(version_msg()) + if output == OutputMode.JSON: + click.echo(version_obj().dump_json()) + elif output == OutputMode.YAML: + click.echo(version_obj().dump_yaml()) + else: + click.echo(version_msg()) diff --git a/packages/jumpstarter-cli-common/pyproject.toml b/packages/jumpstarter-cli-common/pyproject.toml index 8ace88bd8..e5ac9762c 100644 --- a/packages/jumpstarter-cli-common/pyproject.toml +++ b/packages/jumpstarter-cli-common/pyproject.toml @@ -2,27 +2,26 @@ name = "jumpstarter-cli-common" dynamic = ["version", "urls"] description = "" -authors = [ - { name = "Kirk Brauer", email = "kbrauer@hatci.com" }, -] +authors = [{ name = "Kirk Brauer", email = "kbrauer@hatci.com" }] readme = "README.md" license = { text = "Apache-2.0" } requires-python = ">=3.11" dependencies = [ - "jumpstarter", - "asyncclick>=8.1.7.2", - "authlib>=1.4.1", - "truststore>=0.10.1", - "joserfc>=1.0.3", - "yarl>=1.18.3", + "jumpstarter", + "pydantic>=2.8.2", + "asyncclick>=8.1.7.2", + "authlib>=1.4.1", + "truststore>=0.10.1", + "joserfc>=1.0.3", + "yarl>=1.18.3", ] [dependency-groups] dev = [ - "pytest>=8.3.2", - "pytest-anyio>=0.0.0", - "pytest-asyncio>=0.0.0", - "pytest-cov>=5.0.0", + "pytest>=8.3.2", + "pytest-anyio>=0.0.0", + "pytest-asyncio>=0.0.0", + "pytest-cov>=5.0.0", ] [tool.hatch.build.targets.wheel] @@ -34,7 +33,7 @@ source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}. [tool.hatch.version] source = "vcs" -raw-options = { 'root' = '../../'} +raw-options = { 'root' = '../../' } [build-system] requires = ["hatchling", "hatch-vcs"] diff --git a/packages/jumpstarter-kubernetes/pyproject.toml b/packages/jumpstarter-kubernetes/pyproject.toml index 2f85a436f..8d7fd4489 100644 --- a/packages/jumpstarter-kubernetes/pyproject.toml +++ b/packages/jumpstarter-kubernetes/pyproject.toml @@ -8,7 +8,7 @@ license = { text = "Apache-2.0" } requires-python = ">=3.11" dependencies = [ "jumpstarter", - "pydantic>=1.9.0", + "pydantic>=2.8.2", "kubernetes>=31.0.0", "kubernetes-asyncio>=31.1.0", ] From a128a4cd7d99e2d8db23e2b76249d8e6c7840ef3 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 9 Mar 2025 22:14:10 -0400 Subject: [PATCH 23/23] Add entrypoints for all CLI packages --- .../jumpstarter-cli-admin/jumpstarter_cli_admin/__main__.py | 6 ++++++ .../jumpstarter_cli_client/__main__.py | 6 ++++++ .../jumpstarter_cli_driver/__main__.py | 6 ++++++ .../jumpstarter_cli_exporter/__main__.py | 6 ++++++ packages/jumpstarter-cli/jumpstarter_cli/__main__.py | 2 +- 5 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 packages/jumpstarter-cli-admin/jumpstarter_cli_admin/__main__.py create mode 100644 packages/jumpstarter-cli-client/jumpstarter_cli_client/__main__.py create mode 100644 packages/jumpstarter-cli-driver/jumpstarter_cli_driver/__main__.py create mode 100644 packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/__main__.py diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/__main__.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/__main__.py new file mode 100644 index 000000000..b31f2900a --- /dev/null +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/__main__.py @@ -0,0 +1,6 @@ +"""Allow running Jumpstarter through `python -m jumpstarter_cli_admin`.""" + +from . import admin + +if __name__ == "__main__": + admin(prog_name="jmp-admin") diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/__main__.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/__main__.py new file mode 100644 index 000000000..f9c2370b0 --- /dev/null +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/__main__.py @@ -0,0 +1,6 @@ +"""Allow running Jumpstarter through `python -m jumpstarter_cli_client`.""" + +from . import client + +if __name__ == "__main__": + client(prog_name="jmp-client") diff --git a/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/__main__.py b/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/__main__.py new file mode 100644 index 000000000..2ffdc7e55 --- /dev/null +++ b/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/__main__.py @@ -0,0 +1,6 @@ +"""Allow running Jumpstarter through `python -m jumpstarter_cli_driver`.""" + +from . import driver + +if __name__ == "__main__": + driver(prog_name="jmp-driver") diff --git a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/__main__.py b/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/__main__.py new file mode 100644 index 000000000..de54038b4 --- /dev/null +++ b/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/__main__.py @@ -0,0 +1,6 @@ +"""Allow running Jumpstarter through `python -m jumpstarter_cli_exporter`.""" + +from . import exporter + +if __name__ == "__main__": + exporter(prog_name="jmp-exporter") diff --git a/packages/jumpstarter-cli/jumpstarter_cli/__main__.py b/packages/jumpstarter-cli/jumpstarter_cli/__main__.py index 95a4f5ac9..c65c1f914 100644 --- a/packages/jumpstarter-cli/jumpstarter_cli/__main__.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/__main__.py @@ -1,4 +1,4 @@ -"""Allow running Jumpstarter through `python -m jumpstarter-cli`.""" +"""Allow running Jumpstarter through `python -m jumpstarter_cli`.""" from . import jmp