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

Commit 6e523a3

Browse files
authored
Merge pull request #763 from bennyz/custom-lease-name
cli: add support for requesting custom lease name
2 parents 9214623 + 3b97a14 commit 6e523a3

8 files changed

Lines changed: 97 additions & 119 deletions

File tree

packages/jumpstarter-cli/jumpstarter_cli/create.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,17 @@ def create():
2222
@opt_selector
2323
@opt_duration_partial(required=True)
2424
@opt_begin_time
25+
@click.option(
26+
"--lease-id",
27+
type=str,
28+
default=None,
29+
help="Optional lease ID to request (if not provided, server will generate one)",
30+
)
2531
@opt_output_all
2632
@handle_exceptions_with_reauthentication(relogin_client)
27-
def create_lease(config, selector: str, duration: timedelta, begin_time: datetime | None, output: OutputType):
33+
def create_lease(
34+
config, selector: str, duration: timedelta, begin_time: datetime | None, lease_id: str | None, output: OutputType
35+
):
2836
"""
2937
Create a lease
3038
@@ -48,8 +56,14 @@ def create_lease(config, selector: str, duration: timedelta, begin_time: datetim
4856
$$ exit
4957
$ jmp delete lease "${JMP_LEASE}"
5058
59+
You can also specify a unique custom lease ID:
60+
61+
.. code-block:: bash
62+
63+
$ jmp create lease -l foo=bar --duration 1d --lease-id my-custom-lease-id
64+
5165
"""
5266

53-
lease = config.create_lease(selector=selector, duration=duration, begin_time=begin_time)
67+
lease = config.create_lease(selector=selector, duration=duration, begin_time=begin_time, lease_id=lease_id)
5468

5569
model_print(lease, output)

packages/jumpstarter/jumpstarter/client/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ async def client_from_channel(
5959
portal=portal,
6060
stack=stack.enter_context(ExitStack()),
6161
children={reports[k].labels["jumpstarter.dev/name"]: clients[k] for k in topo[index]},
62-
description=getattr(report, 'description', None) or None,
63-
methods_description=getattr(report, 'methods_description', {}) or {},
62+
description=getattr(report, "description", None) or None,
63+
methods_description=getattr(report, "methods_description", {}) or {},
6464
)
6565

6666
clients[index] = client

packages/jumpstarter/jumpstarter/client/decorators.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,20 +34,21 @@ def on():
3434
:param kwargs: Keyword arguments passed to DriverClickGroup
3535
:return: Decorator that creates a DriverClickGroup
3636
"""
37+
3738
def decorator(f: Callable) -> DriverClickGroup:
3839
# Use function docstring if no help= provided
39-
if 'help' not in kwargs or kwargs['help'] is None:
40+
if "help" not in kwargs or kwargs["help"] is None:
4041
if f.__doc__:
41-
kwargs['help'] = f.__doc__.strip()
42+
kwargs["help"] = f.__doc__.strip()
4243

4344
# Server description overrides Click defaults
44-
if getattr(client, 'description', None):
45-
kwargs['help'] = client.description
45+
if getattr(client, "description", None):
46+
kwargs["help"] = client.description
4647

4748
group = DriverClickGroup(client, name=f.__name__, callback=f, **kwargs)
4849

4950
# Transfer Click parameters attached by decorators like @click.option
50-
group.params = getattr(f, '__click_params__', [])
51+
group.params = getattr(f, "__click_params__", [])
5152

5253
return group
5354

@@ -74,8 +75,8 @@ def ssh(args):
7475
:return: click.command decorator
7576
"""
7677
# Server description overrides Click's defaults (help= parameter or docstring)
77-
if getattr(client, 'description', None):
78-
kwargs['help'] = client.description
78+
if getattr(client, "description", None):
79+
kwargs["help"] = client.description
7980

8081
return click.command(**kwargs)
8182

@@ -89,13 +90,14 @@ def __init__(self, client: "DriverClient", *args: Any, **kwargs: Any) -> None:
8990

9091
def command(self, *args: Any, **kwargs: Any) -> Callable:
9192
"""Command decorator with server methods_description override support."""
93+
9294
def decorator(f: Callable) -> click.Command:
93-
name = kwargs.get('name')
95+
name = kwargs.get("name")
9496
if not name:
95-
name = f.__name__.lower().replace('_', '-')
97+
name = f.__name__.lower().replace("_", "-")
9698

9799
if name in self.client.methods_description:
98-
kwargs['help'] = self.client.methods_description[name]
100+
kwargs["help"] = self.client.methods_description[name]
99101

100102
return super(DriverClickGroup, self).command(*args, **kwargs)(f)
101103

packages/jumpstarter/jumpstarter/client/grpc.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -275,11 +275,7 @@ def model_dump_json(self, **kwargs):
275275
if not self.include_online:
276276
exclude_fields.add("online")
277277

278-
data = {
279-
"exporters": [
280-
exporter.model_dump(mode="json", exclude=exclude_fields) for exporter in self.exporters
281-
]
282-
}
278+
data = {"exporters": [exporter.model_dump(mode="json", exclude=exclude_fields) for exporter in self.exporters]}
283279
return json.dumps(data, **json_kwargs)
284280

285281
def model_dump(self, **kwargs):
@@ -289,11 +285,8 @@ def model_dump(self, **kwargs):
289285
if not self.include_online:
290286
exclude_fields.add("online")
291287

292-
return {
293-
"exporters": [
294-
exporter.model_dump(mode="json", exclude=exclude_fields) for exporter in self.exporters
295-
]
296-
}
288+
return {"exporters": [exporter.model_dump(mode="json", exclude=exclude_fields) for exporter in self.exporters]}
289+
297290

298291
class LeaseList(BaseModel):
299292
leases: list[Lease]
@@ -390,6 +383,7 @@ async def CreateLease(
390383
selector: str,
391384
duration: timedelta,
392385
begin_time: datetime | None = None,
386+
lease_id: str | None = None,
393387
):
394388
duration_pb = duration_pb2.Duration()
395389
duration_pb.FromTimedelta(duration)
@@ -409,6 +403,7 @@ async def CreateLease(
409403
client_pb2.CreateLeaseRequest(
410404
parent="namespaces/{}".format(self.namespace),
411405
lease=lease_pb,
406+
lease_id=lease_id or "",
412407
)
413408
)
414409
return Lease.from_protobuf(lease)

packages/jumpstarter/jumpstarter/client/grpc_test.py

Lines changed: 27 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,7 @@ class TestAddExporterRow:
6363
def create_test_exporter(self, online=True, labels=None):
6464
if labels is None:
6565
labels = {"env": "test", "type": "device"}
66-
return Exporter(
67-
namespace="default",
68-
name="test-exporter",
69-
labels=labels,
70-
online=online
71-
)
66+
return Exporter(namespace="default", name="test-exporter", labels=labels, online=online)
7267

7368
def test_basic_row(self):
7469
table = Table()
@@ -119,11 +114,16 @@ def test_row_with_all_options(self):
119114

120115

121116
class TestExporterList:
122-
def create_test_lease(self, client="test-client", status="Active",
123-
effective_begin_time=datetime(2023, 1, 1, 10, 0, 0),
124-
effective_duration=timedelta(hours=1),
125-
begin_time=None, duration=timedelta(hours=1),
126-
effective_end_time=None):
117+
def create_test_lease(
118+
self,
119+
client="test-client",
120+
status="Active",
121+
effective_begin_time=datetime(2023, 1, 1, 10, 0, 0),
122+
effective_duration=timedelta(hours=1),
123+
begin_time=None,
124+
duration=timedelta(hours=1),
125+
effective_end_time=None,
126+
):
127127
lease = Mock(spec=Lease)
128128
lease.client = client
129129
lease.get_status.return_value = status
@@ -135,12 +135,7 @@ def create_test_lease(self, client="test-client", status="Active",
135135
return lease
136136

137137
def test_exporter_without_lease(self):
138-
exporter = Exporter(
139-
namespace="default",
140-
name="test-exporter",
141-
labels={"type": "device"},
142-
online=True
143-
)
138+
exporter = Exporter(namespace="default", name="test-exporter", labels={"type": "device"}, online=True)
144139

145140
table = Table()
146141
Exporter.rich_add_columns(table)
@@ -152,11 +147,7 @@ def test_exporter_without_lease(self):
152147
def test_exporter_with_lease_no_display(self):
153148
lease = self.create_test_lease()
154149
exporter = Exporter(
155-
namespace="default",
156-
name="test-exporter",
157-
labels={"type": "device"},
158-
online=True,
159-
lease=lease
150+
namespace="default", name="test-exporter", labels={"type": "device"}, online=True, lease=lease
160151
)
161152

162153
table = Table()
@@ -170,11 +161,7 @@ def test_exporter_with_lease_no_display(self):
170161
def test_exporter_with_lease_display(self):
171162
lease = self.create_test_lease()
172163
exporter = Exporter(
173-
namespace="default",
174-
name="test-exporter",
175-
labels={"type": "device"},
176-
online=True,
177-
lease=lease
164+
namespace="default", name="test-exporter", labels={"type": "device"}, online=True, lease=lease
178165
)
179166

180167
table = Table()
@@ -198,12 +185,7 @@ def test_exporter_with_lease_display(self):
198185
assert "2023-01-01 11:00:00" in output # Expected release: begin_time (10:00:00) + duration (1h)
199186

200187
def test_exporter_without_lease_but_show_leases(self):
201-
exporter = Exporter(
202-
namespace="default",
203-
name="test-exporter",
204-
labels={"type": "device"},
205-
online=True
206-
)
188+
exporter = Exporter(namespace="default", name="test-exporter", labels={"type": "device"}, online=True)
207189

208190
table = Table()
209191
options = WithOptions(show_leases=True)
@@ -228,19 +210,11 @@ def test_exporter_without_lease_but_show_leases(self):
228210
def test_exporter_online_status_display(self):
229211
"""Test that online status icons are correctly displayed"""
230212
# Test online exporter
231-
exporter_online = Exporter(
232-
namespace="default",
233-
name="online-exporter",
234-
labels={"type": "device"},
235-
online=True
236-
)
213+
exporter_online = Exporter(namespace="default", name="online-exporter", labels={"type": "device"}, online=True)
237214

238215
# Test offline exporter
239216
exporter_offline = Exporter(
240-
namespace="default",
241-
name="offline-exporter",
242-
labels={"type": "device"},
243-
online=False
217+
namespace="default", name="offline-exporter", labels={"type": "device"}, online=False
244218
)
245219

246220
# Test with online status display enabled
@@ -262,26 +236,22 @@ def test_exporter_online_status_display(self):
262236
assert "online-exporter" in output
263237
assert "offline-exporter" in output
264238
assert "yes" in output # Should show "yes" for online
265-
assert "no" in output # Should show "no" for offline
239+
assert "no" in output # Should show "no" for offline
266240

267241
def test_exporter_all_features_display(self):
268242
"""Test all display features together: online status + lease info"""
269243
lease = self.create_test_lease(client="full-test-client", status="Active")
270244

271245
# Create exporters with different combinations of online/lease status
272246
exporter_online_with_lease = Exporter(
273-
namespace="default",
274-
name="online-with-lease",
275-
labels={"env": "prod"},
276-
online=True,
277-
lease=lease
247+
namespace="default", name="online-with-lease", labels={"env": "prod"}, online=True, lease=lease
278248
)
279249

280250
exporter_offline_no_lease = Exporter(
281251
namespace="default",
282252
name="offline-no-lease",
283253
labels={"env": "dev"},
284-
online=False
254+
online=False,
285255
# No lease
286256
)
287257

@@ -306,7 +276,7 @@ def test_exporter_all_features_display(self):
306276
assert "env=prod" in output
307277
assert "env=dev" in output
308278
assert "yes" in output # Online indicator
309-
assert "no" in output # Offline indicator
279+
assert "no" in output # Offline indicator
310280
assert "full-test-client" in output # Lease client
311281
assert "Active" in output # Lease status
312282
assert "Available" in output # Available status for no lease
@@ -317,14 +287,10 @@ def test_exporter_lease_info_extraction(self):
317287
lease = self.create_test_lease(
318288
client="my-client",
319289
status="Expired",
320-
effective_end_time=datetime(2023, 1, 1, 11, 0, 0) # Ended after 1 hour
290+
effective_end_time=datetime(2023, 1, 1, 11, 0, 0), # Ended after 1 hour
321291
)
322292
exporter = Exporter(
323-
namespace="default",
324-
name="test-exporter",
325-
labels={"type": "device"},
326-
online=True,
327-
lease=lease
293+
namespace="default", name="test-exporter", labels={"type": "device"}, online=True, lease=lease
328294
)
329295

330296
# Manually verify the lease data that would be extracted
@@ -359,7 +325,7 @@ def test_exporter_no_lease_info_extraction(self):
359325
namespace="default",
360326
name="test-exporter",
361327
labels={"type": "device"},
362-
online=True
328+
online=True,
363329
# No lease attached
364330
)
365331

@@ -380,16 +346,12 @@ def test_exporter_scheduled_lease_expected_release(self):
380346
client="my-client",
381347
status="Scheduled",
382348
effective_begin_time=None, # Not started yet
383-
effective_duration=None, # Not started yet
349+
effective_duration=None, # Not started yet
384350
begin_time=datetime(2023, 1, 1, 10, 0, 0),
385-
duration=timedelta(hours=1)
351+
duration=timedelta(hours=1),
386352
)
387353
exporter = Exporter(
388-
namespace="default",
389-
name="test-exporter",
390-
labels={"type": "device"},
391-
online=True,
392-
lease=lease
354+
namespace="default", name="test-exporter", labels={"type": "device"}, online=True, lease=lease
393355
)
394356

395357
# Test the table display with scheduled lease
@@ -412,5 +374,3 @@ def test_exporter_scheduled_lease_expected_release(self):
412374
assert "my-client" in output
413375
assert "Scheduled" in output
414376
assert "2023-01-01 11:00:00" in output # begin_time (10:00) + duration (1h)
415-
416-

packages/jumpstarter/jumpstarter/client/lease.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ async def _create(self):
7272
await self.svc.CreateLease(
7373
selector=self.selector,
7474
duration=self.duration,
75+
lease_id=self.name,
7576
)
7677
).name
7778
logger.info("Acquiring lease %s for selector %s for duration %s", self.name, self.selector, self.duration)
@@ -388,9 +389,7 @@ def update_status(self, message: str, force: bool = False):
388389
# Throttle updates to at most every 5 minutes unless forced
389390
now = datetime.now()
390391
should_log = (
391-
force
392-
or self._last_log_time is None
393-
or (now - self._last_log_time) >= self._log_throttle_interval
392+
force or self._last_log_time is None or (now - self._last_log_time) >= self._log_throttle_interval
394393
)
395394

396395
if should_log:

0 commit comments

Comments
 (0)