Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit 23254f4

Browse files
authored
Merge pull request #335 from jumpstarter-dev/cli-json-output
Add JSON and YAML output options for the `jmp-admin`, `jmp-client`, and `jmp-exporter` CLIs
2 parents 8b11ad6 + a128a4c commit 23254f4

36 files changed

Lines changed: 2104 additions & 429 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Allow running Jumpstarter through `python -m jumpstarter_cli_admin`."""
2+
3+
from . import admin
4+
5+
if __name__ == "__main__":
6+
admin(prog_name="jmp-admin")

packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44
import asyncclick as click
55
from jumpstarter_cli_common import (
66
AliasedGroup,
7+
OutputMode,
8+
OutputType,
79
opt_context,
810
opt_kubeconfig,
911
opt_labels,
1012
opt_log_level,
1113
opt_namespace,
14+
opt_nointeractive,
15+
opt_output_all,
1216
)
13-
from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api
17+
from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api, V1Alpha1Client, V1Alpha1Exporter
1418
from kubernetes_asyncio.client.exceptions import ApiException
1519
from kubernetes_asyncio.config.config_exception import ConfigException
1620

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

3539

40+
def print_created_client(client: V1Alpha1Client, output: OutputType):
41+
if output == OutputMode.JSON:
42+
click.echo(client.dump_json())
43+
elif output == OutputMode.YAML:
44+
click.echo(client.dump_yaml())
45+
elif output == OutputMode.NAME:
46+
click.echo(f"client.jumpstarter.dev/{client.metadata.name}")
47+
48+
3649
@create.command("client")
3750
@click.argument("name", type=str, required=False, default=None)
3851
@click.option(
@@ -51,7 +64,6 @@ def create(log_level: Optional[str]):
5164
)
5265
@click.option("--unsafe", is_flag=True, help="Should all driver client packages be allowed to load (UNSAFE!).")
5366
@click.option(
54-
"-o",
5567
"--out",
5668
type=click.Path(dir_okay=False, resolve_path=True, writable=True),
5769
help="Specify an output file for the client config.",
@@ -62,6 +74,8 @@ def create(log_level: Optional[str]):
6274
@opt_kubeconfig
6375
@opt_context
6476
@opt_oidc_username
77+
@opt_nointeractive
78+
@opt_output_all
6579
async def create_client(
6680
name: Optional[str],
6781
kubeconfig: Optional[str],
@@ -73,15 +87,20 @@ async def create_client(
7387
unsafe: bool,
7488
out: Optional[str],
7589
oidc_username: str | None,
90+
nointeractive: bool,
91+
output: OutputType,
7692
):
7793
"""Create a client object in the Kubernetes cluster"""
7894
try:
7995
async with ClientsV1Alpha1Api(namespace, kubeconfig, context) as api:
80-
click.echo(f"Creating client '{name}' in namespace '{namespace}'")
81-
await api.create_client(name, dict(labels), oidc_username)
96+
if output is None:
97+
# Only print status if is not JSON/YAML
98+
click.echo(f"Creating client '{name}' in namespace '{namespace}'")
99+
created_client = await api.create_client(name, dict(labels), oidc_username)
82100
# Save the client config
83-
if save or out is not None or click.confirm("Save client configuration?"):
84-
click.echo("Fetching client credentials from cluster")
101+
if save or out is not None or nointeractive is False and click.confirm("Save client configuration?"):
102+
if output is None:
103+
click.echo("Fetching client credentials from cluster")
85104
client_config = await api.get_client_config(name, allow=[], unsafe=unsafe)
86105
if unsafe is False and allow is None:
87106
unsafe = click.confirm("Allow unsafe driver client imports?")
@@ -98,13 +117,24 @@ async def create_client(
98117
user_config = UserConfigV1Alpha1.load_or_create()
99118
user_config.config.current_client = client_config
100119
UserConfigV1Alpha1.save(user_config)
101-
click.echo(f"Client configuration successfully saved to {client_config.path}")
120+
if output is None:
121+
click.echo(f"Client configuration successfully saved to {client_config.path}")
122+
print_created_client(created_client, output)
102123
except ApiException as e:
103124
handle_k8s_api_exception(e)
104125
except ConfigException as e:
105126
handle_k8s_config_exception(e)
106127

107128

129+
def print_created_exporter(exporter: V1Alpha1Exporter, output: OutputType):
130+
if output == OutputMode.JSON:
131+
click.echo(exporter.dump_json())
132+
elif output == OutputMode.YAML:
133+
click.echo(exporter.dump_yaml())
134+
elif output == OutputMode.NAME:
135+
click.echo(f"exporter.jumpstarter.dev/{exporter.metadata.name}")
136+
137+
108138
@create.command("exporter")
109139
@click.argument("name", type=str, required=False, default=None)
110140
@click.option(
@@ -115,7 +145,6 @@ async def create_client(
115145
default=False,
116146
)
117147
@click.option(
118-
"-o",
119148
"--out",
120149
type=click.Path(dir_okay=False, resolve_path=True, writable=True),
121150
help="Specify an output file for the exporter config.",
@@ -126,6 +155,8 @@ async def create_client(
126155
@opt_kubeconfig
127156
@opt_context
128157
@opt_oidc_username
158+
@opt_nointeractive
159+
@opt_output_all
129160
async def create_exporter(
130161
name: Optional[str],
131162
kubeconfig: Optional[str],
@@ -135,18 +166,24 @@ async def create_exporter(
135166
save: bool,
136167
out: Optional[str],
137168
oidc_username: str | None,
169+
nointeractive: bool,
170+
output: OutputType,
138171
):
139172
"""Create an exporter object in the Kubernetes cluster"""
140173
try:
141174
async with ExportersV1Alpha1Api(namespace, kubeconfig, context) as api:
142-
click.echo(f"Creating exporter '{name}' in namespace '{namespace}'")
143-
await api.create_exporter(name, dict(labels), oidc_username)
175+
if output is None:
176+
click.echo(f"Creating exporter '{name}' in namespace '{namespace}'")
177+
created_exporter = await api.create_exporter(name, dict(labels), oidc_username)
144178
# Save the client config
145-
if save or out is not None or click.confirm("Save exporter configuration?"):
146-
click.echo("Fetching exporter credentials from cluster")
179+
if save or out is not None or nointeractive is False and click.confirm("Save exporter configuration?"):
180+
if output is None:
181+
click.echo("Fetching exporter credentials from cluster")
147182
exporter_config = await api.get_exporter_config(name)
148183
ExporterConfigV1Alpha1.save(exporter_config, out)
149-
click.echo(f"Exporter configuration successfully saved to {exporter_config.path}")
184+
if output is None:
185+
click.echo(f"Exporter configuration successfully saved to {exporter_config.path}")
186+
print_created_exporter(created_exporter, output)
150187
except ApiException as e:
151188
handle_k8s_api_exception(e)
152189
except ConfigException as e:

packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
V1Alpha1Exporter,
1212
V1Alpha1ExporterStatus,
1313
)
14-
from kubernetes_asyncio.client.models import V1ObjectMeta
14+
from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference
1515

1616
from .create import create
1717
from jumpstarter.config import (
@@ -34,9 +34,41 @@
3434
api_version="jumpstarter.dev/v1alpha1",
3535
kind="Client",
3636
metadata=V1ObjectMeta(namespace="default", name=CLIENT_NAME, creation_timestamp="2024-01-01T21:00:00Z"),
37-
status=V1Alpha1ClientStatus(endpoint=CLIENT_ENDPOINT, credential=None),
37+
status=V1Alpha1ClientStatus(
38+
endpoint=CLIENT_ENDPOINT, credential=V1ObjectReference(name=f"{CLIENT_NAME}-credential")
39+
),
3840
)
3941

42+
CLIENT_JSON = """{{
43+
"apiVersion": "jumpstarter.dev/v1alpha1",
44+
"kind": "Client",
45+
"metadata": {{
46+
"creationTimestamp": "2024-01-01T21:00:00Z",
47+
"name": "{name}",
48+
"namespace": "default"
49+
}},
50+
"status": {{
51+
"credential": {{
52+
"name": "{name}-credential"
53+
}},
54+
"endpoint": "{endpoint}"
55+
}}
56+
}}
57+
""".format(name=CLIENT_NAME, endpoint=CLIENT_ENDPOINT)
58+
59+
CLIENT_YAML = """apiVersion: jumpstarter.dev/v1alpha1
60+
kind: Client
61+
metadata:
62+
creationTimestamp: '2024-01-01T21:00:00Z'
63+
name: {name}
64+
namespace: default
65+
status:
66+
credential:
67+
name: {name}-credential
68+
endpoint: {endpoint}
69+
70+
""".format(name=CLIENT_NAME, endpoint=CLIENT_ENDPOINT)
71+
4072
UNSAFE_CLIENT_CONFIG = ClientConfigV1Alpha1(
4173
alias=CLIENT_NAME,
4274
metadata=ObjectMeta(namespace="default", name=CLIENT_NAME),
@@ -108,6 +140,34 @@ async def test_create_client(
108140
mock_save_client.assert_called_once_with(CLIENT_CONFIG, None)
109141
mock_save_client.reset_mock()
110142

143+
# Save with nointeractive
144+
result = await runner.invoke(create, ["client", CLIENT_NAME, "--nointeractive"])
145+
assert result.exit_code == 0
146+
assert "Creating client" in result.output
147+
mock_save_client.assert_not_called()
148+
mock_save_client.reset_mock()
149+
150+
# With JSON output
151+
result = await runner.invoke(create, ["client", CLIENT_NAME, "--nointeractive", "--output", "json"])
152+
assert result.exit_code == 0
153+
assert result.output == CLIENT_JSON
154+
mock_save_client.assert_not_called()
155+
mock_save_client.reset_mock()
156+
157+
# With YAML output
158+
result = await runner.invoke(create, ["client", CLIENT_NAME, "--nointeractive", "--output", "yaml"])
159+
assert result.exit_code == 0
160+
assert result.output == CLIENT_YAML
161+
mock_save_client.assert_not_called()
162+
mock_save_client.reset_mock()
163+
164+
# With name output
165+
result = await runner.invoke(create, ["client", CLIENT_NAME, "--nointeractive", "--output", "name"])
166+
assert result.exit_code == 0
167+
assert result.output == f"client.jumpstarter.dev/{CLIENT_NAME}\n"
168+
mock_save_client.assert_not_called()
169+
mock_save_client.reset_mock()
170+
111171

112172
# Generate a random exporter name
113173
EXPORTER_NAME = uuid.uuid4().hex
@@ -120,8 +180,43 @@ async def test_create_client(
120180
api_version="jumpstarter.dev/v1alpha1",
121181
kind="Exporter",
122182
metadata=V1ObjectMeta(namespace="default", name=EXPORTER_NAME, creation_timestamp="2024-01-01T21:00:00Z"),
123-
status=V1Alpha1ExporterStatus(endpoint=EXPORTER_ENDPOINT, credential=None, devices=[]),
183+
status=V1Alpha1ExporterStatus(
184+
endpoint=EXPORTER_ENDPOINT, credential=V1ObjectReference(name=f"{EXPORTER_NAME}-credential"), devices=[]
185+
),
124186
)
187+
188+
EXPORTER_JSON = """{{
189+
"apiVersion": "jumpstarter.dev/v1alpha1",
190+
"kind": "Exporter",
191+
"metadata": {{
192+
"creationTimestamp": "2024-01-01T21:00:00Z",
193+
"name": "{name}",
194+
"namespace": "default"
195+
}},
196+
"status": {{
197+
"credential": {{
198+
"name": "{name}-credential"
199+
}},
200+
"devices": [],
201+
"endpoint": "{endpoint}"
202+
}}
203+
}}
204+
""".format(name=EXPORTER_NAME, endpoint=EXPORTER_ENDPOINT)
205+
206+
EXPORTER_YAML = """apiVersion: jumpstarter.dev/v1alpha1
207+
kind: Exporter
208+
metadata:
209+
creationTimestamp: '2024-01-01T21:00:00Z'
210+
name: {name}
211+
namespace: default
212+
status:
213+
credential:
214+
name: {name}-credential
215+
devices: []
216+
endpoint: {endpoint}
217+
218+
""".format(name=EXPORTER_NAME, endpoint=EXPORTER_ENDPOINT)
219+
125220
EXPORTER_CONFIG = ExporterConfigV1Alpha1(
126221
alias=EXPORTER_NAME,
127222
metadata=ObjectMeta(namespace="default", name=EXPORTER_NAME),
@@ -171,6 +266,34 @@ async def test_create_exporter(
171266
save_exporter_mock.assert_called_once_with(EXPORTER_CONFIG, out)
172267
save_exporter_mock.reset_mock()
173268

269+
# Save with nointeractive
270+
result = await runner.invoke(create, ["exporter", EXPORTER_NAME, "--nointeractive"])
271+
assert result.exit_code == 0
272+
assert "Creating exporter" in result.output
273+
save_exporter_mock.assert_not_called()
274+
save_exporter_mock.reset_mock()
275+
276+
# Save with JSON output
277+
result = await runner.invoke(create, ["exporter", EXPORTER_NAME, "--nointeractive", "--output", "json"])
278+
assert result.exit_code == 0
279+
assert result.output == EXPORTER_JSON
280+
save_exporter_mock.assert_not_called()
281+
save_exporter_mock.reset_mock()
282+
283+
# Save with YAML output
284+
result = await runner.invoke(create, ["exporter", EXPORTER_NAME, "--nointeractive", "--output", "yaml"])
285+
assert result.exit_code == 0
286+
assert result.output == EXPORTER_YAML
287+
save_exporter_mock.assert_not_called()
288+
save_exporter_mock.reset_mock()
289+
290+
# Save with name output
291+
result = await runner.invoke(create, ["exporter", EXPORTER_NAME, "--nointeractive", "--output", "name"])
292+
assert result.exit_code == 0
293+
assert result.output == f"exporter.jumpstarter.dev/{EXPORTER_NAME}\n"
294+
save_exporter_mock.assert_not_called()
295+
save_exporter_mock.reset_mock()
296+
174297

175298
@pytest.fixture
176299
def anyio_backend():

0 commit comments

Comments
 (0)