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

Commit edac078

Browse files
committed
Update hook behavior to match spec
1 parent f872fa8 commit edac078

5 files changed

Lines changed: 530 additions & 172 deletions

File tree

packages/jumpstarter/jumpstarter/config/exporter.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,31 @@
1818
from jumpstarter.driver import Driver
1919

2020

21+
class HookInstanceConfigV1Alpha1(BaseModel):
22+
"""Configuration for a specific lifecycle hook."""
23+
24+
model_config = ConfigDict(populate_by_name=True)
25+
26+
script: str = Field(alias="script", description="The j script to execute for this hook")
27+
timeout: int = Field(default=120, description="The hook execution timeout in seconds (default: 120s)")
28+
exit_code: int = Field(alias="exitCode", default=0, description="The expected exit code (default: 0)")
29+
on_failure: Literal["pass", "block", "warn"] = Field(
30+
default="pass",
31+
alias="onFailure",
32+
description=(
33+
"Action to take when the expected exit code is not returned: 'pass' continues normally, "
34+
"'block' takes the exporter offline and blocks leases, 'warn' continues and prints a warning"
35+
),
36+
)
37+
38+
2139
class HookConfigV1Alpha1(BaseModel):
2240
"""Configuration for lifecycle hooks."""
2341

2442
model_config = ConfigDict(populate_by_name=True)
2543

26-
pre_lease: str | None = Field(default=None, alias="preLease")
27-
post_lease: str | None = Field(default=None, alias="postLease")
28-
timeout: int = Field(default=300, description="Hook execution timeout in seconds")
44+
before_lease: HookInstanceConfigV1Alpha1 | None = Field(default=None, alias="beforeLease")
45+
after_lease: HookInstanceConfigV1Alpha1 | None = Field(default=None, alias="afterLease")
2946

3047

3148
class ExporterConfigV1Alpha1DriverInstanceProxy(BaseModel):
@@ -62,7 +79,7 @@ def instantiate(self) -> Driver:
6279
description=self.root.description,
6380
methods_description=self.root.methods_description,
6481
children=children,
65-
**self.root.config
82+
**self.root.config,
6683
)
6784

6885
case ExporterConfigV1Alpha1DriverInstanceComposite():
@@ -198,7 +215,7 @@ async def channel_factory():
198215

199216
# Create hook executor if hooks are configured
200217
hook_executor = None
201-
if self.hooks.pre_lease or self.hooks.post_lease:
218+
if self.hooks.before_lease or self.hooks.after_lease:
202219
from jumpstarter.exporter.hooks import HookExecutor
203220

204221
hook_executor = HookExecutor(

packages/jumpstarter/jumpstarter/config/exporter_test.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,16 @@ def test_exporter_config_with_hooks(monkeypatch: pytest.MonkeyPatch, tmp_path: P
116116
endpoint: "jumpstarter.my-lab.com:1443"
117117
token: "test-token"
118118
hooks:
119-
preLease: |
120-
echo "Pre-lease hook for $LEASE_NAME"
121-
j power on
122-
postLease: |
123-
echo "Post-lease hook for $LEASE_NAME"
124-
j power off
125-
timeout: 600
119+
beforeLease:
120+
script: |
121+
echo "Pre-lease hook for $LEASE_NAME"
122+
j power on
123+
timeout: 600
124+
afterLease:
125+
script: |
126+
echo "Post-lease hook for $LEASE_NAME"
127+
j power off
128+
timeout: 600
126129
export:
127130
power:
128131
type: "jumpstarter_driver_power.driver.PduPower"
@@ -134,22 +137,20 @@ def test_exporter_config_with_hooks(monkeypatch: pytest.MonkeyPatch, tmp_path: P
134137

135138
config = ExporterConfigV1Alpha1.load("test-hooks")
136139

137-
assert config.hooks.pre_lease == 'echo "Pre-lease hook for $LEASE_NAME"\nj power on\n'
138-
assert config.hooks.post_lease == 'echo "Post-lease hook for $LEASE_NAME"\nj power off\n'
139-
assert config.hooks.timeout == 600
140+
assert config.hooks.before_lease.script == 'echo "Pre-lease hook for $LEASE_NAME"\nj power on\n'
141+
assert config.hooks.after_lease.script == 'echo "Post-lease hook for $LEASE_NAME"\nj power off\n'
140142

141143
# Test that it round-trips correctly
142144
path.unlink()
143145
ExporterConfigV1Alpha1.save(config)
144146
reloaded_config = ExporterConfigV1Alpha1.load("test-hooks")
145147

146-
assert reloaded_config.hooks.pre_lease == config.hooks.pre_lease
147-
assert reloaded_config.hooks.post_lease == config.hooks.post_lease
148-
assert reloaded_config.hooks.timeout == config.hooks.timeout
148+
assert reloaded_config.hooks.before_lease.script == config.hooks.before_lease.script
149+
assert reloaded_config.hooks.after_lease.script == config.hooks.after_lease.script
149150

150151
# Test that the YAML uses camelCase
151152
yaml_output = ExporterConfigV1Alpha1.dump_yaml(config)
152-
assert "preLease:" in yaml_output
153-
assert "postLease:" in yaml_output
154-
assert "pre_lease:" not in yaml_output
155-
assert "post_lease:" not in yaml_output
153+
assert "beforeLease:" in yaml_output
154+
assert "afterLease:" in yaml_output
155+
assert "before_lease:" not in yaml_output
156+
assert "after_lease:" not in yaml_output

packages/jumpstarter/jumpstarter/exporter/exporter.py

Lines changed: 90 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from jumpstarter.common.streams import connect_router_stream
2727
from jumpstarter.config.tls import TLSConfigV1Alpha1
2828
from jumpstarter.driver import Driver
29-
from jumpstarter.exporter.hooks import HookContext, HookExecutor
29+
from jumpstarter.exporter.hooks import HookContext, HookExecutionError, HookExecutor
3030
from jumpstarter.exporter.session import Session
3131

3232
logger = logging.getLogger(__name__)
@@ -49,6 +49,7 @@ class Exporter(AsyncContextManagerMixin, Metadata):
4949
_pre_lease_ready: Event | None = field(init=False, default=None)
5050
_current_status: ExporterStatus = field(init=False, default=ExporterStatus.OFFLINE)
5151
_current_session: Session | None = field(init=False, default=None)
52+
_session_socket_path: str | None = field(init=False, default=None)
5253

5354
def stop(self, wait_for_lease_exit=False, should_unregister=False):
5455
"""Signal the exporter to stop.
@@ -183,13 +184,18 @@ async def listen(retries=5, backoff=3):
183184

184185
tg.start_soon(listen)
185186

186-
# Wait for pre-lease hook to complete before processing connections
187-
if self._pre_lease_ready is not None:
188-
logger.info("Waiting for pre-lease hook to complete before accepting connections")
189-
await self._pre_lease_ready.wait()
190-
logger.info("Pre-lease hook completed, now accepting connections")
191-
187+
# Create session before hooks run
192188
async with self.session() as path:
189+
# Store socket path for hook execution
190+
self._session_socket_path = path
191+
192+
# Wait for before-lease hook to complete before processing connections
193+
if self._pre_lease_ready is not None:
194+
logger.info("Waiting for before-lease hook to complete before accepting connections")
195+
await self._pre_lease_ready.wait()
196+
logger.info("before-lease hook completed, now accepting connections")
197+
198+
# Process client connections
193199
async for request in listen_rx:
194200
logger.info("Handling new connection request on lease %s", lease_name)
195201
tg.start_soon(
@@ -231,19 +237,15 @@ async def status(retries=5, backoff=3):
231237
tg.start_soon(status)
232238
async for status in status_rx:
233239
if self.lease_name != "" and self.lease_name != status.lease_name:
234-
# Post-lease hook for the previous lease
240+
# After-lease hook for the previous lease
235241
if self.hook_executor and self._current_client_name:
236242
hook_context = HookContext(
237243
lease_name=self.lease_name,
238244
client_name=self._current_client_name,
239245
)
240-
# Shield the post-lease hook from cancellation and await it
246+
# Shield the after-lease hook from cancellation and await it
241247
with CancelScope(shield=True):
242-
await self._update_status(ExporterStatus.AFTER_LEASE_HOOK, "Running afterLease hooks")
243-
# Pass the current session to hook executor for logging
244-
self.hook_executor.main_session = self._current_session
245-
await self.hook_executor.execute_post_lease_hook(hook_context)
246-
await self._update_status(ExporterStatus.AVAILABLE, "Available for new lease")
248+
await self.run_after_lease_hook(hook_context)
247249

248250
self.lease_name = status.lease_name
249251
logger.info("Lease status changed, killing existing connections")
@@ -267,37 +269,14 @@ async def status(retries=5, backoff=3):
267269
logger.info("Currently leased by %s under %s", status.client_name, status.lease_name)
268270
self._current_client_name = status.client_name
269271

270-
# Pre-lease hook when transitioning from unleased to leased
272+
# Before-lease hook when transitioning from unleased to leased
271273
if not previous_leased:
272274
if self.hook_executor:
273275
hook_context = HookContext(
274276
lease_name=status.lease_name,
275277
client_name=status.client_name,
276278
)
277-
278-
# Start pre-lease hook asynchronously
279-
async def run_before_lease_hook(hook_ctx):
280-
try:
281-
await self._update_status(
282-
ExporterStatus.BEFORE_LEASE_HOOK, "Running beforeLease hooks"
283-
)
284-
# Pass the current session to hook executor for logging
285-
self.hook_executor.main_session = self._current_session
286-
await self.hook_executor.execute_pre_lease_hook(hook_ctx)
287-
await self._update_status(ExporterStatus.LEASE_READY, "Ready for commands")
288-
logger.info("beforeLease hook completed successfully")
289-
except Exception as e:
290-
logger.error("beforeLease hook failed: %s", e)
291-
# Still transition to ready even if hook fails
292-
await self._update_status(
293-
ExporterStatus.LEASE_READY, f"Ready (beforeLease hook failed: {e})"
294-
)
295-
finally:
296-
# Always set the event to unblock connections
297-
if self._pre_lease_ready:
298-
self._pre_lease_ready.set()
299-
300-
tg.start_soon(run_before_lease_hook, hook_context)
279+
tg.start_soon(self.run_before_lease_hook, self, hook_context)
301280
else:
302281
# No hook configured, set event immediately
303282
await self._update_status(ExporterStatus.LEASE_READY, "Ready for commands")
@@ -306,18 +285,21 @@ async def run_before_lease_hook(hook_ctx):
306285
else:
307286
logger.info("Currently not leased")
308287

309-
# Post-lease hook when transitioning from leased to unleased
288+
# After-lease hook when transitioning from leased to unleased
310289
if previous_leased and self.hook_executor and self._current_client_name:
311290
hook_context = HookContext(
312291
lease_name=self.lease_name,
313292
client_name=self._current_client_name,
314293
)
315-
# Shield the post-lease hook from cancellation and await it
294+
# Shield the after-lease hook from cancellation and await it
316295
with CancelScope(shield=True):
317296
await self._update_status(ExporterStatus.AFTER_LEASE_HOOK, "Running afterLease hooks")
318297
# Pass the current session to hook executor for logging
319298
self.hook_executor.main_session = self._current_session
320-
await self.hook_executor.execute_post_lease_hook(hook_context)
299+
# Use session socket if available, otherwise create new session
300+
await self.hook_executor.execute_after_lease_hook(
301+
hook_context, socket_path=self._session_socket_path
302+
)
321303
await self._update_status(ExporterStatus.AVAILABLE, "Available for new lease")
322304

323305
self._current_client_name = ""
@@ -330,3 +312,69 @@ async def run_before_lease_hook(hook_ctx):
330312

331313
self._previous_leased = current_leased
332314
self._tg = None
315+
316+
async def run_before_lease_hook(self, hook_ctx: HookContext):
317+
"""
318+
Execute the before-lease hook for the current exporter session.
319+
320+
Args:
321+
hook_ctx (HookContext): The current hook execution context
322+
"""
323+
try:
324+
await self._update_status(ExporterStatus.BEFORE_LEASE_HOOK, "Running beforeLease hooks")
325+
# Pass the current session to hook executor for logging
326+
self.hook_executor.main_session = self._current_session
327+
328+
# Wait for socket path to be available
329+
while self._session_socket_path is None:
330+
await sleep(0.1)
331+
332+
# Execute hook with main session socket
333+
await self.hook_executor.execute_before_lease_hook(hook_ctx, socket_path=self._session_socket_path)
334+
await self._update_status(ExporterStatus.LEASE_READY, "Ready for commands")
335+
logger.info("beforeLease hook completed successfully")
336+
except HookExecutionError as e:
337+
# Hook failed with on_failure='block' - end lease and set failed status
338+
logger.error("beforeLease hook failed (on_failure=block): %s", e)
339+
await self._update_status(
340+
ExporterStatus.BEFORE_LEASE_HOOK_FAILED, f"beforeLease hook failed (on_failure=block): {e}"
341+
)
342+
# Note: We don't take the exporter offline for before_lease hook failures
343+
# The lease is simply not ready, and the exporter remains available for future leases
344+
except Exception as e:
345+
# Unexpected error during hook execution
346+
logger.error("beforeLease hook failed with unexpected error: %s", e, exc_info=True)
347+
await self._update_status(ExporterStatus.BEFORE_LEASE_HOOK_FAILED, f"beforeLease hook failed: {e}")
348+
finally:
349+
# Always set the event to unblock connections
350+
if self._pre_lease_ready:
351+
self._pre_lease_ready.set()
352+
353+
async def run_after_lease_hook(self, hook_ctx: HookContext):
354+
"""
355+
Execute the after-lease hook for the current exporter session.
356+
357+
Args:
358+
hook_ctx (HookContext): The current hook execution context
359+
"""
360+
try:
361+
await self._update_status(ExporterStatus.AFTER_LEASE_HOOK, "Running afterLease hooks")
362+
# Pass the current session to hook executor for logging
363+
self.hook_executor.main_session = self._current_session
364+
# Use session socket if available, otherwise create new session
365+
await self.hook_executor.execute_after_lease_hook(hook_ctx, socket_path=self._session_socket_path)
366+
await self._update_status(ExporterStatus.AVAILABLE, "Available for new lease")
367+
logger.info("afterLease hook completed successfully")
368+
except HookExecutionError as e:
369+
# Hook failed with on_failure='block' - set failed status and shut down exporter
370+
logger.error("afterLease hook failed (on_failure=block): %s", e)
371+
await self._update_status(
372+
ExporterStatus.AFTER_LEASE_HOOK_FAILED, f"afterLease hook failed (on_failure=block): {e}"
373+
)
374+
# Shut down the exporter after after_lease hook failure with on_failure='block'
375+
logger.error("Shutting down exporter due to afterLease hook failure")
376+
self.stop()
377+
except Exception as e:
378+
# Unexpected error during hook execution
379+
logger.error("afterLease hook failed with unexpected error: %s", e, exc_info=True)
380+
await self._update_status(ExporterStatus.AFTER_LEASE_HOOK_FAILED, f"afterLease hook failed: {e}")

0 commit comments

Comments
 (0)