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-admin/jumpstarter_cli_admin/create.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py index 57cc82e09..cbd95667b 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py @@ -4,13 +4,17 @@ import asyncclick as click from jumpstarter_cli_common import ( AliasedGroup, + OutputMode, + OutputType, opt_context, opt_kubeconfig, opt_labels, opt_log_level, opt_namespace, + opt_nointeractive, + opt_output_all, ) -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 +37,15 @@ def create(log_level: Optional[str]): logging.basicConfig(level=logging.INFO) +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}") + + @create.command("client") @click.argument("name", type=str, required=False, default=None) @click.option( @@ -51,7 +64,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 +74,8 @@ def create(log_level: Optional[str]): @opt_kubeconfig @opt_context @opt_oidc_username +@opt_nointeractive +@opt_output_all async def create_client( name: Optional[str], kubeconfig: Optional[str], @@ -73,15 +87,20 @@ 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""" 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 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) if unsafe is False and allow is None: unsafe = click.confirm("Allow unsafe driver client imports?") @@ -98,13 +117,24 @@ 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}") + 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): + 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}") + + @create.command("exporter") @click.argument("name", type=str, required=False, default=None) @click.option( @@ -115,7 +145,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 +155,8 @@ async def create_client( @opt_kubeconfig @opt_context @opt_oidc_username +@opt_nointeractive +@opt_output_all async def create_exporter( name: Optional[str], kubeconfig: Optional[str], @@ -135,18 +166,24 @@ 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""" 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 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) - 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}") + 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/create_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py index bed0d9e70..5781321d2 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 ( @@ -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 @@ -120,8 +180,43 @@ 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_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), @@ -171,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(): diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py index 4cf68e463..9b86d8f9f 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py @@ -4,10 +4,13 @@ import asyncclick as click from jumpstarter_cli_common import ( AliasedGroup, + NameOutputType, opt_context, opt_kubeconfig, opt_log_level, opt_namespace, + opt_nointeractive, + opt_output_name_only, ) from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api from kubernetes_asyncio.client.exceptions import ApiException @@ -42,16 +45,29 @@ def delete(log_level: Optional[str]): @opt_namespace @opt_kubeconfig @opt_context +@opt_output_name_only +@opt_nointeractive 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, + nointeractive: bool, ): """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 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: @@ -59,7 +75,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: @@ -78,19 +95,33 @@ 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], context: Optional[str], namespace: str, delete: bool + name: Optional[str], + kubeconfig: Optional[str], + context: Optional[str], + namespace: str, + delete: bool, + output: NameOutputType, + nointeractive: bool, ): """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?")): + if ExporterConfigV1Alpha1.exists(name) and ( + delete or nointeractive is False and 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/delete_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py index 52cd506f8..daf4a4820 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 ( @@ -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" @@ -136,7 +167,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, @@ -198,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(): diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py index 70cbfa510..a5358b1e5 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py @@ -4,20 +4,17 @@ import asyncclick as click from jumpstarter_cli_common import ( AliasedGroup, - make_table, + OutputType, opt_context, opt_kubeconfig, opt_log_level, opt_namespace, - time_since, + opt_output_all, ) from jumpstarter_kubernetes import ( ClientsV1Alpha1Api, ExportersV1Alpha1Api, LeasesV1Alpha1Api, - V1Alpha1Client, - V1Alpha1Exporter, - V1Alpha1Lease, ) from kubernetes_asyncio.client.exceptions import ApiException from kubernetes_asyncio.config.config_exception import ConfigException @@ -26,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) @@ -38,178 +36,78 @@ 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, - "AGE": time_since(client.metadata.creation_timestamp), - } - - @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_all +async def get_client( + name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: 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) - 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, namespace, output) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: 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 - - @get.command("exporter") @click.argument("name", type=str, required=False, default=None) @opt_namespace @opt_kubeconfig @opt_context +@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], kubeconfig: Optional[str], context: Optional[str], namespace: str, devices: bool + name: Optional[str], + kubeconfig: Optional[str], + context: Optional[str], + namespace: str, + devices: bool, + output: 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) - 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: 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), - } - - @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_all +async def get_lease( + name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: 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 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-admin/jumpstarter_cli_admin/get_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py index f4ce22bdb..65df1051c 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, ) @@ -35,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") @@ -42,18 +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="asdfb123423"), - ) + 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( @@ -65,29 +124,116 @@ 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() -@pytest.mark.anyio -@patch.object(ClientsV1Alpha1Api, "list_clients") -@patch.object(ClientsV1Alpha1Api, "_load_kube_config") -async def test_get_clients(_load_kube_config_mock, list_clients_mock: AsyncMock): - runner = CliRunner() - - # List clients - list_clients_mock.return_value = [ +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="asdfb123423"), + 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="asdfb123423"), + 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 +@patch.object(ClientsV1Alpha1Api, "list_clients") +@patch.object(ClientsV1Alpha1Api, "_load_kube_config") +async def test_get_clients(_load_kube_config_mock, list_clients_mock: AsyncMock): + runner = CliRunner() + + # List clients + list_clients_mock.return_value = CLIENTS_LIST result = await runner.invoke(get, ["clients"]) assert result.exit_code == 0 assert "test" in result.output @@ -95,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 = [] + 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 @@ -109,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( @@ -136,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 @@ -164,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( @@ -177,14 +497,8 @@ async def test_get_exporter_devices(_load_kube_config_mock, get_exporter_mock: A assert "NotFound" in result.output -@pytest.mark.anyio -@patch.object(ExportersV1Alpha1Api, "list_exporters") -@patch.object(ExportersV1Alpha1Api, "_load_kube_config") -async def test_get_exporters(_load_kube_config_mock, list_exporters_mock: AsyncMock): - runner = CliRunner() - - # List exporters - list_exporters_mock.return_value = [ +EXPORTERS_LIST = V1Alpha1ExporterList( + items=[ V1Alpha1Exporter( api_version="jumpstarter.dev/v1alpha1", kind="Exporter", @@ -206,31 +520,125 @@ async def test_get_exporters(_load_kube_config_mock, list_exporters_mock: AsyncM ), ), ] +) + +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") +async def test_get_exporters(_load_kube_config_mock, list_exporters_mock: AsyncMock): + runner = CliRunner() + + # List exporters + 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, "list_exporters", - return_value=[], + return_value=V1Alpha1ExporterList(items=[]), ): result = await runner.invoke(get, ["exporters"]) assert result.exit_code == 1 assert "No resources found" in result.output -@pytest.mark.anyio -@patch.object(ExportersV1Alpha1Api, "list_exporters") -@patch.object(ExportersV1Alpha1Api, "_load_kube_config") -async def test_get_exporters_devices(_load_kube_config_mock, list_exporters_mock: AsyncMock): - runner = CliRunner() - - # List exporters - list_exporters_mock.return_value = [ +EXPORTER_DEVICES_LIST = V1Alpha1ExporterList( + items=[ V1Alpha1Exporter( api_version="jumpstarter.dev/v1alpha1", kind="Exporter", @@ -256,6 +664,105 @@ async def test_get_exporters_devices(_load_kube_config_mock, list_exporters_mock ), ), ] +) + +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") +async def test_get_exporters_devices(_load_kube_config_mock, list_exporters_mock: AsyncMock): + runner = CliRunner() + + # List exporters + 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 @@ -265,8 +772,29 @@ 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 = [] + 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 @@ -334,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") @@ -370,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( @@ -383,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") @@ -390,7 +1136,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 @@ -408,8 +1154,29 @@ 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 = [] + 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/import_res.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py index 387c2092c..08f5bfb36 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,22 +66,26 @@ 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) + 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) - click.echo(f"Client configuration successfully saved to {client_config.path}") + if output is None: + click.echo(f"Client configuration successfully saved to {config_path}") + else: + click.echo(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}") + config_path = ExporterConfigV1Alpha1.save(exporter_config, out) + if output is None: + click.echo(f"Exporter configuration successfully saved to {config_path}") + else: + 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 4adff16da..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 @@ -55,7 +56,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 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 @@ -63,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 @@ -108,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-cli-admin/jumpstarter_cli_admin/print.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py new file mode 100644 index 000000000..484d0695f --- /dev/null +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py @@ -0,0 +1,176 @@ +import asyncclick as click +from jumpstarter_cli_common import ( + OutputMode, + 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: 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(make_table(CLIENT_COLUMNS, [make_client_row(client)])) + + +def print_clients(clients: V1Alpha1List[V1Alpha1Client], namespace: str, output: OutputType): + if output == OutputMode.JSON: + 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: + 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) if exporter.status and exporter.status.devices else 0), + "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: + 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 + + +def print_exporter(exporter: V1Alpha1Exporter, devices: bool, 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}") + 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: OutputType): + if output == OutputMode.JSON: + 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"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: + 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)))) + + +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: OutputType): + if output == OutputMode.JSON: + 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)])) + + +def print_leases(leases: V1Alpha1List[V1Alpha1Lease], namespace: str, output: OutputType): + if output == OutputMode.JSON: + 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: + click.echo(make_table(LEASE_COLUMNS, list(map(make_lease_row, leases.items)))) 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-client/jumpstarter_cli_client/client_config.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py index 2cf48df20..8488c9c3e 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,28 @@ from typing import Optional import asyncclick as click -from jumpstarter_cli_common import make_table +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 -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.", @@ -51,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, @@ -61,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): @@ -73,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: @@ -81,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 @@ -100,16 +117,20 @@ 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.") +@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,24 +139,35 @@ def list_client_configs(): configs = ClientConfigV1Alpha1.list() - columns = ["CURRENT", "NAME", "ENDPOINT", "PATH"] + 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()) + elif output == OutputMode.NAME: + if len(configs) > 0: + click.echo(configs[0].alias) + 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), - } + 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)) + rows = list(map(make_row, configs)) + click.echo(make_table(columns, rows)) @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-common/jumpstarter_cli_common/__init__.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py index aa400508f..2b16c92c2 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py @@ -1,5 +1,19 @@ from .alias import AliasedGroup -from .opt import opt_context, opt_kubeconfig, opt_labels, opt_log_level, opt_namespace +from .opt import ( + 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 from .version import get_client_version, version @@ -11,7 +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 63dc07ffd..815b6c318 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( @@ -17,3 +19,45 @@ 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") + + +class OutputMode(str): + JSON = "json" + YAML = "yaml" + NAME = "name" + PATH = "path" + + +OutputType = Optional[OutputMode] + +opt_output_all = click.option( + "-o", + "--output", + type=click.Choice([OutputMode.JSON, OutputMode.YAML, OutputMode.NAME]), + default=None, + help='Output mode. Use "-o name" for shorter output (resource/name).', +) + +NameOutputType = Optional[Literal["name"]] + +opt_output_name_only = click.option( + "-o", + "--output", + type=click.Choice([OutputMode.NAME]), + 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)." +) 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-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-exporter/jumpstarter_cli_exporter/exporter_config.py b/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_config.py index 673119f3e..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,7 +1,14 @@ import asyncclick as click -from jumpstarter_cli_common import make_table +from jumpstarter_cli_common import ( + OutputMode, + OutputType, + PathOutputType, + make_table, + opt_output_all, + opt_output_path_only, +) -from jumpstarter.config.exporter import ExporterConfigV1Alpha1, ObjectMeta +from jumpstarter.config.exporter import ExporterConfigListV1Alpha1, ExporterConfigV1Alpha1, ObjectMeta arg_alias = click.argument("alias", default="default") @@ -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") @@ -53,15 +67,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-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 diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py index f1612ea21..2febbd246 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py @@ -1,20 +1,31 @@ -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", "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..836e44b6d 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py @@ -1,11 +1,14 @@ import asyncio import base64 import logging -from dataclasses import dataclass from typing import Literal, Optional from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference +from pydantic import Field +from .json import JsonBaseModel +from .list import V1Alpha1List +from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi from jumpstarter.config import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers, ObjectMeta @@ -15,46 +18,52 @@ CREATE_CLIENT_COUNT = 10 -@dataclass(kw_only=True) -class V1Alpha1ClientStatus: - credential: Optional[V1ObjectReference] = None +class V1Alpha1ClientStatus(JsonBaseModel): + credential: Optional[SerializeV1ObjectReference] = None endpoint: str -@dataclass(kw_only=True) -class V1Alpha1Client: - api_version: Literal["jumpstarter.dev/v1alpha1"] - kind: Literal["Client"] - metadata: V1ObjectMeta - status: V1Alpha1ClientStatus - - -class ClientsV1Alpha1Api(AbstractAsyncCustomObjectApi): - """Interact with the clients custom resource API""" +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] @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"].get("endpoint", ""), ) - if "status" in result - else V1Alpha1ClientStatus(credential=None, endpoint=""), + 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.get("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: @@ -85,24 +94,24 @@ 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") - 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 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/clients_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py index 7641b8e6e..d42383294 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py @@ -1,2 +1,58 @@ -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": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "name": "test-client", + "namespace": "default", + "resourceVersion": "1", + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8" + }, + "status": { + "credential": null, + "endpoint": "https://test-client" + } +}""" + ) + + +def test_client_dump_yaml(): + assert ( + TEST_CLIENT.dump_yaml() + == """apiVersion: jumpstarter.dev/v1alpha1 +kind: Client +metadata: + creationTimestamp: '2021-10-01T00:00:00Z' + generation: 1 + name: test-client + namespace: default + resourceVersion: '1' + uid: 7a25eb81-6443-47ec-a62f-50165bffede8 +status: + credential: null + endpoint: https://test-client +""" + ) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py index 03a473c01..56cb3af84 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py @@ -1,10 +1,13 @@ import asyncio import base64 -from dataclasses import dataclass from typing import Literal from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference +from pydantic import Field +from .json import JsonBaseModel +from .list import V1Alpha1List +from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi from jumpstarter.config import ExporterConfigV1Alpha1, ObjectMeta @@ -12,69 +15,72 @@ CREATE_EXPORTER_COUNT = 10 -@dataclass(kw_only=True) -class V1Alpha1ExporterDevice: +class V1Alpha1ExporterDevice(JsonBaseModel): labels: dict[str, str] uuid: str -@dataclass(kw_only=True) -class V1Alpha1ExporterStatus: - credential: V1ObjectReference - endpoint: str +class V1Alpha1ExporterStatus(JsonBaseModel): + credential: SerializeV1ObjectReference devices: list[V1Alpha1ExporterDevice] + endpoint: str -@dataclass(kw_only=True) -class V1Alpha1Exporter: - api_version: Literal["jumpstarter.dev/v1alpha1"] - kind: Literal["Exporter"] - metadata: V1ObjectMeta +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 - -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 [], ), ) - async def list_exporters(self) -> list[V1Alpha1Exporter]: + +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 [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 @@ -106,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/exporters_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py new file mode 100644 index 000000000..78104c0ad --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py @@ -0,0 +1,77 @@ +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": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "name": "test-exporter", + "namespace": "default", + "resourceVersion": "1", + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8" + }, + "status": { + "credential": { + "name": "test-credential" + }, + "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: + creationTimestamp: '2021-10-01T00:00:00Z' + generation: 1 + name: test-exporter + namespace: default + resourceVersion: '1' + uid: 7a25eb81-6443-47ec-a62f-50165bffede8 +status: + credential: + name: test-credential + devices: + - labels: + test: label + uuid: f4cf49ab-fc64-46c6-94e7-a40502eb77b1 + endpoint: https://test-exporter +""" + ) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/json.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/json.py new file mode 100644 index 000000000..872c6696e --- /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(mode="json", 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 38f0a1396..f57b9fe40 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py @@ -1,59 +1,56 @@ -import pprint -from dataclasses import dataclass from typing import Literal, Optional from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference +from pydantic import Field +from .json import JsonBaseModel +from .list import V1Alpha1List +from .serialize import SerializeV1Condition, SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi -@dataclass(kw_only=True) -class V1Alpha1LeaseStatus: - begin_time: str - end_time: Optional[str] +class V1Alpha1LeaseStatus(JsonBaseModel): + begin_time: Optional[str] = Field(alias="beginTime") + conditions: list[SerializeV1Condition] + end_time: Optional[str] = Field(alias="endTime") ended: bool - exporter: Optional[V1ObjectReference] - conditions: list[V1Condition] + exporter: Optional[SerializeV1ObjectReference] -@dataclass(kw_only=True) -class V1Alpha1LeaseSpec: - client: V1ObjectReference +class V1Alpha1LeaseSpec(JsonBaseModel): + client: SerializeV1ObjectReference duration: Optional[str] selector: dict[str, str] -@dataclass(kw_only=True) -class V1Alpha1Lease: - api_version: Literal["jumpstarter.dev/v1alpha1"] - kind: Literal["Lease"] - metadata: V1ObjectMeta - status: V1Alpha1LeaseStatus +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 - - -class LeasesV1Alpha1Api(AbstractAsyncCustomObjectApi): - """Interact with the leases custom resource API""" + status: V1Alpha1LeaseStatus @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( @@ -64,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"], ), ) - async def list_leases(self) -> list[V1Alpha1Lease]: + +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 [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 new file mode 100644 index 000000000..ffeb532c9 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py @@ -0,0 +1,13 @@ +from typing import Literal + +from pydantic import Field + +from .json import JsonBaseModel + + +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") diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py new file mode 100644 index 000000000..35173600d --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py @@ -0,0 +1,14 @@ +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]: + 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)] +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..456359e54 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py @@ -0,0 +1,114 @@ +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(): + print(TEST_LEASE.dump_json()) + assert ( + TEST_LEASE.dump_json() + == """{ + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Lease", + "metadata": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "name": "test-lease", + "namespace": "default", + "resourceVersion": "1", + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8" + }, + "spec": { + "client": { + "name": "test-client" + }, + "duration": "1h", + "selector": { + "test": "label", + "another": "something" + } + }, + "status": { + "beginTime": "2021-10-01T00:00:00Z", + "conditions": [ + { + "lastTransitionTime": "2021-10-01T00:00:00Z", + "message": "", + "reason": "", + "status": "True", + "type": "Active" + } + ], + "endTime": "2021-10-01T01:00:00Z", + "ended": false, + "exporter": { + "name": "test-exporter" + } + } +}""" + ) + + +def test_lease_dump_yaml(): + print(TEST_LEASE.dump_yaml()) + assert ( + TEST_LEASE.dump_yaml() + == """apiVersion: jumpstarter.dev/v1alpha1 +kind: Lease +metadata: + creationTimestamp: '2021-10-01T00:00:00Z' + generation: 1 + name: test-lease + namespace: default + resourceVersion: '1' + uid: 7a25eb81-6443-47ec-a62f-50165bffede8 +spec: + client: + name: test-client + duration: 1h + selector: + another: something + test: label +status: + beginTime: '2021-10-01T00:00:00Z' + conditions: + - lastTransitionTime: '2021-10-01T00:00:00Z' + message: '' + reason: '' + status: 'True' + type: Active + endTime: '2021-10-01T01:00:00Z' + ended: false + exporter: + name: test-exporter +""" + ) diff --git a/packages/jumpstarter-kubernetes/pyproject.toml b/packages/jumpstarter-kubernetes/pyproject.toml index fd1d4045e..8d7fd4489 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>=2.8.2", + "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/packages/jumpstarter/jumpstarter/config/__init__.py b/packages/jumpstarter/jumpstarter/config/__init__.py index fbdb65b86..b61613a8d 100644 --- a/packages/jumpstarter/jumpstarter/config/__init__.py +++ b/packages/jumpstarter/jumpstarter/config/__init__.py @@ -1,10 +1,11 @@ from .client import ( + ClientConfigListV1Alpha1, ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers, ) 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__ = [ @@ -18,8 +19,10 @@ "ObjectMeta", "UserConfigV1Alpha1", "UserConfigV1Alpha1Config", + "ClientConfigListV1Alpha1", "ClientConfigV1Alpha1", "ClientConfigV1Alpha1Drivers", + "ExporterConfigListV1Alpha1", "ExporterConfigV1Alpha1", "ExporterConfigV1Alpha1DriverInstance", ] diff --git a/packages/jumpstarter/jumpstarter/config/client.py b/packages/jumpstarter/jumpstarter/config/client.py index 2a793e398..e1bd5dc92 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") @@ -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: @@ -193,11 +193,12 @@ def save(cls, config: Self, path: Optional[os.PathLike] = None): 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: @@ -222,9 +223,25 @@ 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): + 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) diff --git a/packages/jumpstarter/jumpstarter/config/exporter.py b/packages/jumpstarter/jumpstarter/config/exporter.py index eeaee3674..f9f7ca091 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,10 +114,10 @@ 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): + 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) @@ -125,11 +125,14 @@ def save(cls, config: Self, path: Optional[str] = None): 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 - 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): @@ -165,3 +168,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) diff --git a/packages/jumpstarter/jumpstarter/config/user.py b/packages/jumpstarter/jumpstarter/config/user.py index dec027783..343e691f7 100644 --- a/packages/jumpstarter/jumpstarter/config/user.py +++ b/packages/jumpstarter/jumpstarter/config/user.py @@ -82,16 +82,21 @@ 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 or cls.USER_CONFIG_PATH - def use_client(self, name: Optional[str]): + 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) + if self.config.current_client is not None: + return self.config.current_client.path + else: + return None 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]