From d0a307a00b0be23cdb9249e3afbd552d22cab0e6 Mon Sep 17 00:00:00 2001 From: Nick Cao Date: Tue, 11 Mar 2025 15:02:16 -0400 Subject: [PATCH 1/6] Fully implement kubectl style commands on client cli --- .../jumpstarter_cli_client/__init__.py | 12 ++- .../jumpstarter_cli_client/client_exporter.py | 46 --------- .../jumpstarter_cli_client/client_lease.py | 96 ------------------- .../jumpstarter_cli_client/client_shell.py | 26 ++--- .../jumpstarter_cli_client/common.py | 34 +++++++ .../jumpstarter_cli_client/create.py | 78 +++++++++++++++ .../jumpstarter_cli_client/delete.py | 46 +++++++++ .../jumpstarter_cli_client/get.py | 86 +++++++++++++++++ .../jumpstarter_cli_client/update.py | 53 ++++++++++ .../jumpstarter_cli_common/exceptions.py | 4 + .../jumpstarter/jumpstarter/config/client.py | 53 +++++++++- 11 files changed, 366 insertions(+), 168 deletions(-) delete mode 100644 packages/jumpstarter-cli-client/jumpstarter_cli_client/client_exporter.py delete mode 100644 packages/jumpstarter-cli-client/jumpstarter_cli_client/client_lease.py create mode 100644 packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py create mode 100644 packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py create mode 100644 packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py create mode 100644 packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py create mode 100644 packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/__init__.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/__init__.py index 0c8fef955..79ea4d292 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/__init__.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/__init__.py @@ -6,11 +6,13 @@ from jumpstarter_cli_common import AliasedGroup, opt_log_level, version from jumpstarter_cli_common.exceptions import handle_exceptions -from .client_exporter import list_client_exporters -from .client_lease import client_lease from .client_login import client_login from .client_shell import client_shell from .config import config +from .create import create +from .delete import delete +from .get import get +from .update import update from jumpstarter.common.utils import env @@ -38,9 +40,11 @@ def cli(): sys.exit(1) -client.add_command(list_client_exporters) client.add_command(config) -client.add_command(client_lease) +client.add_command(create) +client.add_command(get) +client.add_command(delete) +client.add_command(update) client.add_command(client_login) client.add_command(client_shell) client.add_command(version) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_exporter.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_exporter.py deleted file mode 100644 index 632b6d6d7..000000000 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_exporter.py +++ /dev/null @@ -1,46 +0,0 @@ -import asyncclick as click -from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_labels, opt_output_all -from jumpstarter_cli_common.exceptions import handle_exceptions - -from jumpstarter.config import ( - ClientConfigV1Alpha1, - UserConfigV1Alpha1, -) - - -@click.command("list-exporters", short_help="List available exporters.") -@click.argument("name", type=str, default="") -@opt_labels -@opt_output_all -@handle_exceptions -def list_client_exporters(name: str | None, labels: list[(str, str)], output: OutputType): - if name: - config = ClientConfigV1Alpha1.load(name) - else: - config = UserConfigV1Alpha1.load_or_create().config.current_client - if not config: - raise click.BadParameter( - "no client specified, and no default client set: specify a client name, or use jmp client config use", - param_hint="name", - ) - - exporters = config.list_exporters(filter=",".join("{}={}".format(i[0], i[1]) for i in labels)) - - if output == OutputMode.JSON: - click.echo(exporters.dump_json()) - elif output == OutputMode.YAML: - click.echo(exporters.dump_yaml()) - elif output == OutputMode.NAME: - for exporter in exporters.exporters: - click.echo(exporter.name) - else: - columns = ["NAME", "LABELS"] - - def make_row(exporter): - return { - "NAME": exporter.name, - "LABELS": ",".join(("{}={}".format(i[0], i[1]) for i in exporter.labels.items())), - } - - rows = list(map(make_row, exporters.exporters)) - click.echo(make_table(columns, rows)) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_lease.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_lease.py deleted file mode 100644 index 033ccdeee..000000000 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_lease.py +++ /dev/null @@ -1,96 +0,0 @@ -import asyncclick as click -from jumpstarter_cli_common import AliasedGroup -from jumpstarter_cli_common.exceptions import handle_exceptions - -from jumpstarter.common import MetadataFilter -from jumpstarter.config import ( - ClientConfigV1Alpha1, - UserConfigV1Alpha1, -) - - -@click.group(name="lease", cls=AliasedGroup, short_help="") -def client_lease(): - """Manage leases held by the current client""" - pass - - -@client_lease.command("list") -@click.argument("name", type=str, default="") -@handle_exceptions -def lease_list(name): - if name: - config = ClientConfigV1Alpha1.load(name) - else: - config = UserConfigV1Alpha1.load_or_create().config.current_client - if not config: - raise ValueError("no client specified") - - for lease in config.list_leases(): - print(lease) - - -@client_lease.command("release") -@click.argument("name", type=str, default="") -@click.option("-l", "--lease", "lease", type=str, default="") -@click.option("--all", "all_leases", is_flag=True) -@handle_exceptions -def lease_release(name, lease, all_leases): - if name: - config = ClientConfigV1Alpha1.load(name) - else: - config = UserConfigV1Alpha1.load_or_create().config.current_client - if not config: - raise click.BadParameter( - "no client specified, and no default client set: specify a client name, or use jmp client config use", - param_hint="name", - ) - - if all_leases: - for lease in config.list_leases(): - config.release_lease(lease) - else: - if not lease: - raise click.BadParameter( - "no lease specified, provide one or use --all to release all leases", param_hint="lease" - ) - config.release_lease(lease) - - -@client_lease.command("request") -@click.option("-l", "--label", "labels", type=(str, str), multiple=True) -@click.argument("name", type=str, default="") -@handle_exceptions -def lease_request(name, labels): - """Request an exporter lease from the jumpstarter controller. - - The result of this command will be a lease ID that can be used to - connect to the remote exporter. - - This is useful for multi-step workflows where you want to hold a lease - for a specific exporter while performing multiple operations, or for - CI environments where one step will request the lease and other steps - will perform operations on the leased exporter. - - Example: - - .. code-block:: bash - - $ JMP_LEASE=$(jmp lease request -l label match) - $ jmp shell - $$ j --help - $$ exit - $ jmp lease release -l "${JMP_LEASE}" - - """ - if name: - config = ClientConfigV1Alpha1.load(name) - else: - config = UserConfigV1Alpha1.load_or_create().config.current_client - if not config: - raise click.BadParameter( - "no client specified, and no default client set: specify a client name, or use jmp client config use", - param_hint="name", - ) - lease = config.request_lease(metadata_filter=MetadataFilter(labels=dict(labels))) - print(lease.name) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_shell.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_shell.py index 1ef884920..c7fc150f9 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_shell.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_shell.py @@ -3,33 +3,25 @@ import asyncclick as click from jumpstarter_cli_common.exceptions import handle_exceptions +from .common import load_context, opt_context, opt_selector_simple, selector_to_labels from jumpstarter.common import MetadataFilter from jumpstarter.common.utils import launch_shell -from jumpstarter.config import ( - ClientConfigV1Alpha1, - UserConfigV1Alpha1, -) @click.command("shell", short_help="Spawns a shell connecting to a leased remote exporter") -@click.argument("name", type=str, default="") -@click.option("-l", "--label", "labels", type=(str, str), multiple=True) @click.option("-n", "--lease", "lease_name", type=str) +@opt_context +@opt_selector_simple @handle_exceptions -def client_shell(name: str, labels, lease_name): +def client_shell(context: str | None, selector: str, lease_name): """Spawns a shell connecting to a leased remote exporter""" - if name: - config = ClientConfigV1Alpha1.load(name) - else: - config = UserConfigV1Alpha1.load_or_create().config.current_client - if not config: - raise click.BadParameter( - "no client specified, and no default client set: specify a client name, or use jmp client config use", - param_hint="name", - ) + config = load_context(context) exit_code = 0 - with config.lease(metadata_filter=MetadataFilter(labels=dict(labels)), lease_name=lease_name) as lease: + + with config.lease( + metadata_filter=MetadataFilter(labels=selector_to_labels(selector)), lease_name=lease_name + ) as lease: with lease.serve_unix() as path: with lease.monitor(): exit_code = launch_shell(path, "remote", config.drivers.allow, config.drivers.unsafe) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py new file mode 100644 index 000000000..ea82b49ed --- /dev/null +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py @@ -0,0 +1,34 @@ +import asyncclick as click + +from jumpstarter.config import ( + ClientConfigV1Alpha1, + UserConfigV1Alpha1, +) + +opt_context = click.option("--client", "--context", "context", help="Name of client config") + +opt_selector_simple = click.option( + "-l", + "--selector", + help="Selector (label query) to filter on, only supports '=', (e.g. -l key1=value1,key2=value2)." + " Matching objects must satisfy all of the specified label constraints.", + required=True, +) + + +def selector_to_labels(selector: str): + # TODO: support complex selectors (e.g. !=) + return dict([term.split("=") for term in selector.split(",")]) + + +def load_context(context: str | None) -> ClientConfigV1Alpha1: + if context: + config = ClientConfigV1Alpha1.load(context) + else: + config = UserConfigV1Alpha1.load_or_create().config.current_client + if not config: + raise click.BadOptionUsage( + "--context", + "no client context specified, and no default client context set", + ) + return config diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py new file mode 100644 index 000000000..85bbe4756 --- /dev/null +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py @@ -0,0 +1,78 @@ +from datetime import timedelta + +import asyncclick as click +from jumpstarter_cli_common import ( + OutputMode, + OutputType, + make_table, + opt_output_all, +) +from jumpstarter_cli_common.exceptions import handle_exceptions +from pydantic import TypeAdapter + +from .common import load_context, opt_context, opt_selector_simple + + +@click.group() +def create(): + """ + Create a resource + """ + + +@create.command(name="lease") +@opt_context +@opt_selector_simple +@click.option("--duration", "duration", type=str, required=True) +@opt_output_all +@handle_exceptions +async def create_lease(context: str | None, selector: str, duration: str, output: OutputType): + """ + Create a lease + + Request an exporter lease from the jumpstarter controller. + + The result of this command will be a lease ID that can be used to + connect to the remote exporter. + + This is useful for multi-step workflows where you want to hold a lease + for a specific exporter while performing multiple operations, or for + CI environments where one step will request the lease and other steps + will perform operations on the leased exporter. + + Example: + + .. code-block:: bash + + $ JMP_LEASE=$(jmp client create lease -l foo=bar --duration 1d --output name) + $ jmp shell + $$ j --help + $$ exit + $ jmp client delete lease "${JMP_LEASE}" + + """ + config = load_context(context) + + duration = TypeAdapter(timedelta).validate_python(duration) + + lease = config.create_lease(selector=selector, duration=duration) + + match output: + case OutputMode.JSON: + click.echo(lease.dump_json()) + case OutputMode.YAML: + click.echo(lease.dump_yaml()) + case OutputMode.NAME: + click.echo(lease.name) + case _: + columns = ["NAME", "SELECTOR", "DURATION", "CLIENT", "EXPORTER"] + rows = [ + { + "NAME": lease.name, + "SELECTOR": lease.selector, + "DURATION": str(lease.duration.total_seconds()), + "CLIENT": lease.client, + "EXPORTER": lease.exporter, + } + ] + click.echo(make_table(columns, rows)) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py new file mode 100644 index 000000000..06ca84a7a --- /dev/null +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py @@ -0,0 +1,46 @@ +import asyncclick as click +from jumpstarter_cli_common import OutputMode, OutputType, opt_output_name_only +from jumpstarter_cli_common.exceptions import handle_exceptions + +from .common import load_context, opt_context + + +@click.group() +def delete(): + """ + Delete resources + """ + + +@delete.command(name="leases") +@opt_context +@click.argument("name", required=False) +@click.option("--all", "all", is_flag=True) +@opt_output_name_only +@handle_exceptions +def delete_leases(context: str | None, name: str, all: bool, output: OutputType): + """ + Delete leases + """ + + config = load_context(context) + + names = [] + + if name is not None: + names.append(name) + elif all: + leases = config.list_leases(filter="") + for lease in leases.leases: + if lease.client == config.metadata.name: + names.append(lease.name) + else: + raise click.ClickException("One of NAME or --all must be specified") + + for name in names: + config.delete_lease(name=name) + match output: + case OutputMode.NAME: + click.echo(name) + case _: + click.echo('lease "{}" deleted'.format(name)) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py new file mode 100644 index 000000000..bc4821a0c --- /dev/null +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py @@ -0,0 +1,86 @@ +import asyncclick as click +from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_output_all +from jumpstarter_cli_common.exceptions import handle_exceptions + +from .common import load_context, opt_context + +opt_selector = click.option( + "-l", + "--selector", + help="Selector (label query) to filter on, supports '=', '==', and '!=' (e.g. -l key1=value1,key2=value2)." + " Matching objects must satisfy all of the specified label constraints.", +) + + +@click.group() +def get(): + """ + Display one or many resources + """ + + +@get.command(name="exporters") +@opt_context +@opt_selector +@opt_output_all +@handle_exceptions +def get_exporters(context: str | None, selector: str | None, output: OutputType): + """ + Display one or many exporters + """ + config = load_context(context) + + exporters = config.list_exporters(filter=selector) + + match output: + case OutputMode.JSON: + click.echo(exporters.dump_json()) + case OutputMode.YAML: + click.echo(exporters.dump_yaml()) + case OutputMode.NAME: + for exporter in exporters.exporters: + click.echo(exporter.name) + case _: + columns = ["NAME", "LABELS"] + rows = [ + { + "NAME": exporter.name, + "LABELS": ",".join(("{}={}".format(i[0], i[1]) for i in exporter.labels.items())), + } + for exporter in exporters.exporters + ] + click.echo(make_table(columns, rows)) + + +@get.command(name="leases") +@opt_context +@opt_selector +@opt_output_all +@handle_exceptions +def get_leases(context: str | None, selector: str | None, output: OutputType): + """ + Display one or many leases + """ + config = load_context(context) + + leases = config.list_leases(filter=selector) + + match output: + case OutputMode.JSON: + click.echo(leases.dump_json()) + case OutputMode.YAML: + click.echo(leases.dump_yaml()) + case OutputMode.NAME: + for lease in leases.leases: + click.echo(lease.name) + case _: + columns = ["NAME", "CLIENT", "EXPORTER"] + rows = [ + { + "NAME": lease.name, + "CLIENT": lease.client, + "EXPORTER": lease.exporter, + } + for lease in leases.leases + ] + click.echo(make_table(columns, rows)) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py new file mode 100644 index 000000000..c5f4acc04 --- /dev/null +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py @@ -0,0 +1,53 @@ +from datetime import timedelta + +import asyncclick as click +from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_output_all +from jumpstarter_cli_common.exceptions import handle_exceptions +from pydantic import TypeAdapter + +from .common import load_context, opt_context + + +@click.group() +def update(): + """ + Update a resource + """ + + +@update.command(name="lease") +@opt_context +@click.argument("name") +@click.option("--duration", "duration", type=str, required=True) +@opt_output_all +@handle_exceptions +async def update_lease(context: str | None, name: str, duration: str, output: OutputType): + """ + Update a lease + """ + + config = load_context(context) + + duration = TypeAdapter(timedelta).validate_python(duration) + + lease = config.update_lease(name, duration) + + match output: + case OutputMode.JSON: + click.echo(lease.dump_json()) + case OutputMode.YAML: + click.echo(lease.dump_yaml()) + case OutputMode.NAME: + click.echo(lease.name) + case _: + columns = ["NAME", "SELECTOR", "DURATION", "CLIENT", "EXPORTER"] + rows = [ + { + "NAME": lease.name, + "SELECTOR": lease.selector, + "DURATION": str(lease.duration.total_seconds()), + "CLIENT": lease.client, + "EXPORTER": lease.exporter, + } + ] + click.echo(make_table(columns, rows)) diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/exceptions.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/exceptions.py index fd2a9a85f..07b91b3ad 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/exceptions.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/exceptions.py @@ -1,3 +1,5 @@ +from functools import wraps + import asyncclick as click from jumpstarter.common.exceptions import JumpstarterException @@ -11,6 +13,7 @@ def format_message(self) -> str: def async_handle_exceptions(func): """Decorator to handle exceptions in async functions.""" + @wraps(func) async def wrapped(*args, **kwargs): try: return await func(*args, **kwargs) @@ -27,6 +30,7 @@ async def wrapped(*args, **kwargs): def handle_exceptions(func): """Decorator to handle exceptions in blocking functions.""" + @wraps(func) def wrapped(*args, **kwargs): try: return func(*args, **kwargs) diff --git a/packages/jumpstarter/jumpstarter/config/client.py b/packages/jumpstarter/jumpstarter/config/client.py index 6e0346339..715038daf 100644 --- a/packages/jumpstarter/jumpstarter/config/client.py +++ b/packages/jumpstarter/jumpstarter/config/client.py @@ -1,5 +1,6 @@ import os from contextlib import asynccontextmanager, contextmanager +from datetime import timedelta from pathlib import Path from typing import ClassVar, Literal, Optional, Self @@ -83,9 +84,28 @@ def request_lease(self, metadata_filter: MetadataFilter): with start_blocking_portal() as portal: return portal.call(self.request_lease_async, metadata_filter, portal) - def list_leases(self): + def list_leases(self, filter: str): with start_blocking_portal() as portal: - return portal.call(self.list_leases_async) + return portal.call(self.list_leases_async, filter) + + def create_lease( + self, + selector: str, + duration: timedelta, + ): + with start_blocking_portal() as portal: + return portal.call(self.create_lease_async, selector, duration) + + def delete_lease( + self, + name: str, + ): + with start_blocking_portal() as portal: + return portal.call(self.delete_lease_async, name) + + def update_lease(self, name, duration: timedelta): + with start_blocking_portal() as portal: + return portal.call(self.update_lease_async, name, duration) def release_lease(self, name): with start_blocking_portal() as portal: @@ -106,6 +126,25 @@ async def list_exporters_async( with translate_grpc_exceptions(): return await svc.ListExporters(page_size=page_size, page_token=page_token, filter=filter) + async def create_lease_async( + self, + selector: str, + duration: timedelta, + ): + svc = ClientService(channel=await self.channel(), namespace=self.metadata.namespace) + with translate_grpc_exceptions(): + return await svc.CreateLease( + selector=selector, + duration=duration, + ) + + async def delete_lease_async(self, name: str): + svc = ClientService(channel=await self.channel(), namespace=self.metadata.namespace) + with translate_grpc_exceptions(): + await svc.DeleteLease( + name=name, + ) + async def request_lease_async( self, metadata_filter: MetadataFilter, @@ -128,11 +167,15 @@ async def request_lease_async( with translate_grpc_exceptions(): return await lease.request_async() - async def list_leases_async(self): + async def list_leases_async(self, filter: str): + svc = ClientService(channel=await self.channel(), namespace=self.metadata.namespace) + with translate_grpc_exceptions(): + return await svc.ListLeases(filter=filter) + + async def update_lease_async(self, name, duration: timedelta): svc = ClientService(channel=await self.channel(), namespace=self.metadata.namespace) with translate_grpc_exceptions(): - result = await svc.ListLeases() - return [lease.name for lease in result.leases] + return await svc.UpdateLease(name=name, duration=duration) async def release_lease_async(self, name): svc = ClientService(channel=await self.channel(), namespace=self.metadata.namespace) From e6622154ae3fa89a3eee3544ae40364d0b29fbc5 Mon Sep 17 00:00:00 2001 From: Nick Cao Date: Mon, 17 Mar 2025 11:07:40 -0400 Subject: [PATCH 2/6] Implement custom DURATION type --- .../jumpstarter_cli_client/common.py | 19 +++++++++++++++++++ .../jumpstarter_cli_client/create.py | 9 +++------ .../jumpstarter_cli_client/update.py | 9 +++------ 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py index ea82b49ed..250871b71 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py @@ -1,4 +1,7 @@ +from datetime import timedelta + import asyncclick as click +from pydantic import TypeAdapter from jumpstarter.config import ( ClientConfigV1Alpha1, @@ -32,3 +35,19 @@ def load_context(context: str | None) -> ClientConfigV1Alpha1: "no client context specified, and no default client context set", ) return config + + +class DurationParamType(click.ParamType): + name = "duration" + + def convert(self, value, param, ctx): + if isinstance(value, timedelta): + return value + + try: + return TypeAdapter(timedelta).validate_python(value) + except ValueError: + self.fail(f"{value!r} is not a valid duration", param, ctx) + + +DURATION = DurationParamType() diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py index 85bbe4756..2d2788f9d 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py @@ -8,9 +8,8 @@ opt_output_all, ) from jumpstarter_cli_common.exceptions import handle_exceptions -from pydantic import TypeAdapter -from .common import load_context, opt_context, opt_selector_simple +from .common import DURATION, load_context, opt_context, opt_selector_simple @click.group() @@ -23,10 +22,10 @@ def create(): @create.command(name="lease") @opt_context @opt_selector_simple -@click.option("--duration", "duration", type=str, required=True) +@click.option("--duration", "duration", type=DURATION, required=True) @opt_output_all @handle_exceptions -async def create_lease(context: str | None, selector: str, duration: str, output: OutputType): +async def create_lease(context: str | None, selector: str, duration: timedelta, output: OutputType): """ Create a lease @@ -53,8 +52,6 @@ async def create_lease(context: str | None, selector: str, duration: str, output """ config = load_context(context) - duration = TypeAdapter(timedelta).validate_python(duration) - lease = config.create_lease(selector=selector, duration=duration) match output: diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py index c5f4acc04..2c361033c 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py @@ -3,9 +3,8 @@ import asyncclick as click from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_output_all from jumpstarter_cli_common.exceptions import handle_exceptions -from pydantic import TypeAdapter -from .common import load_context, opt_context +from .common import DURATION, load_context, opt_context @click.group() @@ -18,18 +17,16 @@ def update(): @update.command(name="lease") @opt_context @click.argument("name") -@click.option("--duration", "duration", type=str, required=True) +@click.option("--duration", "duration", type=DURATION, required=True) @opt_output_all @handle_exceptions -async def update_lease(context: str | None, name: str, duration: str, output: OutputType): +async def update_lease(context: str | None, name: str, duration: timedelta, output: OutputType): """ Update a lease """ config = load_context(context) - duration = TypeAdapter(timedelta).validate_python(duration) - lease = config.update_lease(name, duration) match output: From 857172295ae521678b094edaff33b4c4fbee6757 Mon Sep 17 00:00:00 2001 From: Nick Cao Date: Mon, 17 Mar 2025 11:22:06 -0400 Subject: [PATCH 3/6] Dedup client config handling --- .../jumpstarter_cli_client/client_shell.py | 7 ++--- .../jumpstarter_cli_client/common.py | 30 +++++++++++-------- .../jumpstarter_cli_client/create.py | 7 ++--- .../jumpstarter_cli_client/delete.py | 8 ++--- .../jumpstarter_cli_client/get.py | 12 ++++---- .../jumpstarter_cli_client/update.py | 8 ++--- 6 files changed, 34 insertions(+), 38 deletions(-) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_shell.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_shell.py index c7fc150f9..5d0168f73 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_shell.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_shell.py @@ -3,19 +3,18 @@ import asyncclick as click from jumpstarter_cli_common.exceptions import handle_exceptions -from .common import load_context, opt_context, opt_selector_simple, selector_to_labels +from .common import opt_config, opt_selector_simple, selector_to_labels from jumpstarter.common import MetadataFilter from jumpstarter.common.utils import launch_shell @click.command("shell", short_help="Spawns a shell connecting to a leased remote exporter") @click.option("-n", "--lease", "lease_name", type=str) -@opt_context +@opt_config @opt_selector_simple @handle_exceptions -def client_shell(context: str | None, selector: str, lease_name): +def client_shell(config, selector: str, lease_name): """Spawns a shell connecting to a leased remote exporter""" - config = load_context(context) exit_code = 0 diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py index 250871b71..19b317835 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py @@ -8,8 +8,6 @@ UserConfigV1Alpha1, ) -opt_context = click.option("--client", "--context", "context", help="Name of client config") - opt_selector_simple = click.option( "-l", "--selector", @@ -24,17 +22,20 @@ def selector_to_labels(selector: str): return dict([term.split("=") for term in selector.split(",")]) -def load_context(context: str | None) -> ClientConfigV1Alpha1: - if context: - config = ClientConfigV1Alpha1.load(context) - else: - config = UserConfigV1Alpha1.load_or_create().config.current_client - if not config: - raise click.BadOptionUsage( - "--context", - "no client context specified, and no default client context set", - ) - return config +class ClientParamType(click.ParamType): + name = "client" + + def convert(self, value, param, ctx): + if isinstance(value, ClientConfigV1Alpha1): + return value + + if isinstance(value, bool): # hack to allow loading the default config + config = UserConfigV1Alpha1.load_or_create().config.current_client + if config is None: + self.fail("no client config specified, and no default client config set", param, ctx) + return config + else: + return ClientConfigV1Alpha1.load(value) class DurationParamType(click.ParamType): @@ -51,3 +52,6 @@ def convert(self, value, param, ctx): DURATION = DurationParamType() +CLIENT = ClientParamType() + +opt_config = click.option("--client", "config", type=CLIENT, default=False, help="Name of client config") diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py index 2d2788f9d..a6cc2e315 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py @@ -9,7 +9,7 @@ ) from jumpstarter_cli_common.exceptions import handle_exceptions -from .common import DURATION, load_context, opt_context, opt_selector_simple +from .common import DURATION, opt_config, opt_selector_simple @click.group() @@ -20,12 +20,12 @@ def create(): @create.command(name="lease") -@opt_context +@opt_config @opt_selector_simple @click.option("--duration", "duration", type=DURATION, required=True) @opt_output_all @handle_exceptions -async def create_lease(context: str | None, selector: str, duration: timedelta, output: OutputType): +async def create_lease(config, selector: str, duration: timedelta, output: OutputType): """ Create a lease @@ -50,7 +50,6 @@ async def create_lease(context: str | None, selector: str, duration: timedelta, $ jmp client delete lease "${JMP_LEASE}" """ - config = load_context(context) lease = config.create_lease(selector=selector, duration=duration) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py index 06ca84a7a..cd08acb73 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py @@ -2,7 +2,7 @@ from jumpstarter_cli_common import OutputMode, OutputType, opt_output_name_only from jumpstarter_cli_common.exceptions import handle_exceptions -from .common import load_context, opt_context +from .common import opt_config @click.group() @@ -13,18 +13,16 @@ def delete(): @delete.command(name="leases") -@opt_context +@opt_config @click.argument("name", required=False) @click.option("--all", "all", is_flag=True) @opt_output_name_only @handle_exceptions -def delete_leases(context: str | None, name: str, all: bool, output: OutputType): +def delete_leases(config, name: str, all: bool, output: OutputType): """ Delete leases """ - config = load_context(context) - names = [] if name is not None: diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py index bc4821a0c..91fd9914f 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py @@ -2,7 +2,7 @@ from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_output_all from jumpstarter_cli_common.exceptions import handle_exceptions -from .common import load_context, opt_context +from .common import opt_config opt_selector = click.option( "-l", @@ -20,15 +20,14 @@ def get(): @get.command(name="exporters") -@opt_context +@opt_config @opt_selector @opt_output_all @handle_exceptions -def get_exporters(context: str | None, selector: str | None, output: OutputType): +def get_exporters(config, selector: str | None, output: OutputType): """ Display one or many exporters """ - config = load_context(context) exporters = config.list_exporters(filter=selector) @@ -53,15 +52,14 @@ def get_exporters(context: str | None, selector: str | None, output: OutputType) @get.command(name="leases") -@opt_context +@opt_config @opt_selector @opt_output_all @handle_exceptions -def get_leases(context: str | None, selector: str | None, output: OutputType): +def get_leases(config, selector: str | None, output: OutputType): """ Display one or many leases """ - config = load_context(context) leases = config.list_leases(filter=selector) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py index 2c361033c..9b899d268 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py @@ -4,7 +4,7 @@ from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_output_all from jumpstarter_cli_common.exceptions import handle_exceptions -from .common import DURATION, load_context, opt_context +from .common import DURATION, opt_config @click.group() @@ -15,18 +15,16 @@ def update(): @update.command(name="lease") -@opt_context +@opt_config @click.argument("name") @click.option("--duration", "duration", type=DURATION, required=True) @opt_output_all @handle_exceptions -async def update_lease(context: str | None, name: str, duration: timedelta, output: OutputType): +async def update_lease(config, name: str, duration: timedelta, output: OutputType): """ Update a lease """ - config = load_context(context) - lease = config.update_lease(name, duration) match output: From 846f2860bd20ddcefcaa94307a3f4d451946507e Mon Sep 17 00:00:00 2001 From: Nick Cao Date: Mon, 17 Mar 2025 11:26:37 -0400 Subject: [PATCH 4/6] Dedup opt_selector --- .../jumpstarter_cli_client/common.py | 7 +++++++ .../jumpstarter_cli_client/delete.py | 14 ++++++++++---- .../jumpstarter_cli_client/get.py | 9 +-------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py index 19b317835..665f2fe84 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py @@ -8,6 +8,13 @@ UserConfigV1Alpha1, ) +opt_selector = click.option( + "-l", + "--selector", + help="Selector (label query) to filter on, supports '=', '==', and '!=' (e.g. -l key1=value1,key2=value2)." + " Matching objects must satisfy all of the specified label constraints.", +) + opt_selector_simple = click.option( "-l", "--selector", diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py index cd08acb73..063e24532 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py @@ -2,7 +2,7 @@ from jumpstarter_cli_common import OutputMode, OutputType, opt_output_name_only from jumpstarter_cli_common.exceptions import handle_exceptions -from .common import opt_config +from .common import opt_config, opt_selector @click.group() @@ -15,10 +15,11 @@ def delete(): @delete.command(name="leases") @opt_config @click.argument("name", required=False) +@opt_selector @click.option("--all", "all", is_flag=True) @opt_output_name_only @handle_exceptions -def delete_leases(config, name: str, all: bool, output: OutputType): +def delete_leases(config, name: str, selector: str | None, all: bool, output: OutputType): """ Delete leases """ @@ -27,13 +28,18 @@ def delete_leases(config, name: str, all: bool, output: OutputType): if name is not None: names.append(name) + elif selector: + leases = config.list_leases(filter=selector) + for lease in leases.leases: + if lease.client == config.metadata.name: + names.append(lease.name) elif all: - leases = config.list_leases(filter="") + leases = config.list_leases(filter=None) for lease in leases.leases: if lease.client == config.metadata.name: names.append(lease.name) else: - raise click.ClickException("One of NAME or --all must be specified") + raise click.ClickException("One of NAME, --selector or --all must be specified") for name in names: config.delete_lease(name=name) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py index 91fd9914f..4cd27596f 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py @@ -2,14 +2,7 @@ from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_output_all from jumpstarter_cli_common.exceptions import handle_exceptions -from .common import opt_config - -opt_selector = click.option( - "-l", - "--selector", - help="Selector (label query) to filter on, supports '=', '==', and '!=' (e.g. -l key1=value1,key2=value2)." - " Matching objects must satisfy all of the specified label constraints.", -) +from .common import opt_config, opt_selector @click.group() From 660a92dd63a40a90a32455331b4b7719319cdb00 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Tue, 18 Mar 2025 10:37:02 -0400 Subject: [PATCH 5/6] Fix broken jmp admin get leases command with updated CRDs --- .../jumpstarter-cli-admin/jumpstarter_cli_admin/print.py | 6 ++++-- .../jumpstarter_kubernetes/leases.py | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py index 3d42dad12..fc0a4cb38 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py @@ -131,12 +131,14 @@ def get_reason(lease: V1Alpha1Lease): return "Expired" else: return "Complete" + else: + return reason def make_lease_row(lease: V1Alpha1Lease): selectors = [] - for label in lease.spec.selector: - selectors.append(f"{label}:{str(lease.spec.selector[label])}") + for label in lease.spec.selector.match_labels: + selectors.append(f"{label}:{str(lease.spec.selector.match_labels[label])}") return { "NAME": lease.metadata.name, "CLIENT": lease.spec.client.name if lease.spec.client is not None else "", diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py index f57b9fe40..67361684c 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py @@ -17,10 +17,14 @@ class V1Alpha1LeaseStatus(JsonBaseModel): exporter: Optional[SerializeV1ObjectReference] +class V1Alpha1LeaseSelector(JsonBaseModel): + match_labels: dict[str, str] = Field(alias="matchLabels") + + class V1Alpha1LeaseSpec(JsonBaseModel): client: SerializeV1ObjectReference duration: Optional[str] - selector: dict[str, str] + selector: V1Alpha1LeaseSelector class V1Alpha1Lease(JsonBaseModel): @@ -38,7 +42,6 @@ def from_dict(dict: dict): metadata=V1ObjectMeta( 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"], @@ -69,7 +72,7 @@ def from_dict(dict: dict): if "clientRef" in dict["spec"] else None, duration=dict["spec"]["duration"] if "duration" in dict["spec"] else None, - selector=dict["spec"]["selector"], + selector=V1Alpha1LeaseSelector(match_labels=dict["spec"]["selector"]["matchLabels"]), ), ) From fa1828775230aadd053f10de0b686dbf7f5e5b66 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Tue, 18 Mar 2025 10:48:09 -0400 Subject: [PATCH 6/6] Fix tests broken by updated CRDs --- .../jumpstarter_cli_admin/get_test.py | 26 +++++++++++++------ .../jumpstarter_kubernetes/__init__.py | 10 ++++++- .../jumpstarter_kubernetes/test_leases.py | 15 ++++++----- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py index 69ebbfc24..fa7521d12 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py @@ -15,6 +15,7 @@ V1Alpha1ExporterStatus, V1Alpha1Lease, V1Alpha1LeaseList, + V1Alpha1LeaseSelector, V1Alpha1LeaseSpec, V1Alpha1LeaseStatus, ) @@ -837,7 +838,7 @@ async def test_get_exporters_devices(_load_kube_config_mock, list_exporters_mock spec=V1Alpha1LeaseSpec( client=V1ObjectReference(name="test_client"), duration="5m", - selector={"hardware": "rpi4"}, + selector=V1Alpha1LeaseSelector(match_labels={"hardware": "rpi4"}), ), ) @@ -868,7 +869,7 @@ async def test_get_exporters_devices(_load_kube_config_mock, list_exporters_mock spec=V1Alpha1LeaseSpec( client=V1ObjectReference(name="test_client"), duration="1h", - selector={}, + selector=V1Alpha1LeaseSelector(match_labels={}), ), ) @@ -885,7 +886,9 @@ async def test_get_exporters_devices(_load_kube_config_mock, list_exporters_mock "name": "test_client" }, "duration": "1h", - "selector": {} + "selector": { + "matchLabels": {} + } }, "status": { "beginTime": "2024-01-01T21:00:00Z", @@ -918,7 +921,8 @@ async def test_get_exporters_devices(_load_kube_config_mock, list_exporters_mock client: name: test_client duration: 1h - selector: {} + selector: + matchLabels: {} status: beginTime: '2024-01-01T21:00:00Z' conditions: @@ -1022,7 +1026,9 @@ async def test_get_lease(_load_kube_config_mock, get_lease_mock: AsyncMock): }, "duration": "5m", "selector": { - "hardware": "rpi4" + "matchLabels": { + "hardware": "rpi4" + } } }, "status": { @@ -1057,7 +1063,9 @@ async def test_get_lease(_load_kube_config_mock, get_lease_mock: AsyncMock): "name": "test_client" }, "duration": "1h", - "selector": {} + "selector": { + "matchLabels": {} + } }, "status": { "beginTime": "2024-01-01T21:00:00Z", @@ -1096,7 +1104,8 @@ async def test_get_lease(_load_kube_config_mock, get_lease_mock: AsyncMock): name: test_client duration: 5m selector: - hardware: rpi4 + matchLabels: + hardware: rpi4 status: beginTime: '2024-01-01T21:00:00Z' conditions: @@ -1120,7 +1129,8 @@ async def test_get_lease(_load_kube_config_mock, get_lease_mock: AsyncMock): client: name: test_client duration: 1h - selector: {} + selector: + matchLabels: {} status: beginTime: '2024-01-01T21:00:00Z' conditions: diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py index 2febbd246..6a9bf1b76 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py @@ -7,7 +7,14 @@ V1Alpha1ExporterStatus, ) from .install import get_ip_address, helm_installed, install_helm_chart -from .leases import LeasesV1Alpha1Api, V1Alpha1Lease, V1Alpha1LeaseList, V1Alpha1LeaseSpec, V1Alpha1LeaseStatus +from .leases import ( + LeasesV1Alpha1Api, + V1Alpha1Lease, + V1Alpha1LeaseList, + V1Alpha1LeaseSelector, + V1Alpha1LeaseSpec, + V1Alpha1LeaseStatus, +) from .list import V1Alpha1List __all__ = [ @@ -24,6 +31,7 @@ "V1Alpha1Lease", "V1Alpha1LeaseStatus", "V1Alpha1LeaseList", + "V1Alpha1LeaseSelector", "V1Alpha1LeaseSpec", "V1Alpha1List", "get_ip_address", diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py index 456359e54..ef523d38c 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py @@ -1,6 +1,6 @@ from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference -from jumpstarter_kubernetes import V1Alpha1Lease, V1Alpha1LeaseSpec, V1Alpha1LeaseStatus +from jumpstarter_kubernetes import V1Alpha1Lease, V1Alpha1LeaseSelector, V1Alpha1LeaseSpec, V1Alpha1LeaseStatus TEST_LEASE = V1Alpha1Lease( api_version="jumpstarter.dev/v1alpha1", @@ -16,7 +16,7 @@ spec=V1Alpha1LeaseSpec( client=V1ObjectReference(name="test-client"), duration="1h", - selector={"test": "label", "another": "something"}, + selector=V1Alpha1LeaseSelector(match_labels={"test": "label", "another": "something"}), ), status=V1Alpha1LeaseStatus( begin_time="2021-10-01T00:00:00Z", @@ -53,8 +53,10 @@ def test_lease_dump_json(): }, "duration": "1h", "selector": { - "test": "label", - "another": "something" + "matchLabels": { + "test": "label", + "another": "something" + } } }, "status": { @@ -96,8 +98,9 @@ def test_lease_dump_yaml(): name: test-client duration: 1h selector: - another: something - test: label + matchLabels: + another: something + test: label status: beginTime: '2021-10-01T00:00:00Z' conditions: