Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
93786eb
feat(cli): add jmp describe for exporters, leases, and clients
kirkbrauer Aug 29, 2026
7a862ed
refactor(client): move driver introspection into jumpstarter.client f…
kirkbrauer Aug 29, 2026
52162b0
Merge branch 'client-introspect-lib' into cli-describe-devices
kirkbrauer Aug 29, 2026
f3fd4ca
feat(cli): add --devices to jmp describe lease
kirkbrauer Aug 29, 2026
0dfc27c
fix(client): drive lease introspection from outside the event loop
kirkbrauer Aug 30, 2026
fb7b060
feat(client): describe command parameters well enough to prompt for them
kirkbrauer Aug 30, 2026
5d9e792
refactor(client): address review on the introspection move
kirkbrauer Aug 31, 2026
e7acae1
Merge main into cli-describe-devices for upstream review
kirkbrauer Sep 5, 2026
6ab8185
feat(cli): describe lease driver trees with --drivers
kirkbrauer Sep 5, 2026
1dd009a
test: cover introspection review fixes across MCP and client APIs
kirkbrauer Sep 5, 2026
b73bf49
Merge reviewed introspection library into cli-describe-drivers
kirkbrauer Sep 5, 2026
080009e
fix(client): retain declared help inputs and valid root call examples
kirkbrauer Sep 5, 2026
8f3c163
Merge introspection edge-case fixes from #1042
kirkbrauer Sep 5, 2026
b51f524
fix(client): preserve structured mapping defaults in introspection
kirkbrauer Sep 5, 2026
3795de4
Merge structured mapping-default fix from #1042
kirkbrauer Sep 5, 2026
cf391f2
fix(client): reject colliding JSON mapping keys in defaults
kirkbrauer Sep 6, 2026
048339a
Merge mapping key-collision fix from #1042
kirkbrauer Sep 6, 2026
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
21 changes: 21 additions & 0 deletions docs/source/getting-started/guides/setup/distributed-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,27 @@ Conditions:
Ready True Ready An exporter has been acquired for the client 2026-08-31 14:04:36 UTC
```

For a lease you hold, add `--drivers` to connect to its exporter and discover
its drivers and runnable `j` commands:

```console
$ jmp describe lease 01a05822-e378-71cc-a98c-a216ad4a9432 --client hello --drivers
$ jmp describe lease 01a05822-e378-71cc-a98c-a216ad4a9432 --client hello --drivers -o json
```

The human-readable description adds **Drivers** and **Commands** tables. With
`-o json` or `-o yaml`, the result is `{lease, driver_tree}`: `lease` contains the
usual lease metadata, while `driver_tree` contains a `drivers` list and the recursive
`cli_tree`, including command help and parameters. This is discovery only; it
does not execute the listed driver commands, create a lease, or release your
existing lease when it finishes. It does require a connection to the exporter
and uses the selected client's driver-access settings. Without `--drivers`,
the existing metadata-only behavior and output shape are unchanged.

Here, **drivers** means the software driver clients exposed by the lease session,
not an inventory of physical devices attached to the exporter. The exporter's
existing device report is unchanged by this command.

Describing a client reads the local configuration rather than the cluster, so it
works without a connection and reports whether the client's token is still valid:

Expand Down
95 changes: 75 additions & 20 deletions python/packages/jumpstarter-cli/jumpstarter_cli/describe.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from pydantic import BaseModel, ConfigDict, Field

from .login import relogin_client
from jumpstarter.client.grpc import Lease
from jumpstarter.client.introspect import describe_drivers
from jumpstarter.config.client import ClientConfigV1Alpha1
from jumpstarter.config.user import UserConfigV1Alpha1

Expand Down Expand Up @@ -57,25 +59,20 @@ def _condition_time(condition) -> datetime | None:


def _print_conditions(conditions) -> None:
if not conditions:
click.echo("Conditions: <none>")
return
click.echo("Conditions:")
headers = ["Type", "Status", "Reason", "Message", "Last Transition Time"]
rows = [
_print_table(
"Conditions",
["Type", "Status", "Reason", "Message", "Last Transition Time"],
[
_format_value(condition.type),
_format_value(condition.status),
_format_value(condition.reason),
_format_value(condition.message),
_format_value(_condition_time(condition)),
]
for condition in conditions
]
widths = [max([len(header)] + [len(row[i]) for row in rows]) for i, header in enumerate(headers)]
dashes = ["-" * len(header) for header in headers]
for cells in [headers, dashes, *rows]:
click.echo(" " + " ".join(cell.ljust(width) for cell, width in zip(cells, widths, strict=True)).rstrip())
[
_format_value(condition.type),
_format_value(condition.status),
_format_value(condition.reason),
_format_value(condition.message),
_format_value(_condition_time(condition)),
]
for condition in conditions
],
)


@click.group(cls=AliasedGroup)
Expand Down Expand Up @@ -149,20 +146,76 @@ def describe_exporter(config, name: str, output: OutputType):
click.echo("Lease: <none>")


class LeaseDescription(BaseModel):
lease: Lease
driver_tree: dict


def _walk_commands(tree: dict, path: list[str]) -> list[tuple[str, str]]:
commands = []
for name, subtree in sorted((tree.get("subcommands") or {}).items()):
subpath = [*path, name]
if subtree.get("subcommands"):
commands.extend(_walk_commands(subtree, subpath))
else:
help_text = (subtree.get("help") or "").strip().splitlines()
commands.append((" ".join(subpath), help_text[0] if help_text else ""))
return commands


def _print_table(label: str, headers: list[str], rows: list[list[str]]) -> None:
if not rows:
click.echo(f"{label}: <none>")
return
click.echo(f"{label}:")
widths = [max([len(header)] + [len(row[i]) for row in rows]) for i, header in enumerate(headers)]
dashes = ["-" * len(header) for header in headers]
for cells in [headers, dashes, *rows]:
click.echo(" " + " ".join(cell.ljust(width) for cell, width in zip(cells, widths, strict=True)).rstrip())


def _print_drivers(driver_tree: dict) -> None:
_print_table(
"Drivers",
["Path", "Class", "Methods"],
[
[
".".join(driver["driver_path"]) or "(root)",
driver["class"],
", ".join(driver["methods"]),
]
for driver in driver_tree["drivers"]
],
)
commands = _walk_commands(driver_tree["cli_tree"], ["j"]) if driver_tree.get("cli_tree") else []
_print_table("Commands", ["Command", "Description"], [[command, help] for command, help in commands])


@describe.command(name="lease")
@opt_config(exporter=False)
@click.argument("name")
@click.option(
"--drivers",
"show_drivers",
is_flag=True,
default=False,
help="Connect to the leased exporter and include its driver tree and driver commands.",
)
@opt_output
@handle_exceptions_with_reauthentication(relogin_client)
def describe_lease(config, name: str, output: OutputType):
def describe_lease(config, name: str, show_drivers: bool, output: OutputType):
"""
Show details of a specific lease
"""

lease = config.get_lease(name=name)
driver_tree = describe_drivers(config, name) if show_drivers else None

if output:
model_print(lease, output)
if driver_tree is not None:
model_print(LeaseDescription(lease=lease, driver_tree=driver_tree), output)
else:
model_print(lease, output)
return

_print_fields(
Expand All @@ -181,6 +234,8 @@ def describe_lease(config, name: str, output: OutputType):
_print_mapping("Tags", lease.tags)
_print_mapping("Context", lease.context)
_print_conditions(lease.conditions)
if driver_tree is not None:
_print_drivers(driver_tree)


class ClientDescription(BaseModel):
Expand Down
143 changes: 143 additions & 0 deletions python/packages/jumpstarter-cli/jumpstarter_cli/describe_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path
from unittest.mock import MagicMock, patch

import yaml
from click.testing import CliRunner
from jumpstarter_protocol import kubernetes_pb2

Expand Down Expand Up @@ -359,3 +360,145 @@ def test_desc_alias(self):
ctx = MagicMock()
ctx.fail = MagicMock()
assert jmp.get_command(ctx, "desc") is describe


_DRIVER_TREE = {
"drivers": [
{
"path": "client",
"driver_path": [],
"class": "jumpstarter_driver_composite.client.CompositeClient",
"description": None,
"methods": [],
},
{
"path": "client.power",
"driver_path": ["power"],
"class": "jumpstarter_driver_power.client.PowerClient",
"description": None,
"methods": ["cycle", "off", "on"],
},
],
"cli_tree": {
"name": "j",
"help": "Generic composite device",
"params": [],
"subcommands": {
"power": {
"name": "power",
"help": "Power control",
"params": [],
"subcommands": {
"on": {"name": "on", "help": "Turn power on", "params": [], "subcommands": {}},
"off": {"name": "off", "help": "Turn power off", "params": [], "subcommands": {}},
},
}
},
},
}


class TestDescribeLeaseDrivers:
def setup_method(self):
self.runner = CliRunner()

def test_devices_is_not_an_alias_for_driver_introspection(self):
result = self.runner.invoke(describe, ["lease", "lease-1", "--devices"])
assert result.exit_code == 2
assert "No such option: --devices" in result.output

def test_pretty_output_drivers(self):
config = MagicMock()
config.get_lease.return_value = _make_lease()
with (
_patch_remote_config(config),
patch("jumpstarter_cli.describe.describe_drivers", return_value=_DRIVER_TREE) as mock_drivers,
):
result = self.runner.invoke(describe, ["lease", "lease-1", "--client", "test", "--drivers"])
assert result.exit_code == 0, result.output
assert "Drivers:" in result.output
assert "(root)" in result.output
assert "jumpstarter_driver_power.client.PowerClient" in result.output
assert "cycle, off, on" in result.output
assert "Commands:" in result.output
assert "j power on" in result.output
assert "Turn power on" in result.output
mock_drivers.assert_called_once_with(config, "lease-1")

def test_pretty_output_no_drivers_flag(self):
config = MagicMock()
config.get_lease.return_value = _make_lease()
with (
_patch_remote_config(config),
patch("jumpstarter_cli.describe.describe_drivers", return_value=_DRIVER_TREE) as mock_drivers,
):
result = self.runner.invoke(describe, ["lease", "lease-1", "--client", "test"])
assert result.exit_code == 0, result.output
assert "Drivers:" not in result.output
mock_drivers.assert_not_called()

def test_json_output_drivers(self):
config = MagicMock()
config.get_lease.return_value = _make_lease()
with (
_patch_remote_config(config),
patch("jumpstarter_cli.describe.describe_drivers", return_value=_DRIVER_TREE),
):
result = self.runner.invoke(describe, ["lease", "lease-1", "--client", "test", "--drivers", "-o", "json"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["lease"]["name"] == "lease-1"
assert data["driver_tree"]["drivers"][1]["class"] == "jumpstarter_driver_power.client.PowerClient"
assert data["driver_tree"]["cli_tree"]["subcommands"]["power"]["subcommands"]["on"]["help"] == "Turn power on"

def test_yaml_output_drivers(self):
config = MagicMock()
config.get_lease.return_value = _make_lease()
with (
_patch_remote_config(config),
patch("jumpstarter_cli.describe.describe_drivers", return_value=_DRIVER_TREE),
):
result = self.runner.invoke(describe, ["lease", "lease-1", "--client", "test", "--drivers", "-o", "yaml"])
assert result.exit_code == 0, result.output
data = yaml.safe_load(result.output)
assert data["lease"]["name"] == "lease-1"
assert data["driver_tree"] == _DRIVER_TREE
assert "devices" not in data

def test_json_without_drivers_preserves_the_lease_shape(self):
config = MagicMock()
config.get_lease.return_value = _make_lease()
with (
_patch_remote_config(config),
patch("jumpstarter_cli.describe.describe_drivers") as mock_drivers,
):
result = self.runner.invoke(describe, ["lease", "lease-1", "--client", "test", "-o", "json"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["name"] == "lease-1"
assert "lease" not in data
assert "driver_tree" not in data
mock_drivers.assert_not_called()

def test_driver_connection_failure_is_reported(self):
config = MagicMock()
config.get_lease.return_value = _make_lease()
with (
_patch_remote_config(config),
patch("jumpstarter_cli.describe.describe_drivers", side_effect=ConnectionError("exporter unreachable")),
):
result = self.runner.invoke(describe, ["lease", "lease-1", "--client", "test", "--drivers", "-o", "json"])
assert result.exit_code != 0
assert "exporter unreachable" in result.output

def test_stub_root_cli_tree_none(self):
config = MagicMock()
config.get_lease.return_value = _make_lease()
driver_tree = {"drivers": _DRIVER_TREE["drivers"], "cli_tree": None}
with (
_patch_remote_config(config),
patch("jumpstarter_cli.describe.describe_drivers", return_value=driver_tree),
):
result = self.runner.invoke(describe, ["lease", "lease-1", "--client", "test", "--drivers"])
assert result.exit_code == 0, result.output
assert "Commands: <none>" in result.output
Loading
Loading