Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6c3108f
Use pydantic to validate and serialize k8s objects for Jumpstarter
kirkbrauer Mar 8, 2025
4679023
Add JSON output CLI option for admin CLI
kirkbrauer Mar 9, 2025
8dfef75
Fix get clients
kirkbrauer Mar 9, 2025
fb26b40
Fix Pydantic validation and model issues
kirkbrauer Mar 9, 2025
58ce7b1
Refactor to make printing objects more type-safe
kirkbrauer Mar 9, 2025
45b33c8
Add output to admin create commands
kirkbrauer Mar 9, 2025
a928a28
Add name output mode for simplified output
kirkbrauer Mar 9, 2025
4cce53e
Add OutputType meta type and name-only outputs for admin delete
kirkbrauer Mar 9, 2025
a46430c
Add nointeractive option for automated script usage
kirkbrauer Mar 9, 2025
9c6d95a
Fixed all admin CLI tests
kirkbrauer Mar 9, 2025
b0e2614
Add admin create tests for JSON/YAML/name output
kirkbrauer Mar 9, 2025
0a336cd
Add nointeractive and name output tests for admin delete
kirkbrauer Mar 9, 2025
17b63e7
Add JSON, YAML, and name output tests for admin get
kirkbrauer Mar 9, 2025
bb977c3
Add nointeractive test to import client
kirkbrauer Mar 9, 2025
bef5b06
Add tests for import path output
kirkbrauer Mar 9, 2025
a316e6d
Add JSON output for client list-configs
kirkbrauer Mar 9, 2025
d217883
Add JSON output for exporter configs list
kirkbrauer Mar 9, 2025
5276158
Fix jumpstarter-kubernetes tests
kirkbrauer Mar 9, 2025
08958d8
Add path output options for client/exporter CLIs
kirkbrauer Mar 9, 2025
99f09db
Fix issue when setting current client to None
kirkbrauer Mar 9, 2025
59dcf46
Fix coderabbit issues
kirkbrauer Mar 10, 2025
696a82c
Add JSON output for version subcommand
kirkbrauer Mar 10, 2025
a128a4c
Add entrypoints for all CLI packages
kirkbrauer Mar 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Allow running Jumpstarter through `python -m jumpstarter_cli_admin`."""

from . import admin

if __name__ == "__main__":
admin(prog_name="jmp-admin")
63 changes: 50 additions & 13 deletions packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@
import asyncclick as click
from jumpstarter_cli_common import (
AliasedGroup,
OutputMode,
OutputType,
opt_context,
opt_kubeconfig,
opt_labels,
opt_log_level,
opt_namespace,
opt_nointeractive,
opt_output_all,
)
from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api
from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api, V1Alpha1Client, V1Alpha1Exporter
from kubernetes_asyncio.client.exceptions import ApiException
from kubernetes_asyncio.config.config_exception import ConfigException

Expand All @@ -33,6 +37,15 @@ def create(log_level: Optional[str]):
logging.basicConfig(level=logging.INFO)


def print_created_client(client: V1Alpha1Client, output: OutputType):
if output == OutputMode.JSON:
click.echo(client.dump_json())
elif output == OutputMode.YAML:
click.echo(client.dump_yaml())
elif output == OutputMode.NAME:
click.echo(f"client.jumpstarter.dev/{client.metadata.name}")


@create.command("client")
@click.argument("name", type=str, required=False, default=None)
@click.option(
Expand All @@ -51,7 +64,6 @@ def create(log_level: Optional[str]):
)
@click.option("--unsafe", is_flag=True, help="Should all driver client packages be allowed to load (UNSAFE!).")
@click.option(
"-o",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

+1 to aligning with kubectl :)

"--out",
type=click.Path(dir_okay=False, resolve_path=True, writable=True),
help="Specify an output file for the client config.",
Expand All @@ -62,6 +74,8 @@ def create(log_level: Optional[str]):
@opt_kubeconfig
@opt_context
@opt_oidc_username
@opt_nointeractive
@opt_output_all
async def create_client(
name: Optional[str],
kubeconfig: Optional[str],
Expand All @@ -73,15 +87,20 @@ async def create_client(
unsafe: bool,
out: Optional[str],
oidc_username: str | None,
nointeractive: bool,
output: OutputType,
):
"""Create a client object in the Kubernetes cluster"""
try:
async with ClientsV1Alpha1Api(namespace, kubeconfig, context) as api:
click.echo(f"Creating client '{name}' in namespace '{namespace}'")
await api.create_client(name, dict(labels), oidc_username)
if output is None:
# Only print status if is not JSON/YAML
click.echo(f"Creating client '{name}' in namespace '{namespace}'")
created_client = await api.create_client(name, dict(labels), oidc_username)
# Save the client config
if save or out is not None or click.confirm("Save client configuration?"):
click.echo("Fetching client credentials from cluster")
if save or out is not None or nointeractive is False and click.confirm("Save client configuration?"):
if output is None:
click.echo("Fetching client credentials from cluster")
client_config = await api.get_client_config(name, allow=[], unsafe=unsafe)
if unsafe is False and allow is None:
unsafe = click.confirm("Allow unsafe driver client imports?")
Expand All @@ -98,13 +117,24 @@ async def create_client(
user_config = UserConfigV1Alpha1.load_or_create()
user_config.config.current_client = client_config
UserConfigV1Alpha1.save(user_config)
click.echo(f"Client configuration successfully saved to {client_config.path}")
if output is None:
click.echo(f"Client configuration successfully saved to {client_config.path}")
print_created_client(created_client, output)
except ApiException as e:
handle_k8s_api_exception(e)
except ConfigException as e:
handle_k8s_config_exception(e)


def print_created_exporter(exporter: V1Alpha1Exporter, output: OutputType):
if output == OutputMode.JSON:
click.echo(exporter.dump_json())
elif output == OutputMode.YAML:
click.echo(exporter.dump_yaml())
elif output == OutputMode.NAME:
click.echo(f"exporter.jumpstarter.dev/{exporter.metadata.name}")


@create.command("exporter")
@click.argument("name", type=str, required=False, default=None)
@click.option(
Expand All @@ -115,7 +145,6 @@ async def create_client(
default=False,
)
@click.option(
"-o",
"--out",
type=click.Path(dir_okay=False, resolve_path=True, writable=True),
help="Specify an output file for the exporter config.",
Expand All @@ -126,6 +155,8 @@ async def create_client(
@opt_kubeconfig
@opt_context
@opt_oidc_username
@opt_nointeractive
@opt_output_all
async def create_exporter(
name: Optional[str],
kubeconfig: Optional[str],
Expand All @@ -135,18 +166,24 @@ async def create_exporter(
save: bool,
out: Optional[str],
oidc_username: str | None,
nointeractive: bool,
output: OutputType,
):
"""Create an exporter object in the Kubernetes cluster"""
try:
async with ExportersV1Alpha1Api(namespace, kubeconfig, context) as api:
click.echo(f"Creating exporter '{name}' in namespace '{namespace}'")
await api.create_exporter(name, dict(labels), oidc_username)
if output is None:
click.echo(f"Creating exporter '{name}' in namespace '{namespace}'")
created_exporter = await api.create_exporter(name, dict(labels), oidc_username)
# Save the client config
if save or out is not None or click.confirm("Save exporter configuration?"):
click.echo("Fetching exporter credentials from cluster")
if save or out is not None or nointeractive is False and click.confirm("Save exporter configuration?"):
if output is None:
click.echo("Fetching exporter credentials from cluster")
exporter_config = await api.get_exporter_config(name)
ExporterConfigV1Alpha1.save(exporter_config, out)
click.echo(f"Exporter configuration successfully saved to {exporter_config.path}")
if output is None:
click.echo(f"Exporter configuration successfully saved to {exporter_config.path}")
print_created_exporter(created_exporter, output)
except ApiException as e:
handle_k8s_api_exception(e)
except ConfigException as e:
Expand Down
129 changes: 126 additions & 3 deletions packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
V1Alpha1Exporter,
V1Alpha1ExporterStatus,
)
from kubernetes_asyncio.client.models import V1ObjectMeta
from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference

from .create import create
from jumpstarter.config import (
Expand All @@ -34,9 +34,41 @@
api_version="jumpstarter.dev/v1alpha1",
kind="Client",
metadata=V1ObjectMeta(namespace="default", name=CLIENT_NAME, creation_timestamp="2024-01-01T21:00:00Z"),
status=V1Alpha1ClientStatus(endpoint=CLIENT_ENDPOINT, credential=None),
status=V1Alpha1ClientStatus(
endpoint=CLIENT_ENDPOINT, credential=V1ObjectReference(name=f"{CLIENT_NAME}-credential")
),
)

CLIENT_JSON = """{{
"apiVersion": "jumpstarter.dev/v1alpha1",
"kind": "Client",
"metadata": {{
"creationTimestamp": "2024-01-01T21:00:00Z",
"name": "{name}",
"namespace": "default"
}},
"status": {{
"credential": {{
"name": "{name}-credential"
}},
"endpoint": "{endpoint}"
}}
}}
""".format(name=CLIENT_NAME, endpoint=CLIENT_ENDPOINT)

CLIENT_YAML = """apiVersion: jumpstarter.dev/v1alpha1
kind: Client
metadata:
creationTimestamp: '2024-01-01T21:00:00Z'
name: {name}
namespace: default
status:
credential:
name: {name}-credential
endpoint: {endpoint}

""".format(name=CLIENT_NAME, endpoint=CLIENT_ENDPOINT)

UNSAFE_CLIENT_CONFIG = ClientConfigV1Alpha1(
alias=CLIENT_NAME,
metadata=ObjectMeta(namespace="default", name=CLIENT_NAME),
Expand Down Expand Up @@ -108,6 +140,34 @@ async def test_create_client(
mock_save_client.assert_called_once_with(CLIENT_CONFIG, None)
mock_save_client.reset_mock()

# Save with nointeractive
result = await runner.invoke(create, ["client", CLIENT_NAME, "--nointeractive"])
assert result.exit_code == 0
assert "Creating client" in result.output
mock_save_client.assert_not_called()
mock_save_client.reset_mock()

# With JSON output
result = await runner.invoke(create, ["client", CLIENT_NAME, "--nointeractive", "--output", "json"])
assert result.exit_code == 0
assert result.output == CLIENT_JSON
mock_save_client.assert_not_called()
mock_save_client.reset_mock()

# With YAML output
result = await runner.invoke(create, ["client", CLIENT_NAME, "--nointeractive", "--output", "yaml"])
assert result.exit_code == 0
assert result.output == CLIENT_YAML
mock_save_client.assert_not_called()
mock_save_client.reset_mock()

# With name output
result = await runner.invoke(create, ["client", CLIENT_NAME, "--nointeractive", "--output", "name"])
assert result.exit_code == 0
assert result.output == f"client.jumpstarter.dev/{CLIENT_NAME}\n"
mock_save_client.assert_not_called()
mock_save_client.reset_mock()


# Generate a random exporter name
EXPORTER_NAME = uuid.uuid4().hex
Expand All @@ -120,8 +180,43 @@ async def test_create_client(
api_version="jumpstarter.dev/v1alpha1",
kind="Exporter",
metadata=V1ObjectMeta(namespace="default", name=EXPORTER_NAME, creation_timestamp="2024-01-01T21:00:00Z"),
status=V1Alpha1ExporterStatus(endpoint=EXPORTER_ENDPOINT, credential=None, devices=[]),
status=V1Alpha1ExporterStatus(
endpoint=EXPORTER_ENDPOINT, credential=V1ObjectReference(name=f"{EXPORTER_NAME}-credential"), devices=[]
),
)

EXPORTER_JSON = """{{
"apiVersion": "jumpstarter.dev/v1alpha1",
"kind": "Exporter",
"metadata": {{
"creationTimestamp": "2024-01-01T21:00:00Z",
"name": "{name}",
"namespace": "default"
}},
"status": {{
"credential": {{
"name": "{name}-credential"
}},
"devices": [],
"endpoint": "{endpoint}"
}}
}}
""".format(name=EXPORTER_NAME, endpoint=EXPORTER_ENDPOINT)

EXPORTER_YAML = """apiVersion: jumpstarter.dev/v1alpha1
kind: Exporter
metadata:
creationTimestamp: '2024-01-01T21:00:00Z'
name: {name}
namespace: default
status:
credential:
name: {name}-credential
devices: []
endpoint: {endpoint}

""".format(name=EXPORTER_NAME, endpoint=EXPORTER_ENDPOINT)

EXPORTER_CONFIG = ExporterConfigV1Alpha1(
alias=EXPORTER_NAME,
metadata=ObjectMeta(namespace="default", name=EXPORTER_NAME),
Expand Down Expand Up @@ -171,6 +266,34 @@ async def test_create_exporter(
save_exporter_mock.assert_called_once_with(EXPORTER_CONFIG, out)
save_exporter_mock.reset_mock()

# Save with nointeractive
result = await runner.invoke(create, ["exporter", EXPORTER_NAME, "--nointeractive"])
assert result.exit_code == 0
assert "Creating exporter" in result.output
save_exporter_mock.assert_not_called()
save_exporter_mock.reset_mock()

# Save with JSON output
result = await runner.invoke(create, ["exporter", EXPORTER_NAME, "--nointeractive", "--output", "json"])
assert result.exit_code == 0
assert result.output == EXPORTER_JSON
save_exporter_mock.assert_not_called()
save_exporter_mock.reset_mock()

# Save with YAML output
result = await runner.invoke(create, ["exporter", EXPORTER_NAME, "--nointeractive", "--output", "yaml"])
assert result.exit_code == 0
assert result.output == EXPORTER_YAML
save_exporter_mock.assert_not_called()
save_exporter_mock.reset_mock()

# Save with name output
result = await runner.invoke(create, ["exporter", EXPORTER_NAME, "--nointeractive", "--output", "name"])
assert result.exit_code == 0
assert result.output == f"exporter.jumpstarter.dev/{EXPORTER_NAME}\n"
save_exporter_mock.assert_not_called()
save_exporter_mock.reset_mock()


@pytest.fixture
def anyio_backend():
Expand Down
Loading