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

Commit ab363e6

Browse files
authored
Merge pull request #360 from jumpstarter-dev/lease-expiration-notification
Lease expiration notification
2 parents d149734 + 6291d51 commit ab363e6

4 files changed

Lines changed: 49 additions & 9 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,7 @@ async def test_get_exporters(_load_kube_config_mock, list_exporters_mock: AsyncM
764764

765765
EXPORTERS_DEVICES_LIST_NAME = EXPORTERS_LIST_NAME
766766

767+
767768
@pytest.mark.anyio
768769
@patch.object(ExportersV1Alpha1Api, "list_exporters")
769770
@patch.object(ExportersV1Alpha1Api, "_load_kube_config")
@@ -1141,6 +1142,7 @@ async def test_get_lease(_load_kube_config_mock, get_lease_mock: AsyncMock):
11411142
lease.jumpstarter.dev/82a8ac0d-d7ff-4009-8948-18a3c5c607b2
11421143
"""
11431144

1145+
11441146
@pytest.mark.anyio
11451147
@patch.object(LeasesV1Alpha1Api, "list_leases")
11461148
@patch.object(LeasesV1Alpha1Api, "_load_kube_config")

packages/jumpstarter-cli-client/jumpstarter_cli_client/client_shell.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def client_shell(name: str, labels, lease_name):
3131
exit_code = 0
3232
with config.lease(metadata_filter=MetadataFilter(labels=dict(labels)), lease_name=lease_name) as lease:
3333
with lease.serve_unix() as path:
34-
exit_code = launch_shell(path, "remote", config.drivers.allow, config.drivers.unsafe)
34+
with lease.monitor():
35+
exit_code = launch_shell(path, "remote", config.drivers.allow, config.drivers.unsafe)
3536

3637
sys.exit(exit_code)

packages/jumpstarter/jumpstarter/client/grpc.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass, field
4-
from datetime import timedelta
4+
from datetime import datetime, timedelta
55

66
import yaml
77
from google.protobuf import duration_pb2, field_mask_pb2, json_format
@@ -52,6 +52,7 @@ class Lease(BaseModel):
5252
client: str
5353
exporter: str
5454
conditions: list[kubernetes_pb2.Condition]
55+
effective_begin_time: datetime | None = None
5556

5657
model_config = ConfigDict(
5758
arbitrary_types_allowed=True,
@@ -72,13 +73,20 @@ def from_protobuf(cls, data: client_pb2.Lease) -> Lease:
7273
else:
7374
exporter = ""
7475

76+
effective_begin_time = None
77+
if data.effective_begin_time:
78+
effective_begin_time = data.effective_begin_time.ToDatetime(
79+
tzinfo=datetime.now().astimezone().tzinfo,
80+
)
81+
7582
return cls(
7683
namespace=namespace,
7784
name=name,
7885
selector=data.selector,
7986
duration=data.duration.ToTimedelta(),
8087
client=client,
8188
exporter=exporter,
89+
effective_begin_time=effective_begin_time,
8290
conditions=data.conditions,
8391
)
8492

packages/jumpstarter/jumpstarter/client/lease.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
contextmanager,
88
)
99
from dataclasses import dataclass, field
10-
from datetime import timedelta
10+
from datetime import datetime, timedelta
1111

12-
from anyio import fail_after, sleep
12+
from anyio import create_task_group, fail_after, sleep
1313
from anyio.from_thread import BlockingPortal
1414
from grpc.aio import Channel
1515
from jumpstarter_protocol import jumpstarter_pb2, jumpstarter_pb2_grpc
@@ -62,6 +62,11 @@ async def _create(self):
6262
).name
6363
logger.info("Created lease request for selector %s for duration %s", selector, duration)
6464

65+
async def get(self):
66+
with translate_grpc_exceptions():
67+
svc = ClientService(channel=self.channel, namespace=self.namespace)
68+
return await svc.GetLease(name=self.name)
69+
6570
def request(self):
6671
"""Request a lease, or verifies a lease which was already created.
6772
@@ -96,11 +101,7 @@ async def _acquire(self):
96101
with fail_after(300): # TODO: configurable timeout
97102
while True:
98103
logger.debug("Polling Lease %s", self.name)
99-
with translate_grpc_exceptions():
100-
result = await self.svc.GetLease(
101-
name=self.name,
102-
)
103-
104+
result = await self.get()
104105
# lease ready
105106
if condition_true(result.conditions, "Ready"):
106107
logger.debug("Lease %s acquired", self.name)
@@ -156,6 +157,29 @@ async def serve_unix_async(self):
156157
async with TemporaryUnixListener(self.handle_async) as path:
157158
yield path
158159

160+
@asynccontextmanager
161+
async def monitor_async(self, threshold: timedelta = timedelta(minutes=5)):
162+
async def _monitor():
163+
while True:
164+
lease = await self.get()
165+
if lease.effective_begin_time:
166+
end_time = lease.effective_begin_time + lease.duration
167+
remain = end_time - datetime.now(tz=datetime.now().astimezone().tzinfo)
168+
if remain < threshold:
169+
logger.info("Lease {} ending soon in {} at {}".format(self.name, remain, end_time))
170+
await sleep(threshold.total_seconds())
171+
else:
172+
await sleep(5)
173+
else:
174+
await sleep(1)
175+
176+
async with create_task_group() as tg:
177+
tg.start_soon(_monitor)
178+
try:
179+
yield
180+
finally:
181+
tg.cancel_scope.cancel()
182+
159183
@asynccontextmanager
160184
async def connect_async(self, stack):
161185
async with self.serve_unix_async() as path:
@@ -172,3 +196,8 @@ def connect(self):
172196
def serve_unix(self):
173197
with self.portal.wrap_async_context_manager(self.serve_unix_async()) as path:
174198
yield path
199+
200+
@contextmanager
201+
def monitor(self, threshold: timedelta = timedelta(minutes=5)):
202+
with self.portal.wrap_async_context_manager(self.monitor_async(threshold)):
203+
yield

0 commit comments

Comments
 (0)