From 28e4c74aadce2c13b7aeb1e1d4af555e8497d4aa Mon Sep 17 00:00:00 2001 From: Nick Cao Date: Thu, 20 Mar 2025 15:08:50 -0400 Subject: [PATCH] Implement unified cli for client and exporter --- Makefile | 1 - docs/source/api-reference/drivers/flashers.md | 2 +- docs/source/api-reference/drivers/yepkit.md | 2 +- docs/source/cli/clients.md | 4 +- docs/source/cli/reference/index.md | 23 +--- docs/source/cli/reference/jmp-admin.md | 2 +- docs/source/cli/reference/jmp-client.md | 7 -- docs/source/cli/reference/jmp-driver.md | 7 -- docs/source/cli/reference/jmp-exporter.md | 7 -- docs/source/cli/reference/jmp.md | 7 ++ docs/source/cli/run-tests.md | 4 +- docs/source/config/cli.md | 36 +++--- docs/source/config/oidc.md | 10 +- .../getting-started/setup-exporter-client.md | 40 +++++-- .../getting-started/setup-local-exporter.md | 10 +- docs/source/installation/python-package.md | 17 ++- docs/source/introduction/exporters.md | 2 +- packages/jumpstarter-cli-client/README.md | 1 - .../jumpstarter_cli_client/__init__.py | 53 --------- .../jumpstarter_cli_client/__main__.py | 6 - .../jumpstarter_cli_client/client_login.py | 103 ----------------- .../jumpstarter_cli_client/client_shell.py | 27 ----- .../jumpstarter_cli_client/client_test.py | 90 --------------- .../jumpstarter_cli_client/py.typed | 0 .../jumpstarter-cli-client/pyproject.toml | 42 ------- .../jumpstarter_cli_common/__init__.py | 2 + .../jumpstarter_cli_common/config.py | 93 ++++++++++++++++ .../jumpstarter_cli_common/opt.py | 1 - packages/jumpstarter-cli-exporter/README.md | 1 - .../jumpstarter_cli_exporter/__init__.py | 29 ----- .../jumpstarter_cli_exporter/__main__.py | 6 - .../jumpstarter_cli_exporter/exporter.py | 63 ----------- .../exporter_login.py | 59 ---------- .../jumpstarter_cli_exporter/exporter_test.py | 74 ------------- .../jumpstarter_cli_exporter/py.typed | 0 .../jumpstarter-cli-exporter/pyproject.toml | 42 ------- .../jumpstarter_cli/__init__.py | 37 ++++++- .../jumpstarter_cli/cli_test.py | 17 ++- .../jumpstarter_cli}/common.py | 23 ---- .../jumpstarter-cli/jumpstarter_cli/config.py | 13 +++ .../jumpstarter_cli/config_client.py} | 12 +- .../jumpstarter_cli/config_exporter.py} | 12 +- .../jumpstarter_cli}/create.py | 9 +- .../jumpstarter_cli}/delete.py | 6 +- .../jumpstarter_cli}/get.py | 8 +- packages/jumpstarter-cli/jumpstarter_cli/j.py | 20 ++++ .../jumpstarter-cli/jumpstarter_cli/login.py | 104 ++++++++++++++++++ .../jumpstarter-cli/jumpstarter_cli/run.py | 27 +++++ .../jumpstarter-cli/jumpstarter_cli/shell.py | 41 +++++++ .../jumpstarter_cli}/update.py | 6 +- packages/jumpstarter-cli/pyproject.toml | 3 +- packages/jumpstarter-driver-yepkit/README.md | 4 +- .../jumpstarter/common/exceptions.py | 2 +- pyproject.toml | 2 - uv.lock | 62 ----------- 55 files changed, 458 insertions(+), 823 deletions(-) delete mode 100644 docs/source/cli/reference/jmp-client.md delete mode 100644 docs/source/cli/reference/jmp-driver.md delete mode 100644 docs/source/cli/reference/jmp-exporter.md create mode 100644 docs/source/cli/reference/jmp.md delete mode 100644 packages/jumpstarter-cli-client/README.md delete mode 100644 packages/jumpstarter-cli-client/jumpstarter_cli_client/__init__.py delete mode 100644 packages/jumpstarter-cli-client/jumpstarter_cli_client/__main__.py delete mode 100644 packages/jumpstarter-cli-client/jumpstarter_cli_client/client_login.py delete mode 100644 packages/jumpstarter-cli-client/jumpstarter_cli_client/client_shell.py delete mode 100644 packages/jumpstarter-cli-client/jumpstarter_cli_client/client_test.py delete mode 100644 packages/jumpstarter-cli-client/jumpstarter_cli_client/py.typed delete mode 100644 packages/jumpstarter-cli-client/pyproject.toml create mode 100644 packages/jumpstarter-cli-common/jumpstarter_cli_common/config.py delete mode 100644 packages/jumpstarter-cli-exporter/README.md delete mode 100644 packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/__init__.py delete mode 100644 packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/__main__.py delete mode 100644 packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter.py delete mode 100644 packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_login.py delete mode 100644 packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_test.py delete mode 100644 packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/py.typed delete mode 100644 packages/jumpstarter-cli-exporter/pyproject.toml rename packages/{jumpstarter-cli-client/jumpstarter_cli_client => jumpstarter-cli/jumpstarter_cli}/common.py (59%) create mode 100644 packages/jumpstarter-cli/jumpstarter_cli/config.py rename packages/{jumpstarter-cli-client/jumpstarter_cli_client/config.py => jumpstarter-cli/jumpstarter_cli/config_client.py} (93%) rename packages/{jumpstarter-cli-exporter/jumpstarter_cli_exporter/config.py => jumpstarter-cli/jumpstarter_cli/config_exporter.py} (93%) rename packages/{jumpstarter-cli-client/jumpstarter_cli_client => jumpstarter-cli/jumpstarter_cli}/create.py (89%) rename packages/{jumpstarter-cli-client/jumpstarter_cli_client => jumpstarter-cli/jumpstarter_cli}/delete.py (89%) rename packages/{jumpstarter-cli-client/jumpstarter_cli_client => jumpstarter-cli/jumpstarter_cli}/get.py (94%) create mode 100644 packages/jumpstarter-cli/jumpstarter_cli/j.py create mode 100644 packages/jumpstarter-cli/jumpstarter_cli/login.py create mode 100644 packages/jumpstarter-cli/jumpstarter_cli/run.py create mode 100644 packages/jumpstarter-cli/jumpstarter_cli/shell.py rename packages/{jumpstarter-cli-client/jumpstarter_cli_client => jumpstarter-cli/jumpstarter_cli}/update.py (92%) diff --git a/Makefile b/Makefile index 0d5994e1c..a81a5aeae 100644 --- a/Makefile +++ b/Makefile @@ -62,7 +62,6 @@ clean: clean-docs clean-venv clean-build clean-test .PHONY: sync docs docs-all serve-all test test-packages build clean-test clean-docs clean-venv clean-build \ mypy-jumpstarter \ mypy-jumpstarter-cli-admin \ - mypy-jumpstarter-cli-client \ mypy-jumpstarter-driver-can \ mypy-jumpstarter-driver-dutlink \ mypy-jumpstarter-driver-network \ diff --git a/docs/source/api-reference/drivers/flashers.md b/docs/source/api-reference/drivers/flashers.md index 608057d0d..3bb0d6f0e 100644 --- a/docs/source/api-reference/drivers/flashers.md +++ b/docs/source/api-reference/drivers/flashers.md @@ -85,7 +85,7 @@ This doesn't work with sphinx-click, so we'll just use the raw CLI ``` --> ```bash -$ jmp client shell -l board ti-03 +$ jmp shell -l board=ti-03 INFO:jumpstarter.client.lease:Created lease request for labels {'board': 'ti-03'} for 0:30:00 jumpstarter ⚡remote ➤ j storage Usage: j storage [OPTIONS] COMMAND [ARGS]... diff --git a/docs/source/api-reference/drivers/yepkit.md b/docs/source/api-reference/drivers/yepkit.md index b8c032191..6331f4696 100644 --- a/docs/source/api-reference/drivers/yepkit.md +++ b/docs/source/api-reference/drivers/yepkit.md @@ -51,7 +51,7 @@ client.power.off() ### CLI access ```bash -$ sudo ~/.cargo/bin/uv run jmp exporter shell -c ./packages/jumpstarter-driver-yepkit/examples/exporter.yaml +$ sudo ~/.cargo/bin/uv run jmp shell --exporter-config ./packages/jumpstarter-driver-yepkit/examples/exporter.yaml WARNING:Ykush:No serial number provided for ykush, using the first one found: YK25838 INFO:Ykush:Power OFF for Ykush YK25838 on port 1 INFO:Ykush:Power OFF for Ykush YK25838 on port 2 diff --git a/docs/source/cli/clients.md b/docs/source/cli/clients.md index b4d1ff377..b048c2fdd 100644 --- a/docs/source/cli/clients.md +++ b/docs/source/cli/clients.md @@ -82,10 +82,10 @@ self-signed certificates. 3. Those details can be installed as a secret on CI, or passed down to the final user. - Then the user can create the client using the [jmp client](./reference/jmp-client.md#jmp-client-config-create) CLI: + Then the user can create the client using the [jmp](./reference/jmp.md#jmp-config-client-create) CLI: ```bash - $ jmp client config create my-client + $ jmp config client create my-client Enter a valid Jumpstarter service endpoint: devl.jumpstarter.dev Enter a Jumpstarter auth token (hidden): *** Enter a comma-separated list of allowed driver packages (optional): diff --git a/docs/source/cli/reference/index.md b/docs/source/cli/reference/index.md index e413a386c..9d25d5333 100644 --- a/docs/source/cli/reference/index.md +++ b/docs/source/cli/reference/index.md @@ -5,8 +5,7 @@ This section provides details on the Jumpstarter CLI. ## jmp The base jmp command contains a set of subcommands for the different featuers, those -can also be installed and used independently as `jmp-admin`, `jmp-client`, and -`jmp-exporter`, `jmp-driver`. +can also be installed and used independently as `jmp-admin` and `jmp`. ```bash jmp [OPTIONS] COMMAND [ARGS]... @@ -14,30 +13,16 @@ jmp [OPTIONS] COMMAND [ARGS]... ### commands -```{toctree} -:maxdepth: 1 -jmp-admin.md -``` - The `jmp-admin` or `jmp admin` CLI allows administration of exporters and clients in a Kubernetes cluster. To use this CLI, you must have a valid `kubeconfig` and access to the cluter/namespace where the Jumpstarter controller resides. ```{toctree} :maxdepth: 1 -jmp-client.md +jmp-admin.md ``` -The `jmp-client` or `jmp client` CLI allows interaction with Jumpstarter as a clients. - - -```{toctree} -:maxdepth: 1 -jmp-exporter.md -``` -The `jmp-exporter` or `jmp exporter` CLI allows you to run Jumpstarter exporters as services, container, or standalone. +The `jmp` CLI allows interaction with Jumpstarter as a clients or exporter. ```{toctree} :maxdepth: 1 -jmp-driver.md +jmp.md ``` - -The `jmp-driver` or `jmp driver` CLI allows you to list and create Jumpstarter drivers. diff --git a/docs/source/cli/reference/jmp-admin.md b/docs/source/cli/reference/jmp-admin.md index 808d0a96d..3373cd2ca 100644 --- a/docs/source/cli/reference/jmp-admin.md +++ b/docs/source/cli/reference/jmp-admin.md @@ -1,4 +1,4 @@ -# admin +# jmp-admin ```{eval-rst} .. click:: jumpstarter_cli_admin:admin diff --git a/docs/source/cli/reference/jmp-client.md b/docs/source/cli/reference/jmp-client.md deleted file mode 100644 index 1b619d2e3..000000000 --- a/docs/source/cli/reference/jmp-client.md +++ /dev/null @@ -1,7 +0,0 @@ -# client - -```{eval-rst} -.. click:: jumpstarter_cli_client:client - :prog: jmp-client - :nested: full -``` diff --git a/docs/source/cli/reference/jmp-driver.md b/docs/source/cli/reference/jmp-driver.md deleted file mode 100644 index 2d2bec8a7..000000000 --- a/docs/source/cli/reference/jmp-driver.md +++ /dev/null @@ -1,7 +0,0 @@ -# driver - -```{eval-rst} -.. click:: jumpstarter_cli_driver:driver - :prog: jmp-driver - :nested: full -``` diff --git a/docs/source/cli/reference/jmp-exporter.md b/docs/source/cli/reference/jmp-exporter.md deleted file mode 100644 index e6aba2db9..000000000 --- a/docs/source/cli/reference/jmp-exporter.md +++ /dev/null @@ -1,7 +0,0 @@ -# exporter - -```{eval-rst} -.. click:: jumpstarter_cli_exporter:exporter - :prog: jmp-exporter - :nested: full -``` diff --git a/docs/source/cli/reference/jmp.md b/docs/source/cli/reference/jmp.md new file mode 100644 index 000000000..44ac11755 --- /dev/null +++ b/docs/source/cli/reference/jmp.md @@ -0,0 +1,7 @@ +# jmp + +```{eval-rst} +.. click:: jumpstarter_cli:jmp + :prog: jmp + :nested: full +``` diff --git a/docs/source/cli/run-tests.md b/docs/source/cli/run-tests.md index 9706e7951..3f1d8b9f0 100644 --- a/docs/source/cli/run-tests.md +++ b/docs/source/cli/run-tests.md @@ -29,7 +29,7 @@ Communication between the local client and exporter take place over a local socket provided by `$JUMPSTARTER_HOST`. ``` -$ jmp-exporter shell -c /exporter-config.yaml +$ jmp shell --exporter-config /exporter-config.yaml $$ echo $JUMPSTARTER_HOST $$ j ... $$ exit @@ -45,7 +45,7 @@ the base class will attempt to: 1. Use a local connection based on the `JUMPSTARTER_HOST` environment variable 2. Use an existing lease based on the `JMP_LEASE` environment variable, and existing credentials. - See the cli reference for [jmp lease request](../cli-reference/jmp.md#jmp-lease-request). + See the cli reference for [jmp create lease](../cli-reference/jmp.md#jmp-create-lease). 3. Request a lease based on the `selector` provided in the test class. ```{eval-rst} diff --git a/docs/source/config/cli.md b/docs/source/config/cli.md index a5e638343..ba95df666 100644 --- a/docs/source/config/cli.md +++ b/docs/source/config/cli.md @@ -8,7 +8,7 @@ Jumpstarter can be configured as either a client, an exporter, or both depending on your use case and deployment strategy. By default, a local client and exporter session are automatically initialized when -running test scripts through `jmp exporter shell` command. +running test scripts through `jmp shell` command. This allows for easy testing of new drivers, client libraries, or verifying that tests work on your local bench. @@ -90,30 +90,30 @@ please follow the instructions in the [Jumpstarter service CLI](../cli/clients.m Importing a new client is as simple as copying the administrator provided yaml file to `~/.config/jumpstarter/clients/`, alternatively if we have the token -and endpoint the `jmp client config create ` command can be used to create +and endpoint the `jmp config client create ` command can be used to create the config file. -To switch between different client configs, use the `jmp client config use ` command: +To switch between different client configs, use the `jmp config client use ` command: ```bash -$ jmp client config use another +$ jmp config client use another Using client config '/home/jdoe/.config/jumpstarter/clients/another.yaml' ``` -All client configurations can be listed with `jmp client config list`: +All client configurations can be listed with `jmp config client list`: ```bash -$ jmp client config list +$ jmp config client list CURRENT NAME ENDPOINT PATH * default jumpstarter1.my-lab.com:1443 /home/jdoe/.config/jumpstarter/clients/default.yaml myclient jumpstarter2.my-lab.com:1443 /home/jdoe/.config/jumpstarter/clients/myclient.yaml another jumpstarter3.my-lab.com:1443 /home/jdoe/.config/jumpstarter/clients/another.yaml ``` -Clients can also be removed using `jmp client config delete `: +Clients can also be removed using `jmp config client delete `: ```bash -$ jmp client config delete myclient +$ jmp config client delete myclient Deleted client config '/home/jdoe/.config/jumpstarter/clients/myclient.yaml' ``` @@ -202,10 +202,10 @@ please follow the instructions in the [Jumpstarter service CLI](../cli/exporters ### Creating a exporter configuration file To create a new exporter configuration file from a know endpoint and -token the `jmp exporter config create ` command can be used. +token the `jmp config exporter create ` command can be used. ```bash -$ jmp exporter config create myexporter +$ jmp config exporter create myexporter Endpoint: grpc.jumpstarter.my.domain.com Token: <> ``` @@ -213,29 +213,29 @@ Token: <> To use a specific config when starting the exporter: ```bash -$ jmp exporter run my-exporter +$ jmp run --exporter my-exporter Using exporter config '/etc/jumpstarter/exporters/another/exporter.yaml' ``` The path to a config can also be provided: ```bash -jmp exporter run -c /etc/jumpstarter/exporters/another/exporter.yaml +jmp run --exporter-config /etc/jumpstarter/exporters/another/exporter.yaml ``` -All exporter configurations can be listed with `jmp exporter config list`: +All exporter configurations can be listed with `jmp config exporter list`: ```bash -$ jmp exporter config list +$ jmp config exporter list ALIAS PATH test-exporter-2 /etc/jumpstarter/exporters/test-exporter-2.yaml my-exporter /etc/jumpstarter/exporters/my-exporter.yaml ``` -Exporers can also be removed using `jmp exporter config delete `: +Exporters can also be removed using `jmp config exporter delete `: ```bash -$ jmp exporter config delete myexporter +$ jmp config exporter delete myexporter Deleted exporter config '/etc/jumpstarter/exporters/myexporter.yaml' ``` @@ -269,7 +269,7 @@ To run the exporter container on a test runner using Podman: $ sudo podman run --rm -ti --name my-exporter --net=host --privileged \ -v /run/udev:/run/udev -v /dev:/dev -v /etc/jumpstarter:/etc/jumpstarter \ quay.io/jumpstarter-dev/jumpstarter:{{version}} \ - jmp-exporter run my-exporter + jmp run --exporter my-exporter INFO:jumpstarter.exporter.exporter:Registering exporter with controller INFO:jumpstarter.exporter.exporter:Currently not leased @@ -296,7 +296,7 @@ Description=My exporter [Container] ContainerName=my-exporter -Exec=/jumpstarter/bin/jmp exporter run my-exporter +Exec=/jumpstarter/bin/jmp run --exporter my-exporter Image=quay.io/jumpstarter-dev/jumpstarter:{{version}} Network=host PodmanArgs=--privileged diff --git a/docs/source/config/oidc.md b/docs/source/config/oidc.md index e1a4250b3..8549d82d2 100644 --- a/docs/source/config/oidc.md +++ b/docs/source/config/oidc.md @@ -33,7 +33,7 @@ Finally, instruct the users to login with the following commands ``` # for clients -jmp client login --endpoint \ +jmp login --client --endpoint \ --namespace --name \ --issuer https:///realms/ # without additional options, the users would be directed to login with the web browser @@ -43,10 +43,10 @@ jmp client login --endpoint \ --token # for exporters -jmp exporter login --endpoint \ +jmp login --exporter --endpoint \ --namespace --name \ --issuer https:///realms/ -# --username, --password and --token are also accepted by jmp exporter login +# --username, --password and --token are also accepted ``` ### Dex (for authenticating with kubernetes Service Accounts) @@ -147,14 +147,14 @@ Finally, instruct the users to login with the following commands in pods configu ``` # for clients -jmp client login --endpoint \ +jmp login --client --endpoint \ --namespace --name \ --issuer https://dex.dex.svc.cluster.local:5556 \ --connector-id kubernetes \ --token $(cat /var/run/secrets/kubernetes.io/serviceaccount/token) # for exporters -jmp exporter login --endpoint \ +jmp login --exporter --endpoint \ --namespace --name \ --issuer https://dex.dex.svc.cluster.local:5556 \ --connector-id kubernetes \ diff --git a/docs/source/getting-started/setup-exporter-client.md b/docs/source/getting-started/setup-exporter-client.md index 59dcc1eeb..76d4cf226 100644 --- a/docs/source/getting-started/setup-exporter-client.md +++ b/docs/source/getting-started/setup-exporter-client.md @@ -56,7 +56,7 @@ To edit the config file with your default text editor, run the following command ```bash # Opens the config for "testing" in your default editor -$ jmp exporter config edit testing +$ jmp config exporter edit testing ``` Add the `storage` and `power` drivers under the `export` field in the config file. @@ -79,13 +79,13 @@ export: ## Run an Exporter -To run the exporter locally, we can use the `jmp exporter` CLI tool. +To run the exporter locally, we can use the `jmp` CLI tool. Run the following command to start the exporter locally using the config file: ```bash # Runs the exporter "testing" locally -$ jmp exporter run testing +$ jmp run --exporter testing ``` The exporter will stay running until the process is exited via `^C` or the shell @@ -129,18 +129,34 @@ Python API. ```bash # Spawn a shell using the "hello" client -$ jmp client shell hello +$ jmp shell --client hello --selector example.com/board=foo -# Usage for jmp client shell -$ jmp client shell --help -Usage: jmp client shell [OPTIONS] [NAME] +# Usage for jmp shell +$ jmp shell --help +Usage: jmp shell [OPTIONS] - Spawns a shell connecting to a leased remote exporter + Spawns a shell connecting to a local or remote exporter Options: - -l, --label ... - -n, --lease TEXT - --help Show this message and exit. + --exporter-config PATH Path of exporter config + --exporter TEXT Alias of exporter config + --client-config PATH Path to client config + --client TEXT Alias of client config + --lease TEXT + -l, --selector TEXT 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. + --duration DURATION Accepted duration formats: + + PnYnMnDTnHnMnS - ISO 8601 duration format + HH:MM:SS - time in hours, minutes, seconds + D days, HH:MM:SS - time prefixed by X days + D d, HH:MM:SS - time prefixed by X d + + See https://docs.rs/speedate/latest/speedate/ for + details [default: (00:30:00)] + --help Show this message and exit. ``` Once a lease is acquired, we can interact with the drivers hosted by the exporter @@ -148,7 +164,7 @@ within the shell instance. ```bash # Spawn a shell using the "hello" client -$ jmp client shell hello +$ jmp shell --client hello --selector example.com/board=foo # Running inside client shell $ j diff --git a/docs/source/getting-started/setup-local-exporter.md b/docs/source/getting-started/setup-local-exporter.md index a805e4250..283059c67 100644 --- a/docs/source/getting-started/setup-local-exporter.md +++ b/docs/source/getting-started/setup-local-exporter.md @@ -55,7 +55,7 @@ running. ```shell # Spawn a new exporter shell for "demo" -$ jmp exporter shell demo +$ jmp shell --exporter demo ``` ### Interact with the Exporter Shell @@ -65,7 +65,7 @@ be available though the magic `j` command within the exporter shell. ```shell # Enter the shell -$ jmp exporter shell demo +$ jmp shell --exporter demo # Running inside exporter shell $ j @@ -104,7 +104,7 @@ directly from the command line. This comes in handy when no CLI is available. ```shell # Enter the shell -$ jmp exporter shell demo +$ jmp shell --exporter demo # Running python inside exporter shell $ python - <=8.3.2", - "pytest-anyio>=0.0.0", - "pytest-asyncio>=0.0.0", - "pytest-cov>=5.0.0", -] - -[project.scripts] -jmp-client = "jumpstarter_cli_client:client" -j = "jumpstarter_cli_client:j" - -[tool.hatch.build.targets.wheel] -packages = ["jumpstarter_cli_client"] - -[tool.hatch.metadata.hooks.vcs.urls] -Homepage = "https://jumpstarter.dev" -source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip" - -[tool.hatch.version] -source = "vcs" -raw-options = { 'root' = '../../'} - -[build-system] -requires = ["hatchling", "hatch-vcs"] -build-backend = "hatchling.build" diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py index 2b16c92c2..afd6343c8 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py @@ -1,4 +1,5 @@ from .alias import AliasedGroup +from .config import opt_config from .opt import ( NameOutputType, OutputMode, @@ -21,6 +22,7 @@ __all__ = [ "AliasedGroup", "make_table", + "opt_config", "opt_context", "opt_log_level", "opt_kubeconfig", diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/config.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/config.py new file mode 100644 index 000000000..ee200457a --- /dev/null +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/config.py @@ -0,0 +1,93 @@ +from functools import partial, reduce, wraps +from pathlib import Path + +import asyncclick as click + +from jumpstarter.config import ( + ClientConfigV1Alpha1, + ExporterConfigV1Alpha1, + UserConfigV1Alpha1, +) + + +def opt_config_inner( # noqa: C901 + f, + *, + client: bool, + exporter: bool, + allow_missing: bool, +): + params = {} + + def callback(ctx, param, value): + if value is not None: + params[param.name] = value + + option = partial(click.option, expose_value=False, callback=callback) + + options = [] + options_names = [] + + if client: + options += [ + option("--client", help="Alias of client config"), + option("--client-config", type=click.Path(), help="Path to client config"), + ] + options_names += [ + "--client", + "--client-config", + ] + + if exporter: + options += [ + option("--exporter", help="Alias of exporter config"), + option("--exporter-config", type=click.Path(), help="Path of exporter config"), + ] + options_names += [ + "--exporter", + "--exporter-config", + ] + + @wraps(f) + def wrapper(*args, **kwds): # noqa: C901 + try: + match len(params): + case 0: + if client: + config = UserConfigV1Alpha1.load_or_create().config.current_client + if config is None: + raise click.ClickException( + f"none of {', '.join(options_names)} is specified, and default config is not set" + ) + else: + raise click.BadParameter(f"one of {', '.join(options_names)} should be specified") + case 1: + try: + match next(iter(params.items())): + case ("client", alias): + config = ClientConfigV1Alpha1.load(alias) + case ("client_config", path): + config = ClientConfigV1Alpha1.from_file(path) + case ("exporter", alias): + config = ExporterConfigV1Alpha1.load(alias) + case ("exporter_config", path): + config = ExporterConfigV1Alpha1.load_path(Path(path)) + except FileNotFoundError: + if allow_missing: + config = next(iter(params.items())) + else: + raise + case _: + raise click.BadParameter(f"only one of {', '.join(options_names)} should be specified") + except click.ClickException: + raise + except Exception as e: + raise click.ClickException("Failed to load config: {}".format(e)) from e + + return f(*args, **kwds, config=config) + + return reduce(lambda w, opt: opt(w), options, wrapper) + + +def opt_config(*, client: bool = True, exporter: bool = True, allow_missing=False): + return partial(opt_config_inner, client=client, exporter=exporter, allow_missing=allow_missing) diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py index 815b6c318..cd9937bd7 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py @@ -3,7 +3,6 @@ import asyncclick as click opt_log_level = click.option( - "-l", "--log-level", "log_level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), diff --git a/packages/jumpstarter-cli-exporter/README.md b/packages/jumpstarter-cli-exporter/README.md deleted file mode 100644 index 6f2310302..000000000 --- a/packages/jumpstarter-cli-exporter/README.md +++ /dev/null @@ -1 +0,0 @@ -# Jumpstarter Exporter CLI \ No newline at end of file diff --git a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/__init__.py b/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/__init__.py deleted file mode 100644 index 4c3857bda..000000000 --- a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -import logging -from typing import Optional - -import asyncclick as click -from jumpstarter_cli_common import AliasedGroup, opt_log_level, version - -from .config import config -from .exporter import exporter_shell, run_exporter -from .exporter_login import exporter_login - - -@click.group(cls=AliasedGroup) -@opt_log_level -def exporter(log_level: Optional[str]): - """Jumpstarter exporter CLI tool""" - if log_level: - logging.basicConfig(level=log_level.upper()) - else: - logging.basicConfig(level=logging.INFO) - - -exporter.add_command(config) -exporter.add_command(run_exporter) -exporter.add_command(exporter_login) -exporter.add_command(exporter_shell) -exporter.add_command(version) - -if __name__ == "__main__": - exporter() diff --git a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/__main__.py b/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/__main__.py deleted file mode 100644 index de54038b4..000000000 --- a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""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.py b/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter.py deleted file mode 100644 index 17116b421..000000000 --- a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter.py +++ /dev/null @@ -1,63 +0,0 @@ -import sys -import traceback -from pathlib import Path - -import asyncclick as click -from jumpstarter_cli_common.exceptions import handle_exceptions - -from jumpstarter.common.utils import launch_shell -from jumpstarter.config.exporter import ExporterConfigV1Alpha1 - -arg_alias = click.argument("alias", default="default") - -opt_config_path = click.option( - "-c", "--config", "config_path", type=click.Path(exists=True), help="Path of exporter config, overrides ALIAS" -) - - -async def _serve_with_exc_handling(exporter): - result = 0 - try: - await exporter.serve() - except* Exception as excgroup: - print(f"Exception while serving on the exporter: {excgroup.exceptions}", file=sys.stderr) - for exc in excgroup.exceptions: - traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr) - result = 1 - return result - - -@click.command("run") -@arg_alias -@opt_config_path -@handle_exceptions -async def run_exporter(alias, config_path): - """Run an exporter locally.""" - try: - if config_path: - config = ExporterConfigV1Alpha1.load_path(Path(config_path)) - else: - config = ExporterConfigV1Alpha1.load(alias) - except FileNotFoundError as err: - raise click.ClickException(f'exporter "{alias}" does not exist') from err - - return await _serve_with_exc_handling(config) - - -@click.command("shell") -@arg_alias -@opt_config_path -@handle_exceptions -def exporter_shell(alias, config_path): - """Spawns a shell connecting to a transient exporter""" - try: - if config_path: - config = ExporterConfigV1Alpha1.load_path(Path(config_path)) - else: - config = ExporterConfigV1Alpha1.load(alias) - except FileNotFoundError as err: - raise click.ClickException(f'exporter "{alias}" does not exist') from err - - with config.serve_unix() as path: - # SAFETY: the exporter config is local thus considered trusted - launch_shell(path, "local", allow=[], unsafe=True) diff --git a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_login.py b/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_login.py deleted file mode 100644 index 8d7801be8..000000000 --- a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_login.py +++ /dev/null @@ -1,59 +0,0 @@ -import asyncclick as click -from jumpstarter_cli_common.oidc import Config, decode_jwt_issuer, opt_oidc - -from jumpstarter.config.exporter import ExporterConfigV1Alpha1, ObjectMeta - - -@click.command("login", short_help="Login") -@click.argument("alias", default="default") -@click.option("-e", "--endpoint", type=str, help="Enter the Jumpstarter service endpoint.", default=None) -@click.option("--namespace", type=str, help="Enter the Jumpstarter exporter namespace.", default=None) -@click.option("--name", type=str, help="Enter the Jumpstarter exporter name.", default=None) -@opt_oidc -async def exporter_login( - alias: str, - endpoint: str, - namespace: str, - name: str, - username: str | None, - password: str | None, - token: str | None, - issuer: str, - client_id: str, - connector_id: str, -): - """Login into a jumpstarter instance""" - try: - config = ExporterConfigV1Alpha1.load(alias) - issuer = decode_jwt_issuer(config.token) - except FileNotFoundError: - if namespace is None: - namespace = click.prompt("Enter the Jumpstarter exporter namespace") - if name is None: - name = click.prompt("Enter the Jumpstarter exporter name") - if endpoint is None: - endpoint = click.prompt("Enter the Jumpstarter service endpoint") - - config = ExporterConfigV1Alpha1( - alias=alias, - metadata=ObjectMeta(namespace=namespace, name=name), - endpoint=endpoint, - token="", - ) - - if issuer is None: - issuer = click.prompt("Enter the OIDC issuer") - - oidc = Config(issuer=issuer, client_id=client_id) - - if token is not None: - kwargs = {"connector_id": connector_id} if connector_id is not None else {} - tokens = await oidc.token_exchange_grant(token, **kwargs) - elif username is not None and password is not None: - tokens = await oidc.password_grant(username, password) - else: - tokens = await oidc.authorization_code_grant() - - config.token = tokens["access_token"] - - ExporterConfigV1Alpha1.save(config) diff --git a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_test.py b/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_test.py deleted file mode 100644 index 87a6d81d7..000000000 --- a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/exporter_test.py +++ /dev/null @@ -1,74 +0,0 @@ -import pytest -from asyncclick.testing import CliRunner - -from . import exporter - - -@pytest.mark.anyio -async def test_exporter(): - runner = CliRunner() - - # create exporter non-interactively - result = await runner.invoke( - exporter, - [ - "config", - "create", - "test1", - "--namespace", - "default", - "--name", - "test1", - "--endpoint", - "example.com:443", - "--token", - "dummy", - ], - ) - assert result.exit_code == 0 - - # create duplicate exporter - result = await runner.invoke( - exporter, - [ - "config", - "create", - "test1", - "--namespace", - "default", - "--name", - "test1", - "--endpoint", - "example.com:443", - "--token", - "dummy", - ], - ) - assert result.exit_code != 0 - - # create exporter interactively - result = await runner.invoke( - exporter, ["config", "create", "test2"], input="default\ntest2\nexample.org:443\ndummytoken\n" - ) - assert result.exit_code == 0 - - # list exporters - result = await runner.invoke(exporter, ["config", "list"]) - assert result.exit_code == 0 - assert "test1" in result.output - assert "test2" in result.output - - # delete exporter - result = await runner.invoke(exporter, ["config", "delete", "test2"]) - assert result.exit_code == 0 - - ## list exporters - result = await runner.invoke(exporter, ["config", "list"]) - assert result.exit_code == 0 - assert "test1" in result.output - assert "test2" not in result.output - - -@pytest.fixture -def anyio_backend(): - return "asyncio" diff --git a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/py.typed b/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/py.typed deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/jumpstarter-cli-exporter/pyproject.toml b/packages/jumpstarter-cli-exporter/pyproject.toml deleted file mode 100644 index 15fe94fc6..000000000 --- a/packages/jumpstarter-cli-exporter/pyproject.toml +++ /dev/null @@ -1,42 +0,0 @@ -[project] -name = "jumpstarter-cli-exporter" -dynamic = ["version", "urls"] -description = "" -authors = [ - { name = "Nick Cao", email = "ncao@redhat.com" }, - { name = "Miguel Angel Ajo Pelayo", email = "majopela@redhat.com" }, - { name = "Kirk Brauer", email = "kbrauer@hatci.com" }, -] -readme = "README.md" -license = { text = "Apache-2.0" } -requires-python = ">=3.11" -dependencies = [ - "jumpstarter-cli-common", - "asyncclick>=8.1.7.2", -] - -[dependency-groups] -dev = [ - "pytest>=8.3.2", - "pytest-anyio>=0.0.0", - "pytest-asyncio>=0.0.0", - "pytest-cov>=5.0.0", -] - -[project.scripts] -jmp-client = "jumpstarter_cli_exporter:exporter" - -[tool.hatch.build.targets.wheel] -packages = ["jumpstarter_cli_exporter"] - -[tool.hatch.metadata.hooks.vcs.urls] -Homepage = "https://jumpstarter.dev" -source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip" - -[tool.hatch.version] -source = "vcs" -raw-options = { 'root' = '../../'} - -[build-system] -requires = ["hatchling", "hatch-vcs"] -build-backend = "hatchling.build" diff --git a/packages/jumpstarter-cli/jumpstarter_cli/__init__.py b/packages/jumpstarter-cli/jumpstarter_cli/__init__.py index 36973683c..34fdcfb3e 100644 --- a/packages/jumpstarter-cli/jumpstarter_cli/__init__.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/__init__.py @@ -1,21 +1,46 @@ +import logging + import asyncclick as click from jumpstarter_cli_admin import admin -from jumpstarter_cli_client import client -from jumpstarter_cli_common import AliasedGroup, version +from jumpstarter_cli_common import AliasedGroup, opt_log_level, version from jumpstarter_cli_driver import driver -from jumpstarter_cli_exporter import exporter + +from .config import config +from .create import create +from .delete import delete +from .get import get +from .j import j +from .login import login +from .run import run +from .shell import shell +from .update import update @click.group(cls=AliasedGroup) -def jmp(): +@opt_log_level +def jmp(log_level): """The Jumpstarter CLI""" + if log_level: + logging.basicConfig(level=log_level.upper()) + else: + logging.basicConfig(level=logging.INFO) + + +jmp.add_command(create) +jmp.add_command(delete) +jmp.add_command(update) +jmp.add_command(get) +jmp.add_command(shell) +jmp.add_command(run) +jmp.add_command(login) +jmp.add_command(config) -jmp.add_command(client) jmp.add_command(driver) -jmp.add_command(exporter) jmp.add_command(admin) jmp.add_command(version) +__all__ = ["jmp", "j"] + if __name__ == "__main__": jmp() diff --git a/packages/jumpstarter-cli/jumpstarter_cli/cli_test.py b/packages/jumpstarter-cli/jumpstarter_cli/cli_test.py index 51c9d5084..5e56b358a 100644 --- a/packages/jumpstarter-cli/jumpstarter_cli/cli_test.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/cli_test.py @@ -8,10 +8,19 @@ async def test_cli(): runner = CliRunner() result = await runner.invoke(jmp, []) - assert "admin" in result.output - assert "client" in result.output - assert "exporter" in result.output - assert "version" in result.output + for subcommand in [ + "config", + "create", + "delete", + "driver", + "get", + "login", + "run", + "shell", + "update", + "version", + ]: + assert subcommand in result.output @pytest.fixture diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py b/packages/jumpstarter-cli/jumpstarter_cli/common.py similarity index 59% rename from packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py rename to packages/jumpstarter-cli/jumpstarter_cli/common.py index 685328051..903a5bac1 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/common.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/common.py @@ -4,11 +4,6 @@ import asyncclick as click from pydantic import TypeAdapter -from jumpstarter.config import ( - ClientConfigV1Alpha1, - UserConfigV1Alpha1, -) - opt_selector = click.option( "-l", "--selector", @@ -17,22 +12,6 @@ ) -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): name = "duration" @@ -47,9 +26,7 @@ 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") opt_duration_partial = partial( click.option, "--duration", diff --git a/packages/jumpstarter-cli/jumpstarter_cli/config.py b/packages/jumpstarter-cli/jumpstarter_cli/config.py new file mode 100644 index 000000000..d280d56f7 --- /dev/null +++ b/packages/jumpstarter-cli/jumpstarter_cli/config.py @@ -0,0 +1,13 @@ +import asyncclick as click + +from .config_client import config_client +from .config_exporter import config_exporter + + +@click.group +def config(): + pass + + +config.add_command(config_client) +config.add_command(config_exporter) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/config.py b/packages/jumpstarter-cli/jumpstarter_cli/config_client.py similarity index 93% rename from packages/jumpstarter-cli-client/jumpstarter_cli_client/config.py rename to packages/jumpstarter-cli/jumpstarter_cli/config_client.py index a5188de1a..714381a35 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/config.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/config_client.py @@ -20,14 +20,14 @@ ) -@click.group() -def config(): +@click.group("client") +def config_client(): """ Modify jumpstarter client config files """ -@config.command("create", short_help="Create a client config.") +@config_client.command("create", short_help="Create a client config.") @click.argument("alias") @click.option( "--out", @@ -122,7 +122,7 @@ def set_next_client(name: str): user_config.use_client(None) -@config.command("delete", short_help="Delete a client config.") +@config_client.command("delete", short_help="Delete a client config.") @click.argument("name", type=str) @opt_output_path_only @handle_exceptions @@ -134,7 +134,7 @@ def delete_client_config(name: str, output: PathOutputType): click.echo(path) -@config.command("list", short_help="List available client configurations.") +@config_client.command("list", short_help="List available client configurations.") @opt_output_all @handle_exceptions def list_client_configs(output: OutputType): @@ -168,7 +168,7 @@ def make_row(c: ClientConfigV1Alpha1): click.echo(make_table(columns, rows)) -@config.command("use", short_help="Select the current client config.") +@config_client.command("use", short_help="Select the current client config.") @click.argument("name", type=str) @opt_output_path_only @handle_exceptions diff --git a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/config.py b/packages/jumpstarter-cli/jumpstarter_cli/config_exporter.py similarity index 93% rename from packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/config.py rename to packages/jumpstarter-cli/jumpstarter_cli/config_exporter.py index aefba8047..84063f1e7 100644 --- a/packages/jumpstarter-cli-exporter/jumpstarter_cli_exporter/config.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/config_exporter.py @@ -13,14 +13,14 @@ arg_alias = click.argument("alias", default="default") -@click.group() -def config(): +@click.group("exporter") +def config_exporter(): """ Modify jumpstarter exporter config files """ -@config.command("create") +@config_exporter.command("create") @click.option("--namespace", prompt=True) @click.option("--name", prompt=True) @click.option("--endpoint", prompt=True) @@ -48,7 +48,7 @@ def create_exporter_config(alias, namespace, name, endpoint, token, output: Path click.echo(path) -@config.command("delete") +@config_exporter.command("delete") @arg_alias @opt_output_path_only def delete_exporter_config(alias, output: PathOutputType): @@ -62,7 +62,7 @@ def delete_exporter_config(alias, output: PathOutputType): click.echo(path) -@config.command("edit") +@config_exporter.command("edit") @arg_alias def edit_exporter_config(alias): """Edit an exporter config.""" @@ -73,7 +73,7 @@ def edit_exporter_config(alias): click.edit(filename=config.path) -@config.command("list") +@config_exporter.command("list") @opt_output_all def list_exporter_configs(output: OutputType): """List exporter configs.""" diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py b/packages/jumpstarter-cli/jumpstarter_cli/create.py similarity index 89% rename from packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py rename to packages/jumpstarter-cli/jumpstarter_cli/create.py index 2b3656222..43fba15ec 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/create.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/create.py @@ -5,11 +5,12 @@ OutputMode, OutputType, make_table, + opt_config, opt_output_all, ) from jumpstarter_cli_common.exceptions import handle_exceptions -from .common import opt_config, opt_duration_partial, opt_selector +from .common import opt_duration_partial, opt_selector @click.group() @@ -20,7 +21,7 @@ def create(): @create.command(name="lease") -@opt_config +@opt_config(exporter=False) @opt_selector @opt_duration_partial(required=True) @opt_output_all @@ -43,11 +44,11 @@ async def create_lease(config, selector: str, duration: timedelta, output: Outpu .. code-block:: bash - $ JMP_LEASE=$(jmp client create lease -l foo=bar --duration 1d --output name) + $ JMP_LEASE=$(jmp create lease -l foo=bar --duration 1d --output name) $ jmp shell $$ j --help $$ exit - $ jmp client delete lease "${JMP_LEASE}" + $ jmp delete lease "${JMP_LEASE}" """ diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py b/packages/jumpstarter-cli/jumpstarter_cli/delete.py similarity index 89% rename from packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py rename to packages/jumpstarter-cli/jumpstarter_cli/delete.py index 063e24532..5109309eb 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/delete.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/delete.py @@ -1,8 +1,8 @@ import asyncclick as click -from jumpstarter_cli_common import OutputMode, OutputType, opt_output_name_only +from jumpstarter_cli_common import OutputMode, OutputType, opt_config, opt_output_name_only from jumpstarter_cli_common.exceptions import handle_exceptions -from .common import opt_config, opt_selector +from .common import opt_selector @click.group() @@ -13,7 +13,7 @@ def delete(): @delete.command(name="leases") -@opt_config +@opt_config(exporter=False) @click.argument("name", required=False) @opt_selector @click.option("--all", "all", is_flag=True) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py b/packages/jumpstarter-cli/jumpstarter_cli/get.py similarity index 94% rename from packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py rename to packages/jumpstarter-cli/jumpstarter_cli/get.py index 4cd27596f..570f8fd4e 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/get.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/get.py @@ -1,8 +1,8 @@ import asyncclick as click -from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_output_all +from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_config, opt_output_all from jumpstarter_cli_common.exceptions import handle_exceptions -from .common import opt_config, opt_selector +from .common import opt_selector @click.group() @@ -13,7 +13,7 @@ def get(): @get.command(name="exporters") -@opt_config +@opt_config(exporter=False) @opt_selector @opt_output_all @handle_exceptions @@ -45,7 +45,7 @@ def get_exporters(config, selector: str | None, output: OutputType): @get.command(name="leases") -@opt_config +@opt_config(exporter=False) @opt_selector @opt_output_all @handle_exceptions diff --git a/packages/jumpstarter-cli/jumpstarter_cli/j.py b/packages/jumpstarter-cli/jumpstarter_cli/j.py new file mode 100644 index 000000000..84b1ac0ec --- /dev/null +++ b/packages/jumpstarter-cli/jumpstarter_cli/j.py @@ -0,0 +1,20 @@ +import sys + +import asyncclick as click +from jumpstarter_cli_common.exceptions import handle_exceptions + +from jumpstarter.common.utils import env + + +def j(): + with env() as client: + + @handle_exceptions + def cli(): + client.cli()(standalone_mode=False) + + try: + cli() + except click.ClickException as e: + e.show() + sys.exit(1) diff --git a/packages/jumpstarter-cli/jumpstarter_cli/login.py b/packages/jumpstarter-cli/jumpstarter_cli/login.py new file mode 100644 index 000000000..06140432a --- /dev/null +++ b/packages/jumpstarter-cli/jumpstarter_cli/login.py @@ -0,0 +1,104 @@ +import asyncclick as click +from jumpstarter_cli_common import opt_config +from jumpstarter_cli_common.oidc import Config, decode_jwt_issuer, opt_oidc + +from jumpstarter.config import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers, ObjectMeta +from jumpstarter.config.exporter import ExporterConfigV1Alpha1 + + +@click.command("login", short_help="Login") +@click.option("-e", "--endpoint", type=str, help="Enter the Jumpstarter service endpoint.", default=None) +@click.option("--namespace", type=str, help="Enter the Jumpstarter exporter namespace.", default=None) +@click.option("--name", type=str, help="Enter the Jumpstarter exporter name.", default=None) +@opt_oidc +# client specific +# TODO: warn if used with exporter +@click.option( + "--allow", + type=str, + help="A comma-separated list of driver client packages to load.", + default="", +) +@click.option( + "--unsafe", is_flag=True, help="Should all driver client packages be allowed to load (UNSAFE!).", default=None +) +# end client specific +@opt_config(allow_missing=True) +async def login( # noqa: C901 + config, + endpoint: str, + namespace: str, + name: str, + username: str | None, + password: str | None, + token: str | None, + issuer: str, + client_id: str, + connector_id: str, + unsafe, + allow, +): + """Login into a jumpstarter instance""" + + match config: + case ClientConfigV1Alpha1(): + issuer = decode_jwt_issuer(config.token) + case ExporterConfigV1Alpha1(): + issuer = decode_jwt_issuer(config.token) + case (kind, value): + if namespace is None: + namespace = click.prompt("Enter the Jumpstarter exporter namespace") + if name is None: + name = click.prompt("Enter the Jumpstarter exporter name") + if endpoint is None: + endpoint = click.prompt("Enter the Jumpstarter service endpoint") + + if kind.startswith("client"): + if unsafe is None: + unsafe = click.confirm("Allow unsafe driver client imports?") + if unsafe is False and allow == "": + allow = click.prompt( + "Enter a comma-separated list of allowed driver packages (optional)", default="", type=str + ) + + if kind.startswith("client"): + config = ClientConfigV1Alpha1( + alias=value if kind == "client" else "default", + metadata=ObjectMeta(namespace=namespace, name=name), + endpoint=endpoint, + token="", + drivers=ClientConfigV1Alpha1Drivers(allow=allow.split(","), unsafe=unsafe), + ) + + if kind.startswith("exporter"): + config = ExporterConfigV1Alpha1( + alias=value if kind == "exporter" else "default", + metadata=ObjectMeta(namespace=namespace, name=name), + endpoint=endpoint, + token="", + ) + + if issuer is None: + issuer = click.prompt("Enter the OIDC issuer") + + oidc = Config(issuer=issuer, client_id=client_id) + + if token is not None: + kwargs = {"connector_id": connector_id} if connector_id is not None else {} + tokens = await oidc.token_exchange_grant(token, **kwargs) + elif username is not None and password is not None: + tokens = await oidc.password_grant(username, password) + else: + tokens = await oidc.authorization_code_grant() + + config.token = tokens["access_token"] + + match kind: + case "client": + ClientConfigV1Alpha1.save(config) + case "client_config": + ClientConfigV1Alpha1.save(config, value) + case "exporter": + ExporterConfigV1Alpha1.save(config) + case "exporter_config": + ExporterConfigV1Alpha1.save(config, value) diff --git a/packages/jumpstarter-cli/jumpstarter_cli/run.py b/packages/jumpstarter-cli/jumpstarter_cli/run.py new file mode 100644 index 000000000..219222476 --- /dev/null +++ b/packages/jumpstarter-cli/jumpstarter_cli/run.py @@ -0,0 +1,27 @@ +import sys +import traceback + +import asyncclick as click +from jumpstarter_cli_common import opt_config +from jumpstarter_cli_common.exceptions import handle_exceptions + + +async def _serve_with_exc_handling(exporter): + result = 0 + try: + await exporter.serve() + except* Exception as excgroup: + print(f"Exception while serving on the exporter: {excgroup.exceptions}", file=sys.stderr) + for exc in excgroup.exceptions: + traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr) + result = 1 + return result + + +@click.command("run") +@opt_config(client=False) +@handle_exceptions +async def run(config): + """Run an exporter locally.""" + + return await _serve_with_exc_handling(config) diff --git a/packages/jumpstarter-cli/jumpstarter_cli/shell.py b/packages/jumpstarter-cli/jumpstarter_cli/shell.py new file mode 100644 index 000000000..c37e2a765 --- /dev/null +++ b/packages/jumpstarter-cli/jumpstarter_cli/shell.py @@ -0,0 +1,41 @@ +import sys +from datetime import timedelta + +import asyncclick as click +from jumpstarter_cli_common import opt_config +from jumpstarter_cli_common.exceptions import handle_exceptions + +from .common import opt_duration_partial, opt_selector +from jumpstarter.common.utils import launch_shell +from jumpstarter.config import ClientConfigV1Alpha1, ExporterConfigV1Alpha1 + + +@click.command("shell") +@opt_config() +# client specific +# TODO: warn if these are specified with exporter config +@click.option("--lease", "lease_name") +@opt_selector +@opt_duration_partial(default=timedelta(minutes=30), show_default="00:30:00") +# end client specific +@handle_exceptions +def shell(config, lease_name, selector, duration): + """ + Spawns a shell connecting to a local or remote exporter + """ + + match config: + case ClientConfigV1Alpha1(): + exit_code = 0 + + with config.lease(selector=selector, lease_name=lease_name, duration=duration) as lease: + with lease.serve_unix() as path: + with lease.monitor(): + exit_code = launch_shell(path, "remote", config.drivers.allow, config.drivers.unsafe) + + sys.exit(exit_code) + + case ExporterConfigV1Alpha1(): + with config.serve_unix() as path: + # SAFETY: the exporter config is local thus considered trusted + launch_shell(path, "local", allow=[], unsafe=True) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py b/packages/jumpstarter-cli/jumpstarter_cli/update.py similarity index 92% rename from packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py rename to packages/jumpstarter-cli/jumpstarter_cli/update.py index 0b3d13ffd..f4f3badbf 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/update.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/update.py @@ -1,10 +1,10 @@ from datetime import timedelta import asyncclick as click -from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_output_all +from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_config, opt_output_all from jumpstarter_cli_common.exceptions import handle_exceptions -from .common import opt_config, opt_duration_partial +from .common import opt_duration_partial @click.group() @@ -15,7 +15,7 @@ def update(): @update.command(name="lease") -@opt_config +@opt_config(exporter=False) @click.argument("name") @opt_duration_partial(required=True) @opt_output_all diff --git a/packages/jumpstarter-cli/pyproject.toml b/packages/jumpstarter-cli/pyproject.toml index 690e0ad31..c70c0a7a4 100644 --- a/packages/jumpstarter-cli/pyproject.toml +++ b/packages/jumpstarter-cli/pyproject.toml @@ -12,9 +12,7 @@ license = { text = "Apache-2.0" } requires-python = ">=3.11" dependencies = [ "jumpstarter-cli-admin", - "jumpstarter-cli-client", "jumpstarter-cli-driver", - "jumpstarter-cli-exporter", ] [dependency-groups] @@ -27,6 +25,7 @@ dev = [ [project.scripts] jmp = "jumpstarter_cli:jmp" +j = "jumpstarter_cli:j" [tool.hatch.build.targets.wheel] packages = ["jumpstarter_cli"] diff --git a/packages/jumpstarter-driver-yepkit/README.md b/packages/jumpstarter-driver-yepkit/README.md index 6e6806d26..5e9e914d8 100644 --- a/packages/jumpstarter-driver-yepkit/README.md +++ b/packages/jumpstarter-driver-yepkit/README.md @@ -5,7 +5,7 @@ This driver is for the ykush USB Hub from Yepkit. It allows you to control the p If you want to test this locally, you can use the following commands from the root of the repository: ```bash -sudo $(which uv) run jmp exporter shell --config ./packages/jumpstarter-driver-yepkit/examples/exporter.yaml +sudo $(which uv) run jmp shell --exporter-config ./packages/jumpstarter-driver-yepkit/examples/exporter.yaml ``` -Please note that sudo is necessary to gain access to the raw USB interfaces. \ No newline at end of file +Please note that sudo is necessary to gain access to the raw USB interfaces. diff --git a/packages/jumpstarter/jumpstarter/common/exceptions.py b/packages/jumpstarter/jumpstarter/common/exceptions.py index f786207f1..7bd4bf444 100644 --- a/packages/jumpstarter/jumpstarter/common/exceptions.py +++ b/packages/jumpstarter/jumpstarter/common/exceptions.py @@ -50,7 +50,7 @@ class FileAccessError(JumpstarterException): pass -class FileNotFoundError(JumpstarterException): +class FileNotFoundError(JumpstarterException, FileNotFoundError): """Raised when a file is not found.""" pass diff --git a/pyproject.toml b/pyproject.toml index b27f06c4e..ee76f417a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,10 +5,8 @@ members = ["packages/*", "examples/*"] jumpstarter = { workspace = true } jumpstarter-cli = { workspace = true } jumpstarter-cli-admin = { workspace = true } -jumpstarter-cli-client = { workspace = true } jumpstarter-cli-common = { workspace = true } jumpstarter-cli-driver = { workspace = true } -jumpstarter-cli-exporter = { workspace = true } jumpstarter-driver-can = { workspace = true } jumpstarter-driver-composite = { workspace = true } jumpstarter-driver-dutlink = { workspace = true } diff --git a/uv.lock b/uv.lock index eebc3a20c..ed1ceacb4 100644 --- a/uv.lock +++ b/uv.lock @@ -8,10 +8,8 @@ members = [ "jumpstarter-all", "jumpstarter-cli", "jumpstarter-cli-admin", - "jumpstarter-cli-client", "jumpstarter-cli-common", "jumpstarter-cli-driver", - "jumpstarter-cli-exporter", "jumpstarter-driver-can", "jumpstarter-driver-composite", "jumpstarter-driver-dutlink", @@ -1020,9 +1018,7 @@ name = "jumpstarter-cli" source = { editable = "packages/jumpstarter-cli" } dependencies = [ { name = "jumpstarter-cli-admin" }, - { name = "jumpstarter-cli-client" }, { name = "jumpstarter-cli-driver" }, - { name = "jumpstarter-cli-exporter" }, ] [package.dev-dependencies] @@ -1036,9 +1032,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "jumpstarter-cli-admin", editable = "packages/jumpstarter-cli-admin" }, - { name = "jumpstarter-cli-client", editable = "packages/jumpstarter-cli-client" }, { name = "jumpstarter-cli-driver", editable = "packages/jumpstarter-cli-driver" }, - { name = "jumpstarter-cli-exporter", editable = "packages/jumpstarter-cli-exporter" }, ] [package.metadata.requires-dev] @@ -1079,32 +1073,6 @@ dev = [ { name = "pytest-cov", specifier = ">=5.0.0" }, ] -[[package]] -name = "jumpstarter-cli-client" -source = { editable = "packages/jumpstarter-cli-client" } -dependencies = [ - { name = "jumpstarter-cli-common" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-anyio" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, -] - -[package.metadata] -requires-dist = [{ name = "jumpstarter-cli-common", editable = "packages/jumpstarter-cli-common" }] - -[package.metadata.requires-dev] -dev = [ - { name = "pytest", specifier = ">=8.3.2" }, - { name = "pytest-anyio", specifier = ">=0.0.0" }, - { name = "pytest-asyncio", specifier = ">=0.0.0" }, - { name = "pytest-cov", specifier = ">=5.0.0" }, -] - [[package]] name = "jumpstarter-cli-common" source = { editable = "packages/jumpstarter-cli-common" } @@ -1175,36 +1143,6 @@ dev = [ { name = "pytest-cov", specifier = ">=5.0.0" }, ] -[[package]] -name = "jumpstarter-cli-exporter" -source = { editable = "packages/jumpstarter-cli-exporter" } -dependencies = [ - { name = "asyncclick" }, - { name = "jumpstarter-cli-common" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-anyio" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, -] - -[package.metadata] -requires-dist = [ - { name = "asyncclick", specifier = ">=8.1.7.2" }, - { name = "jumpstarter-cli-common", editable = "packages/jumpstarter-cli-common" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pytest", specifier = ">=8.3.2" }, - { name = "pytest-anyio", specifier = ">=0.0.0" }, - { name = "pytest-asyncio", specifier = ">=0.0.0" }, - { name = "pytest-cov", specifier = ">=5.0.0" }, -] - [[package]] name = "jumpstarter-driver-can" source = { editable = "packages/jumpstarter-driver-can" }