Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
298 changes: 134 additions & 164 deletions python/packages/jumpstarter/jumpstarter/exporter/exporter.py

Large diffs are not rendered by default.

254 changes: 150 additions & 104 deletions python/packages/jumpstarter/jumpstarter/exporter/exporter_test.py

Large diffs are not rendered by default.

6 changes: 0 additions & 6 deletions python/packages/jumpstarter/jumpstarter/exporter/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,6 @@ async def run_before_lease_hook(
error_msg = "Timeout waiting for lease scope to be ready"
logger.error(error_msg)
await report_status(ExporterStatus.BEFORE_LEASE_HOOK_FAILED, error_msg)
lease_scope.before_lease_hook.set()
return
await anyio.sleep(interval)
elapsed += interval
Expand Down Expand Up @@ -652,11 +651,6 @@ async def run_before_lease_hook(
ExporterStatus.BEFORE_LEASE_HOOK_FAILED,
f"beforeLease hook failed: {e}",
)
# Unexpected errors don't trigger shutdown - just block the lease

finally:
# Always set the event to unblock connections
lease_scope.before_lease_hook.set()

async def run_after_lease_hook(
self,
Expand Down
43 changes: 15 additions & 28 deletions python/packages/jumpstarter/jumpstarter/exporter/hooks_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,13 @@ def hook_config() -> HookConfigV1Alpha1:

@pytest.fixture
def lease_scope():
from anyio import Event

from jumpstarter.exporter.lease_context import LeaseContext

lease_scope = LeaseContext(
lease_name="test-lease-123",
before_lease_hook=Event(),
client_name="test-client",
)
# Add mock session to lease_scope
mock_session = MagicMock()
# Return a no-op context manager for context_log_source
mock_session.context_log_source.return_value = nullcontext()
lease_scope.session = mock_session
lease_scope.socket_path = "/tmp/test_socket"
Expand Down Expand Up @@ -206,16 +201,13 @@ async def test_failed_hook_with_warn_logs_warning_inside_log_source_context(self
"""
from contextlib import contextmanager

from anyio import Event

from jumpstarter.exporter.lease_context import LeaseContext

hook_config = HookConfigV1Alpha1(
before_lease=HookInstanceConfigV1Alpha1(script="exit 1", timeout=10, on_failure="warn"),
)
executor = HookExecutor(config=hook_config)

# Track whether context_log_source is active when warning is logged
context_active = False
warning_logged_in_context = False

Expand All @@ -230,7 +222,6 @@ def tracking_context_log_source(logger_name, source):

lease_scope = LeaseContext(
lease_name="test-lease-ctx",
before_lease_hook=Event(),
client_name="test-client",
)
mock_session = MagicMock()
Expand Down Expand Up @@ -432,6 +423,7 @@ async def test_before_lease_hook_exit_sets_skip_flag(self, lease_scope) -> None:
)

assert lease_scope.skip_after_lease_hook is True
assert lease_scope.lifecycle.skip_after_lease is True
mock_shutdown.assert_called_once_with(exit_code=1, wait_for_lease_exit=True, should_unregister=True)

async def test_before_lease_hook_endlease_does_not_set_skip_flag(self, lease_scope) -> None:
Expand Down Expand Up @@ -778,38 +770,34 @@ async def test_infrastructure_messages_at_debug_not_info(self, lease_scope) -> N
# User output should be at INFO level
assert any("user output" in call for call in info_calls)

async def test_before_lease_hook_always_sets_event_on_failure(self, lease_scope) -> None:
"""Issue C3: before_lease_hook event must be set even when hook fails.

When the beforeLease hook fails with on_failure=endLease, the event must
still be set to unblock process_connections in handle_lease. Otherwise
the lease hangs indefinitely.
"""
async def test_before_lease_hook_reports_failure_status(self, lease_scope) -> None:
"""When the beforeLease hook fails with on_failure=endLease, the hook must
report BEFORE_LEASE_HOOK_FAILED status. The lifecycle transition to READY
is now handled by the exporter wrapper, not the hook executor."""
hook_config = HookConfigV1Alpha1(
before_lease=HookInstanceConfigV1Alpha1(script="exit 1", timeout=10, on_failure="endLease"),
)
executor = HookExecutor(config=hook_config)

mock_report_status = AsyncMock()
mock_shutdown = MagicMock()
status_calls = []

assert not lease_scope.before_lease_hook.is_set()
async def mock_report_status(status, msg):
status_calls.append((status, msg))

mock_shutdown = MagicMock()

await executor.run_before_lease_hook(
lease_scope,
mock_report_status,
mock_shutdown,
)

# Event must always be set to unblock connections
assert lease_scope.before_lease_hook.is_set()

async def test_before_lease_hook_always_sets_event_on_exit(self, lease_scope) -> None:
"""Issue C3b: before_lease_hook event must be set when hook fails with exit.
failed = [s for s, _ in status_calls if s == ExporterStatus.BEFORE_LEASE_HOOK_FAILED]
assert len(failed) > 0

Same as C3 but for on_failure=exit. The event must be set, shutdown called,
and skip_after_lease_hook set to True.
"""
async def test_before_lease_hook_exit_sets_skip_and_shutdown(self, lease_scope) -> None:
"""When hook fails with on_failure=exit, shutdown is called
and skip_after_lease_hook is set."""
hook_config = HookConfigV1Alpha1(
before_lease=HookInstanceConfigV1Alpha1(script="exit 1", timeout=10, on_failure="exit"),
)
Expand All @@ -824,7 +812,6 @@ async def test_before_lease_hook_always_sets_event_on_exit(self, lease_scope) ->
mock_shutdown,
)

assert lease_scope.before_lease_hook.is_set()
assert lease_scope.skip_after_lease_hook is True
mock_shutdown.assert_called_once()

Expand Down
45 changes: 23 additions & 22 deletions python/packages/jumpstarter/jumpstarter/exporter/lease_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from anyio import Event

from jumpstarter.common import ExporterStatus
from jumpstarter.exporter.lease_lifecycle import LeaseLifecycle

if TYPE_CHECKING:
from jumpstarter.exporter.session import Session
Expand All @@ -19,42 +20,42 @@
class LeaseContext:
"""Encapsulates all resources associated with an active lease.

This class bundles together the session, socket path, synchronization event,
This class bundles together the session, socket path, lifecycle controller,
and lease identity information that are needed throughout the lease lifecycle.
By grouping these resources, we make their relationships and lifecycles explicit.

Attributes:
lease_name: Name of the current lease assigned by the controller
lifecycle: LeaseLifecycle FSM that coordinates all lease phase transitions
end_session_requested: Event that signals when client requests end session (gRPC layer)
session: The Session object managing the device and gRPC services (set in handle_lease)
socket_path: Unix socket path where the session is serving (set in handle_lease)
hook_socket_path: Separate Unix socket for hook j commands to avoid SSL frame corruption
before_lease_hook: Event that signals when before-lease hook completes
end_session_requested: Event that signals when client requests end session (to run afterLease hook)
after_lease_hook_started: Event that signals when afterLease hook has started (prevents double execution)
after_lease_hook_done: Event that signals when afterLease hook has completed
lease_ended: Event that signals when the lease has ended (from controller status update)
client_name: Name of the client currently holding the lease (empty if unleased)
current_status: Current exporter status (stored here for access before session is created)
status_message: Message describing the current status
"""

lease_name: str
before_lease_hook: Event
lifecycle: LeaseLifecycle = field(default_factory=LeaseLifecycle)
end_session_requested: Event = field(default_factory=Event)
after_lease_hook_started: Event = field(default_factory=Event)
after_lease_hook_done: Event = field(default_factory=Event)
lease_ended: Event = field(default_factory=Event) # Signals lease has ended (from controller)
session: "Session | None" = None
socket_path: str = ""
hook_socket_path: str = "" # Separate socket for hook j commands to avoid SSL corruption
hook_socket_path: str = ""
client_name: str = field(default="")
current_status: ExporterStatus = field(default=ExporterStatus.AVAILABLE)
status_message: str = field(default="")
skip_after_lease_hook: bool = False

@property
def skip_after_lease_hook(self) -> bool:
return self.lifecycle.skip_after_lease

@skip_after_lease_hook.setter
def skip_after_lease_hook(self, value: bool) -> None:
self.lifecycle.skip_after_lease = value

def __post_init__(self):
"""Validate that required resources are present."""
assert self.before_lease_hook is not None, "LeaseScope requires a before_lease_hook event"
assert self.lease_name, "LeaseScope requires a non-empty lease_name"

def is_ready(self) -> bool:
Expand Down Expand Up @@ -90,22 +91,22 @@ def update_status(self, status: ExporterStatus, message: str = ""):
"""
self.current_status = status
self.status_message = message
# Also update session if it exists
if self.session:
self.session.update_status(status, message)

def drivers_ready(self) -> bool:
"""Check if drivers are ready for use (beforeLease hook completed).
"""Check if drivers are ready for use (lifecycle has reached READY or later).

Returns True if the beforeLease hook has completed and drivers can be accessed.
Used by Session to gate driver calls during hook execution.
Returns True if the lease lifecycle has passed the READY gate and drivers
can be accessed. Used by Session to gate driver calls during hook execution.
"""
return self.before_lease_hook.is_set()
return self.lifecycle.drivers_ready()

async def wait_for_drivers(self) -> None:
"""Wait for drivers to be ready (beforeLease hook to complete).
"""Wait for drivers to be ready (lifecycle reaches READY phase).

This method blocks until the beforeLease hook completes, allowing
clients to connect early but wait for driver access.
This method blocks until the beforeLease hook completes and the lifecycle
transitions to READY, allowing clients to connect early but wait for
driver access.
"""
await self.before_lease_hook.wait()
await self.lifecycle.wait_ready()
Loading
Loading